Skip to content

fix: harden IPNS signedRecord validation, verification, and key zeroization#529

Merged
FSM1 merged 36 commits into
mainfrom
feat/crypto-signature-secret-leak-hardening
Jun 20, 2026
Merged

fix: harden IPNS signedRecord validation, verification, and key zeroization#529
FSM1 merged 36 commits into
mainfrom
feat/crypto-signature-secret-leak-hardening

Conversation

@FSM1

@FSM1 FSM1 commented Jun 19, 2026

Copy link
Copy Markdown
Owner

Summary

Phase 51: Crypto-Signature & Secret-Leak Hardening
Goal: Enforce IPNS signedRecord validation/verification and private-key zeroization (HARD-02).
Status: Verified ✓

Closes the three deferred IPNS signed-record findings from the #448 security review (S1/S2/S3). The server stays a zero-knowledge relay and the DB remains the authoritative CID source — these are defense-in-depth / correctness hardening (Medium severity). The web logger redaction + Faro transport item (todo #15) was scoped out of this phase (deferred with end-user monitoring).

Changes

51-01 — S1: publish-time embedded-vs-DTO validation

On IPNS publish, reject (400) when the embedded signed-record CID ≠ DTO metadataCid; offset-aware sequence check tolerating the first-publish pre-increment convention. Runs after the CAS check so concurrency 409s outrank tamper 400s; the pre-shipped anti-rollback 409 is preserved.
Key files: apps/api/src/ipns/ipns.service.ts (+ spec)

51-02 — S2: web fail-closed verification

resolveIpnsRecord throws on a present-but-invalid signature (was warn + return); absent signature fields → allow + signatureVerified=false + warn.
Key files: apps/web/src/services/ipns.service.ts (+ test)

51-03 — S2/S3: Rust verification + zeroization

verify_ipns_resolve_signature + signature fields on IpnsResolveResponse; the FUSE folder-key descent honors signatureVerified. unwrap_key returns Zeroizing<Vec<u8>>; folder keys threaded as Zeroizing through the BFS queue / getters.
Key files: crates/api-client/*, crates/crypto/src/ecies.rs, crates/fuse/src/lib.rs

51-04 — S3: sdk-core zeroization sweep

fill(0) in finally on createAndPublishIpnsRecord / publishVaultKeyBlob; updateFolderMetadataAndPublish documented caller-owns-key skip + guard test; S2 resolve regression guard.
Key files: packages/sdk-core/src/{ipns,vault,folder}/index.ts (+ tests)

Post-review hardening

  • Desktop build fix: unwrap_key Zeroizing cascade into commands/vault.rs.
  • Quality: deduped the IPNS base64 decode in crates/api-client.
  • Zeroization sweep completion (CodeRabbit): SDK-state root_folder_keyZeroizing, folder-key replay cache → Zeroizing, reencrypt worker + FUSE operations.rs transient [u8;32] arrays → Zeroizing (macOS + WinFsp paths in lockstep).

SDK E2E regression fix (commit e1f822e2c)

The initial S3 sweep added params.ipnsPrivateKey.fill(0) to createAndPublishIpnsRecord,
but that function is a callee receiving a caller-owned, reused buffer (the client caches
per-folder IPNS keys in its folderTree, zeroed only on destroy(); publishWithCas reuses
the buffer across CAS retries). Zeroing there corrupted the key on the second+ publish, so the
API rejected it (400 publicKey does not correspond to the given ipnsName) — breaking 48/89 SDK
E2E tests. Fixed by removing the callee zeroization (matching publishWithCas's documented
caller-owns-key contract); no key leak — terminal owners still zero. Reproduced and verified
green against the full local stack: SDK E2E 89/89, sdk-core 209/209, api 893/893.

Requirements Addressed

  • HARD-02 — Crypto-signature & secret-leak hardening: IPNS signedRecord validation, signature verification, private-key zeroization.

Verification

  • Automated: 893 jest (api) + 60 vitest (web) + 71 cargo (Rust) + 209 vitest (sdk-core) + 89 SDK E2E — all green; cargo check --workspace green.
  • Goal-backward verifier: 10/10 must-haves PASS (51-VERIFICATION.md).
  • Security audit: SECURED — 15/15 threats closed (51-SECURITY.md).
  • Nyquist validation: compliant, 0 gaps (51-VALIDATION.md).
  • Windows WinFsp build (windows/operations.rs zeroization) — not locally buildable on macOS; gated by CI.

Key Decisions

  • D-01 (S1): strict embedded-CID reject + offset-aware sequence (first-publish tolerant).
  • D-02 (S2): fail closed on a present-but-invalid signature, everywhere.
  • D-03 (S2): absent signature fields → allow + flag — avoids bricking legacy vaults; DB CID is authoritative.
  • D-04 (S2): Rust verification included now (web + sdk-core + Rust consistent).
  • D-05 (S3): exhaustive zeroization with a caller-owns-key convention + guard tests.

Deferred (captured todos)

  • FUSE per-file IPNS publish records the sequence as published on a server Conflict — pre-existing (#352), FUSE-durability domain (Phase 52).
  • IPNS resolve verify chokepoint coverage (other Rust resolve sites) + web/sdk-core resolve dedup + shared cross-language verify vectors.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • Security
    • Enforced IPNS record validation at publish-time to prevent tampering with embedded metadata and sequence numbers.
    • Strengthened signature verification to fail-closed when cryptographic signatures are present but invalid.
    • Hardened secret key material handling across the system to ensure sensitive cryptographic keys are securely wiped from memory after use.

FSM1 and others added 30 commits June 19, 2026 20:15
Entire-Checkpoint: 3af957aad5e8
Entire-Checkpoint: 63ac3e2a0b38
Entire-Checkpoint: 3972a20de96b
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- CID mismatch throws BadRequestException (400)
- Non-first-publish sequence mismatch throws BadRequestException (400)
- First-publish tolerates embedded seq 0n and 1n
- Valid CID+seq pass-through succeeds
- Anti-rollback ConflictException (409) regression guard
…dation

- Add BadRequestException (400) when signedRecord embedded CID differs from
  metadataCid DTO (strict check via /ipfs/ regex extraction)
- Add offset-aware BadRequestException (400) when embedded sequence disagrees
  with expectedSequenceNumber: first-publish tolerates 0n/1n difference,
  subsequent publishes require exactly expectedSeq+1n
- Reuse already-parsed incoming record from anti-rollback block (parseIpnsRecord
  called at most once per request)
- CAS ConflictException (409) fires before S1 sequence check to preserve
  concurrent-modification semantics
- Fix pre-existing tests: align parseIpnsRecord mock embedded CID and sequence
  with DTO values now that S1 validates them (Rule 1 auto-fix)
- 79/79 tests pass; S1 CID + offset-aware sequence gates green
- 2 Rule-1 auto-fixes documented (CAS ordering, test mock alignment)
- Add 6 vitest cases covering fail-closed behavior for resolveIpnsRecord
- Tests 1-2 (present-but-invalid throw, key-substitution throw) FAIL as expected
- Tests 3-6 (absent-fields allow, valid pass, 404 null, non-404 propagate) pass
- Uses .test.ts suffix required by web vitest include pattern
- Remove swallowing try/catch from inner verification block
- Throw on present-but-invalid signature (D-02, S2 fix)
- Throw on pubKey to ipnsName mismatch (D-02, key substitution guard)
- Keep allow+flag path for absent signature fields (D-03, legacy compat)
- Leave outer 404 catch unchanged so only 404 maps to null
- Mirrors sdk-core canonical behavior exactly
- Add cipherbox-crypto workspace dep to crates/api-client/Cargo.toml
- Add signature_v2, data, pub_key Option<String> fields to IpnsResolveResponse
- Add stub verify_ipns_resolve_signature (unimplemented!) with 5 failing tests
- Tests: deserialize sig fields, absent→None, invalid→Some(false), valid→Some(true), name mismatch→Some(false)
- D-03: Ok(None) when any sig field absent (backward-compat allow+flag)
- D-02: Ok(Some(false)) on invalid Ed25519 signature (fail-closed)
- D-04: Ok(Some(true)) on valid sig + pubKey derives to matching IPNS name
- Ok(Some(false)) on pubKey length != 32 or name mismatch
- All 6 unit tests pass (GREEN)
S3/D-05: close raw Vec<u8> key escapes across Rust crates

- ecies::unwrap_key now returns Zeroizing<Vec<u8>> (zeroize on drop)
- resolve_folder_key BFS queue is VecDeque<(String, Zeroizing<Vec<u8>>)>
- get_folder_key returns Option<Zeroizing<Vec<u8>>>
- spawn_file_meta_reencrypt params source/dest_folder_key are Zeroizing
- UploadJournalResult.folder_key_for_file_meta is Option<Zeroizing<Vec<u8>>>
- Remove redundant Zeroizing::new() wrappers around unwrap_key call sites
- Update type annotations in write_ops and windows/write_ops
- Fix cross_language.rs and ecies unit tests for new return type
S2/D-04: gate resolve_folder_key BFS descent on signature check

- Call verify_ipns_resolve_signature after each resolve_ipns in BFS
- Ok(None): warn and continue (D-03, absent fields = legacy record)
- Ok(Some(false)): return Err, fail closed (D-02, invalid signature)
- Ok(Some(true)): proceed (signature valid + name binding confirmed)
- Err: return Err surfacing the verification failure

cargo test -p cipherbox-api-client -p cipherbox-fuse: 66 passed, 0 failed
- Add 51-03-SUMMARY.md with task outcomes, test results, deviations
- Update STATE.md plan counter (3→4), metrics, key decisions
…on guard

- Test A: assert ipnsPrivateKey buffer zeroed after successful createAndPublishIpnsRecord
- Test B: assert ipnsPrivateKey buffer zeroed even when publish throws (finally path)
- Test C: assert vaultKeyKeypair.privateKey zeroed after publishVaultKeyBlob
- Test D: S2 regression guard - resolveIpnsRecord throws on present-but-invalid signature
- Tests A/B/C fail RED (no fill(0) yet); Test D passes (S2 already correct)

S3/D-05 enforcement guard per T-47-01 caller-owns-key convention
…ultKeyBlob

- Wrap createAndPublishIpnsRecord body in try/finally; finally zeroes params.ipnsPrivateKey
- Wrap publishVaultKeyBlob publish logic in try/finally; finally zeroes vaultKeyKeypair.privateKey
- Both functions are the terminal owners of their respective key buffers (D-05/T-47-01)
- S3 zeroization guard tests A/B/C now pass GREEN; S2 regression test D still passes
…ecision

- Add T-47-01/D-05 skip comment to folder/index.ts: caller (client.ts folderTree)
  retains ownership of ipnsPrivateKey and folderKey across the session lifetime
- Add SKIP guard test to folder.test.ts: asserts keys are UNCHANGED after return,
  documenting deliberate non-zeroing and preventing accidental future fill(0)
- Contrast with updateFileMetadata which DOES zero (per-use key, terminal consumer)
- Full sdk-core suite: 209 tests passing
- 51-04-SUMMARY.md: zeroization convention applied to ipns + vault paths
- STATE.md: advance to plan 4/4 complete, add 51-04 decision
- Full sdk-core suite: 209 tests passing
Re-stage cargo fmt reformatting of the in-scope 51-03 files that a later
pre-commit hook left as uncommitted drift in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
S1/S2/S3 hardening for HARD-02 verified: API embedded-vs-DTO 400,
web + Rust fail-closed IPNS verification, sdk-core + Rust key zeroization.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: ecf81ac2076b
Completes Phase 51 D-05 exhaustive zeroization with three defense-in-depth
memory-hygiene fixes flagged by CodeRabbit:

- SDK state root_folder_key field is now Option<Zeroizing<Vec<u8>>> instead of
  plain Vec, so the decrypted root key is wiped on drop. Desktop vault.rs stores
  the unwrap_key Zeroizing directly; mount_filesystem (macOS + winfsp) takes
  Zeroizing and no longer re-wraps.
- replay_for_vault folder-key cache is HashMap<String, Zeroizing<Vec<u8>>>, so
  cached folder keys are zeroized when the cache drops at end of replay.
- spawn_file_meta_reencrypt wraps its fixed-size [u8; 32] source/dest key copies
  in Zeroizing.

root_ipns_private_key is out of scope (not flagged, HKDF-derived).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: beeb90918390
Wrap transient [u8; 32] key arrays built via try_into() from decrypted
key material (file keys, folder keys, IPNS keys) in zeroize::Zeroizing so
plaintext key bytes are wiped on scope exit instead of lingering on the
stack. Mirrors the established spawn_file_meta_reencrypt pattern. Applies
to both the macOS/Linux FUSE and Windows WinFsp operation paths, keeping
the two platforms in lockstep. Completes the Phase 51 D-05 exhaustive
zeroization sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: b05ee7305656
@github-actions github-actions Bot added the release:tee-worker:fix Patch version bump (bug fix) for tee-worker label Jun 19, 2026
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Release Preview

Package Bump Label Source
api minor release:api:feat Direct (feat commit)
cipherbox-core patch release:cipherbox-core:fix Cascade (cipherbox-crypto minor)
cipherbox-crypto minor release:cipherbox-crypto:feat Direct (feat commit)
cipherbox-fuse minor release:cipherbox-fuse:feat Direct (feat commit)
cipherbox-sdk patch release:cipherbox-sdk:fix Direct (fix commit)
desktop minor release:desktop:fix Direct (fix commit)
sdk patch release:sdk:fix Cascade (sdk-core minor)
sdk-core minor release:sdk-core:feat Direct (feat commit)
tee-worker patch release:tee-worker:fix Cascade (sdk-core minor)
web minor release:web:feat Direct (feat commit)

Cascade Details

  • cipherbox-crypto minor -> cipherbox-core patch (direct dependency)
  • sdk-core minor -> sdk patch (direct dependency)
  • sdk-core minor -> tee-worker patch (direct dependency)

@codecov

codecov Bot commented Jun 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 57.93103% with 305 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.02%. Comparing base (b7acb57) to head (20a2ea2).

Files with missing lines Patch % Lines
crates/fuse/src/operations.rs 1.63% 181 Missing ⚠️
crates/fuse/src/lib.rs 27.86% 44 Missing ⚠️
crates/fuse/src/inode.rs 85.18% 40 Missing ⚠️
crates/fuse/src/write_ops.rs 0.00% 19 Missing ⚠️
crates/api-client/src/ipns.rs 93.87% 9 Missing ⚠️
crates/api-client/src/types.rs 0.00% 6 Missing ⚠️
apps/desktop/src-tauri/src/fuse/mod.rs 0.00% 4 Missing ⚠️
apps/api/src/ipns/ipns.service.ts 88.23% 1 Missing and 1 partial ⚠️

❌ Your patch check has failed because the patch coverage (57.93%) is below the target coverage (80.00%). You can increase the patch coverage or adjust the target coverage.

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #529      +/-   ##
==========================================
+ Coverage   64.78%   67.02%   +2.23%     
==========================================
  Files         143      159      +16     
  Lines       11132    18187    +7055     
  Branches     1252     1258       +6     
==========================================
+ Hits         7212    12190    +4978     
- Misses       3676     5752    +2076     
- Partials      244      245       +1     
Flag Coverage Δ
api 85.33% <88.23%> (+<0.01%) ⬆️
api-client 85.33% <88.23%> (+<0.01%) ⬆️
core 85.33% <88.23%> (+<0.01%) ⬆️
crypto 85.33% <88.23%> (+<0.01%) ⬆️
desktop 18.21% <0.00%> (-13.11%) ⬇️
rust 62.37% <57.52%> (?)
sdk 85.33% <88.23%> (+<0.01%) ⬆️
sdk-core 85.33% <88.23%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
apps/desktop/src-tauri/src/commands/vault.rs 0.00% <ø> (ø)
crates/crypto/src/ecies.rs 100.00% <100.00%> (ø)
crates/fuse/src/journal_helpers.rs 90.36% <100.00%> (ø)
crates/sdk/src/state.rs 100.00% <100.00%> (ø)
apps/api/src/ipns/ipns.service.ts 88.39% <88.23%> (-0.02%) ⬇️
apps/desktop/src-tauri/src/fuse/mod.rs 16.23% <0.00%> (+16.23%) ⬆️
crates/api-client/src/types.rs 0.00% <0.00%> (ø)
crates/api-client/src/ipns.rs 68.57% <93.87%> (ø)
crates/fuse/src/write_ops.rs 23.98% <0.00%> (ø)
crates/fuse/src/inode.rs 87.76% <85.18%> (ø)
... and 2 more

... and 62 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@FSM1 FSM1 force-pushed the feat/crypto-signature-secret-leak-hardening branch from d8ca746 to b90cbb1 Compare June 19, 2026 23:56
@github-actions github-actions Bot added release:api:feat Minor version bump (new feature) for api release:web:feat Minor version bump (new feature) for web release:cipherbox-crypto:feat Minor version bump (new feature) for cipherbox-crypto release:cipherbox-fuse:feat Minor version bump (new feature) for cipherbox-fuse release:cipherbox-core:fix Patch version bump (bug fix) for cipherbox-core and removed release:api:refactor Patch version bump (code refactoring) for api release:web:fix Patch version bump (bug fix) for web release:cipherbox-fuse:fix Patch version bump (bug fix) for cipherbox-fuse labels Jun 19, 2026
@greptile-apps

greptile-apps Bot commented Jun 20, 2026

Copy link
Copy Markdown

Greptile Summary

This PR closes three deferred IPNS security findings from the #448 review: publish-time embedded-CID and sequence validation (S1), fail-closed signature verification in web/SDK-core (S2), and Rust signature verification plus exhaustive key zeroization (S3). It also fixes an SDK E2E regression introduced by a callee incorrectly zeroing a caller-owned key buffer.

  • S1 (API): publishRecord now validates that the embedded signedRecord CID matches the DTO metadataCid and that the embedded sequence number matches expectedSequenceNumber+1 (or 0/1 tolerance on first publish). CAS 409s still outrank tamper 400s.
  • S2 (web + sdk-core + Rust): resolveIpnsRecord now throws on any present-but-invalid signature (fail closed), allows absent fields with a warning (D-03 legacy), and rejects partial signature fields as a downgrade-attack vector.
  • S3 (Rust + SDK): unwrap_key returns Zeroizing<Vec<u8>>; BFS queue, folder-key cache, and KeyState.root_folder_key are all wrapped in Zeroizing; publishVaultKeyBlob zeroes the derived IPNS private key in finally; updateFolderMetadataAndPublish deliberately does NOT zero (caller-owns-key contract, documented and guard-tested).

Confidence Score: 5/5

Safe to merge. The changes are defense-in-depth hardening with no regressions: the SDK E2E callee-zeroing bug is fixed, all 89 SDK E2E and 893 API tests pass, and the fail-closed logic is consistently applied across all three layers.

The three IPNS security findings are addressed with matching tests at every layer (API spec, web vitest, sdk-core vitest, Rust unit tests). The zeroization cascade is complete and the caller-owns-key contract is correctly documented and guard-tested. No logic errors were found in the S1 ordering (anti-rollback → CAS → CID → seq), the S2 partial-field fail-closed path, or the Rust BFS verification integration.

No files require special attention. The inode.rs and operations.rs changes are formatting-only or straightforward Zeroizing propagation consistent with the rest of the sweep.

Important Files Changed

Filename Overview
apps/api/src/ipns/ipns.service.ts S1 embedded-vs-DTO CID and sequence validation added; anti-rollback parse reused to avoid double-parse; CAS 409 correctly outranks S1 400; first-publish diff=0
apps/web/src/services/ipns.service.ts resolveIpnsRecord now throws on partial or invalid signature fields (fail-closed D-02); legacy absent fields allowed with warning (D-03); 404 detection correctly checks both error.status and error.response?.status for axios compat.
packages/sdk-core/src/ipns/index.ts resolveIpnsRecord mirrors the web service fail-closed logic; createAndPublishIpnsRecord correctly does not zero the caller-owned key buffer, with thorough documentation.
packages/sdk-core/src/vault/index.ts publishVaultKeyBlob wraps its body in try/finally and zeroes the internally-derived vaultKeyKeypair.privateKey on all exit paths; correct terminal-owner pattern.
crates/api-client/src/ipns.rs verify_ipns_resolve_signature correctly distinguishes absent (Ok(None)), partial/invalid (Ok(Some(false))), and valid+name-bound (Ok(Some(true))); 7 unit tests cover all branches including cross-language crypto vectors.
crates/fuse/src/lib.rs resolve_folder_key BFS now calls verify_ipns_resolve_signature before trusting each resolved CID; resolve_folder_key_cached return type upgraded to Zeroizing<Vec>; folder-key BFS queue and replay cache wrapped in Zeroizing.
crates/crypto/src/ecies.rs unwrap_key return type changed from Vec to Zeroizing<Vec> via a single .map(Zeroizing::new); all call sites in the workspace updated accordingly.
crates/sdk/src/state.rs KeyState.root_folder_key field type upgraded to Zeroizing<Vec>; tests updated to construct and compare through Zeroizing wrappers.
packages/sdk-core/src/folder/index.ts updateFolderMetadataAndPublish documented not to zero caller-owned keys; guard test in folder.test.ts asserts keys are unchanged after the call.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Client
    participant API as API (ipns.service.ts)
    participant Web as Web/SDK-core (resolveIpnsRecord)
    participant FUSE as FUSE (resolve_folder_key)
    participant Crypto as crates/crypto (unwrap_key)

    Note over Client,API: S1 — Publish-time validation
    Client->>API: publishRecord(signedRecord, metadataCid, expectedSeq)
    API->>API: parseIpnsRecord → incomingParsed
    API->>API: "Anti-rollback: incoming.seq >= stored.seq (409 on regress)"
    API->>API: "CAS: expectedSeq == DB seq (409 on mismatch)"
    API->>API: "S1 CID: incomingParsed.value == /ipfs/metadataCid (400 on mismatch)"
    API->>API: "S1 seq: embedded == expectedSeq+1 (400 on mismatch)"
    API-->>Client: 200 OK / 400 / 409

    Note over Web,Crypto: S2 — Resolve fail-closed (web + sdk-core)
    Web->>Web: resolveIpnsRecord(ipnsName)
    Web->>Web: Check sig fields: all absent → warn+allow (D-03)
    Web->>Web: Partial fields → throw (downgrade attack)
    Web->>Web: verifyIpnsSignature(sig, data, pubKey)
    Web->>Web: Invalid sig → throw (D-02)
    Web->>Web: "deriveIpnsName(pubKey) != ipnsName → throw"
    Web-->>Web: "return { cid, signatureVerified: true/false }"

    Note over FUSE,Crypto: S3 — Rust verify + zeroization
    FUSE->>FUSE: resolve_folder_key BFS loop
    FUSE->>FUSE: verify_ipns_resolve_signature(resp, current_ipns)
    FUSE->>FUSE: Ok(None) → warn+continue (D-03)
    FUSE->>FUSE: Ok(Some(false)) → Err (fail closed)
    FUSE->>Crypto: "unwrap_key(enc_key, priv_key) → Zeroizing<Vec<u8>>"
    Crypto-->>FUSE: "Zeroizing<Vec<u8>> (zeroed on drop)"
    FUSE->>FUSE: BFS queue / cache hold Zeroizing keys
Loading
%%{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
    participant API as API (ipns.service.ts)
    participant Web as Web/SDK-core (resolveIpnsRecord)
    participant FUSE as FUSE (resolve_folder_key)
    participant Crypto as crates/crypto (unwrap_key)

    Note over Client,API: S1 — Publish-time validation
    Client->>API: publishRecord(signedRecord, metadataCid, expectedSeq)
    API->>API: parseIpnsRecord → incomingParsed
    API->>API: "Anti-rollback: incoming.seq >= stored.seq (409 on regress)"
    API->>API: "CAS: expectedSeq == DB seq (409 on mismatch)"
    API->>API: "S1 CID: incomingParsed.value == /ipfs/metadataCid (400 on mismatch)"
    API->>API: "S1 seq: embedded == expectedSeq+1 (400 on mismatch)"
    API-->>Client: 200 OK / 400 / 409

    Note over Web,Crypto: S2 — Resolve fail-closed (web + sdk-core)
    Web->>Web: resolveIpnsRecord(ipnsName)
    Web->>Web: Check sig fields: all absent → warn+allow (D-03)
    Web->>Web: Partial fields → throw (downgrade attack)
    Web->>Web: verifyIpnsSignature(sig, data, pubKey)
    Web->>Web: Invalid sig → throw (D-02)
    Web->>Web: "deriveIpnsName(pubKey) != ipnsName → throw"
    Web-->>Web: "return { cid, signatureVerified: true/false }"

    Note over FUSE,Crypto: S3 — Rust verify + zeroization
    FUSE->>FUSE: resolve_folder_key BFS loop
    FUSE->>FUSE: verify_ipns_resolve_signature(resp, current_ipns)
    FUSE->>FUSE: Ok(None) → warn+continue (D-03)
    FUSE->>FUSE: Ok(Some(false)) → Err (fail closed)
    FUSE->>Crypto: "unwrap_key(enc_key, priv_key) → Zeroizing<Vec<u8>>"
    Crypto-->>FUSE: "Zeroizing<Vec<u8>> (zeroed on drop)"
    FUSE->>FUSE: BFS queue / cache hold Zeroizing keys
Loading

Reviews (4): Last reviewed commit: "fix: close empty-string signature downgr..." | Re-trigger Greptile

Comment thread crates/fuse/src/lib.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (4)
crates/fuse/src/operations.rs (1)

224-237: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not record a successful publish after a conflict.

If publish_ipns returns Conflict, the per-file IPNS update was not accepted, but this function still falls through to coordinator.record_publish(file_ipns_name, new_seq) and logs success. Return an error or retry instead, otherwise the local sequence cache can advance past the server and mask a lost metadata publish.

Proposed fix
         match cipherbox_api_client::ipns::publish_ipns(api, &req)
             .await
             .map_err(|e| format!("{}", e))?
         {
             cipherbox_api_client::PublishResult::Success => {}
-            cipherbox_api_client::PublishResult::Conflict { .. } => {
-                log::warn!(
-                    "Unexpected conflict on per-file IPNS publish for {}",
-                    file_ipns_name
-                );
+            cipherbox_api_client::PublishResult::Conflict {
+                current_sequence_number,
+            } => {
+                return Err(format!(
+                    "per-file IPNS publish conflict for {} (server sequence {})",
+                    file_ipns_name, current_sequence_number
+                ));
             }
         }
 
         coordinator.record_publish(file_ipns_name, new_seq);
🤖 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/operations.rs` around lines 224 - 237, When publish_ipns
returns PublishResult::Conflict in the match statement, the code currently logs
a warning but then falls through to call coordinator.record_publish, which
incorrectly marks the publish as successful even though the server rejected it.
Modify the Conflict branch to return an error or implement a retry mechanism
instead of continuing, ensuring that coordinator.record_publish is only called
for the Success case to prevent the local sequence cache from advancing beyond
what the server has accepted.
crates/fuse/src/platform/windows/operations.rs (1)

383-397: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return on per-file IPNS conflicts before recording success.

The Conflict arm only logs, then Line 396 records new_seq and Line 397 logs success. If the server rejected the publish, the coordinator advances to a sequence that was never accepted and the new FileMetadata CID can be lost.

🐛 Proposed fix
         match cipherbox_api_client::ipns::publish_ipns(api, &req)
             .await
             .map_err(|e| e.to_string())?
         {
-            cipherbox_api_client::PublishResult::Success => {}
-            cipherbox_api_client::PublishResult::Conflict { .. } => {
-                log::warn!(
-                    "Unexpected conflict on per-file IPNS publish for {}",
-                    file_ipns_name
-                );
+            cipherbox_api_client::PublishResult::Success => {
+                coordinator.record_publish(file_ipns_name, new_seq);
+                log::info!("Per-file IPNS publish succeeded for {}", file_ipns_name);
+            }
+            cipherbox_api_client::PublishResult::Conflict { current_sequence_number } => {
+                return Err(format!(
+                    "Per-file IPNS publish conflict for {}: server has sequence {}",
+                    file_ipns_name, current_sequence_number
+                ));
             }
         }
-
-        coordinator.record_publish(file_ipns_name, new_seq);
-        log::info!("Per-file IPNS publish succeeded for {}", file_ipns_name);
🤖 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/platform/windows/operations.rs` around lines 383 - 397, The
match statement handling the PublishResult for IPNS publish needs to be modified
to return early on conflict. Currently, when a PublishResult::Conflict occurs,
the code logs a warning but then continues to execute coordinator.record_publish
and the success log message. This means the coordinator advances the sequence
number even though the server rejected the publish, potentially losing the new
FileMetadata CID. Add a return statement in the Conflict arm of the match
statement to exit the function immediately after logging the conflict warning,
preventing the subsequent record_publish call and success log from executing.
crates/fuse/src/inode.rs (1)

399-412: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset identity-specific state when only the display-name fallback matches.

The new stable-ID lookup still falls back to find_child(...), but later code treats that inode as the same folder/file identity. If remote metadata replaces foo with a different ipns_name / file_meta_ipns_name but keeps the same display name, folders can preserve stale loaded children, and resolved files can keep the old CID when modified_at is unchanged. Track whether the stable ID actually matched; if not, clear folder loaded state and force file re-resolution/key refresh.

🐛 Proposed direction
-                    let existing_ino = ipns_to_ino
+                    let matched_by_stable_id = ipns_to_ino.contains_key(&folder.ipns_name);
+                    let existing_ino = ipns_to_ino
                         .get(&folder.ipns_name)
                         .copied()
                         .or_else(|| self.find_child(parent_ino, &folder.name));
@@
-                    let (existing_children, was_loaded) = if existing_ino.is_some() {
+                    let (existing_children, was_loaded) = if existing_ino.is_some()
+                        && matched_by_stable_id
+                    {
                         let old = self.inodes.get(&ino);
                         let ch = old.and_then(|o| o.children.clone());
-                                if modified != existing.attr.mtime {
+                                let pointer_changed = file_meta_ipns_name.as_deref()
+                                    != Some(file_pointer.file_meta_ipns_name.as_str());
+                                if pointer_changed || modified != existing.attr.mtime {
                                     log::info!(
-                                        "File '{}': modified_at changed (remote edit detected), marking for re-resolution",
+                                        "File '{}': identity or modified_at changed, marking for re-resolution",
                                         file_pointer.name
                                     );
                                     (true, None)

Also applies to: 461-475, 515-580

🤖 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/inode.rs` around lines 399 - 412, The code currently treats
any inode found by either the ipns_to_ino lookup or the find_child() fallback as
the same identity, but when the ipns_name has changed (matched only by display
name via find_child), the inode's identity has actually changed and
identity-specific state must be reset. Distinguish between a match from
ipns_to_ino (stable ID match) and a match only from find_child() (display name
fallback), and when only the fallback matches, clear folder loaded state and
force file re-resolution by refreshing the CID and metadata keys. Apply this fix
consistently across all affected code sections that handle folder and file
identity tracking, including the sections around lines 461-475 and 515-580.
crates/fuse/src/lib.rs (1)

1717-1741: ⚠️ Potential issue | 🟠 Major

Keep cached folder-key returns wrapped in Zeroizing.

Lines 1725 and 1741 clone the cached key into a plain Vec<u8>, creating an unzeroized plaintext copy that persists until allocator reuse. Return Zeroizing<Vec<u8>> instead and use .clone() to preserve the zeroizing wrapper. All call sites remain compatible: functions expecting &[u8] will receive it via deref coercion, and the test assertion will work through PartialEq.

🛡️ Proposed fix
 async fn resolve_folder_key_cached(
     cache: &mut std::collections::HashMap<String, Zeroizing<Vec<u8>>>,
     api: &ApiClient,
     private_key: &[u8],
     root_folder_key: &[u8],
     root_ipns_name: &str,
     folder_ipns_name: &str,
-) -> Result<Vec<u8>, String> {
+) -> Result<Zeroizing<Vec<u8>>, String> {
     if let Some(key) = cache.get(folder_ipns_name) {
-        return Ok(key.to_vec());
+        return Ok(key.clone());
     }
     let key = resolve_folder_key(
         api,
         private_key,
         root_folder_key,
         root_ipns_name,
         folder_ipns_name,
     )
     .await?;
     let cached = cache
         .entry(folder_ipns_name.to_string())
         .or_insert_with(|| key.clone());
-    Ok(cached.to_vec())
+    Ok(cached.clone())
 }
🤖 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/lib.rs` around lines 1717 - 1741, The function currently
returns unzeroized plaintext copies of the cached key by calling `.to_vec()` on
the `Zeroizing<Vec<u8>>` values at two locations (line 1725 when returning from
cache and line 1741 when returning the resolved key). Change the return type of
this function from `Result<Vec<u8>, String>` to `Result<Zeroizing<Vec<u8>>,
String>`, and replace both `.to_vec()` calls with `.clone()` to preserve the
zeroizing wrapper throughout the key's lifetime. Call sites will remain
compatible because `Zeroizing<Vec<u8>>` can be automatically deref coerced to
`&[u8]` where needed.
🧹 Nitpick comments (2)
packages/sdk-core/src/__tests__/ipns.test.ts (1)

201-221: ⚡ Quick win

Make the S2 regression cover a distinct branch.

Lines 183-199 already assert the same present-fields + verifyEd25519=false fail-closed path. Either remove this duplicate or change it to cover a missing edge, such as partial signature fields, invalid base64 propagation, or verifying deriveIpnsName is not called after signature failure.

As per coding guidelines, **/*.test.ts should 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/sdk-core/src/__tests__/ipns.test.ts` around lines 201 - 221, The
test 'S2 regression: throws on present-but-invalid signature (fail-closed)' is
duplicating the test coverage already established in lines 183-199, both testing
the same scenario where signature fields are present but verifyEd25519 returns
false. Either remove this duplicate test entirely, or modify it to cover a
distinct missing edge case such as handling partial/incomplete signature fields,
verifying invalid base64 encoding propagation, or confirming that deriveIpnsName
is not called when signature verification fails. Ensure the test covers a
meaningful scenario distinct from the existing test to maintain code quality
standards.

Source: Coding guidelines

packages/sdk-core/src/__tests__/vault.test.ts (1)

56-83: ⚡ Quick win

Add a rejected-path guard for the finally zeroization.

This covers successful publish only, but the production contract says the derived private key is zeroed on all exit paths. Add a rejection case for addToIpfs or createAndPublishIpnsRecord and assert privateKeyBuf is still cleared.

Example failure-path coverage
+    it('D: zeroes the derived private key when publish fails', async () => {
+      const { deriveVaultKeyIpnsKeypair } = await import('`@cipherbox/crypto`');
+      const privateKeyBuf = new Uint8Array(32).fill(0xcd);
+
+      vi.mocked(deriveVaultKeyIpnsKeypair).mockResolvedValueOnce({
+        ipnsName: 'k51vaultkey-fail',
+        publicKey: new Uint8Array(32).fill(1),
+        privateKey: privateKeyBuf,
+      });
+      vi.mocked(addToIpfs).mockRejectedValueOnce(new Error('upload failed'));
+
+      await expect(
+        publishVaultKeyBlob({
+          userPrivateKey: new Uint8Array(32).fill(0x01),
+          userPublicKey: new Uint8Array(33).fill(0x02),
+          rootFolderKey: new Uint8Array(32).fill(0x03),
+          ctx: mockCtx,
+        })
+      ).rejects.toThrow('upload failed');
+
+      expect(privateKeyBuf.every((b) => b === 0)).toBe(true);
+    });

As per coding guidelines, **/*.test.ts should focus on edge cases and test quality.

🤖 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/sdk-core/src/__tests__/vault.test.ts` around lines 56 - 83, The
current test only verifies that the private key buffer is zeroed on the
successful publish path, but the code should zero it on all exit paths including
when operations fail. Add a new test case that mocks either addToIpfs or
createAndPublishIpnsRecord to reject or throw an error, then calls
publishVaultKeyBlob and verifies that the privateKeyBuf is still zeroed to zero
bytes even when the operation fails. This ensures the finally block zeroization
works correctly for both success and error paths.

Source: Coding guidelines

🤖 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 `@apps/api/src/ipns/ipns.service.spec.ts`:
- Around line 1491-1499: The test setup for mockParseIpnsRecord uses
mockResolvedValueOnce which depends on call execution order, but
Promise.allSettled only guarantees result order, causing test flakiness. Replace
the two chained mockResolvedValueOnce calls on mockParseIpnsRecord with a
mockImplementation that inspects the distinct record bytes (file vs folder)
passed as arguments and returns the appropriate sequence value based on the
record content rather than call order.

In `@apps/api/src/ipns/ipns.service.ts`:
- Around line 264-269: The validation in the IPNS record check is too permissive
because it only extracts and compares the first CID substring rather than
validating the entire signed value. Instead of using the regex to extract
embeddedCid and comparing it to metadataCid, construct the expected full path as
`/ipfs/${metadataCid}` and directly compare it to the complete
incomingParsed.value to ensure the full signed record matches exactly what will
be published, preventing divergence between database and network resolution.
- Around line 271-294: The sequence validation logic in the S1 sequence check
block only validates the embedded sequence when expectedSequenceNumber is
provided, but omitting this parameter should not skip validation entirely.
Extend the validation to also handle the case when expectedSequenceNumber is
undefined by using the DB sequence as the baseline: for first publish (when
existing is false), validate that incomingParsed.sequence equals 1n, and for
subsequent publishes, validate that incomingParsed.sequence equals the existing
record's sequence plus 1n. Additionally, add idempotent republish handling to
allow equal-sequence publishes without incrementing the stored sequence. This
ensures that arbitrary sequence numbers cannot be embedded when CAS is omitted,
preventing anti-rollback violations.

In `@apps/desktop/src-tauri/src/fuse/mod.rs`:
- Line 79: The root_folder_key and derived subfolder keys are being stored in
plain [u8; 32] arrays at lines 79, 201, and 239, which will not be securely
wiped from memory when dropped. Replace all occurrences of plain [u8; 32] arrays
used for storing these derived folder keys with Zeroizing<[u8; 32]> to ensure
the sensitive key material is properly zeroed out of memory, consistent with the
approach already used in crates/fuse/src/operations.rs.

In `@apps/web/src/services/__tests__/ipns.service.test.ts`:
- Around line 120-128: The test mocks an incorrect error structure by setting
error.status directly, but real Axios errors from the customInstance wrapper use
error.response.status instead. Update the existing test case to mock
error.response = { status: 404 } to match the actual client error shape.
Additionally, update the catch block in the resolveIpnsRecord function to check
both error.status and error.response?.status patterns by extracting the status
code with const status = anyError.status ?? anyError.response?.status before
comparing to 404. Finally, add a separate test case to verify that the fallback
error.status pattern is also handled correctly.

In `@apps/web/src/services/ipns.service.ts`:
- Around line 178-195: The current code in the signature verification block only
handles the case where all three fields (signatureV2, data, and pubKey) are
present, and otherwise treats the response as a legacy record. However,
responses with partial signature data (some but not all three fields) should be
explicitly rejected as incomplete/tampered rather than being treated as legacy.
Modify the logic to detect when any of the signature fields are present but not
all three are present, and throw an error for incomplete signature data in that
case. Only responses where all signature fields are completely absent should
fall through to the legacy path that sets signatureVerified to false.

In `@crates/api-client/src/ipns.rs`:
- Around line 71-112: The current implementation does not verify that the signed
CBOR data matches the response's cid and sequence_number fields, allowing
signature verification to succeed even if these fields were swapped.
Additionally, the current logic returns Ok(None) for any missing signature
field, but should only return Ok(None) if all three fields (signature_v2, data,
pub_key) are absent and fail closed for partial fields. Change the pattern
matching logic to explicitly check for the case where all three fields are
absent (returning Ok(None)) versus partially absent fields (returning
Ok(Some(false))), then after the successful signature verification check with
verify_ed25519, decode the CBOR data payload to extract the cid and
sequence_number fields and compare them against resp.cid and
resp.sequence_number. If they do not match, return Ok(Some(false)) to fail
closed.

In `@crates/fuse/src/platform/windows/operations.rs`:
- Around line 226-230: The code creates plain byte arrays via try_into() before
wrapping them in Zeroizing, leaving untracked sensitive key material in memory.
In the operations.rs file, refactor the file_key_arr assignment around line
226-230 and the folder_key_arr and ipns_key_arr assignments around lines 272-276
and 341-345 respectively to allocate the Zeroizing buffer first with a default
array, validate the slice length, then copy the key bytes into the zeroizing
buffer after the length check succeeds, ensuring all intermediate temporary
arrays are avoided and sensitive data remains within zeroized memory throughout.

In `@packages/sdk-core/src/__tests__/folder.test.ts`:
- Around line 439-443: The mock for createAndPublishIpnsRecord returns
sequenceNumber as a string value '1', but the actual SDK-core contract expects
sequenceNumber to be a bigint type. Locate the
mockFns.createAndPublishIpnsRecord.mockResolvedValueOnce call and change the
sequenceNumber property from the string '1' to a bigint representation (1n) to
match the actual contract and ensure the mock accurately reflects the runtime
behavior.

In `@packages/sdk-core/src/__tests__/ipns.test.ts`:
- Around line 263-272: The try/catch block in the test for
createAndPublishIpnsRecord with the invalid ipnsName parameter lacks an explicit
assertion to verify that an error is actually thrown. Replace the bare catch
block with a proper test assertion that explicitly checks that the promise
rejects (using expect().rejects or similar assertion syntax depending on your
test framework). This ensures the test fails if the function stops throwing an
error unexpectedly, rather than silently passing just because the key zeroing
logic executes.

In `@packages/sdk-core/src/ipns/index.ts`:
- Around line 98-102: The issue is that within the retry loop in
`publishWithCas`, the `ipnsPrivateKey` buffer is zeroed by the `finally` block
in the first `createAndPublishIpnsRecord` call (at lines 98-102), but when a 409
conflict triggers a retry, the same zeroed buffer is passed to the second
`createAndPublishIpnsRecord` call, causing signature failures. To fix this,
create a copy of `ipnsPrivateKey` before entering the retry loop in
`publishWithCas` and pass this copy to `createAndPublishIpnsRecord` on each
iteration, or alternatively create a fresh copy of `ipnsPrivateKey` for each
retry iteration within the loop. This ensures each attempt has an intact key
buffer while respecting the caller-owns-key convention where only the original
caller-provided buffer is ultimately responsible for zeroing.

---

Outside diff comments:
In `@crates/fuse/src/inode.rs`:
- Around line 399-412: The code currently treats any inode found by either the
ipns_to_ino lookup or the find_child() fallback as the same identity, but when
the ipns_name has changed (matched only by display name via find_child), the
inode's identity has actually changed and identity-specific state must be reset.
Distinguish between a match from ipns_to_ino (stable ID match) and a match only
from find_child() (display name fallback), and when only the fallback matches,
clear folder loaded state and force file re-resolution by refreshing the CID and
metadata keys. Apply this fix consistently across all affected code sections
that handle folder and file identity tracking, including the sections around
lines 461-475 and 515-580.

In `@crates/fuse/src/lib.rs`:
- Around line 1717-1741: The function currently returns unzeroized plaintext
copies of the cached key by calling `.to_vec()` on the `Zeroizing<Vec<u8>>`
values at two locations (line 1725 when returning from cache and line 1741 when
returning the resolved key). Change the return type of this function from
`Result<Vec<u8>, String>` to `Result<Zeroizing<Vec<u8>>, String>`, and replace
both `.to_vec()` calls with `.clone()` to preserve the zeroizing wrapper
throughout the key's lifetime. Call sites will remain compatible because
`Zeroizing<Vec<u8>>` can be automatically deref coerced to `&[u8]` where needed.

In `@crates/fuse/src/operations.rs`:
- Around line 224-237: When publish_ipns returns PublishResult::Conflict in the
match statement, the code currently logs a warning but then falls through to
call coordinator.record_publish, which incorrectly marks the publish as
successful even though the server rejected it. Modify the Conflict branch to
return an error or implement a retry mechanism instead of continuing, ensuring
that coordinator.record_publish is only called for the Success case to prevent
the local sequence cache from advancing beyond what the server has accepted.

In `@crates/fuse/src/platform/windows/operations.rs`:
- Around line 383-397: The match statement handling the PublishResult for IPNS
publish needs to be modified to return early on conflict. Currently, when a
PublishResult::Conflict occurs, the code logs a warning but then continues to
execute coordinator.record_publish and the success log message. This means the
coordinator advances the sequence number even though the server rejected the
publish, potentially losing the new FileMetadata CID. Add a return statement in
the Conflict arm of the match statement to exit the function immediately after
logging the conflict warning, preventing the subsequent record_publish call and
success log from executing.

---

Nitpick comments:
In `@packages/sdk-core/src/__tests__/ipns.test.ts`:
- Around line 201-221: The test 'S2 regression: throws on present-but-invalid
signature (fail-closed)' is duplicating the test coverage already established in
lines 183-199, both testing the same scenario where signature fields are present
but verifyEd25519 returns false. Either remove this duplicate test entirely, or
modify it to cover a distinct missing edge case such as handling
partial/incomplete signature fields, verifying invalid base64 encoding
propagation, or confirming that deriveIpnsName is not called when signature
verification fails. Ensure the test covers a meaningful scenario distinct from
the existing test to maintain code quality standards.

In `@packages/sdk-core/src/__tests__/vault.test.ts`:
- Around line 56-83: The current test only verifies that the private key buffer
is zeroed on the successful publish path, but the code should zero it on all
exit paths including when operations fail. Add a new test case that mocks either
addToIpfs or createAndPublishIpnsRecord to reject or throw an error, then calls
publishVaultKeyBlob and verifies that the privateKeyBuf is still zeroed to zero
bytes even when the operation fails. This ensures the finally block zeroization
works correctly for both success and error paths.
🪄 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: 0aac2332-410a-42d4-9e9b-25d1d750a8af

📥 Commits

Reviewing files that changed from the base of the PR and between b7acb57 and d8ca746.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (46)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-01-PLAN.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-01-SUMMARY.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-02-PLAN.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-02-SUMMARY.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-03-PLAN.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-03-SUMMARY.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-04-PLAN.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-04-SUMMARY.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-CONTEXT.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-DISCUSSION-LOG.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-PATTERNS.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-RESEARCH.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-SECURITY.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-VALIDATION.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-VERIFICATION.md
  • .planning/todos/pending/2026-06-20-fuse-per-file-ipns-publish-conflict-recorded-as-success.md
  • .planning/todos/pending/2026-06-20-ipns-resolve-verify-coverage-and-web-sdk-dedup.md
  • apps/api/src/ipns/ipns.service.spec.ts
  • apps/api/src/ipns/ipns.service.ts
  • apps/desktop/src-tauri/src/commands/vault.rs
  • apps/desktop/src-tauri/src/fuse/mod.rs
  • apps/desktop/src-tauri/src/fuse/windows/mod.rs
  • apps/web/src/services/__tests__/ipns.service.test.ts
  • apps/web/src/services/ipns.service.ts
  • crates/api-client/Cargo.toml
  • crates/api-client/src/ipns.rs
  • crates/api-client/src/types.rs
  • crates/crypto/src/ecies.rs
  • crates/crypto/tests/cross_language.rs
  • crates/fuse/src/inode.rs
  • crates/fuse/src/journal_helpers.rs
  • crates/fuse/src/lib.rs
  • crates/fuse/src/operations.rs
  • crates/fuse/src/platform/windows/operations.rs
  • crates/fuse/src/platform/windows/write_ops.rs
  • crates/fuse/src/write_ops.rs
  • crates/sdk/src/state.rs
  • packages/sdk-core/src/__tests__/folder.test.ts
  • packages/sdk-core/src/__tests__/ipns.test.ts
  • packages/sdk-core/src/__tests__/vault.test.ts
  • packages/sdk-core/src/folder/index.ts
  • packages/sdk-core/src/ipns/index.ts
  • packages/sdk-core/src/vault/index.ts

Comment thread apps/api/src/ipns/ipns.service.spec.ts Outdated
Comment thread apps/api/src/ipns/ipns.service.ts Outdated
Comment thread apps/api/src/ipns/ipns.service.ts
Comment thread apps/desktop/src-tauri/src/fuse/mod.rs
Comment thread apps/web/src/services/__tests__/ipns.service.test.ts Outdated
Comment thread crates/api-client/src/ipns.rs Outdated
Comment thread crates/fuse/src/platform/windows/operations.rs Outdated
Comment thread packages/sdk-core/src/__tests__/folder.test.ts
Comment thread packages/sdk-core/src/__tests__/ipns.test.ts Outdated
Comment thread packages/sdk-core/src/ipns/index.ts Outdated
FSM1 and others added 2 commits June 20, 2026 02:26
Phase 51 (S3/D-05) added `params.ipnsPrivateKey.fill(0)` in a finally to
createAndPublishIpnsRecord, treating it as a "buffer-owning boundary". It is
not: it is a callee that receives a caller-owned, reused buffer. The SDK
client caches per-folder IPNS keys in its folderTree (zeroed only on
destroy()) and passes the same buffer on every mutation; publishWithCas
reuses the buffer across CAS retries. After the first publish the buffer was
all-zero, so the next publish derived the wrong public key and the API
rejected it with 400 "publicKey does not correspond to the given ipnsName",
cascading "Child not found" failures across every publish-dependent flow.

This broke 48/89 SDK E2E tests (the only gate exercising the real
cross-package publish round-trip; the phase's unit checks and the S1 spec
mocked parseIpnsRecord, so it slipped through).

Fix: createAndPublishIpnsRecord no longer zeros its ipnsPrivateKey param,
matching publishWithCas's contract ("NEVER zeroes key material — callers are
responsible") and updateFolderMetadataAndPublish ("CALLER RETAINS
OWNERSHIP"). No key leak — terminal owners still zero: client.destroy()
(cached folderTree keys), clearBytes()/finally (transient unwrapped keys),
and publishVaultKeyBlob / shared-write.ts (freshly-derived keypairs). The
S3/D-05 guard tests A/B were inverted to assert the caller-owned buffer is
preserved.

Verified locally against the full stack: SDK E2E 89/89, sdk-core 209/209,
api 893/893, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: bfbe30d8f64a
Address the actionable CodeRabbit findings on PR #529 that fall within
HARD-02 (crypto-signature & secret-leak hardening):

S2 — fail closed on PARTIAL signature fields. A resolve response carrying
some-but-not-all of {signatureV2, data, pubKey} previously fell into the
legacy allow path (signatureVerified=false), letting an attacker strip
fields to bypass D-02. Now only an all-absent record is treated as legacy;
any partial subset fails closed. Applied consistently across the three
verify paths: crates/api-client (Ok(None) only when all three absent, else
Ok(Some(false))), apps/web, and packages/sdk-core.

S1 — anchor the full signed IPNS value. The publish-time check matched only
the first CID substring via regex, so `/ipfs/<cid>/extra` could pass while
delegated routing published a divergent raw value. Compare the whole value
to `/ipfs/${metadataCid}` exactly.

S3 — close secret-leak gaps. crates/fuse resolve_folder_key_cached now
returns Zeroizing<Vec<u8>> (cache hits no longer hand back unzeroized plain
copies). Desktop mount pre-population wraps derived [u8;32] folder keys in
Zeroizing.

Test quality: replace the duplicate sdk-core S2 regression test with a
partial-fields fail-closed case; assert publish errors propagate
(rejects.toThrow) instead of swallowing in try/catch; add a vault rejected-
path zeroization guard; fix a bigint mock; make the API concurrent-batch
spec order-independent (Promise.allSettled preserves result order, not parse
call order). Added partial-field tests for web + sdk-core + api-client.

Deferred (captured as todos, out of HARD-02 scope or risky):
- per-file IPNS Conflict recorded as success (pre-existing #352, Phase 52)
- inode stable-ID identity reset on display-name fallback (Phase 52)
- validate embedded sequence when CAS omitted (risks non-CAS publish paths)
- bind verified record to resp.cid via CBOR decode (heavy lift)

Verified: SDK E2E 89/89, sdk-core 210, api 79 (ipns spec), web 61,
cargo api-client/crypto green, cargo check --workspace clean, lint clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: a91dbc72b885
@FSM1

FSM1 commented Jun 20, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit review — disposition

Thanks @coderabbitai. Triaged all findings; in-scope items fixed in e1f822e2c (the SDK-E2E regression) and a9d8467a0 (this batch). Out-of-scope / pre-existing items captured as todos for the right phase.

Fixed (in a9d8467a0)

  • S2 fail-closed on partial signature fields (crates/api-client/src/ipns.rs, apps/web/src/services/ipns.service.ts, packages/sdk-core/src/ipns/index.ts): a response with some-but-not-all of {signatureV2, data, pubKey} now fails closed instead of falling into the legacy allow path. Ok(None) (Rust) / legacy path (JS) is reserved for all three absent. Added partial-field tests in all three. (Addresses the Critical on ipns.rs:112 partial-fields part, and the Major on web ipns.service.ts:195.)
  • S1 value anchoring (apps/api/src/ipns/ipns.service.ts:269): compare the full embedded value to `/ipfs/${metadataCid}` instead of a first-CID substring regex.
  • S3 secret-leak — resolve_folder_key_cached (crates/fuse/src/lib.rs): returns Zeroizing<Vec<u8>>; cache hits no longer hand back unzeroized plain copies.
  • S3 secret-leak — desktop mount pre-population (apps/desktop/src-tauri/src/fuse/mod.rs): derived [u8;32] folder keys wrapped in Zeroizing.
  • Test quality: replaced the duplicate sdk-core S2 regression with a partial-fields case; rejects.toThrow instead of try/catch swallow (ipns.test.ts); added a vault rejected-path zeroization guard (vault.test.ts test D); fixed the bigint mock (folder.test.ts); made the API concurrent-batch spec order-independent (Promise.allSettled preserves result order, not parse call order).

Already fixed earlier (in e1f822e2c)

  • 🔴 Critical packages/sdk-core/src/ipns/index.ts:102createAndPublishIpnsRecord was zeroing a caller-owned, reused key buffer (the client caches per-folder IPNS keys; publishWithCas reuses across retries), corrupting the key on the 2nd+ publish. Removed the callee zeroization (terminal owners still zero). This was the root cause of a 48/89 SDK-E2E failure and is now green.

Deferred — captured as todos (out of HARD-02 scope / pre-existing / risky)

  • Per-file IPNS Conflict recorded as success (crates/fuse/src/operations.rs, platform/windows/operations.rs): pre-existing since #352; FUSE-durability domain → Phase 52. (todo: fuse-per-file-ipns-publish-conflict-recorded-as-success)
  • inode.rs stable-ID identity reset on display-name fallback: pre-existing FUSE sync correctness → Phase 52. (todo: fuse-inode-stable-id-identity-reset)
  • Validate embedded sequence when CAS omitted (ipns.service.ts:294): the proposed fix forces embedded === DB+1 on non-CAS publish paths (desktop vault.rs and per-file publishes use expected_sequence_number: None), risking a new regression for a niche tamper case. Needs SDK-E2E validation first. (todo: ipns-publish-validate-embedded-sequence-without-cas)
  • Bind verified record to resp.cid via CBOR decode (ipns.rs heavy-lift part): folded into the existing IPNS-resolve-verify-coverage todo (pairs with making verification a resolve_ipns_verified chokepoint).
  • Windows transient-temp Zeroizing::new(try_into()) pattern: marginal (the persistent copy is already wrapped; the temporary is a move) — deferred with the Phase 52 FUSE work.

All checks were green locally before push: SDK E2E 89/89, sdk-core 210, api ipns spec 79, web 61, cargo (api-client incl. new partial-field tests, crypto) green, cargo check --workspace clean, lint clean.

@coderabbitai

coderabbitai Bot commented Jun 20, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@FSM1 — thorough disposition, thank you. Everything looks well-reasoned:

  • Critical regression catch (e1f822e2c): the callee-zeroization-of-reused-key root cause was exactly the right call to revert. Zeroing a buffer that publishWithCas holds and re-passes on retry is a functional correctness violation that outweighs the marginal zeroization benefit at that call site.

  • Partial-field fail-closed (a9d8467a0): the "all-three-absent → legacy path, any-present → fail-closed" semantic is the correct boundary and is now consistently applied across Rust, web, and SDK-core.

  • S1 value anchoring (full `/ipfs/${metadataCid}` comparison vs. substring regex): tightens the tamper surface correctly.

  • S3 cache hit zeroization (resolve_folder_key_cached): was the most impactful of the remaining S3 gaps; good that it's addressed.

  • CAS-omitted sequence deferral: the risk analysis is correct — non-CAS callers (desktop vault.rs, per-file publishes) would be affected by the proposed forced embedded === DB+1 check, and that deserves SDK-E2E coverage before landing.

All five deferral entries map cleanly to named todos and appropriate future phases. Happy to trigger a fresh review once the post-a9d8467a0 diff is pushed if you'd like a final pass.

🐇 ✓


✏️ Learnings added
Learnt from: FSM1
URL: https://github.com/FSM1/cipher-box/pull/529

Timestamp: 2026-06-20T00:59:10.975Z
Learning: In `packages/sdk-core/src/ipns/index.ts`, `createAndPublishIpnsRecord` must NOT zero `params.ipnsPrivateKey` inside the callee. The client (`publishWithCas`) holds and reuses the per-folder IPNS private key buffer across retries and multiple sequential publishes. Callee-zeroization corrupts the key on the 2nd+ publish. Terminal key ownership and zeroization responsibility belongs to the caller, not to `createAndPublishIpnsRecord`. (Fixed in commit `e1f822e2c`, root cause of 48/89 SDK-E2E failure in PR `#529`.)

Learnt from: FSM1
URL: https://github.com/FSM1/cipher-box/pull/529

Timestamp: 2026-06-20T00:59:10.975Z
Learning: For IPNS resolve signature verification across Rust (`crates/api-client/src/ipns.rs`), web (`apps/web/src/services/ipns.service.ts`), and SDK-core (`packages/sdk-core/src/ipns/index.ts`): the semantic is "all three of {signatureV2, data, pubKey} absent → legacy allow path (return signatureVerified=false without throwing); any field present but not all three, or all three present but verification fails → fail closed (throw/error)." Ok(None)/Rust and the legacy JS path are reserved exclusively for the all-three-absent case. (Established in PR `#529`, Phase 51-02.)

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 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 `@packages/sdk-core/src/__tests__/ipns.test.ts`:
- Around line 206-220: The current test case in the S2 block covers omitted
signature fields but does not test the edge case where signature fields are
present as empty strings, which could bypass fail-closed logic if the
implementation relies on truthiness checks. Add an additional test case (or
extend the existing mock) that sets signatureV2 to an empty string '' instead of
omitting it entirely, and verify that resolveIpnsRecord still rejects with the
'incomplete signature data' error and does not call verifyEd25519 or
deriveIpnsName, ensuring the fail-closed behavior holds regardless of whether
fields are omitted or present as empty values.

In `@packages/sdk-core/src/__tests__/vault.test.ts`:
- Around line 88-97: The test named 'D: zeroes the derived
vaultKeyKeypair.privateKey when publish fails' currently mocks addToIpfs to
reject immediately, which prevents the code from ever reaching the
createAndPublishIpnsRecord call where the actual publish failure should occur.
To properly exercise the publish-failure path as the test name suggests, change
the mocks so that addToIpfs succeeds (remove or change the mocked rejection) and
instead mock createAndPublishIpnsRecord to fail with an error. This ensures the
test actually reaches and validates the behavior when the IPNS publish step
fails, not the upload step.

In `@packages/sdk-core/src/ipns/index.ts`:
- Around line 213-218: The condition checking for signature fields in the IPNS
resolve response uses truthy checks which treat empty values as absent. Instead
of using the current `if (signatureV2 || data || pubKey)` pattern, use nullish
coalescing checks to distinguish between truly missing fields (null/undefined)
versus present-but-empty fields. This ensures that empty strings or other falsy
values for signatureV2, data, or pubKey are properly treated as invalid data
that should be rejected during verification, rather than being downgraded to the
legacy unsigned path.
🪄 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: 3db650d8-971f-4930-a06e-7c17724cbf21

📥 Commits

Reviewing files that changed from the base of the PR and between 735c361 and a9d8467.

📒 Files selected for processing (16)
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-04-SUMMARY.md
  • .planning/todos/pending/2026-06-20-fuse-inode-stable-id-identity-reset.md
  • .planning/todos/pending/2026-06-20-ipns-publish-validate-embedded-sequence-without-cas.md
  • .planning/todos/pending/2026-06-20-ipns-resolve-verify-coverage-and-web-sdk-dedup.md
  • apps/api/src/ipns/ipns.service.spec.ts
  • apps/api/src/ipns/ipns.service.ts
  • apps/desktop/src-tauri/src/fuse/mod.rs
  • apps/web/src/services/__tests__/ipns.service.test.ts
  • apps/web/src/services/ipns.service.ts
  • crates/api-client/src/ipns.rs
  • crates/fuse/src/lib.rs
  • packages/sdk-core/src/__tests__/folder.test.ts
  • packages/sdk-core/src/__tests__/ipns.test.ts
  • packages/sdk-core/src/__tests__/vault.test.ts
  • packages/sdk-core/src/ipns/index.ts
  • packages/sdk-core/src/vault/index.ts
✅ Files skipped from review due to trivial changes (4)
  • .planning/todos/pending/2026-06-20-ipns-publish-validate-embedded-sequence-without-cas.md
  • .planning/todos/pending/2026-06-20-fuse-inode-stable-id-identity-reset.md
  • .planning/todos/pending/2026-06-20-ipns-resolve-verify-coverage-and-web-sdk-dedup.md
  • .planning/phases/51-crypto-signature-secret-leak-hardening/51-04-SUMMARY.md
🚧 Files skipped from review as they are similar to previous changes (6)
  • packages/sdk-core/src/tests/folder.test.ts
  • apps/web/src/services/ipns.service.ts
  • apps/api/src/ipns/ipns.service.ts
  • apps/api/src/ipns/ipns.service.spec.ts
  • crates/api-client/src/ipns.rs
  • crates/fuse/src/lib.rs

Comment thread packages/sdk-core/src/__tests__/ipns.test.ts
Comment thread packages/sdk-core/src/__tests__/vault.test.ts Outdated
Comment thread packages/sdk-core/src/ipns/index.ts
Second round of CodeRabbit follow-ups on PR #529 (all adversarially verified):

S2 — empty-string fail-closed. The resolve signature gate used truthy checks
(`if (signatureV2 || data || pubKey)`), so a record with all three fields
present as empty strings ('') was downgraded to the legacy allow path. Switch
to nullish presence checks (`!= null`) in both apps/web and packages/sdk-core
so present-but-empty fields are treated as present and fail closed; only
all-omitted records take the legacy path. (Rust verifier already fails closed
on empty via base64-decode + verify.)

Web 404 detection. apps/web resolve only checked `error.status`, but real
axios errors from the api-client surface status at `error.response?.status`,
so live 404s would throw instead of returning null. Now checks
`error.status ?? error.response?.status`, matching sdk-core.

S3 — no transient plaintext key. Added a `zeroizing_32_from_slice` helper
(preallocate `Zeroizing<[u8;32]>`, length-check, then `copy_from_slice`) and
applied it to the file/folder/IPNS key sites in crates/fuse operations.rs and
platform/windows/operations.rs, so no plain `[u8;32]` temporary is
materialized before the zeroizing wrapper.

Tests: add empty-string fail-closed cases (web + sdk-core); web 404 test now
mocks the axios `error.response` shape plus a fallback `error.status` case;
vault test D now exercises the real publish-failure path (upload succeeds,
publish rejects) instead of the upload path.

Verified: SDK E2E 89/89, sdk-core 211, web 63, cargo fuse 60, lint + cargo
check clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: b3b9ba579e3d
@FSM1 FSM1 merged commit 13f741e into main Jun 20, 2026
27 checks passed
@FSM1 FSM1 deleted the feat/crypto-signature-secret-leak-hardening branch June 20, 2026 01:29
FSM1 added a commit that referenced this pull request Jun 21, 2026
* docs: file resolved hardening todos and add deferred-findings phases 56-58

Bookkeeping (verified against live code via parallel review agents):
- File #5 IPNS S1/S2/S3 review to completed/ (bulk shipped in PR #529); residue tracked by phase 58 todos.
- File #10 large-file refactor survey to completed/ (Tier-1/2 shipped in PR #538); re-capture the 14 open Tier-3 items.
- Fold the per-file-IPNS-conflict todo into the PR #538 robustness todo (one unified site post-#538) and file to completed/.
- Trim the metadata zeroize todo to its one remaining helper (spawn_metadata_publish).

New phases via gsd-tools phase.add (HARD-07..09):
- 56 FUSE & IPNS Durability Hardening — PR #538 deferred findings.
- 57 API CID/Provider Hardening & Module Dedup.
- 58 IPNS Signature-Verify Coverage — Phase 51 / PR #529 S1/S2 residue.

Update ROADMAP (sections, progress table, execution order), REQUIREMENTS (HARD-07..09 + traceability), and STATE body. STATE frontmatter progress counts left unreconciled (pre-existing drift, todo #6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: f7d4c3a60cd5

* docs(56): capture phase context

Entire-Checkpoint: bd91ecff0d20

* docs(state): record phase 56 context session

Entire-Checkpoint: e93a1c24b15f

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:api:feat Minor version bump (new feature) for api release:cipherbox-core:fix Patch version bump (bug fix) for cipherbox-core release:cipherbox-crypto:feat Minor version bump (new feature) for cipherbox-crypto release:cipherbox-fuse:feat Minor version bump (new feature) for cipherbox-fuse release:cipherbox-sdk:fix Patch version bump (bug fix) for cipherbox-sdk release:desktop:fix Patch version bump (bug fix) for desktop release:sdk:fix Patch version bump (bug fix) for sdk release:sdk-core:feat Minor version bump (new feature) for sdk-core release:tee-worker:fix Patch version bump (bug fix) for tee-worker release:web:feat Minor version bump (new feature) for web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant