fix: close rust and fuse scope-exit read-revocation bypasses#607
Conversation
Entire-Checkpoint: 969d6d9de215
Resolve plan-checker blocker + 2 warnings on the Phase 74 draft plans: - 74-05: route grant-query ops through a RotationTransport seam extension (collect_sent_shares/update_grant/revoke_share) instead of an ungeneric self.transport.api reach-around; keeps grant_scope.rs and FuseRotationDeps::new untouched, so wave 2 (74-02/74-03/74-05) has zero files_modified overlap. - 74-01/74-02/74-03/74-04 Task 1: tighten RED-confirmation <automated> blocks to the negation form so an unexpected pass genuinely fails (no trailing echo mask). - 74-VALIDATION.md: sync with authored plans (status ready, nyquist_compliant true, real per-task verification map, sign-off checked). plan-checker: VERIFICATION PASSED (iteration 2). SC1/SC2/SC3 all covered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: f8220eed0534
Entire-Checkpoint: 2ed267d94d2e
Adds rotate_read_surfaces_every_rotated_node_key_for_a_deep_tree, seeding a 3-node tree (grant-root -> folderB -> fileC) and asserting RotateReadResult.rotated_nodes contains every level's post-rotation key, keyed by ipns_name. Fails to compile until Task 2 adds the field (RED confirmed). Entire-Checkpoint: ff5d15794cc5
Adds RotatedNodeKey and RotateReadResult.rotated_nodes (additive, existing read_key/generation/sequence_number root-convenience fields kept), keyed by ipns_name per the LOCKED cross-language contract (parity twin is plan 74-02). Populated at three hooks proven to fire for every committed node: - the root commit branch (rotate_read_from_node_inner) - the BFS child commit branch (same function's walk loop) - repair_dirty_node's crash-resume repair path (its recovered_key IS the node's current valid post-rotation key, readily available, so folded in per RESEARCH Open Question 1 rather than deferred) CommittedRotation gains no ipns_name field (stays host-agnostic); ipns_name is threaded in at each call site where it is already in scope. No zeroing of key material inside the engine (D-09). Deep-tree test from the prior RED commit now passes; scoped cipherbox-sdk rotation::engine:: suite is green (27/27), no regression to existing call sites. Entire-Checkpoint: d72fb57614e5
Entire-Checkpoint: 53b90d437aeb
- Add raw-TCP capturing mock server (mirrors delete.rs's spawn_mock_rotation_server pattern) to shares.rs test module - Tests assert update_grant PATCHes /shares/:shareId/grant with encryptedReadKey+rootGeneration only, and revoke_share DELETEs /shares/:shareId - Both non-2xx paths assert ApiError::ApiResponse with op-name-prefixed message - RED: module fails to compile — update_grant/revoke_share do not exist yet Entire-Checkpoint: e6bc166427be
- ApiClient::authenticated_patch/authenticated_delete mirror authenticated_post's auth-header + reqwest builder shape - shares::update_grant PATCHes /shares/:shareId/grant with UpdateGrantRequest (encryptedReadKey + rootGeneration only, camelCase); root_generation formatted from u64 to the DTO's numeric string; forwards an already-ECIES-wrapped ciphertext, no key material handled here - shares::revoke_share DELETEs /shares/:shareId - Both treat 204 as success and map non-2xx to ApiError::ApiResponse with op-name-prefixed messages - Task 1 wire tests now pass; scoped api-client suite green (35/35) Entire-Checkpoint: 3c496f34e462
…unctions plan Entire-Checkpoint: 1993674c3eaa
Author two WinFsp unit tests mirroring the fuser rename.rs D-15d twins (rename_enotempty_destination_rejects_before_gate_with_no_rotation_attempt, rename_overwriting_a_covered_destination_gates_dest_ino_scope_exit), each asserting the correct rejection status code plus zero partial mutation. They exercise handle_rename end-to-end via a WinFspContext wrapping the shared test_support harness (widened from fuse-only to fuse-or-winfsp, with the fuser-specific CaptureSender/reply_error_code kept fuse-gated). Against today's unfixed handle_rename ordering these fail closed with the wrong status code, proving the not-yet-added dest gate is exercised. Entire-Checkpoint: 8241ef943871
Reorder WinFsp handle_rename to match the fuser rename.rs D-15d pipeline: the replace_if_exists collision check stays first and unchanged; the STATUS_DIRECTORY_NOT_EMPTY destination-replacement validation now runs BEFORE the source scope-exit gate (a doomed rename never triggers a rotation); the existing source gate is unchanged; a NEW destination scope-exit gate runs immediately after it, before the previously-ungated fs.inodes.remove(dest_ino), returning STATUS_ACCESS_DENIED on failure. Uses the plain (non-coalesced) gate, matching the fuser reference. Closes a Windows-only overwrite-rename revocation bypass (T-74-09) and advances SC3. Entire-Checkpoint: 1d6d481fa431
Entire-Checkpoint: 124fda63eb1d
Entire-Checkpoint: 07d2d7569182
- Seed grant-root Folder, intermediate Folder, and File inodes with distinct ipns_names and zeroed pre-rotation read_keys - Build a RotateReadResult.rotated_nodes map with new keys for all three - Reference the not-yet-existing refresh_rotated_inode_read_keys — genuine compile-error RED, confirmed via cargo test - Export RotatedNodeKey from cipherbox_sdk::rotation (was previously crate-internal to engine.rs, needed by grant_scope.rs's test and Task 2) Entire-Checkpoint: 3d2d751017b3
- Rename refresh_grant_root_read_key to refresh_rotated_inode_read_keys and generalize it to loop over RotateReadResult.rotated_nodes, matching each entry's ipns_name against every inode with no early return - Extend the match arms from Root | Folder to Root | Folder | File so file inodes (rotated via CRIT-1/mint_file_key_on_rotate) are refreshed too, closing a related staleness gap - Update the rotate_read_on_scope_exit call site to the new signature - Move the RotatedNodeKey import into the test module (cfg(test)-only use) to avoid an unused-import warning in the non-test build Entire-Checkpoint: 503fec2268bc
Entire-Checkpoint: da2f729bb44a
Extend the RotationTransport seam with collect_sent_shares/update_grant/ revoke_share, implement them on the test FakeTransport, and add FakeTransport-driven tests for FuseRotationDeps::query_grants_rooted_at/ update_grant/delete_grant. ApiClientTransport does not yet implement the three new trait methods, so cipherbox-fuse fails to compile - genuine RED before the GREEN implementation task wires the production adapter. Entire-Checkpoint: 094dc96e6df9
Implement ApiClientTransport's collect_sent_shares/update_grant/ revoke_share against the 74-04 wire functions, and replace the FuseRotationDeps ROT-04 no-op defaults with real overrides that delegate to the transport seam: query_grants_rooted_at filters collect_sent_shares by root_node_id and hex-decodes recipient_public_key (0x stripped, 04 prefix kept); update_grant forwards the already-ECIES-wrapped key without re-wrapping; delete_grant forwards to revoke_share. grant_scope.rs and FuseRotationDeps::new are unchanged. Also routes GET /shares/sent in delete.rs's pre-existing mock rotation server to an empty page - that test previously relied on query_grants_rooted_at's no-op default never hitting the network, and now genuinely exercises the live seam. Entire-Checkpoint: 2dcc4862be6a
Entire-Checkpoint: cac7917a5e5c
Adds rotateReadFromNode surfaces every rotated node key for a deep tree, seeding a 3-node root -> folderB -> fileC tree via existing mock helpers and asserting result.rotatedNodes contains an entry per rotated ipnsName. Mirrors the Rust 74-01 deep-tree parity test. RED confirmed: fails at runtime because RotateReadResult has no rotatedNodes field yet. Entire-Checkpoint: 4fb4a20f759a
Adds RotatedNodeKey type and RotateReadResult.rotatedNodes: Map<string, RotatedNodeKey> to packages/sdk-core/src/rotation/engine.ts, mirroring the Rust twin's rotated_nodes widening from plan 74-01 field-for-field (sequenceNumber kept bigint, matching this file's existing IPNS sequence-number convention rather than the plan table's literal number). Populated at the root commit branch and the BFS child commit branch inside rotateReadFromNode, keyed by ipnsName. Exported via the rotation and top-level barrels. Deep-tree parity test (Task 1) now green; sdk-core rotation suite passes 370/370. Entire-Checkpoint: 7404bd85df73
Entire-Checkpoint: 2bd87c30ebce
Entire-Checkpoint: 323fc85b8e4b
Extends shared-scope-exit-rotation.mts with a depth>=2 leg (grant-root -> folderB -> fileC/fileSibling) asserting a revoked recipient cannot decrypt any node under the grant root at any depth, exercising 74-03's intermediate Folder/File inode read_key refresh (SC1). Adds a retained-recipient leg in the same deep tree, polling for a re-minted encryptedReadKey via FuseRotationDeps::query_grants_rooted_at/update_grant (74-05, SC2) to prove an active grantee keeps access rather than being wrongly cut. The existing shallow D-16 legs (Part A/B) are left unchanged. Also adds tests/desktop-e2e/tsconfig.json (mirrors the web-e2e pattern), which did not previously exist -- required to typecheck the desktop-e2e .mts/.ts scripts at all. Entire-Checkpoint: dc1c247c9a99
Part D of shared-scope-exit-rotation.mts (landed alongside Task 1's deep/retained-vs-revoked legs in the same file edit, e1c1291) exercises an overwrite-rename onto a covered destination: a revoked recipient's stale key must fail post-rename (74-06's WinFsp handle_rename destination scope-exit gate, SC3) while a retained recipient's re-minted key still decrypts. Confirmed via grep that no equivalent rename-overwrite leg already existed in tests/desktop-e2e/scripts (RESEARCH A3) before adding this one to the same script. Updates run-all.sh/run-all.ps1's Step 8 comments (the invocation itself was already correct and unchanged) to document the new Part C/D coverage, so the WinFsp-authoritative proof for SC3 is clearly signposted on the Windows CI leg. Entire-Checkpoint: c1cd6cb3d525
Entire-Checkpoint: 547aaa19035e
Release Preview
Cascade Details
|
Greptile SummaryThis PR closes Rust, FUSE, and desktop read-revocation gaps around scope-exit rotation. The main changes are:
Confidence Score: 5/5This looks safe to merge.
Important Files Changed
Reviews (8): Last reviewed commit: "fix(74): make access-token TTL configura..." | Re-trigger Greptile |
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #607 +/- ##
==========================================
+ Coverage 70.12% 77.55% +7.43%
==========================================
Files 157 197 +40
Lines 15403 32657 +17254
Branches 1952 1952
==========================================
+ Hits 10801 25327 +14526
- Misses 4348 7076 +2728
Partials 254 254
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 688156af5d34
There was a problem hiding this comment.
Actionable comments posted: 11
🧹 Nitpick comments (2)
crates/fuse/src/write_ops/grant_scope.rs (1)
575-600: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick winSwap loop order for O(inodes) instead of O(rotated_nodes × inodes).
Iterating all inodes once per rotated node is O(R×I). Since each inode's
ipns_nameshould be unique, a single pass over inodes with an O(1)rotated_nodes.get(...)lookup does the same job in O(I) — meaningfully cheaper for deep-tree rotations with many rotated nodes and/or a large materialized inode table.♻️ Proposed loop-order swap
fn refresh_rotated_inode_read_keys(inodes: &mut InodeTable, result: &RotateReadResult) { - for rotated in result.rotated_nodes.values() { - for inode in inodes.inodes.values_mut() { - match &mut inode.kind { - InodeKind::Root { ipns_name, read_key, .. } - | InodeKind::Folder { ipns_name, read_key, .. } - | InodeKind::File { ipns_name, read_key, .. } - if ipns_name.as_str() == rotated.ipns_name.as_str() => - { - read_key.copy_from_slice(rotated.read_key.as_slice()); - } - _ => {} - } - } - } + for inode in inodes.inodes.values_mut() { + match &mut inode.kind { + InodeKind::Root { ipns_name, read_key, .. } + | InodeKind::Folder { ipns_name, read_key, .. } + | InodeKind::File { ipns_name, read_key, .. } => { + if let Some(rotated) = result.rotated_nodes.get(ipns_name.as_str()) { + read_key.copy_from_slice(rotated.read_key.as_slice()); + } + } + _ => {} + } + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/fuse/src/write_ops/grant_scope.rs` around lines 575 - 600, Update refresh_rotated_inode_read_keys to iterate over inodes once, extract each inode’s ipns_name and read_key across the existing Root, Folder, and File variants, then use an O(1) lookup in result.rotated_nodes to copy the matching rotated read key. Preserve the existing no-op behavior for unmatched inode kinds or names.crates/api-client/src/client.rs (1)
94-113: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winDrop the access-token read lock before awaiting the HTTP request.
Both methods hold the
RwLockread guard throughsend().await, which can blockset_access_tokenandclear_access_tokenfor the full request timeout. Clone the token before building the request, then release the guard before.send().await; apply the same pattern consistently to the existing authenticated helpers.Also applies to: 116-130
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/api-client/src/client.rs` around lines 94 - 113, Update authenticated_patch and the existing authenticated helper methods to clone the access token while holding the RwLock read guard, then release the guard before building and sending the HTTP request. Ensure bearer authentication uses the cloned token and no lock guard remains alive across send().await.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
@.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-01-PLAN.md:
- Around line 33-41: Update the RotatedNodeKey.sequenceNumber entry in both
parity tables:
.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-01-PLAN.md
lines 33-41 and
.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-02-PLAN.md
lines 34-40. Document the TypeScript representation as bigint, or explicitly
describe the language-specific representation, so both locked contracts match
the implementation’s safe 64-bit sequence handling.
In
@.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-04-PLAN.md:
- Around line 93-103: Constrain root_generation in the proposed update_grant API
to the server-supported 0..=i64::MAX range by using a bounded type or validating
before formatting and sending it. Update the corresponding contract statement in
.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-04-PLAN.md
lines 93-103 and correct the u64 claim in
.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-04-SUMMARY.md
lines 129-134.
In
@.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-07-PLAN.md:
- Around line 36-39: Align the SC1 acceptance criteria with the executed test:
in .planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-07-PLAN.md
lines 36-39, make folderB and fileSibling the authoritative hard decryptability
assertions and treat fileC as informational rather than a required hard
assertion. In
.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-07-SUMMARY.md
lines 36-40, preserve the existing rationale and authoritative assertion
wording; no unrelated changes are needed.
In
@.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-VERIFICATION.md:
- Around line 1-6: Update the frontmatter status in the verification report,
including the corresponding reports for phases 19–25, from passed to
human_needed so it matches the report status and remains pending until the
Windows and desktop-E2E validations complete.
In @.planning/ROADMAP.md:
- Around line 917-938: Update the Phase 74 progress block in the roadmap to
retain only the final “7/7 plans complete” state, removing the intermediate
executed counts from “6/7” through “1/7” while preserving the completed plan
checklist.
In @.planning/STATE.md:
- Around line 624-625: Update the session checkpoint metadata in STATE.md so the
“Stopped at” entry reflects the actual final plan completed for Phase 74 rather
than 74-02-PLAN.md, while preserving the existing “Last session” timestamp and
completion status.
In `@crates/fuse/src/platform/windows/write_ops.rs`:
- Around line 1102-1105: Update the destination lookup in the rename flow around
find_child so a result equal to source_ino is normalized to None before applying
the replace_if_exists collision check. Preserve ordinary overwrite handling for
other destination inodes and allow same-path or case-only renames to continue
through the normal rename/no-op path without deleting the source inode.
In `@crates/fuse/src/write_ops/rotation_deps.rs`:
- Around line 264-286: The per-node re-mint flow currently refetches sent shares
through query_grants_rooted_at. Add rotation-scoped caching of the
collect_sent_shares result, initialize or populate it once during the rotation,
and have query_grants_rooted_at filter the cached list by root_node_id instead
of calling the transport; preserve existing key parsing and error handling.
In `@packages/sdk-core/src/rotation/engine.ts`:
- Around line 2047-2051: Update the dirty-resume flow around repairDirtyNode so
each repaired node is inserted into the shared rotatedNodes map after resolving
its current record, including its ipnsName, recovered readKeyPrime, generation,
and sequence number. Preserve the existing BFS map instance and add a regression
test confirming dirty-resume callers receive refreshed cached keys for repaired
descendants.
In `@tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts`:
- Around line 321-329: Move the protected try/finally scope to include the
recipient authentication calls around authenticateRecipient and the existing
variables eve, carol, frank, and grace. Store each successfully authenticated
recipient in clearable optional bindings, and ensure the shared finally clears
every assigned private key even when a later authentication rejects.
- Around line 938-947: Reload folderBRef using carolNewGrantRootKey after Carol
receives the rotated grant, then pass the refreshed reference to
deriveChildReadKey before calling canRead. Update the retained-access check
around carolNewFolderBKey while preserving the existing acceptance assertion.
---
Nitpick comments:
In `@crates/api-client/src/client.rs`:
- Around line 94-113: Update authenticated_patch and the existing authenticated
helper methods to clone the access token while holding the RwLock read guard,
then release the guard before building and sending the HTTP request. Ensure
bearer authentication uses the cloned token and no lock guard remains alive
across send().await.
In `@crates/fuse/src/write_ops/grant_scope.rs`:
- Around line 575-600: Update refresh_rotated_inode_read_keys to iterate over
inodes once, extract each inode’s ipns_name and read_key across the existing
Root, Folder, and File variants, then use an O(1) lookup in result.rotated_nodes
to copy the matching rotated read key. Preserve the existing no-op behavior for
unmatched inode kinds or names.
🪄 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: 245635b7-9a71-4992-80a8-eb23ce165905
📒 Files selected for processing (43)
.planning/ROADMAP.md.planning/STATE.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-01-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-01-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-02-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-02-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-03-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-03-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-04-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-04-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-05-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-05-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-06-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-06-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-07-PLAN.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-07-SUMMARY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-PATTERNS.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-RESEARCH.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-SECURITY.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-UAT.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-VALIDATION.md.planning/phases/74-rust-and-fuse-rotation-revocation-soundness/74-VERIFICATION.md.planning/security/REVIEW-2026-07-11-phase74-rotation.md.planning/todos/pending/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md.planning/todos/pending/2026-07-11-ts-rotatednodes-defensive-copy-parity.mdcrates/api-client/src/client.rscrates/api-client/src/shares.rscrates/fuse/src/lib.rscrates/fuse/src/platform/windows/write_ops.rscrates/fuse/src/test_support.rscrates/fuse/src/write_ops/grant_scope.rscrates/fuse/src/write_ops/implementation/delete.rscrates/fuse/src/write_ops/rotation_deps.rscrates/sdk/src/rotation/engine.rscrates/sdk/src/rotation/mod.rspackages/sdk-core/src/__tests__/rotation/engine.test.tspackages/sdk-core/src/index.tspackages/sdk-core/src/rotation/engine.tspackages/sdk-core/src/rotation/index.tstests/desktop-e2e/scripts/run-all.ps1tests/desktop-e2e/scripts/run-all.shtests/desktop-e2e/scripts/shared-scope-exit-rotation.mtstests/desktop-e2e/tsconfig.json
Entire-Checkpoint: c156c30c01c2
For same-path or case-only renames find_child can return source_ino; the D-15d destination gate and removal would then delete the source inode before recreating its name mapping, leaving a dangling mapping / lost inode. Normalize dest_ino == source_ino to None so it follows the normal rename/no-op path, mirroring the fuser reference (implementation/rename.rs). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 6bf094ec6f4a
…nt fetch caching, e2e stale folderBRef Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 8b04d661e183
The rotation engine's re_mint_grants_rooted_at sent the SHARE grant encryptedReadKey as base64, but the API validates all share encryptedReadKey fields as even-length hex (update-grant.dto.ts) and decodes via Buffer.from(.., 'hex'). Base64 400s at runtime and fails the desktop-e2e SC#2 retained-recipient leg. Switch to hex::encode (internal node-codec fields stay base64). Update the unit test to hex-decode and assert the re-minted key matches even-length hex; fix the api-client update_grant doc comment to say hex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 13bbc5e2f6c8
On the crash-recovery dirty-resume path, repaired descendant read keys were not surfaced to the caller, so a FUSE/WinFsp inode refresh kept stale pre-rotation keys (latent revocation-bypass / mis-decrypt hazard). Rust: repair_dirty_node already inserted each recovered key into rotated_nodes, but the terminal return discarded the whole map when fresh_root was None (root already committed on resume). Capture the root's already-committed post-rotation convenience values on the dirty-resume branch and return a RotateReadResult carrying the map when any descendant was repaired. TS: repairDirtyNode never inserted the recovered readKeyPrime into the shared rotatedNodes map; add the insert (defensive copy) keyed by the child ipnsName, mirroring the Rust hook. Adds a dirty-resume regression test in each engine asserting the repaired descendant's key is present in the returned map. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 9afa56f06d93
…d check The SC#2 retained-recipient (Carol) check derived her folderB read key from the pre-rotation folderBRef, whose readKeySealed was sealed under the OLD grant-root key. The scope-exit rotation re-seals folderB under the NEW grant-root key, so unwrapping the stale seal with the new key cannot succeed. Reload folderB's CURRENT SealedChildRef via pollFindChild before deriveChildReadKey, mirroring the pattern used elsewhere in the file, so SC#2 genuinely proves retained-vs-revoked. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 2a8c73d7a37f
…ixes Entire-Checkpoint: 9a2e96b3faea
…15d parity Entire-Checkpoint: 804032a9d7f7
A FUSE scope-exit read-key rotation republishes its rotated folders out-of-band via the rotation engine, so they were never recorded as locally mutated. A stale in-flight metadata refresh -- one that resolved a pre-rotation snapshot but completes AFTER the rotation -- then clobbers the freshly-refreshed inode read keys via apply_owned_children, resetting them to pre-rotation values. A later parent republish uses the reset key and reverts the grant-root's child-refs, so the parent's child-ref key and the child's own record key diverge and every subsequent rotate_one / verify_subtree_clean fails AES-GCM, failing closed. This surfaced only on macOS: FUSE-T/SMB's overwrite-rename opens the SOURCE file, reading the just-rotated node and tripping the divergence; Linux's kernel rename never opens the source, so the identical desktop-e2e Part D passed there. Phase 74 SC1 added file rotation, which made rotated files readable-and-clobberable. Fix: after refresh_rotated_inode_read_keys, add every rotated folder inode to fs.mutated_folders. drain_refresh_completions already guards that set against exactly this folder-state desync -- it runs mark_remotely_edited_files_unresolved, which PRESERVES keys, instead of apply_owned_children. A rotation is a local mutation of those folders. Verified: cargo test -p cipherbox-fuse 117/117; desktop-e2e shared scope-exit rotation suite 3/3 on macOS FUSE-T with all prior failure signals -- rotate FAILED / AES-GCM / open EIO -- at zero. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 917035580c96
…ktop-e2e The desktop test-login binary holds a single 15m access token for the entire E2E suite and cannot silently refresh, so on the slowest leg (macOS FUSE-T, with the 120s+300s macOS-optional sync waits) the token expired mid-run at login+15m, 401-locking every IPNS resolve/publish and hanging the D-16 shared scope-exit acceptance in Part C. This surfaced only after the Part D stale-refresh clobber fix let Parts A-C run to completion. Make ACCESS_TOKEN_TTL env-configurable (production default unchanged at 15m) and set it to 2h for the desktop-e2e API stack. Logs a follow-up todo for the underlying product gap: the Rust background client has no auto-refresh on 401. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL Entire-Checkpoint: 4f7e2b3523c0
* docs(75): research cross-language IPNS and node-codec verification parity
* docs(phase-75): add validation strategy
* docs(75): add pattern map for cross-language verification parity
* docs(75): create phase plan
Entire-Checkpoint: 28cad9348085
* docs(75): populate validation strategy from research and plans
Entire-Checkpoint: e739f36a0c16
* docs(75): resolve research open questions and annotate roadmap wave deps
Entire-Checkpoint: 0198826aa4cd
* feat(75-01): extend IPNS verify-vector generator with 4 new failing cases
- Parameterize buildCborData to accept Validity string and ValidityType
- Add expired-valid-sig, wrong-validity-type, malformed-rfc3339-trailing-component,
and malformed-rfc3339-impossible-date cases, each real Ed25519-signed and
expected_result invalid
- Regenerate tests/vectors/ipns/verify.json to 12 cases; original 8 unchanged
Entire-Checkpoint: e4bebeade437
* docs(75-01): complete extend IPNS verify-vector generator plan
Entire-Checkpoint: 07e263ae724b
* test(75-04): replace fileIv KAT samples with encoding-unambiguous base64
- GCM body vectors (12-byte fileIv) use base64 samples containing a
non-hex character; CTR body vectors (16-byte fileIv) use base64
samples with natural == padding — both are invalid as hex
- add expected_file_iv_len_bytes (12 GCM / 16 CTR) per file body vector
- expected_read_body_hex regenerated via the canonical TS codec
(encodeReadBody), not hand-edited
- seal_vectors[0].fixed_iv and tests/vectors/crypto/node-aad.json left
untouched (hex-only harness IVs, no base64 code path)
Entire-Checkpoint: 665933e8071a
* feat(75-04): add base64-decode-and-assert-length block to Rust node-codec KAT
- BodyVector gains expected_file_iv_len_bytes: Option<usize> (present only
on file-kind vectors; folder/root carry no content.fileIv)
- new node_codec_kat_file_iv_is_base64_not_hex test base64-decodes
content.fileIv and every versions[].fileIv, asserting decoded length
equals expected_file_iv_len_bytes — a hex decode would fail on the
non-hex/padded samples from 75-04 Task 1
- existing round-trip / expected_read_body_hex assertions unchanged
Entire-Checkpoint: 02524fcaefa2
* feat(75-04): add base64-decode-and-assert-length it() to TS node-codec KAT
- local base64ToUint8Array mirrors decode.ts's private helper (the
production decode path), deliberately not the fromHex() helper
- new describe block base64-decodes content.fileIv and every
versions[].fileIv for every file-kind body vector carrying
expected_file_iv_len_bytes, asserting decoded length matches — byte-
identical assertion to the Rust twin (Task 2), driven off the same
shared VECTORS import
- non-vacuous guard requires at least 2 file body vectors exercised
- existing PRIMARY LOCK assertions unchanged
Entire-Checkpoint: c0a4c666e3cb
* docs(75-04): complete node-codec KAT fileIv encoding lock plan
Entire-Checkpoint: 142c93154f2c
* feat(75-05): add cross-language UUID acceptance oracle
- tests/vectors/crypto/uuid-acceptance.json: shared accept/reject cases
for canonical-only (Option A) UUID acceptance domain
- covers canonical lower/upper, simple-32-hex, loose-hyphen, braced,
urn:uuid, non-hex, wrong-length, empty forms
Entire-Checkpoint: 521b2bd35fb0
* test(75-05): add failing canonical-only UUID acceptance tests
- rejects simple-32-hex and loose-hyphen forms (currently accepted by
the loose strip-then-check uuidToBytes)
- oracle-driven block over tests/vectors/crypto/uuid-acceptance.json
- RED: 3 tests fail against the current implementation
Entire-Checkpoint: ce39c54d2f7b
* feat(75-05): tighten TS uuidToBytes to canonical-only (SC3)
- validate the canonical 8-4-4-4-12 hyphenated shape against the raw
input before hyphen-stripping, rejecting simple-32-hex and
loose-hyphen forms that were previously accepted
- GREEN: all 200 @cipherbox/crypto tests pass, including the
uuid-acceptance.json oracle block
Entire-Checkpoint: 013c621b6186
* test(75-05): add failing cross-language UUID acceptance oracle test
- uuid_acceptance_cross_language drives build_node_aad over every
uuid-acceptance.json case, asserting Ok for accept / Err for reject
- RED: current Uuid::parse_str over-accepts simple-32-hex, braced,
and urn:uuid forms that the shared oracle marks reject
Entire-Checkpoint: 44cf5054c3fb
* feat(75-05): add canonical-form pre-check to Rust build_node_aad (SC3)
- is_canonical_uuid_form: dependency-free byte-position check for the
8-4-4-4-12 hyphenated shape, applied before Uuid::parse_str
- rejects simple-32-hex, braced {...}, and urn:uuid:... forms that
Uuid::parse_str previously accepted
- no regex/once_cell dependency added to crates/crypto/Cargo.toml
- GREEN: build_node_aad unit tests and cross_language tests pass,
Rust and TS now produce identical accept/reject verdicts for every
uuid-acceptance.json case
Entire-Checkpoint: c7ea0bac3b3d
* docs(75-05): complete UUID acceptance-domain parity plan
Entire-Checkpoint: 12fdaeb7318e
* test(75-02): add failing tests for ValidityType decoding
- Extend decode_ipns_cbor_validity's return type to a tuple carrying
Validity bytes plus an Option<i64> ValidityType placeholder
- Add tests asserting ValidityType 0/1 extraction and duplicate-key
rejection; extraction not yet implemented so these fail (RED)
- Thread bind_verified's single call site through the new tuple shape
(ValidityType value not yet consumed)
Entire-Checkpoint: ed32f9cc25f3
* feat(75-02): decode ValidityType in decode_ipns_cbor_validity
- Implement ValidityType extraction alongside Validity bytes, with
duplicate-key rejection mirroring the existing Validity guard
- Returns Option<i64>: Some(n) when present and an integer, None when
absent; the == 0 EOL gate is left to the caller (bind_verified,
Task 2), this decoder only reports the value
Task 1 of 75-02 (Phase 75: cross-language IPNS/node-codec parity).
Entire-Checkpoint: e3dc7037318b
* test(75-02): add failing tests for bind_verified ValidityType gate
- Add tests asserting bind_verified rejects records with a missing or
non-zero ValidityType, and still accepts/expires ValidityType-0
records exactly as before
- Missing/non-zero-ValidityType tests fail (RED) since the gate is
not yet implemented
Entire-Checkpoint: 3897451ec1c1
* feat(75-02): gate bind_verified on ValidityType == 0, widen to pub
- bind_verified now fails closed on missing or non-zero ValidityType
before treating Validity as an EOL/expiry timestamp (Phase 75
gap #7 — Rust side; TS mirrors this in Plan 03)
- Widen bind_verified from pub(crate) to pub so crates/fuse's test
target can call it directly (Task 3 dedups classify_vector onto it)
- parse_rfc3339_to_unix_secs and the 5-minute skew buffer are
untouched
Task 2 of 75-02.
Entire-Checkpoint: 210682e3dc7b
* test(75-02): dedup classify_vector into a bind_verified wrapper
- Replace classify_vector's hand-spelled cid/sequence binding block
with a thin wrapper that calls the now-pub cipherbox_api_client
bind_verified — deletes the duplicated binding logic that let
gap #9 go undetected (D-07's expiry leg never got mirrored here)
- Bump the non-vacuous vector-count guard 8 -> 12 to match the
4 new invalid cases landed by Plan 75-01 (expired, wrong-validity-
type, and two malformed-RFC3339 sub-cases)
- All 12 cases now classify to their expected_result, including the
4 new cases exercising the Task 2 ValidityType gate end-to-end
Task 3 of 75-02. SC1 (Rust half) met.
Entire-Checkpoint: f9c10ada7067
* docs(75-02): complete Rust ValidityType EOL binding plan
Entire-Checkpoint: bf3c6f7ccd53
* test(75-03): add failing strict RFC3339 + ValidityType assertions
- Bump shared-vector count guard from 8 to 12 (Plan 01's 4 new invalid cases)
- Add 4 per-vector tests for expired-valid-sig, wrong-validity-type,
malformed-rfc3339-trailing-component, malformed-rfc3339-impossible-date
- Add parseRfc3339ToUnixSecs unit tests mirroring the Rust parser's
rejection branches (trailing components, impossible dates, non-digit
fraction, missing Z)
- Add ValidityType absent/non-zero rejection assertions
- RED: fails against the current loose new Date(validityStr) parse and
unread ValidityType field
Entire-Checkpoint: 86715bc90ed1
* feat(75-03): port strict RFC3339 parser + ValidityType==0 gate to TS
- Add parseRfc3339ToUnixSecs, a manual strict parser mirroring the Rust
parse_rfc3339_to_unix_secs branch-for-branch (crates/api-client/src/ipns.rs):
rejects trailing date/time components, impossible calendar dates
(leap-aware), non-digit fractional seconds, and missing Z, without
rolling an invalid date forward
- Replace new Date(validityStr).getTime() in resolveIpnsRecord with the
strict parser; preserve the 5-minute skew buffer and expiry comparison
- Add a fail-closed cborFields['ValidityType'] === 0 gate before treating
Validity as an expiry timestamp, mirroring Rust's bind_verified
(T-75-07)
- TS and Rust now reject the same 12-case tests/vectors/ipns/verify.json
fixture identically (SC1 TS half)
Entire-Checkpoint: 864bd5c6787d
* docs(75-03): complete strict RFC3339 + ValidityType parity plan
Entire-Checkpoint: 5112a3fb234e
* docs(phase-75): complete phase execution
Entire-Checkpoint: 36c3ccaf9bc2
* docs(phase-75): auto-close 4 todos resolved by this phase
Entire-Checkpoint: a2cb16d0454e
* docs(phase-75): evolve PROJECT.md after phase completion
Entire-Checkpoint: 4025885d9fe0
* docs(74): close 3 stranded phase-74 todos resolved by shipped code
Phase 74 (merged PR #607) resolved these but the GSD auto-close under-closed
them, leaving them in pending despite resolves_phase: 74:
- deep-scope-exit rotation now refreshes ALL rotated_nodes inodes (grant_scope.rs)
- WinFsp rename gates the overwritten dest scope-exit (74-06, windows/write_ops.rs)
- query_grants_rooted_at is a real /shares/sent remint, not a no-op (74-07)
Verified each against the merged code + tests before moving; appended a
resolution note to each. No pending resolves_phase:74 todos remain.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Entire-Checkpoint: 64ff86dfcd51
* fix(75): close cross-language IPNS verification parity divergences
Phase 75 crypto/privacy review found the Rust and TS IPNS verifiers reach
different verdicts on some signed records. Closed the two material gaps on both
sides so they fail closed identically:
- Duplicate CBOR map keys: TS cborDecode defaulted to last-wins (a signed
ValidityType:[1,0] decoded to 0 and was accepted) while Rust rejects
duplicates. TS now decodes with rejectDuplicateMapKeys: true.
- RFC3339 fixed-width fields: Rust parse::<T>() accepted a leading +/- and
overflowing years; TS did not enforce a 4-digit year. Both now use a
fixed-width digit check (year 4, others 2), rejecting leading signs and
non-canonical-width fields identically.
Locked with parser tests on both languages plus a duplicate-map-key integration
test. One LOW benign-direction divergence (CBOR float-0.0 ValidityType) is
deferred to a todo. Full report in .planning/security/REVIEW-2026-07-11-phase75-parity.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Entire-Checkpoint: 42263fd885f8
* fix(75): harden UUID canonical parity and CodeRabbit review findings
CodeRabbit CLI review of the phase 75 branch surfaced a real SC3 soundness gap
plus test-rigor and bookkeeping items:
- uuidToBytes accepted a trailing-newline UUID because JS `$` (no `m` flag) also
matches before a final "\n", while Rust is_canonical_uuid_form rejects it
(bytes.len() != 36) — a cross-language divergence. Added a length === 36 guard
and a "\n" regression case to the shared uuid-acceptance.json oracle (now run by
both the TS and Rust oracle tests).
- Tightened the UUID-oracle reject assertions on both sides to require the specific
INVALID_AAD_INPUT verdict, and made the TS loop fail loudly on an unknown expected
value instead of silently treating it as a reject.
- Reconciled STATE.md metadata and banner-marked the pre-fix crypto review report so
the SECURED verdict is unambiguous.
Verified: @cipherbox/crypto 200 passed; cipherbox-crypto cross_language 7 passed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Entire-Checkpoint: e4e60b8860fd
* docs(75): extract phase 75 learnings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Entire-Checkpoint: 01f9fb37c55b
* fix(75): reject any duplicate CBOR key in Rust IPNS decoders for TS parity
Greptile P1: the TS resolve fix (rejectDuplicateMapKeys: true) rejects a
duplicate of ANY key, but the Rust decoders only rejected duplicates of their
target fields (Value/Sequence, Validity/ValidityType). A signed record with a
duplicate ignored key like TTL was therefore Rust-accepts / TS-rejects — a new
parser-differential in the opposite direction.
decode_ipns_cbor_data and decode_ipns_cbor_validity now reject any duplicate
text map key via a seen-keys set, matching the TS decoder. Verified:
cipherbox-core 28 passed (incl. new dup-TTL test), api-client 25, fuse vectors 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Entire-Checkpoint: 1ef91f3a89ac
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Phase 74 (v2.0 M4 closeout) closes the remaining scope-exit read-revocation bypasses on the Rust/desktop side so the revocation guarantee holds end-to-end. The rotation engine now surfaces every rotated node's new read key (not just the grant root), all intermediate FUSE inodes are refreshed on rotation, the desktop grant-re-mint seam is wired (retained recipients keep access, revoked ones are cut off), and WinFsp overwrite-rename is dest-gated with fuser ordering parity.
What changed (by success criterion)
RotateReadResult.rotatedNodeswidened to carry every rotated node's read key in both engines (crates/sdk/src/rotation/engine.rs,packages/sdk-core/src/rotation/engine.ts); FUSErefresh_rotated_inode_read_keysrefreshes every rotated inode (Root/Folder/File) before relink, closing the stale-key reseal path (crates/fuse/src/write_ops/grant_scope.rs).FuseRotationDepsdrivescollect_sent_shares/update_grant/revoke_sharevia a newRotationTransportseam onApiClientTransport(crates/api-client/src/{client,shares}.rs,crates/fuse/src/write_ops/rotation_deps.rs) — retained recipients re-wrapped under the NEW read key, revoked ones cut off (revocation-by-absence).crates/fuse/src/platform/windows/write_ops.rs).Verification
Local gates (all green):
cargo test -p cipherbox-sdk+-p cipherbox-fuse, sdk-core rotation 370, plus workspacecargo checkgreen at every wave boundary.tee-republishlease-renewer infra dependency (host tee-worker epoch-mismatch), unrelated to this phase's rotation path.74-SECURITY.mdSECURED,threats_open: 0(16/16 closed)..planning/security/REVIEW-2026-07-11-phase74-rotation.md.CI-gated legs (the final runtime gate — run by this PR)
Two success-criteria legs cannot compile/run on a macOS host and are verified by this PR's CI, not locally. Until they pass, treat runtime verification of these as pending:
Cargo Check & Test (Windows)— the SC3 WinFsp code (#[cfg(windows)]) only compiles under the winfsp feature on Windows CI.Desktop E2E Tests(macOS/Windows/Linux matrix) — the SC1/SC2/SC3 live FUSE/WinFsp-mount acceptance leg (shared-scope-exit-rotation.mts).The code for both is source-verified line-by-line against the fuser-path parity; these are runtime confirmations. (Recorded in
74-UAT.md/74-VERIFICATION.mdrequired_ci_gates.)Follow-ups logged (todos, not blockers)
.planning/todos/pending/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md— MEDIUM: grant re-mint trusts the server for the recipient pubkey binding (inherited, newly reachable on desktop); needs a design decision on binding verification..planning/todos/pending/2026-07-11-ts-rotatednodes-defensive-copy-parity.md— LOW/latent: TSrotatedNodesstores read keys by reference; add defensive copies to match Rust's.clone()and remove a future-zeroing hazard.Review notes
--base main, committed): 8 findings, all on internal.planning/tracking docs (completion-marker phrasing re: the CI-gated legs above), 0 on code — dismissed; the authoritative caveat is stated here and in74-UAT.md..planning/bookkeeping rides this PR per GSD convention.🤖 Generated with Claude Code
https://claude.ai/code/session_012Z3zThQZPm6bVk2476WURL
Summary by CodeRabbit