feat: rotation write-plane and re-mint durability with recipient-pubkey pinning#615
feat: rotation write-plane and re-mint durability with recipient-pubkey pinning#615FSM1 wants to merge 30 commits into
Conversation
…ntext Entire-Checkpoint: 5116042cf8fb
8 plans across 4 waves covering D-01..D-04. Research, patterns, and Nyquist validation artifacts included. Decision-coverage gate 14/14. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an additive optional recipientPins list to NodeWriteBody (Rust Vec<Vec<u8>> / TS string[]), sealed inside the owner writeKey body for the D-03 re-mint recipient-identity check. Both codecs omit the field from the wire when empty, preserving the frozen seal_vectors[0] KAT byte-for-byte; a new non-empty-pin seal_vectors[1] locks the pinned path across Rust and TypeScript. Tolerant decode defaults to empty and never fail-closes; no deny_unknown_fields. Documents the additive change in METADATA_SCHEMAS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…he, replay recovery Task 1 (RED): pins the D-01/D-02 contracts before implementation. - rotation_deps.rs: reconstruct_write_body stub (returns None), a collect_sent_shares call-counter on FakeTransportInner, and tests A (reconstruct round-trip), B (None fallback), C (<=1 sent-shares fetch). - replay.rs: test D — reconstructed write_sealed lets recover_signing_seed recover a rotated node's signing seed (T-80-04 durability). - Tests A, C, D fail against the stub/uncached code (non-vacuous RED); B is a satisfied fail-open guard. Rule 3 (blocking compile-unblock, bundled): plan 80-01 added the required recipient_pins field to NodeWriteBody but did not update downstream constructors, leaving crates/sdk and crates/fuse non-compiling. Adds recipient_pins: Vec::new() (byte-identical wire, field skipped when empty) to every downstream NodeWriteBody constructor so the workspace builds. No sibling 80-x plan owns this fix. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 2 (GREEN, D-01): ApiClientTransport::publish now reconstructs the write-body from the locally-materialized InodeTable and injects it when the read-key rotation engine leaves write_sealed None. reconstruct_write_body pulls the node's own write key + ipns_private_key + child WriteChildRefs (child write keys copied verbatim from child inodes, read-key-rotation-independent) and re-seals via seal_node under the node's OWN write key at its NEW generation (ROLE_BODY 0x01). Fails open to None for a non-materialized node (D-01b); never rotates/mutates the write plane. The signing-seed copy is zeroized after encode. Restores owned-walkability (removes the list_folder_owned 'no write_sealed body' flood, T-80-05) and replay.rs signing-seed durability after rotation+remount (T-80-04). Tests A, B, and replay D now pass; C (cache) lands in Task 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Task 3 (GREEN, D-02): adds a job-scoped tokio::sync::OnceCell sent-shares cache on FuseRotationDeps so query_grants_rooted_at fetches GET /shares/sent at most once per rotation walk instead of once per rotated node (was O(nodes x shares), 607x/run observed). The per-share root_node_id filter and 0x-strip/hex-decode/ RotateFailed parsing are byte-for-byte unchanged; only the source (cached slice) differs. OnceCell (not RefCell) because query_grants_rooted_at is async and must not hold a borrow across .await. The cache lives on FuseRotationDeps (the once-per-job instance at grant_scope.rs:488), not ApiClientTransport as the plan text stated: the locked Test C exercises the FakeTransport path and asserts its collect_sent_shares is called <=1, which only holds when caching wraps transport.collect_sent_shares(). Still job-scoped and interior-mutable, no static/global. All 13 rotation_deps tests green. Also includes the 80-02 plan SUMMARY. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D-04 SC3: store a defensive new Uint8Array(...) copy of readKey at every rotatedNodes.set site whose value aliases parentNewReadKey (root + BFS child branches) so a future zero-on-drop of parentNewReadKey cannot zero the returned map entry consumed by the Rust FUSE inode refresh. Matches Rust's Zeroizing clone. parentNewReadKey/parentOldReadKey untouched; no Rust change. D-02 TS SC2-perf: memoize transport.listSentGrants() with a closure-scoped per-pass cache in buildGrantRemintCallbacks so queryGrantsFn fetches sent grants at most once per reconcile pass; per-node rootNodeId filtering unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…reservation - assert-or-throw incl. D-03e empty/absent hard fail, append dedup, extract default - updateFolderMetadataAndPublish recipientPins seal preservation + CAS-409 union - write->read round-trip at the sdk-core seal boundary
- add assertRecipientPinned/appendRecipientPin/extractRecipientPins pure helpers - fail-closed on empty/absent pin list (D-03e) and non-member; normalize both sides to raw bytes - thread recipientPins through updateFolderMetadataAndPublish seal + union across CAS-409 merge (T-80-11)
…ientPubkeyPins read - addRecipientPubkeyPin resolves item, appends pin, CAS-republishes at unchanged generation - getRecipientPubkeyPins returns the sealed pin list as raw pubkey bytes for enforcement - surface recipientPins through getWriteBodyParams so routine folder updates preserve pins
…tation republish
Phase 80 plan 05 (D-03a / D-01 durability). Thread the shared node's
owner-sealed recipient pins from its NodeWriteBody into the FUSE mount's
in-memory state so the Rust re-mint can verify them OFFLINE, and so a
scope-exit rotation republish PRESERVES them.
- listing.rs: ResolvedOwnedChild.recipient_pins read from the SAME
already-unsealed write-body as ipns_private_key in resolve_owned_child.
- inode.rs: InodeKind::{Root,Folder,File} gain a recipient_pins cache
field, populated in apply_owned_children; Debug shows a non-secret
recipient_pins_count while read_key/write_key/ipns_private_key stay
redacted. Fresh-node/root/test construction sites default to empty.
- rotation_deps.rs: reconstruct_write_body now carries the node's cached
recipient_pins into the resealed NodeWriteBody, closing the D-01<->D-03e
gap where a post-rotation re-mint would hard-fail after re-materialize.
Pins are public keys, copied verbatim and never rotated; the read plane
and generation are untouched. TDD: RED via missing-field compile failure,
then GREEN. Scoped: cargo test -p cipherbox-fuse = 126 passed;
cargo test -p cipherbox-sdk = 153 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Insert a fail-closed pin compare before wrap_key in re_mint_grants_rooted_at: verify grant.recipient_public_key is a member of the node's OWN owner-sealed recipient pins before ECIES-wrapping the rotated read key. A non-member (relay-substituted, T-80-15) or an absent/empty pin list (D-03e no-legacy, T-80-16) aborts the whole node's re-mint (RotateFailed), never a per-grant skip. - add required RotationDeps::get_recipient_pubkey_pins seam (no permissive default) - FuseRotationDeps delegates through the RotationTransport seam - ApiClientTransport resolves pins OFFLINE from the InodeTable cache (no fetch) - pin-mismatch + pin-absent negative tests, plus pinned-recipient success test - out of scope: co-writer re-wrap site untouched (T-80-17 accept) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D-03d consumer 2 of 3: reMintGrantsRootedAt now verifies each surviving grant's relay-round-tripped recipientPublicKey against the node's owner-sealed recipientPins via a new getPinsFn seam, reusing sdk-core's assertRecipientPinned helper immediately before wrapKey. A mismatch or an absent/empty pin list throws and aborts the node's re-mint (D-03e no-legacy hard fail), never a per-grant skip. getPinsFn is wired to the client getRecipientPubkeyPins read path in buildGrantRemintCallbacks, keeping the 80-03 listSentGrants memo intact. No API/DTO/DB change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le enforcement - ShareDialog.handleShare commits the pasted recipient pubkey to the node's owner-sealed write-body pin list via addRecipientPubkeyPin (D-03c issuance) - ShareDialog.handleUpgrade fail-closed verifies the server-fed recipient against getRecipientPubkeyPins with assertRecipientPinned before the re-wrap - owner-reconcile.service.ts supplies getRecipientPubkeyPins so runOwnerReconcile getPinsFn enforcement (80-07) resolves real pins end-to-end (D-03d consumer 3) - re-export assertRecipientPinned from the @cipherbox/sdk facade for D-07 access Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fetch_splice_publish_parent re-sealed the parent write-body with empty recipient_pins, dropping the owner-sealed D-03 pins for any shared folder/root that received a journaled child write. After a crash+remount the next InodeTable population would surface empty pins, so a later scope-exit re-mint would hard fail-closed (D-03e), cutting recipients off on rotation. Preserve the pins decoded from the parent's current write-body verbatim (only the write-children plane changes on this path), matching the rotation republish path in rotation_deps.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D-03 pin-preservation was wired into the rotation-republish and journal-replay parent re-splice, but NOT the routine mutation reseal paths — so an ordinary write to a shared folder/file republished it with an empty recipientPins list. On re-materialize a later re-mint read empty pins and hard fail-closed (D-03e), defeating revocation/rotation of that share by ordinary usage. Thread the node's cached owner-sealed recipient pins verbatim (public ECIES keys, copied never rotated) through every routine reseal: - Rust: build_folder_metadata (fs.rs), publish_file_node (content_ops.rs) with its fuser (read_ops.rs) and winfsp (windows/write_ops.rs) callers sourcing the file inode pins. replay.rs file-node reseal left empty with a documented reason (journaled placeholder is pin-less at source; crash-replay residue tracked as a todo). - TS: all six explicit-writeChildren publish sites in client.ts and the nine adoptPublishedFolderState calls now thread recipientPins; adoptPublishedFolderState seeds pins on both the update and create mirror branches so the next mutation reads them forward. Also add the missing reconcile-before-publish (ROT-07 durable anti-rollback) gate to addRecipientPubkeyPin, mirroring every other publish path. Regression tests: fs.rs build_folder_metadata_preserves_cached_recipient_pins; delete-item.test.ts routine-delete pin preservation. Deferred lifecycle items (pruning-on-revoke, growth bound, atomic issuance, crash-replay preservation) captured in .planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md. Found by the Phase 80 crypto-privacy review, security review, and CodeRabbit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the Phase 80 verification (PASS) and consolidated crypto/security review dispositions, reconcile STATE.md progress, and retire the four resolved source todos (D-01..D-04) to completed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
WalkthroughPhase 80 adds recipient-pin metadata across Rust and TypeScript codecs, preserves pins through FUSE and SDK write paths, reconstructs durable rotation write bodies, caches sent-share lookups, and enforces fail-closed recipient verification during re-mint and web upgrades. ChangesRotation durability and recipient pinning
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related PRs
Suggested labels: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed: private package registry requires authentication. Disable ESLint in CodeRabbit settings or use public packages. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Release Preview
Cascade Details
|
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #615 +/- ##
==========================================
+ Coverage 70.23% 78.00% +7.77%
==========================================
Files 159 200 +41
Lines 15362 34317 +18955
Branches 1961 2003 +42
==========================================
+ Hits 10789 26769 +15980
- Misses 4319 7293 +2974
- Partials 254 255 +1
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Greptile SummaryThis PR adds rotation write-plane durability and recipient public-key pinning for share re-minting. The main changes are:
Confidence Score: 4/5This is close, but the file-share path should be fixed before merging.
apps/web/src/components/file-browser/ShareDialog.tsx Important Files Changed
Reviews (10): Last reviewed commit: "fix: re-mint web file-share grants inlin..." | Re-trigger Greptile |
…on sites Phase 80 added recipient_pins to InodeKind and NodeWriteBody but three cipherbox-desktop construction sites were not updated, breaking the workspace cargo check (the phase never pushed CI so the broken consumer surface was latent). Add recipient_pins: Vec::new() to the fresh root inode at mount (fuse/mod.rs + winfsp fuse/windows/mod.rs) and the empty-root vault-init write-body (commands/vault.rs) — no shares exist at init/mount, so an empty pin list is correct; pins are surfaced onto inodes later at materialization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/sdk/src/listing.rs (1)
124-148: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winAdding
recipient_pinsis a source-breaking SDK change
ResolvedOwnedChildis exported fromcrates/sdk/src/lib.rsand its fields are public, so downstream callers can construct it with struct literals. Adding this required field will break those callers unless they update every construction site; keep the public shape stable or introduce a versioned/constructor-based return type first.🤖 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/sdk/src/listing.rs` around lines 124 - 148, The new required recipient_pins field in ResolvedOwnedChild breaks downstream struct-literal construction. Preserve the existing public struct shape by moving recipient_pins into a backward-compatible versioned or constructor-based return type, and update the relevant resolution flow to expose it without requiring existing ResolvedOwnedChild callers to initialize the new field.
🧹 Nitpick comments (5)
packages/core/src/__tests__/node-codec-vectors.test.ts (1)
264-290: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winCover the recipient-pin decode path, not only ciphertext bytes.
This test passes fixture base64 strings directly into
writeBody, so it verifies the KAT but not base64 decoding, compressed-key validation, or binary round-tripping. Add a decode→encode assertion plus negative cases for malformed and invalid-length pins.🤖 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/core/src/__tests__/node-codec-vectors.test.ts` around lines 264 - 290, Update the test around recipientPins to decode each fixture pin from base64 into the binary representation expected by Node.writeBody, then re-encode it and assert the original values round-trip unchanged before encryption. Add negative assertions covering malformed base64 and decoded pins with invalid lengths, using the existing pin decoding/validation helpers if available.Source: Path instructions
crates/sdk/src/rotation/engine.rs (1)
632-640: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winSkip the pin lookup when there are no surviving grants.
get_recipient_pubkey_pins(node_id)is only used on the non-revoked wrap path. Ifgrantsis empty or all revoked, the loop never wraps anything, so this fetch is just an extraInodeTablescan. Gate it behindgrants.iter().any(|g| !g.is_revoked)or return early before fetching pins.🤖 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/sdk/src/rotation/engine.rs` around lines 632 - 640, Only fetch recipient pins in the non-revoked grant path: after `query_grants_rooted_at`, check whether `grants.iter().any(|g| !g.is_revoked)` and skip the `get_recipient_pubkey_pins` call when no surviving grants exist, while preserving pin lookup for grants that will be wrapped.crates/fuse/src/content_ops.rs (1)
263-272: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winAdd a file-node pin-preservation regression test.
This durability-critical path should be covered with non-empty pins: publish a file node, unseal
write_sealed, and assert the exact pin list survives.🤖 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/content_ops.rs` around lines 263 - 272, Add a regression test for the file-node write path around the construction of NodeWriteBody and write_sealed: publish a file node with a non-empty recipient pin list, unseal the resulting write_sealed payload, and assert that the recovered recipient_pins exactly match the original list.crates/sdk/src/listing.rs (1)
555-560: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd a non-empty-pin listing regression test.
The changed extraction is only exercised by the visible empty/default-pin fixtures. Add a fixture containing multiple pins and assert that
resolve_owned_childreturns them unchanged for offline re-mint and rotation preservation.🤖 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/sdk/src/listing.rs` around lines 555 - 560, Add a listing regression test covering a fixture with multiple non-empty recipient pins, exercising resolve_owned_child and asserting the pins are returned unchanged through offline re-mint and rotation-preservation flows. Keep the existing empty/default-pin fixtures and behavior intact.apps/web/src/services/owner-reconcile.service.ts (1)
105-125: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
ipnsNamefor the new IPNS identifier parameter.Rename the factory parameter and closure capture from
shareRootIpnsNametoipnsName; the root-specific meaning is already conveyed by the caller and API context.As per coding guidelines, use
ipnsNamefor IPNS identifiers and avoid inconsistent variations.🤖 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 `@apps/web/src/services/owner-reconcile.service.ts` around lines 105 - 125, Rename the makeWebOwnerReconcileTransport parameter from shareRootIpnsName to ipnsName and update the getRecipientPubkeyPins closure to pass ipnsName to getSdkClient().getRecipientPubkeyPins. Keep the transport behavior unchanged.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 `@packages/core/src/node/decode.ts`:
- Around line 363-390: Update the recipientPins mapping in the write-body
decoder to base64-decode each string, validate that the decoded value is exactly
33 bytes in compressed secp256k1 public-key form, and wrap decoding or
validation failures in CryptoError with DECRYPTION_FAILED. Store the validated
Uint8Array values in base.recipientPins while preserving the existing
absent/empty-field behavior.
In `@packages/core/src/node/types.ts`:
- Around line 144-150: Align the recipientPins contract between its
documentation and decodeWriteBody: normalize an absent or empty wire value to []
and make recipientPins required in the in-memory type, or revise the comment to
document undefined consistently. Update the visible recipientPins declaration
and decodeWriteBody handling together so the documented behavior matches runtime
behavior.
In `@packages/sdk-core/src/folder/registration.ts`:
- Around line 218-227: Prevent routine publishes from erasing existing recipient
pins when writeKey is present but recipientPins is omitted. Update the
registration flow around encodeAndUpload and its sealed-state initialization to
require a current pin snapshot or hydrate pins before sealing, rather than
defaulting to an empty list; preserve CAS-409 remote-pin unioning. Add a
regression test covering a conflict-free publish with omitted recipientPins and
existing pins.
---
Outside diff comments:
In `@crates/sdk/src/listing.rs`:
- Around line 124-148: The new required recipient_pins field in
ResolvedOwnedChild breaks downstream struct-literal construction. Preserve the
existing public struct shape by moving recipient_pins into a backward-compatible
versioned or constructor-based return type, and update the relevant resolution
flow to expose it without requiring existing ResolvedOwnedChild callers to
initialize the new field.
---
Nitpick comments:
In `@apps/web/src/services/owner-reconcile.service.ts`:
- Around line 105-125: Rename the makeWebOwnerReconcileTransport parameter from
shareRootIpnsName to ipnsName and update the getRecipientPubkeyPins closure to
pass ipnsName to getSdkClient().getRecipientPubkeyPins. Keep the transport
behavior unchanged.
In `@crates/fuse/src/content_ops.rs`:
- Around line 263-272: Add a regression test for the file-node write path around
the construction of NodeWriteBody and write_sealed: publish a file node with a
non-empty recipient pin list, unseal the resulting write_sealed payload, and
assert that the recovered recipient_pins exactly match the original list.
In `@crates/sdk/src/listing.rs`:
- Around line 555-560: Add a listing regression test covering a fixture with
multiple non-empty recipient pins, exercising resolve_owned_child and asserting
the pins are returned unchanged through offline re-mint and
rotation-preservation flows. Keep the existing empty/default-pin fixtures and
behavior intact.
In `@crates/sdk/src/rotation/engine.rs`:
- Around line 632-640: Only fetch recipient pins in the non-revoked grant path:
after `query_grants_rooted_at`, check whether `grants.iter().any(|g|
!g.is_revoked)` and skip the `get_recipient_pubkey_pins` call when no surviving
grants exist, while preserving pin lookup for grants that will be wrapped.
In `@packages/core/src/__tests__/node-codec-vectors.test.ts`:
- Around line 264-290: Update the test around recipientPins to decode each
fixture pin from base64 into the binary representation expected by
Node.writeBody, then re-encode it and assert the original values round-trip
unchanged before encryption. Add negative assertions covering malformed base64
and decoded pins with invalid lengths, using the existing pin
decoding/validation helpers if available.
🪄 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: 26eb7671-3f64-4599-b796-bf2963c004de
📒 Files selected for processing (72)
.planning/ROADMAP.md.planning/STATE.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VALIDATION.md.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VERIFICATION.md.planning/security/REVIEW-80.md.planning/todos/completed/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md.planning/todos/completed/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md.planning/todos/completed/2026-07-11-rotation-republish-drops-write-sealed-body.md.planning/todos/completed/2026-07-11-ts-rotatednodes-defensive-copy-parity.md.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.mdapps/web/src/components/file-browser/ShareDialog.tsxapps/web/src/services/owner-reconcile.service.tscrates/core/src/node/encode.rscrates/core/src/node/seal.rscrates/core/src/node/types.rscrates/core/tests/node_write_body_vectors.rscrates/fuse/src/content_ops.rscrates/fuse/src/fs.rscrates/fuse/src/inode.rscrates/fuse/src/journal_helpers.rscrates/fuse/src/platform/windows/write_ops.rscrates/fuse/src/read_ops.rscrates/fuse/src/replay.rscrates/fuse/src/test_support.rscrates/fuse/src/write_ops/grant_scope.rscrates/fuse/src/write_ops/implementation/delete.rscrates/fuse/src/write_ops/implementation/file_data.rscrates/fuse/src/write_ops/implementation/mkdir.rscrates/fuse/src/write_ops/implementation/rename.rscrates/fuse/src/write_ops/rotation_deps.rscrates/sdk/src/emit.rscrates/sdk/src/listing.rscrates/sdk/src/rotation/engine.rsdocs/METADATA_SCHEMAS.mdpackages/core/src/__tests__/node-codec-vectors.test.tspackages/core/src/node/decode.tspackages/core/src/node/encode.tspackages/core/src/node/types.tspackages/sdk-core/src/__tests__/rotation/engine.test.tspackages/sdk-core/src/__tests__/rotation/grant-remint.test.tspackages/sdk-core/src/__tests__/share/recipient-pins.test.tspackages/sdk-core/src/folder/registration.tspackages/sdk-core/src/index.tspackages/sdk-core/src/rotation/engine.tspackages/sdk-core/src/share/index.tspackages/sdk-core/src/share/recipient-pins.tspackages/sdk/src/__tests__/delete-item.test.tspackages/sdk/src/__tests__/owner-reconcile.test.tspackages/sdk/src/client.tspackages/sdk/src/index.tspackages/sdk/src/share/owner-reconcile.tspackages/sdk/src/write-body-params.tstests/vectors/node-codec.json
Address PR review findings on the D-03 recipient-pinning feature: - ShareDialog (greptile P1): publish the owner-sealed recipient pin BEFORE creating the server grant (pin-first). A partial failure now leaves at most a harmless orphan pin (grants nothing without a matching grant, idempotent on retry) instead of a persisted grant with no pin — which the D-03d fail-closed checks would treat as a hard block on re-mint/upgrade for the whole share root. - updateFolderMetadataAndPublish (CodeRabbit Major): a conflict-free (no-409) publish sealed params.recipientPins ?? [], so a caller that OMITTED the pin snapshot silently erased existing owner-sealed pins (the CAS-409 union only runs on a conflict). Fail closed on an omitted snapshot when a write-body is sealed (writeKey present); an explicit [] (unpinned node) is allowed. Thread the current pins at every write-body-sealing caller that previously omitted them: getWriteBodyParams now always returns a concrete list, and bin/index.ts (restore/rehome/lingering-ref-drop) plus the edit-filepointer / rename-folder scripts pass the snapshot. Rotation's parent republish seals no write-body (no writeKey) and is unaffected. - NodeWriteBody.recipientPins doc: correct the encoding (raw secp256k1 bytes, currently 65-byte uncompressed, compared as raw bytes) and the decode contract (left undefined, not normalized to []). Regression tests: registration.test.ts asserts an omitted snapshot fail-closes before any I/O, an explicit snapshot survives a clean publish, and [] is allowed. SDK-E2E 106/106; sdk-core 420; sdk 423. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
greptile P1: addRecipientPubkeyPin reseals the shared node's OWN folder write-body via requireFolder, so it is folder-only — a file item is a leaf child, not a folder-tree entry, and pinning it throws "Shared item not loaded". The pin-first reorder therefore blocked file sharing entirely (the throw pre-empted the grant create). Gate the pin issuance to kind === 'folder'; for files, create the grant as before (file-share pinning is a pre-existing D-03 gap tracked in the recipient-pin-lifecycle todo). Folder-share liveness fix is preserved. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-share upgrade
Two Phase 80 recipient-pin regressions on the web share path.
1. writable-shares 6.1 (Alice upgrades Bob to write) failed: a shared-write
op routed through resealAndPublishParent, which rebuilt the parent write-body
as { ipnsPrivateKey, writeChildren } and DROPPED recipientPins. So a recipient
writing into a shared folder republished the folder root pin-less, erasing the
owner's pin on the wire. The owner then re-resolved empty pins and the D-03d
upgrade gate hard failed closed (D-03e), stranding the upgrade at [read].
commit ddb7082 fixed the six owned client.ts sites plus Rust/FUSE but missed
this TS shared-write chokepoint. Preserve parentNode.writeBody.recipientPins
verbatim across the reseal (public keys, copied never rotated).
2. File-share upgrades threw "Shared item not loaded" (greptile P1,
ShareDialog.tsx): handleUpgrade called the folder-only pin reader
getRecipientPubkeyPins -> requireFolder for a file leaf. Gate the pin
read/enforce on kind === 'folder', symmetric with the issuance write gate.
File shares carry no owner-sealed pin (a file leaf has no NodeWriteBody), so
they skip the enforce rather than fail-closing an unpinnable upgrade — a known
coverage gap tracked in the recipient-pin-lifecycle todo.
Regression test: shared-write.test.ts asserts recipientPins survives a
deleteFromSharedFolder republish (RED before the reseal fix).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e real share flow Phase 80 added a fail-closed recipient-pin gate to the desktop scope-exit rotation's grant re-mint (D-03d/D-03e): re_mint_grants_rooted_at refuses to re-wrap the rotated read key to a recipient that is not among the grant root's owner-sealed recipientPins, and treats an empty pin list as a hard failure. The shared-scope-exit e2e (Parts A/C/D) created its shares with a raw POST /shares and NEVER wrote the recipient pin — the real web share flow pins first (ShareDialog.tsx addRecipientPubkeyPin) then creates the grant. So the re-mint read "0 pinned" and failed closed, aborting the rotation AFTER the read plane was already republished at the new generation. That partial rotation left the mount's stale local read key unable to unseal the new-generation nodes, so every subsequent metadata refresh flooded "list_folder_owned/verify_subtree_clean: AES-GCM decryption failed" and the covered delete/rename EIO'd on all platforms. Faithfully simulate the web pin-first flow: pinRecipientOnGrantRoot derives the grant root's write key from the vault root's WriteChildRef (every grant root here is a root child) and appends the recipient pin into the grant root's owner-sealed write-body via updateFolderMetadataAndPublish (its CAS-409 loop unions pins, so it is race-safe against the mount's concurrent publishing). Pinning happens right after each share create, before the sent_shares wait, so the mount's periodic metadata refresh materializes the pin onto the grant-root inode before the delete. Also fix bump-ipns-sequence: a write-body reseal now REQUIRES a recipientPins snapshot (omitting it would erase pins) — pass the root's current pins verbatim. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…before scope-exit re-mint
The desktop scope-exit rotation reads the grant root's owner-sealed recipient
pins from THIS mount's in-memory inode cache — both the re-mint
(re_mint_grants_rooted_at -> get_recipient_pubkey_pins -> find_recipient_pins)
and the rotation republish (reconstruct_write_body). That cache is materialized
lazily (mount init / the periodic metadata refresh), so a pin written OUT OF
BAND — the real product flow is the owner sharing the folder from the web
client, which reseals the folder write-body with the new pin — may not yet be
reflected on the desktop mount's inode when the owner immediately deletes inside
that folder.
Reading the stale (empty) cache then (a) fail-closes the retained recipient's
re-mint ("0 pinned", D-03e) and (b) republishes the node pin-less
(reconstruct_write_body reseals the empty cache), clobbering the real published
pin — so the rotation aborts mid-flight and every later metadata refresh
AES-GCM-fails against the new-generation record. This is a genuine product race
(share-then-rotate), not only a test artifact; it surfaces deterministically on
the slower FUSE-T (macOS) / WinFsp (Windows) mounts where the ~30s periodic
refresh does not win the race, while the faster Linux fuser mount happened to.
Fix: rotate_read_on_scope_exit now refreshes the grant root inode's cached
recipient_pins from its CURRENT published write-body (resolve + fetch + unseal
under the unchanged write key — write keys never rotate) BEFORE the rotation
reads them, closing the race deterministically. Best-effort: a resolve/fetch/
unseal failure leaves the cache untouched and never fails the rotation itself.
Regression test: refresh_grant_root_recipient_pins_is_a_noop_on_resolve_failure
covers the fail-closed-safe contract (dead endpoint -> cache untouched, no
panic). Happy-path determinism is proven by the desktop e2e going green on
macOS/Windows. cargo test -p cipherbox-fuse --features fuse = 131 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…stale handleShare's useCallback dep array omitted `kind`, which the callback now branches on for the D-03c file-vs-folder pin-issuance gate (folders call the folder-only addRecipientPubkeyPin; files skip it). If `kind` resolves after the callback is memoized without `item` changing, a stale folder branch would call addRecipientPubkeyPin for a file and throw "Shared item not loaded". Add `kind` to the deps (handleUpgrade already carries it). Note react-hooks/exhaustive-deps is not enabled in this repo's eslint config, so nothing was masking it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…osed check A shared FILE is a leaf node with no NodeWriteBody, so it structurally cannot carry an owner-sealed recipient pin — pin issuance (addRecipientPubkeyPin) is folder-only. But the rotation walk rotates EVERY node including files, and re_mint_grants_rooted_at ran unconditionally for each. So a scope-exit rotation of a folder that merely CONTAINS a separately-shared file would rotate that file node, query its grant, read a structurally-empty pin list, and hit the D-03e "0 pinned" hard fail-closed — aborting the WHOLE rotation (partial rotation -> AES-GCM cascade). D-03c/d/e implicitly assumed folder/root shares. Fix at the enforcement layer (Rust engine.rs re_mint_grants_rooted_at + TS engine.ts reMintGrantsRootedAt twin): a `nodeKind === File` node is EXEMPT from the pin fetch + pin check and is re-minted directly. Folder/root grants stay fully fail-closed (the carve-out is file-only). This accepts the pre-existing file-share recipient-substitution limitation (files were never pinned at issuance either; tracked in the recipient-pin-lifecycle todo), not a downgrade of the folder-share guard. Tests: - Rust file_rooted_grant_is_re_minted_without_a_pin_while_folder_still_fails_closed (direct: File exempt + re-minted, Folder still Err). - TS grant-remint Test F / F2 (file re-mints without consulting getPinsFn; folder with empty pins still throws). Documented as D-03g in 80-CONTEXT.md and the recipientPins section of METADATA_SCHEMAS.md. cargo test -p cipherbox-sdk = 154 passed; sdk-core grant-remint = 10 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (3)
apps/desktop/src-tauri/src/fuse/mod.rs (1)
224-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winConsider extracting shared root-inode construction.
This
InodeKind::Rootliteral (including the newrecipient_pinsfield) is duplicated verbatim infuse/windows/mod.rs. A small shared helper would prevent future field additions from requiring synchronized edits across both mount paths.♻️ Sketch of a shared helper
fn build_root_inode_kind( ipns_name: String, read_key: Zeroizing<[u8; 32]>, write_key: Zeroizing<[u8; 32]>, ipns_private_key: Option<Vec<u8>>, ) -> cipherbox_fuse::inode::InodeKind { cipherbox_fuse::inode::InodeKind::Root { ipns_name, read_key, write_key, ipns_private_key: Zeroizing::new(ipns_private_key.unwrap_or_default()), recipient_pins: Vec::new(), } }🤖 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 `@apps/desktop/src-tauri/src/fuse/mod.rs` around lines 224 - 234, Extract a shared root-inode construction helper for the duplicated InodeKind::Root literals used by the Unix and Windows mount paths. Move initialization of ipns_name, keys, ipns_private_key, and recipient_pins into the helper, then update both callers to use it while preserving their existing inputs and behavior.apps/desktop/src-tauri/src/commands/vault.rs (1)
34-39: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAdd a
recipient_pinsassertion to the existing round-trip test.
build_empty_root_published_node_round_tripsdecodeswrite_bodybut never asserts on the newly-addedrecipient_pinsfield, leaving this codec path unverified by that test.✅ Suggested addition
assert!(write_body.write_children.is_empty(), "no write children"); assert_eq!(write_body.ipns_private_key, root_ipns_seed.to_vec()); + assert!(write_body.recipient_pins.is_empty(), "fresh root has no pins");🤖 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 `@apps/desktop/src-tauri/src/commands/vault.rs` around lines 34 - 39, Add an assertion in the existing build_empty_root_published_node_round_trips test verifying that the decoded write_body recipient_pins field is an empty vector, matching the initialization in the NodeWriteBody construction. Keep the assertion alongside the existing round-trip field checks.packages/sdk/src/__tests__/shared-write.test.ts (1)
463-518: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low valueSolid regression test for the pin-dropping bug. Correctly exercises the exact
resealAndPublishParentchokepoint scenario described in the PR.Optionally, consider adding a companion case where the parent has no
recipientPins(undefined) to confirm the reseal path doesn't spuriously materialize[]or otherwise break the "undefined ≡ []" contract documented inNodeWriteBody. As per path instructions, "Focus on test coverage, 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/src/__tests__/shared-write.test.ts` around lines 463 - 518, The existing regression test covers preservation of populated recipientPins, but lacks coverage for an absent pin list. Add a companion test around deleteFromSharedFolder and resealAndPublishParent using a parent without recipientPins, then unseal the captured publication and assert writeBody.recipientPins remains undefined rather than being materialized as an empty array.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 `@tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts`:
- Line 354: Update the shared finally cleanup in the script’s main flow to zero
rootWriteKey after its use in pinRecipientOnGrantRoot, matching the cleanup
performed by bump-ipns-sequence.ts. Preserve the existing cleanup for all other
sensitive keys and ensure rootWriteKey is cleared before leaving the finally
block.
---
Nitpick comments:
In `@apps/desktop/src-tauri/src/commands/vault.rs`:
- Around line 34-39: Add an assertion in the existing
build_empty_root_published_node_round_trips test verifying that the decoded
write_body recipient_pins field is an empty vector, matching the initialization
in the NodeWriteBody construction. Keep the assertion alongside the existing
round-trip field checks.
In `@apps/desktop/src-tauri/src/fuse/mod.rs`:
- Around line 224-234: Extract a shared root-inode construction helper for the
duplicated InodeKind::Root literals used by the Unix and Windows mount paths.
Move initialization of ipns_name, keys, ipns_private_key, and recipient_pins
into the helper, then update both callers to use it while preserving their
existing inputs and behavior.
In `@packages/sdk/src/__tests__/shared-write.test.ts`:
- Around line 463-518: The existing regression test covers preservation of
populated recipientPins, but lacks coverage for an absent pin list. Add a
companion test around deleteFromSharedFolder and resealAndPublishParent using a
parent without recipientPins, then unseal the captured publication and assert
writeBody.recipientPins remains undefined rather than being materialized as an
empty array.
🪄 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: b0d44009-fe21-47aa-aaac-c9cb6daeb8fe
📒 Files selected for processing (25)
.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.mdapps/desktop/src-tauri/src/commands/vault.rsapps/desktop/src-tauri/src/fuse/mod.rsapps/desktop/src-tauri/src/fuse/windows/mod.rsapps/web/src/components/file-browser/ShareDialog.tsxcrates/fuse/src/write_ops/grant_scope.rscrates/fuse/src/write_ops/rotation_deps.rscrates/sdk/src/rotation/engine.rsdocs/METADATA_SCHEMAS.mdpackages/core/src/node/types.tspackages/sdk-core/scripts/edit-filepointer.mtspackages/sdk-core/scripts/rename-folder.mtspackages/sdk-core/src/__tests__/folder/registration.test.tspackages/sdk-core/src/__tests__/folder/write-body.test.tspackages/sdk-core/src/__tests__/rotation/grant-remint.test.tspackages/sdk-core/src/__tests__/share/recipient-pins.test.tspackages/sdk-core/src/folder/registration.tspackages/sdk-core/src/rotation/engine.tspackages/sdk/src/__tests__/shared-write.test.tspackages/sdk/src/bin/index.tspackages/sdk/src/share/shared-write.tspackages/sdk/src/write-body-params.tstests/desktop-e2e/scripts/bump-ipns-sequence.tstests/desktop-e2e/scripts/shared-scope-exit-rotation.mts
🚧 Files skipped from review as they are similar to previous changes (7)
- docs/METADATA_SCHEMAS.md
- .planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md
- packages/sdk/src/write-body-params.ts
- packages/sdk-core/src/folder/registration.ts
- packages/sdk-core/src/tests/share/recipient-pins.test.ts
- packages/sdk-core/src/rotation/engine.ts
- crates/fuse/src/write_ops/rotation_deps.rs
Entire-Checkpoint: 3426a263ffcb
The web scope-exit rotation path drove rotateReadFromNode with innerGrants/grantCallbacks undefined, so its per-node grant re-mint seam never fired. All web re-mint was delegated to the owner-reconcile sweep, which skips file roots (a file has no FolderTree entry). Result: a separately-shared FILE inside a folder the owner rotates gets its read key rotated (mintFileKeyOnRotate) but its grant is re-minted by neither path, so the recipient keeps a stale key and loses access (recipient-pin-lifecycle §5). Desktop/Rust already re-mints inline. Wire the inline seam on web (no sdk-core engine, Rust, or API change — the seam was already built and file-aware): - types.ts: add optional RotationClientCallbacks.resolveInlineGrantRemint - client.ts: performScopeExitRotation resolves it for the rotation root and threads innerGrants/grantCallbacks into rotateReadFromNode (undefined -> unchanged sweep-only behavior) - owner-reconcile.service.ts (web): build the seam with a nodeId->ipnsName -aware getRecipientPubkeyPins so per-node pin reads resolve correctly (files are pin-exempt; folder-root map misses fail closed) - rotation-driver.service.ts (web): expose it via buildRotationClientCallbacks Proven at the sdk chokepoint (client-rotation.test.ts): the resolved bundle is threaded verbatim, and stays undefined when the seam is omitted or resolves no active grants. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J7TQ4SaRtmeFjEs7PUNCgi Entire-Checkpoint: d1e8728f48af
| if (kind === 'folder') { | ||
| await getSdkClient().addRecipientPubkeyPin(item.ipnsName, recipientPublicKey); | ||
| } |
There was a problem hiding this comment.
File kind fallback breaks sharing
This branch still treats kind === 'folder' as authoritative, but the caller can pass the folder fallback while the selected child is still missing from the resolved item map. When a real file is opened in that state, this path calls the folder-only addRecipientPubkeyPin(item.ipnsName) before sharesControllerCreateShare, and the SDK rejects the file IPNS as not loaded as a folder. File sharing can still fail until the dialog is retried after type resolution catches up. The upgrade path uses the same kind gate for getRecipientPubkeyPins, so a misclassified file can also fail before the grant update.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/sdk/src/__tests__/client-rotation.test.ts (1)
308-414: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winGood coverage; consider adding a rejection-path test.
The three cases (threaded, omitted, resolved-undefined) are solid. Given
resolveInlineGrantRemintcallbacks can fail closed (e.g., owner-reconcile'sgetRecipientPubkeyPinscan throw), a fourth case asserting what happens when the callback rejects — and whether that surfaces as arenameItem/mutation failure — would lock in the intended failure-mode behavior.As per path instructions: "Focus on test coverage, 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/src/__tests__/client-rotation.test.ts` around lines 308 - 414, Add a rejection-path test alongside the existing resolveInlineGrantRemint lifecycle tests, using a callback that rejects and asserting the intended failure behavior of renameItem, including that the mutation rejects with the same error or expected failure and rotateReadFromNode is not incorrectly invoked. Keep the existing successful, omitted, and undefined-resolution cases unchanged.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.
Nitpick comments:
In `@packages/sdk/src/__tests__/client-rotation.test.ts`:
- Around line 308-414: Add a rejection-path test alongside the existing
resolveInlineGrantRemint lifecycle tests, using a callback that rejects and
asserting the intended failure behavior of renameItem, including that the
mutation rejects with the same error or expected failure and rotateReadFromNode
is not incorrectly invoked. Keep the existing successful, omitted, and
undefined-resolution cases unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: ce3031a7-8c89-44e4-b82f-26484a3eded5
📒 Files selected for processing (7)
apps/web/src/services/owner-reconcile.service.tsapps/web/src/services/rotation-driver.service.tspackages/sdk/src/__tests__/client-rotation.test.tspackages/sdk/src/client.tspackages/sdk/src/index.tspackages/sdk/src/types.tstests/desktop-e2e/scripts/shared-scope-exit-rotation.mts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/sdk/src/index.ts
- tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts
Phase 80 — Rotation write-plane and re-mint durability
Closes the remaining scope-exit rotation and re-mint correctness/durability gaps so rotated nodes stay owned-walkable and replay-recoverable, and re-mint stops trusting server-supplied recipient keys or doing O(nodes×shares) work. Retires the four source todos (D-01..D-04).
What shipped
write_sealedreconstruction on rotation republish. A scope-exit read-key rotation no longer republishes rotated nodes withwrite_sealed: None; the FUSE adapter reconstructsNodeWriteBodyfrom the in-memoryInodeTableand re-seals at the node's new generation, solist_folder_ownedandreplay.rssigning-seed recovery survive a rotation./shares/sentcached once per rotation job. Re-mint no longer refetches per rotated node (was O(nodes×shares)); mirrored in the TS owner-reconcile path.NodeWriteBody.recipientPins(server-opaque, cross-device, AAD-bound under the owner write key). All three round-trip consumers — Rust re-mint, TS re-mint, web upgrade/reconcile — verify the/shares/sent-returned pubkey against the pin and fail closed on mismatch, closing the substituted-relay-pubkey confidentiality break. D-03e: a missing/mismatched pin is a HARD fail-closed (no legacy, no TOFU, no backfill).rotatedNodesdefensive 32-byte copy ofreadKey(no aliasing withparentNewReadKey), matching Rust parity.Metadata-schema change: additive
recipientPinsonNodeWriteBody, Rust/TS CBOR parity, new plaintext-JSON KATseal_vectors[1](frozenseal_vectors[0]preserved byte-for-byte).docs/METADATA_SCHEMAS.mdupdated. No API/DTO change (nopnpm api:generate); no DB migration.Flag resolutions & review fixes
replay.rs::fetch_splice_publish_parentdropped recipient pins (FIXED,3e3ec2a3d). The journal-replay parent re-splice re-sealed with emptyrecipient_pins, stripping the owner-sealed pins of a shared parent; the nextInodeTablepopulation would surface empty pins and a later re-mint would hard fail-closed (D-03e). Now preserves the pins decoded from the parent's current write-body verbatim.ddb7082e6). The same class as FLAG 1 existed in the routine mutation reseals (build_folder_metadata,publish_file_node+ fuser/winfsp callers, and the TSclient.tspublish sites +adoptPublishedFolderState), so an ordinary write to a shared folder/file republished it pin-less → later re-mint hard fail-closed, defeating revocation by ordinary usage. Pins are now threaded verbatim through every routine reseal. Rust + TS regression tests added; SDK-E2E stays 106/106.addRecipientPubkeyPinmissing ROT-07 reconcile (FIXED). The pin-issuance publish now callsreconcileFolderSequencebefore publishing, mirroring every other publish path.crates/fuse/src/platform/windows/write_ops.rs) updated by inspection only (macOS/CI build split); confirmed via the CICargo Check & Test (Windows)job below.Review dispositions
Crypto/privacy review, general security sweep, and CodeRabbit CLI. Full record in
.planning/security/REVIEW-80.md.journal_helpers+replay.rsfile-node — narrow, fail-closed-safe, largely unreachable via replay idempotency). See.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md. All fail-closed-safe (no key leak).string[]vsUint8Array[](style refactor, internally consistent);decode.tsbase64 validation (input is AEAD-authenticated);wb_byteszeroize (accepted pre-existing pattern); "compressed" pin comment (doc-only, raw-byte compare is encoding-agnostic).Verification
cipherbox-fuse(fuse feature)🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation