Skip to content

feat: rotation write-plane and re-mint durability with recipient-pubkey pinning#615

Open
FSM1 wants to merge 30 commits into
mainfrom
feat/rotation-write-plane-and-re-mint-durability
Open

feat: rotation write-plane and re-mint durability with recipient-pubkey pinning#615
FSM1 wants to merge 30 commits into
mainfrom
feat/rotation-write-plane-and-re-mint-durability

Conversation

@FSM1

@FSM1 FSM1 commented Jul 12, 2026

Copy link
Copy Markdown
Owner

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

  • D-01 — write_sealed reconstruction on rotation republish. A scope-exit read-key rotation no longer republishes rotated nodes with write_sealed: None; the FUSE adapter reconstructs NodeWriteBody from the in-memory InodeTable and re-seals at the node's new generation, so list_folder_owned and replay.rs signing-seed recovery survive a rotation.
  • D-02 — /shares/sent cached once per rotation job. Re-mint no longer refetches per rotated node (was O(nodes×shares)); mirrored in the TS owner-reconcile path.
  • D-03 — end-to-end recipient-pubkey pinning. The issuance-time recipient pubkey is pinned in the shared node's owner-sealed 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).
  • D-04 — TS rotatedNodes defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity.

Metadata-schema change: additive recipientPins on NodeWriteBody, Rust/TS CBOR parity, new plaintext-JSON KAT seal_vectors[1] (frozen seal_vectors[0] preserved byte-for-byte). docs/METADATA_SCHEMAS.md updated. No API/DTO change (no pnpm api:generate); no DB migration.

Flag resolutions & review fixes

  • FLAG 1 — replay.rs::fetch_splice_publish_parent dropped recipient pins (FIXED, 3e3ec2a3d). The journal-replay parent re-splice re-sealed with empty recipient_pins, stripping the owner-sealed pins of a shared parent; the next InodeTable population 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.
  • HIGH (crypto + security review + CodeRabbit) — routine reseals dropped pins (FIXED, ddb7082e6). The same class as FLAG 1 existed in the routine mutation reseals (build_folder_metadata, publish_file_node + fuser/winfsp callers, and the TS client.ts publish 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.
  • CodeRabbit — addRecipientPubkeyPin missing ROT-07 reconcile (FIXED). The pin-issuance publish now calls reconcileFolderSequence before publishing, mirroring every other publish path.
  • FLAG 2 — winfsp Windows sites (crates/fuse/src/platform/windows/write_ops.rs) updated by inspection only (macOS/CI build split); confirmed via the CI Cargo Check & Test (Windows) job below.

Review dispositions

Crypto/privacy review, general security sweep, and CodeRabbit CLI. Full record in .planning/security/REVIEW-80.md.

  • D-03 verdict: three-consumer fail-closed pin binding is cryptographically sound against the substituted-relay-pubkey threat. Compare is raw-byte equality; pins read only from owner-sealed sources; D-04 defensive copy confirmed non-aliased; KAT parity holds.
  • Fixed: 1 HIGH (routine-reseal pin drop, both languages) + FLAG 1 + the ROT-07 reconcile gap.
  • Deferred (1 todo, material): recipient-pin lifecycle — pruning-on-revoke (defense-in-depth against a relay re-injecting a revoked-but-pinned recipient), unbounded pin growth, non-atomic issuance, and crash-replay pin preservation (journal_helpers + replay.rs file-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).
  • Dismissed (nits): pin domain type string[] vs Uint8Array[] (style refactor, internally consistent); decode.ts base64 validation (input is AEAD-authenticated); wb_bytes zeroize (accepted pre-existing pattern); "compressed" pin comment (doc-only, raw-byte compare is encoding-agnostic).

Verification

Gate Result
Rust cipherbox-fuse (fuse feature) 130 passed, 0 failed
SDK-E2E (client→API IPNS round-trip, TEE simulator up) 106 passed / 16 files
sdk unit 423 passed / 3 skipped
sdk-core unit 417 passed
core unit (incl. node-codec KAT) 204 passed
TS client-chain rebuild (typecheck) clean

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added end-to-end recipient public-key pinning for shares and read-to-write upgrades, including fail-closed enforcement for folder upgrades.
    • Introduced client APIs to add and retrieve recipient pins.
    • Stored, emitted (only when non-empty), and preserved recipient pins across republishing, rotations, and related updates.
  • Bug Fixes

    • Reduced repeated “sent shares” fetching by caching per rotation run.
    • Improved rotation durability by reconstructing missing write data when available and preserving pin state.
    • Added defensive handling to prevent key aliasing issues during rotation.
  • Documentation

    • Updated schema, security review, and phase planning/validation materials for the new pin field.

FSM1 and others added 18 commits July 12, 2026 18:25
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>
@coderabbitai

coderabbitai Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Phase 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.

Changes

Rotation durability and recipient pinning

Layer / File(s) Summary
Recipient-pin wire format
crates/core/src/node/*, packages/core/src/node/*, tests/vectors/*, docs/METADATA_SCHEMAS.md
NodeWriteBody supports optional recipient pins with empty-field omission, tolerant decoding, cross-language sealing vectors, and schema documentation.
FUSE write-plane preservation
crates/fuse/src/{inode.rs,fs.rs,content_ops.rs,replay.rs,read_ops.rs}, crates/fuse/src/platform/windows/*
Recipient pins are cached in inode state and carried through materialization, routine republish, journal/replay, and Windows write paths.
Rust rotation reconstruction and enforcement
crates/fuse/src/write_ops/rotation_deps.rs, crates/sdk/src/rotation/engine.rs
Missing rotated write_sealed bodies can be reconstructed from local state, sent shares are cached per job, and re-minting fails closed when recipients are not pinned.
SDK pin storage and propagation
packages/sdk-core/src/share/*, packages/sdk-core/src/folder/registration.ts, packages/sdk/src/client.ts, packages/sdk/src/write-body-params.ts
Pin helpers, CAS-union persistence, client read/write APIs, and write-body propagation preserve recipient pins across publishes.
TypeScript rotation and reconciliation
packages/sdk-core/src/rotation/*, packages/sdk/src/share/owner-reconcile.ts, packages/sdk/src/__tests__/*
Rotated read keys use defensive copies, sent-grant reads are memoized per reconcile pass, and re-mint validates pinned recipients before wrapping.
Web issuance and upgrade checks
apps/web/src/components/file-browser/ShareDialog.tsx, apps/web/src/services/owner-reconcile.service.ts
Share issuance records recipient pins, upgrades verify them before re-wrapping, and reconcile transports resolve pins per root node.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

  • FSM1/cipher-box#582 — Extends the same SDK rotation grant re-minting path with related callback and validation seams.
  • FSM1/cipher-box#596 — Modifies the same rotation and CAS-409 write-publication paths.
  • FSM1/cipher-box#607 — Extends the Rust rotation re-mint dependency and grant-handling paths used here.

Suggested labels: release:web:feat, release:desktop:feat, release:sdk-core:feat, release:sdk:feat, release:cipherbox-core:feat, release:cipherbox-fuse:feat

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main Phase 80 rotation durability and recipient-pubkey pinning changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/rotation-write-plane-and-re-mint-durability

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added release:cipherbox-core:feat Minor version bump (new feature) for cipherbox-core release:core:feat Minor version bump (new feature) for core release:cipherbox-fuse:feat Minor version bump (new feature) for cipherbox-fuse release:sdk-core:feat Minor version bump (new feature) for sdk-core release:sdk:feat Minor version bump (new feature) for sdk release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:web:feat Minor version bump (new feature) for web release:desktop:fix Patch version bump (bug fix) for desktop release:tee-worker:fix Patch version bump (bug fix) for tee-worker labels Jul 12, 2026
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Release Preview

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

Cascade Details

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

@codecov

codecov Bot commented Jul 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 84.79638% with 168 lines in your changes missing coverage. Please review.
✅ Project coverage is 78.00%. Comparing base (cba7857) to head (04198f8).

Files with missing lines Patch % Lines
crates/fuse/src/write_ops/rotation_deps.rs 88.67% 58 Missing ⚠️
packages/sdk/src/client.ts 42.68% 47 Missing ⚠️
packages/core/src/node/decode.ts 9.09% 20 Missing ⚠️
crates/fuse/src/inode.rs 72.09% 12 Missing ⚠️
crates/fuse/src/replay.rs 88.13% 7 Missing ⚠️
packages/sdk/src/write-body-params.ts 66.66% 6 Missing ⚠️
packages/sdk/src/share/owner-reconcile.ts 54.54% 5 Missing ⚠️
crates/sdk/src/listing.rs 42.85% 4 Missing ⚠️
apps/desktop/src-tauri/src/fuse/mod.rs 0.00% 3 Missing ⚠️
crates/fuse/src/content_ops.rs 0.00% 2 Missing ⚠️
... and 3 more
Additional details and impacted files

Impacted file tree graph

@@            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     
Flag Coverage Δ
api 84.35% <65.66%> (-0.38%) ⬇️
api-client 84.35% <65.66%> (-0.38%) ⬇️
core 84.35% <65.66%> (-0.38%) ⬇️
crypto 84.35% <65.66%> (-0.38%) ⬇️
desktop 26.63% <40.00%> (-4.99%) ⬇️
rust 80.16% <90.19%> (?)
sdk 84.35% <65.66%> (-0.38%) ⬇️
sdk-core 84.35% <65.66%> (-0.38%) ⬇️

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 48.79% <100.00%> (+48.79%) ⬆️
crates/core/src/node/encode.rs 100.00% <100.00%> (ø)
crates/core/src/node/seal.rs 97.10% <100.00%> (ø)
crates/core/src/node/types.rs 100.00% <100.00%> (ø)
crates/fuse/src/fs.rs 82.68% <100.00%> (ø)
crates/fuse/src/journal_helpers.rs 93.56% <100.00%> (ø)
crates/fuse/src/test_support.rs 100.00% <100.00%> (ø)
crates/fuse/src/write_ops/grant_scope.rs 89.89% <100.00%> (ø)
crates/fuse/src/write_ops/implementation/delete.rs 89.78% <100.00%> (ø)
...tes/fuse/src/write_ops/implementation/file_data.rs 58.27% <100.00%> (ø)
... and 20 more

... and 76 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 marked this pull request as ready for review July 12, 2026 20:09
@greptile-apps

greptile-apps Bot commented Jul 12, 2026

Copy link
Copy Markdown

Greptile Summary

This PR adds rotation write-plane durability and recipient public-key pinning for share re-minting. The main changes are:

  • Reconstruct and preserve sealed write bodies during rotation republish.
  • Cache sent-share lookups during rotation and reconcile work.
  • Add owner-sealed recipient pins and fail-closed verification for folder re-mint and upgrade paths.
  • Preserve recipient pins through routine Rust and TypeScript republish paths.
  • Add web inline grant re-mint wiring for rotated share roots.

Confidence Score: 4/5

This is close, but the file-share path should be fixed before merging.

  • The rotation and re-mint plumbing is largely consistent across the changed Rust and TypeScript paths.
  • A real file can still be misclassified as a folder while item type resolution is incomplete.
  • That state can call folder-only pin helpers and block file share creation or upgrade.

apps/web/src/components/file-browser/ShareDialog.tsx

Important Files Changed

Filename Overview
apps/web/src/components/file-browser/ShareDialog.tsx Adds folder-only recipient-pin writes and checks, but unresolved file kind state can still call folder-only pin helpers for file shares.
apps/web/src/services/owner-reconcile.service.ts Adds pin-aware reconcile transports and inline grant re-mint resolution for rotation.
packages/sdk-core/src/rotation/engine.ts Threads node kind into grant re-minting and applies folder pin checks while exempting file roots.
packages/sdk/src/client.ts Threads inline grant re-mint callbacks into rotation and preserves recipient pins through routine publish paths.

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>
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx 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: 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 win

Adding recipient_pins is a source-breaking SDK change

ResolvedOwnedChild is exported from crates/sdk/src/lib.rs and 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 win

Cover 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 win

Skip the pin lookup when there are no surviving grants.

get_recipient_pubkey_pins(node_id) is only used on the non-revoked wrap path. If grants is empty or all revoked, the loop never wraps anything, so this fetch is just an extra InodeTable scan. Gate it behind grants.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 win

Add 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 win

Add 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_child returns 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 win

Use ipnsName for the new IPNS identifier parameter.

Rename the factory parameter and closure capture from shareRootIpnsName to ipnsName; the root-specific meaning is already conveyed by the caller and API context.

As per coding guidelines, use ipnsName for 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

📥 Commits

Reviewing files that changed from the base of the PR and between cba7857 and 47df1b8.

📒 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.md
  • apps/web/src/components/file-browser/ShareDialog.tsx
  • apps/web/src/services/owner-reconcile.service.ts
  • crates/core/src/node/encode.rs
  • crates/core/src/node/seal.rs
  • crates/core/src/node/types.rs
  • crates/core/tests/node_write_body_vectors.rs
  • crates/fuse/src/content_ops.rs
  • crates/fuse/src/fs.rs
  • crates/fuse/src/inode.rs
  • crates/fuse/src/journal_helpers.rs
  • crates/fuse/src/platform/windows/write_ops.rs
  • crates/fuse/src/read_ops.rs
  • crates/fuse/src/replay.rs
  • crates/fuse/src/test_support.rs
  • crates/fuse/src/write_ops/grant_scope.rs
  • crates/fuse/src/write_ops/implementation/delete.rs
  • crates/fuse/src/write_ops/implementation/file_data.rs
  • crates/fuse/src/write_ops/implementation/mkdir.rs
  • crates/fuse/src/write_ops/implementation/rename.rs
  • crates/fuse/src/write_ops/rotation_deps.rs
  • crates/sdk/src/emit.rs
  • crates/sdk/src/listing.rs
  • crates/sdk/src/rotation/engine.rs
  • docs/METADATA_SCHEMAS.md
  • packages/core/src/__tests__/node-codec-vectors.test.ts
  • packages/core/src/node/decode.ts
  • packages/core/src/node/encode.ts
  • packages/core/src/node/types.ts
  • packages/sdk-core/src/__tests__/rotation/engine.test.ts
  • packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
  • packages/sdk-core/src/__tests__/share/recipient-pins.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • packages/sdk-core/src/index.ts
  • packages/sdk-core/src/rotation/engine.ts
  • packages/sdk-core/src/share/index.ts
  • packages/sdk-core/src/share/recipient-pins.ts
  • packages/sdk/src/__tests__/delete-item.test.ts
  • packages/sdk/src/__tests__/owner-reconcile.test.ts
  • packages/sdk/src/client.ts
  • packages/sdk/src/index.ts
  • packages/sdk/src/share/owner-reconcile.ts
  • packages/sdk/src/write-body-params.ts
  • tests/vectors/node-codec.json

Comment thread packages/core/src/node/decode.ts
Comment thread packages/core/src/node/types.ts
Comment thread packages/sdk-core/src/folder/registration.ts
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>
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx Outdated
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>
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx Outdated
FSM1 and others added 3 commits July 13, 2026 00:27
…-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>
Comment thread apps/web/src/components/file-browser/ShareDialog.tsx
FSM1 and others added 2 commits July 13, 2026 01:10
…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>
Comment thread apps/web/src/services/owner-reconcile.service.ts
FSM1 and others added 2 commits July 13, 2026 01:48
…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>
Comment thread apps/web/src/services/owner-reconcile.service.ts

@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: 1

🧹 Nitpick comments (3)
apps/desktop/src-tauri/src/fuse/mod.rs (1)

224-234: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider extracting shared root-inode construction.

This InodeKind::Root literal (including the new recipient_pins field) is duplicated verbatim in fuse/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 win

Add a recipient_pins assertion to the existing round-trip test.

build_empty_root_published_node_round_trips decodes write_body but never asserts on the newly-added recipient_pins field, 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 value

Solid regression test for the pin-dropping bug. Correctly exercises the exact resealAndPublishParent chokepoint 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 in NodeWriteBody. 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

📥 Commits

Reviewing files that changed from the base of the PR and between 47df1b8 and 575fbff.

📒 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.md
  • 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/components/file-browser/ShareDialog.tsx
  • crates/fuse/src/write_ops/grant_scope.rs
  • crates/fuse/src/write_ops/rotation_deps.rs
  • crates/sdk/src/rotation/engine.rs
  • docs/METADATA_SCHEMAS.md
  • packages/core/src/node/types.ts
  • packages/sdk-core/scripts/edit-filepointer.mts
  • packages/sdk-core/scripts/rename-folder.mts
  • packages/sdk-core/src/__tests__/folder/registration.test.ts
  • packages/sdk-core/src/__tests__/folder/write-body.test.ts
  • packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts
  • packages/sdk-core/src/__tests__/share/recipient-pins.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • packages/sdk-core/src/rotation/engine.ts
  • packages/sdk/src/__tests__/shared-write.test.ts
  • packages/sdk/src/bin/index.ts
  • packages/sdk/src/share/shared-write.ts
  • packages/sdk/src/write-body-params.ts
  • tests/desktop-e2e/scripts/bump-ipns-sequence.ts
  • tests/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

Comment thread tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts
Comment thread apps/web/src/services/owner-reconcile.service.ts
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
Comment on lines +232 to +234
if (kind === 'folder') {
await getSdkClient().addRecipientPubkeyPin(item.ipnsName, recipientPublicKey);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 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.

@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.

🧹 Nitpick comments (1)
packages/sdk/src/__tests__/client-rotation.test.ts (1)

308-414: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Good coverage; consider adding a rejection-path test.

The three cases (threaded, omitted, resolved-undefined) are solid. Given resolveInlineGrantRemint callbacks can fail closed (e.g., owner-reconcile's getRecipientPubkeyPins can throw), a fourth case asserting what happens when the callback rejects — and whether that surfaces as a renameItem/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

📥 Commits

Reviewing files that changed from the base of the PR and between 575fbff and 04198f8.

📒 Files selected for processing (7)
  • apps/web/src/services/owner-reconcile.service.ts
  • apps/web/src/services/rotation-driver.service.ts
  • packages/sdk/src/__tests__/client-rotation.test.ts
  • packages/sdk/src/client.ts
  • packages/sdk/src/index.ts
  • packages/sdk/src/types.ts
  • tests/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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:cipherbox-core:feat Minor version bump (new feature) for cipherbox-core release:cipherbox-fuse:feat Minor version bump (new feature) for cipherbox-fuse release:cipherbox-sdk:feat Minor version bump (new feature) for cipherbox-sdk release:core:feat Minor version bump (new feature) for core release:desktop:fix Patch version bump (bug fix) for desktop release:sdk:feat Minor version bump (new feature) 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