feat: strict fail-closed IPNS verification cutover across Rust, TS, and API#555
Conversation
Entire-Checkpoint: 12967cb8e92c
Entire-Checkpoint: 4db0b7d9558c
Entire-Checkpoint: 7fbf038a9e1d
Entire-Checkpoint: af2b5ddb1acc
8 plans across 4 waves covering HARD-11 and decisions D-01..D-12: strict no-legacy verify cutover, embed-1 producer unification, verified-resolve coverage in sdk/desktop, API hot-path verify caching, cross-language vector regen, and the D-12 lockstep staging wipe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: e7a083b2e55a
…04 strict cutover - Add bind_verified_first_publish_seq_skew_now_invalid: asserts Invalid for embedded=0/resp_seq=1 (D-04) - Add bind_verified_absent_fields_returns_invalid: asserts Invalid for None verdict (Legacy removed) - Add bind_verified_valid/cid_swap/seq_mismatch/invalid_sig: core correctness tests - Update absent_fields_returns_none to absent_fields_returns_some_false (D-04: Ok(None) → Ok(Some(false))) - Tests reference bind_verified/VerifyError/VerifiedResolve not yet in api-client (fail to compile = RED) Entire-Checkpoint: e4a096d939fb
…D-04 strict cutover - Add cipherbox-core + ciborium deps to crates/api-client/Cargo.toml (enables decode_ipns_cbor_data) - Move VerifyError (no Legacy), VerifiedResolve, bind_verified, resolve_ipns_verified to api-client/src/ipns.rs - Remove VerifyError::Legacy variant (D-04) — all-absent sig fields now Err(Invalid) - Remove first-publish skew disjunct (resp_seq==1 && embedded_seq==0) for strict equality (D-04) - Remove Ok(None) all-absent branch from verify_ipns_resolve_signature (falls through to Some(false)) - Reduce crates/fuse/src/verify.rs to thin re-export of api-client symbols (D-08) - Fold all 9 fuse Legacy caller arms into Invalid fail-closed handling (D-04 compile-time enforcement) - All 15 api-client unit tests pass; cargo check -p cipherbox-fuse clean Entire-Checkpoint: df13e20983da
- bind_verified_expired_record_returns_invalid: 2020 past date must return Invalid with 'expired' - bind_verified_future_validity_returns_ok: far-future date must pass - bind_verified_within_skew_buffer_returns_ok: not-expired case passes - Tests fail (expired test returns Ok instead of Err) until bind_verified checks Validity field Entire-Checkpoint: c0cc81fc667a
…ew buffer - Add decode_ipns_cbor_validity companion fn to crates/core/src/ipns.rs (surfaces Validity bytes) - Add parse_rfc3339_to_unix_secs manual RFC3339 parser to api-client/src/ipns.rs (no chrono dep) - Add expiry check in bind_verified: reject when validity < now - 300s (5-min skew buffer) - Fail-closed: missing/unparseable Validity treated as expired (D-07) - All 18 api-client tests pass; cargo check --workspace clean - Decision: companion fn decode_ipns_cbor_validity chosen over 3-tuple return to avoid call-site churn Entire-Checkpoint: eca0ef60814b
…t cutover plan Entire-Checkpoint: a531c0ed8137
Entire-Checkpoint: 6f47a12646af
- crates/fuse/src/write_ops/implementation/mkdir.rs: new-folder create_ipns_record seq 0->1; coordinator.record_publish 0->1 - crates/fuse/src/platform/windows/write_ops.rs: Windows new-folder create_ipns_record seq 0->1; coordinator.record_publish 0->1 (winfsp CI-gated) - crates/fuse/src/metadata.rs: bin first-publish make_bin_record(0)->make_bin_record(1); coordinator.record_publish 0->1 - apps/desktop/src-tauri/src/commands/vault.rs: vault-key blob and root-folder create_ipns_record seq 0->1 at both sites Entire-Checkpoint: 1c743c03ab70
- packages/sdk-core/src/vault/index.ts: vault-key blob sequenceNumber 0n->1n - apps/web/src/hooks/useAuth.ts: vault-key blob and root-folder metadata sequenceNumber 0n->1n at both sites - apps/web/src/services/vault-settings.service.ts: first-publish fallback let sequenceNumber 0n->1n; forward-publish BigInt(resolved.sequenceNumber ?? 0)+1n unchanged Entire-Checkpoint: 8ab7632dedd7
- 60-02-SUMMARY.md: 9 producer sites unified, greps clean, self-check passed - STATE.md: advance to plan 3 of 8, add D-02 decision, record metrics - ROADMAP.md: mark 60-02 complete Entire-Checkpoint: c5a25a4c02fb
…solve - D-05-a: resolveIpnsRecord throws when all sig fields absent (fail closed) - D-05-b: throws on first-publish skew seq=0/resp=1 (skew disjunct removed) - D-07-c: throws when CBOR Validity timestamp is in the past (EOL enforce) - D-07-d: returns verified result for valid in-window record (positive case) - Rule 1 fix: update vault test expectation from 0n to 1n (Plan 60-02 changed publishVaultKeyBlob to embed sequenceNumber=1n; test was stale) Entire-Checkpoint: f63fc0de98e2
…expiry D-05: delete legacy else branch in resolveIpnsRecord — absent signature fields now throw fail closed instead of returning signatureVerified:false. D-05: remove skew disjunct (embedded=0 resp=1 accepted) from sequence binding check; strict equality only — all producers now embed 1 (Plan 60-02). D-07: parse CBOR Validity field after CBOR decode; throw when expiry is before now minus 5-minute skew buffer (mirrors Rust Plan 60-01 semantics). Update existing tests that relied on old behavior: legacy-soft-return, first-publish-skew acceptance, cross-language vector first-publish-skew and legacy-absent now expect throws per D-05 reclassification. Entire-Checkpoint: 51ef4c8511ad
Entire-Checkpoint: 4c2527818c3d
…d delete verify.rs - Replace all crate::verify::resolve_ipns_verified / VerifyError references in events.rs, metadata.rs (×3), publish.rs (×2), fs.rs, replay.rs (×9 sites) with cipherbox_api_client::ipns::resolve_ipns_verified / VerifyError - Remove pub mod verify declaration from crates/fuse/src/lib.rs - Delete crates/fuse/src/verify.rs (thin re-export shim no longer needed) - Single verified-resolve chokepoint now lives exclusively in api-client (D-08) - All 9 FUSE caller arms confirmed fail-closed via Invalid (D-04 — folded in 60-01) Entire-Checkpoint: 920e869bbb6c
…_verified - crates/sdk/src/registry.rs: replace raw resolve_ipns with resolve_ipns_verified in fetch_and_decrypt_registry; map VerifyError::Invalid to RegistryError (D-08) - crates/sdk/src/sync.rs: replace raw resolve_ipns with resolve_ipns_verified in poll(); log error and fail poll cycle on Invalid (D-08) - apps/desktop/src-tauri/src/fuse/prepopulate.rs: route all 4 resolve sites (root, root-file-pointer, subfolder, subfolder-file-pointer) through resolve_ipns_verified; fail-closed per-operation with log::error (D-09) - apps/desktop/src-tauri/src/commands/vault.rs: route 2 resolve sites (load_vault_settings, fetch_and_decrypt_vault) through resolve_ipns_verified; fail-closed with log::error on Invalid (D-09) - Zero raw resolve_ipns calls remain in sdk or desktop resolve paths Entire-Checkpoint: 406637b3cf1e
…loseout plan Entire-Checkpoint: 962c268b326f
…ore deps Entire-Checkpoint: bb50b23885d4
… D-06 null-signed-record 404 Entire-Checkpoint: 44a54cdc7038
…igned-record to 404 - D-03: reject embedded sequence 0n on first publish (only 1 accepted) - D-06: parseCachedRecord returns null for null signedRecord rows - D-06: discard inconsistent cached result on CID mismatch instead of warn+override - D-06: remove withCachedPublicKey enrich call and equal-seq signatureV2 enrich block - Update spec tests to reflect new strict behavior (D-03 and D-06) - Publish-side verifyIpnsRecordSignature anchor at ipns.service.ts:87-89 unchanged Entire-Checkpoint: 840e3ee9a14e
withCachedPublicKey is no longer imported or called after the D-06 resolve enrich removal in ipns.service.ts. Entire-Checkpoint: 3824158cb0f4
Entire-Checkpoint: ef974b8a778f
…id in generator - legacy-absent: expected_result "legacy" -> "invalid" (D-04 strict fail-closed) - first-publish-skew: expected_result "valid" -> "invalid" (D-05 strict seq equality) - update expectedResults sanity-check array to match - regenerate tests/vectors/ipns/verify.json via npx tsx (only 2 cases changed) Entire-Checkpoint: c43a0d3b7271
- None => "invalid" (D-04: absent fields fail-closed, no legacy branch) - strict seq_ok = embedded_seq == resp_seq (D-05: skew disjunct removed) - update doc comment: remove stale "legacy" result reference - cargo test -p cipherbox-fuse --test ipns_verify_vectors passes (1/1) Entire-Checkpoint: f41d34fe11b8
Entire-Checkpoint: c4bbfda5bd4d
- Add scripts/bench-ipns-verify.ts: measures verifyIpnsRecordSignature (Ed25519+proto) vs Map.get() cache-hit; typechecked via tsconfig.scripts.json - Add bench-ipns-verify.ts to tsconfig.scripts.json include list - Add docs/CAPACITY.md §1.6: measured per-op verify cost (mean 0.105 ms, p50 0.095 ms, p99 0.337 ms); confirms cache is justified (go decision) - Confirms TEE republish path never calls verifyIpnsRecordSignature Entire-Checkpoint: 124977a3e9c1
Release Preview
Cascade Details
|
Greptile SummaryThis PR completes Phase 60's strict fail-closed IPNS verification across all layers (Rust, TypeScript, API), removing the
Confidence Score: 5/5Safe to merge with the D-12 staging DB wipe executed first; the cutover is operationally gated and all E2E/unit test suites are green. The core verify logic (Rust bind_verified, TS resolveIpnsRecord, API publish path) is thoroughly tested across unit, integration, and E2E layers. All three findings are documentation and operational observations, not logic errors in the new strict paths, and are bounded by the documented D-12 operational gate. apps/api/src/ipns/ipns-verify-cache.spec.ts (test comments misattribute the cache key to parsed signatureV2 rather than raw record bytes); apps/api/src/ipns/ipns.service.ts resolve path for networkSeq > dbSeq (returns unenriched network result without pubKey for Ed25519 names). Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant Client as SDK Client (TS)
participant API as NestJS API
participant Cache as IpnsVerifyCache
participant DB as PostgreSQL
participant DHT as Delegated Routing (DHT)
participant Rust as Rust (fuse/sdk/desktop)
Note over Client,Rust: PUBLISH PATH (D-11 cache short-circuit)
Client->>API: POST /ipns/publish
API->>Cache: isVerified(ipnsName, base64(recordBytes))?
alt Cache HIT
Cache-->>API: true, skip Ed25519 verify
else Cache MISS
API->>API: verifyIpnsRecordSignature
API->>Cache: recordVerified
end
API->>DB: upsertFolderIpns
API->>DHT: publish relay
Note over Client,Rust: RESOLVE PATH
Client->>API: GET /ipns/resolve
API->>DB: findOne signedRecord + publicKey
API->>DHT: resolve network record
alt "dbSeq >= networkSeq"
API-->>Client: DB record with pubKey enriched
else "networkSeq > dbSeq"
API-->>Client: network result may lack pubKey
end
Client->>Client: verifyEd25519 + CBOR binding + Validity EOL
Note over Rust: RUST CHOKEPOINT (D-08)
Rust->>API: resolve_ipns
API-->>Rust: IpnsResolveResponse
Rust->>Rust: verify_ipns_resolve_signature
Rust->>Rust: bind_verified CID+seq+Validity
alt Ok(VerifiedResolve)
Rust-->>Rust: use verified CID
else Err(Invalid)
Rust-->>Rust: fail-closed
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant Client as SDK Client (TS)
participant API as NestJS API
participant Cache as IpnsVerifyCache
participant DB as PostgreSQL
participant DHT as Delegated Routing (DHT)
participant Rust as Rust (fuse/sdk/desktop)
Note over Client,Rust: PUBLISH PATH (D-11 cache short-circuit)
Client->>API: POST /ipns/publish
API->>Cache: isVerified(ipnsName, base64(recordBytes))?
alt Cache HIT
Cache-->>API: true, skip Ed25519 verify
else Cache MISS
API->>API: verifyIpnsRecordSignature
API->>Cache: recordVerified
end
API->>DB: upsertFolderIpns
API->>DHT: publish relay
Note over Client,Rust: RESOLVE PATH
Client->>API: GET /ipns/resolve
API->>DB: findOne signedRecord + publicKey
API->>DHT: resolve network record
alt "dbSeq >= networkSeq"
API-->>Client: DB record with pubKey enriched
else "networkSeq > dbSeq"
API-->>Client: network result may lack pubKey
end
Client->>Client: verifyEd25519 + CBOR binding + Validity EOL
Note over Rust: RUST CHOKEPOINT (D-08)
Rust->>API: resolve_ipns
API-->>Rust: IpnsResolveResponse
Rust->>Rust: verify_ipns_resolve_signature
Rust->>Rust: bind_verified CID+seq+Validity
alt Ok(VerifiedResolve)
Rust-->>Rust: use verified CID
else Err(Invalid)
Rust-->>Rust: fail-closed
end
Reviews (8): Last reviewed commit: "docs(60): record real root cause of macO..." | Re-trigger Greptile |
Release #554 shipped crates/fuse 0.9.0 to main (manifest bumped) but left the release-as: 0.9.0 pin in release-please-config.json. On this PR the pull_request merge tree combines main's manifest (0.9.0) with the pin (0.9.0), so check-stale-release-as.js flags it stale and the Lint job fails — which in turn skips every needs:[changes,lint] test job. Bump the pin to 0.10.0 (next minor after the shipped 0.9.0, matching this PR's fuse changes). The preview bot's don't-downgrade guard preserves a higher existing release-as, so this stays stable across recomputes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 6f4d7a2638e8
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #555 +/- ##
==========================================
+ Coverage 65.97% 69.24% +3.27%
==========================================
Files 148 177 +29
Lines 11476 20830 +9354
Branches 1302 1305 +3
==========================================
+ Hits 7571 14424 +6853
- Misses 3661 6164 +2503
+ Partials 244 242 -2
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Greptile flagged that post-D-04 verify_ipns_resolve_signature never returns Ok(None), so bind_verified's None arm is unreachable from the production resolve path and the all-absent case surfaces as the generic 'signature verification failed', hiding a meaningfully different root cause from an operator debugging a pre-cutover record on a non-wiped environment. Log the distinct cause at the detection site (the only place that can tell all-absent from a partial downgrade — bind_verified only sees Some(false)), and document the None arm as the defensive, test-covered totality arm it now is. No behavior or type change: all paths still fail closed and the 19 api-client ipns tests pass unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: abf288d2aa84
…mn is null The Phase 60 D-06 fail-closed branch in parseCachedRecord discarded any authoritative DB record whose nullable publicKey column was never populated. Writable-shared-folder rows have a null public_key column, so the strict resolve returned nothing and the web-e2e 'writable-shares 3.4 Bob can open and edit the file he uploaded' loaded empty content (expect 'Content uploaded by Bob', received ''). For CipherBox's Ed25519-only IPNS model the k51... name *encodes* the public key, so it is the authoritative source and is always recoverable even when the column cache is absent. Add publicKeyFromIpnsName to @cipherbox/crypto (the exact inverse of deriveIpnsName via peerIdFromCID) and use it as the pubKey fallback in parseCachedRecord. Still fail-closed: a malformed or non-Ed25519 name discards the record, and the strict client re-checks deriveIpnsName(pubKey) === ipnsName. This is the complete fix for the bug class behind the earlier SDK-E2E regression too: pubKey no longer depends on a column that isn't always set. Verified: crypto round-trip test (recovers the exact raw key), API jest 174 (new null-column regression test), SDK E2E 89/89. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 84b678898693
There was a problem hiding this comment.
Actionable comments posted: 11
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/fuse/src/publish.rs (1)
107-119: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winFail closed on
VerifyError::Invalidinstead of using cached sequence.
Invalidcovers signature/CBOR binding failures, not transient availability. Falling back to cache here lets publish flows proceed from an unverifiable live record; keep cache fallback forApierrors only.Proposed fix
Err(cipherbox_api_client::ipns::VerifyError::Invalid(msg)) => { - // D-02: verify failure on soft resolve — fall back to cache (never wedge). - log::warn!( - "resolve_sequence: IPNS {} verify failed: {} — falling back to cache (D-02)", - ipns_name, msg - ); - match self.get_cached(ipns_name) { - Some(cached) => Ok(cached), - None => Err(format!( - "IPNS verify failed and no cached sequence for {}: {}", - ipns_name, msg - )), - } + Err(format!("IPNS {} verify failed: {}", ipns_name, msg)) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fuse/src/publish.rs` around lines 107 - 119, The `resolve_sequence` handling for `cipherbox_api_client::ipns::VerifyError::Invalid` should fail closed instead of falling back to `get_cached`; update the match arm in `publish.rs` so `Invalid(msg)` logs the verification failure and returns an error immediately, while preserving cache fallback only for transient API-related errors elsewhere in the surrounding resolve logic. Use the existing `resolve_sequence`, `get_cached`, and `VerifyError::Invalid` symbols to keep the change scoped correctly.
🧹 Nitpick comments (4)
crates/api-client/src/ipns.rs (1)
8-9: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winRemove the stale FUSE re-export note.
The current stack says
crates/fuse/src/verify.rsis deleted, but this module doc says it was reduced to a thin re-export. That points future maintainers at a non-existent compatibility path.Suggested doc cleanup
-//! share one implementation (D-08). The `crates/fuse/src/verify.rs` file is reduced to -//! a thin re-export of the public symbols here. +//! share one implementation (D-08).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/api-client/src/ipns.rs` around lines 8 - 9, The module doc in ipns.rs still references the removed FUSE compatibility path, so update the top-level documentation comment to remove the stale note about crates/fuse/src/verify.rs being a thin re-export. Keep the doc focused on the current api-client/IPNS implementation and ensure any mention of re-exports or deleted modules is removed from the file-level comment.crates/fuse/src/metadata.rs (1)
504-531: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFix the bin first-publish sequence comment.
Lines 507-508 still describe publishing directly at seq
0, but Line 531 now correctly publishes seq1.Proposed cleanup
- // that not-found as a fatal error, so the first publish must NOT go through the - // CAS helper — it publishes directly at seq 0 with expected_sequence_number: None + // that not-found as a fatal error, so the first publish must NOT go through the + // CAS helper — it publishes directly at seq 1 with expected_sequence_number: None🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fuse/src/metadata.rs` around lines 504 - 531, Update the bin first-publish comment in metadata.rs so it matches the actual behavior in the first-publish path: the logic in make_bin_record and is_first_bin_publish now publishes the initial bin record with sequence 1, not 0. Keep the surrounding explanation about skipping the CAS helper for the first publish, but revise the inline comment near is_first_bin_publish to describe seq 1 consistently with the call that uses make_bin_record(1).crates/fuse/src/write_ops/implementation/mkdir.rs (1)
183-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUpdate the stale first-publish sequence comment.
Line 198 still says sequence
0should never conflict, but this path now publishes and records sequence1.Proposed cleanup
- // Sequence 0 should never conflict -- log and continue + // First-publish sequence 1 should never conflict -- log and continue🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fuse/src/write_ops/implementation/mkdir.rs` around lines 183 - 199, The first-publish sequence comment in the new-folder publish path is stale and conflicts with the current logic in mkdir.rs around publish_ipns and coordinator.record_publish. Update the inline comment in this branch so it correctly reflects that the initial IPNS publish uses sequence 1 and that the conflict path should describe sequence 1, not 0; keep the wording aligned with the existing request setup and publish result handling.packages/crypto/src/__tests__/ipns-record.test.ts (1)
68-92: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a valid-but-non-Ed25519 case to this suite.
The malformed-name assertion only covers CID parse failure.
publicKeyFromIpnsName()also has a fail-closed branch for valid IPNS names that do not inline a 32-byte Ed25519 key, andapps/api/src/ipns/ipns-record.codec.tsnow depends on that branch when recovering a missingpublicKeyfrom cached rows. Without a test for a hashed/non-Ed25519 PeerId, that path can regress while this suite stays green. As per path instructions,**/*.test.ts: Focus on test coverage, edge cases, and test quality. Ensure tests are meaningful and not just for coverage metrics.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/crypto/src/__tests__/ipns-record.test.ts` around lines 68 - 92, Add a valid-but-non-Ed25519 coverage case for publicKeyFromIpnsName in ipns-record.test.ts so the suite exercises the fail-closed branch, not just CID parse failure. Create a real IPNS name/PeerId that is syntactically valid but does not inline a 32-byte Ed25519 public key, then assert publicKeyFromIpnsName throws CryptoError for that input. Keep the existing round-trip tests for deriveIpnsName and publicKeyFromIpnsName, and place the new case alongside the current malformed-name test to protect the recovery logic used by ipns-record.codec.ts.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
@.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-05-PLAN.md:
- Around line 42-43: The plan file currently references local absolute paths,
which makes the committed doc non-portable and leaks environment-specific
details. Update the referenced entries to use repo-relative paths or the
existing tool alias style instead, and verify the surrounding section in
60-05-PLAN.md no longer contains any user-specific filesystem paths.
In
@.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-06-PLAN.md:
- Around line 49-50: Replace the machine-specific absolute paths in the planning
note with repo-relative references; update the references in 60-06-PLAN.md to
point to the existing workflow/template files without including the local
username or full filesystem prefix. This change should be made where the two
linked paths are listed so the plan stays portable and does not leak
identity-specific information.
In
@.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-08-PLAN.md:
- Line 15: The planning doc contains explicit root SSH host details that should
not be tracked in source control. Update the affected references in the planning
content to remove the direct `root@...` access coordinates and replace them with
a sanitized pointer to the approved runbook or secret-managed access path,
keeping the surrounding phase/task text intact.
In `@apps/desktop/src-tauri/src/commands/vault.rs`:
- Around line 182-186: The root-folder publish path in the vault initialization
flow only logs the PublishResult::Conflict case and then continues, which allows
/vault/init to register root_ipns_name anyway. Update the match in the vault.rs
publish handling to fail fast on root-folder conflicts, similar to the vault-key
publish path: return an error from the initialization command when
cipherbox_api_client::PublishResult::Conflict is encountered so the registration
step is skipped. Use the existing publish_ipns and root-folder publish logic as
the place to enforce this abort behavior.
In `@crates/api-client/Cargo.toml`:
- Around line 17-18: Move the ciborium entry out of the normal dependencies for
cipherbox-api-client and into [dev-dependencies], since it is only referenced
from the #[cfg(test)] code in ipns.rs. Update Cargo.toml so runtime dependencies
stay limited to what the library needs, and keep the existing workspace-based
dependency style consistent when relocating ciborium.
In `@crates/api-client/src/ipns.rs`:
- Around line 766-788: The test bind_verified_within_skew_buffer_returns_ok is
not actually asserting the 5-minute skew boundary because it uses a far-future
validity timestamp instead of a real “now minus 2 minutes” case. Update the skew
check in ipns.rs so the boundary is tested against a controllable current time,
either by factoring out the expiry predicate or injecting now into the
validation path, and make this test assert that now - 120s is accepted while now
- 301s is rejected.
In `@crates/core/src/ipns.rs`:
- Around line 136-162: The decoder in decode_ipns_cbor_validity is accepting a
Validity field without verifying that the accompanying ValidityType is the EOL
type expected by bind_verified, which can cause non-EOL data to be treated as
valid. Update the CBOR parsing logic to require a signed ValidityType of 0
whenever Validity is present, and fail closed if ValidityType is missing,
duplicated, or any other value. Keep the duplicate-key hardening behavior and
make the check alongside the existing Validity extraction in
decode_ipns_cbor_validity so the verifier cannot interpret unsupported record
types as EOL.
In `@crates/fuse/src/replay.rs`:
- Around line 455-456: The replay flow is resolving the parent twice, which can
let metadata be merged from one verified parent CID while publishing with a
newer sequence from a later lookup. In replay.rs, update the parent resolution
path in the replay/publish logic around resolve_ipns_verified so the verified
sequence (and associated CID) from the first parent resolve is carried through
to the publish step instead of re-fetching it later. Use the existing parent
resolve result in the replay function to keep the CAS check consistent and avoid
overwriting intervening updates.
In `@crates/fuse/tests/ipns_verify_vectors.rs`:
- Around line 60-65: The vector classifier in `ipns_verify_vectors.rs` is
missing the new signed `Validity` expiry enforcement, so it can still mark
expired records as valid; update the test helper logic used for classification
to match `bind_verified` by checking the decoded IPNS CBOR `Validity` expiry
after `verify_ipns_resolve_signature` and the CID/sequence comparisons. Use the
same expiry parsing/validation path as `resolve_ipns_verified`, either by
reusing an exported helper from `cipherbox-api-client` or by duplicating the
`decode_ipns_cbor_validity` plus RFC3339 expiry check in the test until that
helper is public.
In `@packages/sdk-core/src/ipns/index.ts`:
- Around line 286-299: The IPNS validity parsing in the `verify` flow currently
relies on `new Date(validityStr)`, which accepts looser formats than Rust and
can lead to mismatched verdicts. Update the `Validity` handling to first enforce
a strict RFC3339/nanosecond UTC format before converting to epoch time, using
the existing `validityBytes`, `validityStr`, and `expiryMs` logic as the place
to add the validation. Keep the fail-closed behavior for missing or unparseable
values, but make sure only the exact timestamp shape accepted by the Rust
verifier is allowed.
In `@scripts/bench-ipns-verify.ts`:
- Around line 194-200: The benchmark cache-key simulation is using the wrong
identity shape compared with the publish path. Update the cache-hit setup in
bench-ipns-verify.ts so it matches the contract used by ipns.service.ts: use the
publish-path key format with base64(recordBytes) and the empty sequence slot,
rather than deriving the key from unmarshalled signatureV2 and TEST_SEQ. Keep
the cache simulation aligned with the same key-building logic as the real cache
to avoid skewing the benchmark.
---
Outside diff comments:
In `@crates/fuse/src/publish.rs`:
- Around line 107-119: The `resolve_sequence` handling for
`cipherbox_api_client::ipns::VerifyError::Invalid` should fail closed instead of
falling back to `get_cached`; update the match arm in `publish.rs` so
`Invalid(msg)` logs the verification failure and returns an error immediately,
while preserving cache fallback only for transient API-related errors elsewhere
in the surrounding resolve logic. Use the existing `resolve_sequence`,
`get_cached`, and `VerifyError::Invalid` symbols to keep the change scoped
correctly.
---
Nitpick comments:
In `@crates/api-client/src/ipns.rs`:
- Around line 8-9: The module doc in ipns.rs still references the removed FUSE
compatibility path, so update the top-level documentation comment to remove the
stale note about crates/fuse/src/verify.rs being a thin re-export. Keep the doc
focused on the current api-client/IPNS implementation and ensure any mention of
re-exports or deleted modules is removed from the file-level comment.
In `@crates/fuse/src/metadata.rs`:
- Around line 504-531: Update the bin first-publish comment in metadata.rs so it
matches the actual behavior in the first-publish path: the logic in
make_bin_record and is_first_bin_publish now publishes the initial bin record
with sequence 1, not 0. Keep the surrounding explanation about skipping the CAS
helper for the first publish, but revise the inline comment near
is_first_bin_publish to describe seq 1 consistently with the call that uses
make_bin_record(1).
In `@crates/fuse/src/write_ops/implementation/mkdir.rs`:
- Around line 183-199: The first-publish sequence comment in the new-folder
publish path is stale and conflicts with the current logic in mkdir.rs around
publish_ipns and coordinator.record_publish. Update the inline comment in this
branch so it correctly reflects that the initial IPNS publish uses sequence 1
and that the conflict path should describe sequence 1, not 0; keep the wording
aligned with the existing request setup and publish result handling.
In `@packages/crypto/src/__tests__/ipns-record.test.ts`:
- Around line 68-92: Add a valid-but-non-Ed25519 coverage case for
publicKeyFromIpnsName in ipns-record.test.ts so the suite exercises the
fail-closed branch, not just CID parse failure. Create a real IPNS name/PeerId
that is syntactically valid but does not inline a 32-byte Ed25519 public key,
then assert publicKeyFromIpnsName throws CryptoError for that input. Keep the
existing round-trip tests for deriveIpnsName and publicKeyFromIpnsName, and
place the new case alongside the current malformed-name test to protect the
recovery logic used by ipns-record.codec.ts.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: bef0e64a-8af9-4717-a564-e55cc298ef22
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (65)
.planning/ROADMAP.md.planning/STATE.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-01-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-01-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-02-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-02-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-03-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-03-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-04-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-04-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-05-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-05-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-06-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-06-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-07-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-07-SUMMARY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-08-PLAN.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-CONTEXT.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-PATTERNS.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-RESEARCH.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-SECURITY.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-VALIDATION.md.planning/phases/60-ipns-verification-cross-layer-closeout-desktop-and-api/60-VERIFICATION.md.planning/todos/pending/2026-06-24-desktop-root-folder-publish-conflict-failfast.md.planning/todos/pending/2026-06-24-fuse-metadata-unpin-orphaned-cid-on-verify-fail.md.planning/todos/pending/2026-06-24-replay-reuse-verified-parent-sequence.md.planning/todos/pending/2026-06-24-ts-resolve-strict-rfc3339-validity-parity.mdapps/api/src/ipns/ipns-record.codec.tsapps/api/src/ipns/ipns-verify-cache.spec.tsapps/api/src/ipns/ipns-verify-cache.tsapps/api/src/ipns/ipns.service.spec.tsapps/api/src/ipns/ipns.service.tsapps/desktop/src-tauri/src/commands/vault.rsapps/desktop/src-tauri/src/fuse/prepopulate.rsapps/web/src/hooks/useAuth.tsapps/web/src/services/vault-settings.service.tscrates/api-client/Cargo.tomlcrates/api-client/src/ipns.rscrates/core/src/ipns.rscrates/fuse/src/events.rscrates/fuse/src/fs.rscrates/fuse/src/lib.rscrates/fuse/src/metadata.rscrates/fuse/src/platform/windows/write_ops.rscrates/fuse/src/publish.rscrates/fuse/src/replay.rscrates/fuse/src/verify.rscrates/fuse/src/write_ops/implementation/mkdir.rscrates/fuse/tests/ipns_verify_vectors.rscrates/sdk/src/registry.rscrates/sdk/src/sync.rsdocs/CAPACITY.mddocs/DEVELOPMENT.mdpackages/crypto/src/__tests__/ipns-record.test.tspackages/crypto/src/index.tspackages/crypto/src/ipns/derive-name.tspackages/sdk-core/src/__tests__/ipns.test.tspackages/sdk-core/src/__tests__/vault.test.tspackages/sdk-core/src/ipns/index.tspackages/sdk-core/src/vault/index.tsrelease-please-config.jsonscripts/bench-ipns-verify.tsscripts/gen-ipns-verify-vectors.tstests/vectors/ipns/verify.jsontsconfig.scripts.json
💤 Files with no reviewable changes (2)
- crates/fuse/src/verify.rs
- crates/fuse/src/lib.rs
The web-e2e suite (workers:1, sequential, abort-on-failure) lets a single flaky early test (account-creation, login-to-vault timing, media-preview) skip the whole tail. Pre-existing (predates Phase 60); not a required PR check. Captured for separate stabilization work. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 58634ae9f834
…y tests, bench key
- Move ciborium from [dependencies] to [dev-dependencies] in crates/api-client
(used only in #[cfg(test)]; verify-path CBOR decoding delegates to cipherbox-core).
- Replace the no-op within-skew test (asserted a 2099 timestamp, would pass even
without the buffer) with two now-relative boundary tests: now-120s accepted
(inside the 300s buffer), now-301s rejected as expired. Adds a test-only
secs_to_rfc3339 helper that round-trips through the production parser.
- Align bench-ipns-verify.ts cache-key simulation with the real D-11 identity
`${ipnsName}:${''}:${base64(recordBytes)}` (empty seq slot, full record bytes),
not a signatureV2-based key.
No production behavior change. cargo test -p cipherbox-api-client 20/20; scripts typecheck clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: c7010ab27935
CodeRabbit web review on PR #555 surfaced two cross-layer / out-of-scope items deferred from the strict-cutover PR: (1) enforce ValidityType==0 in the IPNS verifier + keep cross-language vectors in expiry lockstep (Rust+TS+vectors, must stay in parity), (2) repo-wide scrub of the staging SSH host from tracked docs (pre-existing across ~10 docs; key-gated access, no credentials). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 628152972d0b
The full CI E2E dispatch on 1f8f8d8 failed on two pre-existing flakes, not regressions: web media-preview (browser-closed crash) and desktop macOS cross-client-sync (FUSE-T SMB-cache vs 120s window; macOS only). Zero production-code delta since the prior passing desktop run (88f0965) — the only Rust change is skew boundary TESTS inside mod tests. Documented both. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 9924b61bfea1
Proven pre-existing: the identical Test 5 failure occurred on main (541e4c6, run 28043695361, pre-Phase-60). Failure is a re-resolution STALL (detected but never drained on macOS FUSE-T), not slowness — a timeout bump would not fix it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 5147b4572eaa
4-agent root-cause: stall is fs.rs:392 drain_refresh_completions cache-only continue when root is in mutated_folders/publish_queue, suppressing the per-file re-resolution spawn. NOT Phase 60: fs.rs diff 4ins/13del (resolve re-point only); resolve_ipns_verified never called for the stuck file; record strict-verifiable; fails ~15% on main. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Entire-Checkpoint: 405bde2adf2c
The three todos folded into Phase 60 (IPNS verification cross-layer closeout) are fully delivered and merged in #555: - Stream A: strict-equality first-publish cutover - Stream B: desktop/SDK verified-resolve coverage - Stream C: API hot-path verify caching Move them pending/ -> completed/ to reflect delivered scope. The only remaining Phase 60 item is the operational staging-wipe smoke test (60-VERIFICATION.md, human_needed), which is not part of these todos. Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh Entire-Checkpoint: 4bfabb13c6e1 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Test 5 of test-cross-client-sync.sh flakes (~15%) on macOS only. Pinned the root cause definitively via a live local FUSE-T mount under RUST_LOG=debug: the desktop FUSE layer is correct -- it detects the remote edit and re-resolves the FilePointer to the new CID within ~5s -- but the macOS SMB client caches the file content above FUSE-T and never re-calls FUSE `open` within the window, serving the stale read. The mount is `nonotification` and FUSE-T's SMB backend does NOT honor a FUSE `inval_inode` reverse-notification (verified experimentally), so there is no reliable FUSE-side fix; the SMB cache TTL is variable and intermittently exceeds the 120s budget. Downgrade the content-sync leg to optional/warn on macOS only -- mirroring the Test 7 folder-rename leg, which already does this for the same SMB cache reason. Linux still enforces it; the Windows .ps1 still enforces it. This unblocks the CI E2E gate (the flake is platform-limited, not a sync regression). PR #555/#558's re-resolution work stays in as legitimate hardening. Also corrects the related todo's earlier (wrong) "resolved by #558" note. Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh Entire-Checkpoint: e127607fee30 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…top vault init (#566) * fix(fuse): return Err instead of inventing CAS base 0 on legacy sequence parse failure The non-strict `PublishCoordinator::resolve_sequence` cache-fallback contract must never fabricate a base sequence: on a resolve failure it returns `Ok(cached)` only when a cache entry exists, else `Err`. The strict cutover in #555 already removed the old `Legacy { sequence_number }` parse-from-string path (the source of the invented base-0 fallback CodeRabbit flagged on #553), so the source already behaves correctly. This adds the regression coverage the prior fix lacked: - resolve_sequence_falls_back_to_cache_on_resolve_failure (cache present -> Ok(cached)) - resolve_sequence_errs_on_resolve_failure_without_cache (no cache -> Err) Both drive the failure via an unroutable 127.0.0.1:1 API (mirrors the existing strict_resolve_bypasses_cache test), so they run under --features fuse/winfsp in CI. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh * fix(desktop): fail fast on root-folder publish conflict during vault init The root-folder publish Conflict arm in `initialize_vault` only logged a warning and fell through to `/vault/init`, unlike the analogous vault-key conflict arm which fails fast. An unexpected conflict on the first root-folder publish (sequence 1) was silently swallowed and initialization continued on inconsistent state. Mirror the vault-key arm: return Err and abort init. CodeRabbit finding F10 from the Phase 60 ship review; pre-existing, deferred from the strict-cutover phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh * fix(fuse): unpin orphaned blob when verified merge fails closed In `spawn_metadata_publish`, the new metadata blob (`new_cid`) is uploaded and server-pinned before the verified-resolve/merge step. The Success and persistent-Conflict exits both unpin it, but the `VerifyError::Invalid` fail-closed branch returned early without unpinning, stranding an orphaned pinned blob on every retry of a verification failure. Add a best-effort `unpin_content(&api, &new_cid)` before the early Err return, matching the error-logged-not-propagated pattern of the other exits. CodeRabbit finding F11 from the Phase 60 ship review; storage-cleanup leak, deferred from the strict-cutover phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh * fix(fuse): unpin orphaned blob in the verify Api arm too The VerifyError::Api arm of spawn_metadata_publish returned without unpinning the pre-uploaded new_cid, unlike the sibling VerifyError::Invalid arm fixed in this PR. A transient resolve API failure after the conflict branch leaks the same orphaned blob the Invalid-arm cleanup prevents. Mirror the Invalid arm: best-effort unpin_content before the early Err. CodeRabbit outside-diff finding on this PR; one-line mirror of the existing fail-closed cleanup pattern. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SDNQVLoAw4DbPrPvtQPobh --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Phase 60 (req HARD-11) makes IPNS record verification strict fail-closed across every layer — removing all degraded/legacy acceptance paths, unifying first-publish sequencing, and routing every resolve site through a single verified chokepoint. This is the D-01/D-12 lockstep cutover.
What changed (Waves 1-3, code)
cipherbox_api_client::ipnsand shared by sdk/fuse/desktop;VerifyError::Legacyremoved; resolve-side EOL/expiry enforced with a 5-minute skew buffer (D-04, D-07, D-08).1(D-02); the API rejects embedded-0first publishes (D-03).sdk-core): absent signature fields throw, strict sequence equality, Validity/EOL enforced (D-05, D-07).crates/fuse/src/verify.rsdeleted (D-04, D-08, D-09).signed_recordreturns 404; legacy enrich removed (D-06); the mandatory publish-side signature verify anchor is unchanged.docs/CAPACITY.md1.6.legacy-absentandfirst-publish-skewreclassifiedinvalid(D-10).Verification
cargo test --workspacegreen; API jest 173; sdk-core vitest 252; SDK E2E 89/89 (real client to API IPNS publish/resolve round-trip); cross-language parity vectors green.60-SECURITY.md).60-VERIFICATION.md); the one remaining is the operational staging cutover below.Review fixes folded in
The SDK E2E gate caught a real regression unit tests missed: 60-05's removal of the resolve "enrich" assumed the Ed25519 pubKey is embedded in the record, but for Ed25519 it lives in the IPNS name — so resolve returned records without signature fields and the strict client failed closed on every round-trip. Fixed by serving the authoritative DB signed record (with pubKey supplied from the publish-validated
publicKeycolumn, which the client re-checks against the name) on equal-or-higher sequence.CodeRabbit review: 7 in-scope findings fixed (duplicate-Validity-key rejection, RFC3339 strictness, registry/web/codec fail-closed hardening, bounded verify cache plus byte-exact keying test); 4 deferred as todos under
.planning/todos/pending/; 4 false positives skipped after adversarial verification.Operational follow-up (Plan 60-08, not in this diff)
The D-12 cutover requires a staging DB wipe, redeploy, and strict-verify smoke test after this lands and the cross-layer CI gates (Windows winfsp, SDK E2E, Desktop E2E) are green. That is a human-action checkpoint; the phase is operationally complete only after that sign-off. Local-dev-DB-wipe guidance is documented in
docs/DEVELOPMENT.md.🤖 Generated with Claude Code
Summary by CodeRabbit
1across desktop, web, and API.