diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index fc7473a64e..f8358c6af7 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -1145,6 +1145,29 @@ Plans: 2. Scope-exit re-mint binds the new read key to a verified recipient public key (pinned/verified rather than blindly server-supplied), and refetches `/shares/sent` once per rotation job (cached), not once per rotated node. 3. TS `rotatedNodes` stores a defensive 32-byte copy of `readKey` (no aliasing with `parentNewReadKey`), matching Rust parity. +**Plans**: 8 plans (4 waves) + +Plans: +**Wave 1** + +- [ ] 80-01-PLAN.md — D-03b: NodeWriteBody recipientPins field + conditional-emit codec + cross-language JSON KAT + schema doc (wave 1) +- [ ] 80-02-PLAN.md — D-01/D-02: FUSE write-body reconstruction + job-scoped /shares/sent cache + replay durability regression (wave 1) +- [ ] 80-03-PLAN.md — D-04/D-02: TS rotatedNodes defensive copy + owner-reconcile listSentGrants cache (wave 1) + +**Wave 2** *(blocked on Wave 1 completion)* + +- [ ] 80-04-PLAN.md — D-03a/c: sdk-core pin write/read/verify helpers + pin-preserving publish + client wrappers (wave 2) +- [ ] 80-05-PLAN.md — D-03a/D-01: Rust pin plumbing (ResolvedOwnedChild + InodeTable cache + reconstruction preservation) (wave 2) + +**Wave 3** *(blocked on Wave 2 completion)* + +- [ ] 80-06-PLAN.md — D-03d/e: Rust re-mint fail-closed pin enforcement + get_recipient_pubkey_pins seam (wave 3) +- [ ] 80-07-PLAN.md — D-03d/e: TS re-mint fail-closed pin enforcement + getPinsFn seam (wave 3) + +**Wave 4** *(blocked on Wave 3 completion)* + +- [ ] 80-08-PLAN.md — D-03c/d: web issuance pin write + upgrade/reconcile fail-closed enforcement (wave 4) + --- ### Phase 81: TEE Republish and IPNS-Record Correctness diff --git a/.planning/STATE.md b/.planning/STATE.md index 56af7c0c72..b3f52dd3c6 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -6,15 +6,14 @@ current_phase: 78 current_phase_name: recovery-tool-v3-vault-load-guards-web-ux-and-ci-guards status: executing stopped_at: Completed 77-09-PLAN.md -last_updated: "2026-07-12T01:02:55.794Z" +last_updated: "2026-07-12T19:28:11.050Z" last_activity: 2026-07-12 -last_activity_desc: Phase 78 execution started progress: - total_phases: 22 - completed_phases: 19 - total_plans: 218 - completed_plans: 210 - percent: 86 + total_phases: 26 + completed_phases: 23 + total_plans: 239 + completed_plans: 239 + percent: 88 --- # Project State @@ -30,8 +29,8 @@ See: .planning/PROJECT.md (updated 2026-06-27) Phase: 78 (recovery-tool-v3-vault-load-guards-web-ux-and-ci-guards) — EXECUTING Plan: 1 of 8 -Status: Executing Phase 78 -Last activity: 2026-07-12 — Phase 78 execution started +Status: Ready to execute +Last activity: 2026-07-12 Progress: `██████████` 79 / 79 plans (100%) diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md new file mode 100644 index 0000000000..4a7bd4adb4 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-PLAN.md @@ -0,0 +1,250 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 01 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - crates/core/src/node/types.rs + - crates/core/src/node/encode.rs + - crates/core/src/node/decode.rs + - crates/core/tests/node_write_body_vectors.rs + - packages/core/src/node/types.ts + - packages/core/src/node/encode.ts + - packages/core/src/node/decode.ts + - packages/core/src/__tests__/node-codec-vectors.test.ts + - tests/vectors/node-codec.json + - docs/METADATA_SCHEMAS.md +autonomous: true +requirements: + - "SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity" +user_setup: [] + +must_haves: + truths: + - "NodeWriteBody carries an optional recipientPins list that round-trips byte-identically in Rust and TS (D-03b)" + - "The frozen seal_vectors[0] KAT (empty-pin write-body) still passes unchanged — the pin field is omitted from the wire when empty (D-03b, Pitfall 1)" + - "A new seal_vectors[1] KAT with a non-empty pin list is asserted byte-for-byte on both sides (D-03b lockstep)" + - "The Phase-78 recovery tool (apps/web/recovery-src) still tolerates the new field — it never parses NodeWriteBody (D-03b verified no-op)" + artifacts: + - "crates/core/src/node/types.rs — NodeWriteBody.recipient_pins field" + - "packages/core/src/node/types.ts — NodeWriteBody.recipientPins field" + - "tests/vectors/node-codec.json — seal_vectors[1] fixture with non-empty recipientPins" + - "docs/METADATA_SCHEMAS.md — NodeWriteBody recipientPins documented + version-history row" + key_links: + - "encode_write_body / encodeWriteBody conditional emission (omit when empty) preserves seal_vectors[0]" + - "decode_write_body / decodeWriteBody tolerate absent field (default to empty), never throw on it" + prohibitions: + - "MUST NOT add #[serde(deny_unknown_fields)] to NodeWriteBody (forward tolerance; unlike SealedChildRef) (D-03b, Anti-Pattern)" + - "MUST NOT emit the pin field unconditionally — that changes seal_vectors[0] frozen bytes (Pitfall 1)" + - "MUST NOT make the TS pin field required — existing test literals { ipnsPrivateKey, writeChildren } must still compile (Pitfall 2)" + - "MUST NOT invent a CBOR encoder — the write-body wire format is plaintext canonical JSON, then AEAD-sealed (PATTERNS correction)" + - "MUST NOT bump generation or add a 'pin generation' counter — this field rides inside the existing role-0x01 write-body seal (Anti-Pattern)" +--- + + +Add an optional recipient-pubkey pin list to `NodeWriteBody` — the owner-sealed, IPNS-published, +server-opaque store that D-03 uses to verify recipient identity at re-mint. This is a +metadata-schema change (D-03b) to the encrypted node codec (NOT a DB/TypeORM schema, NOT an +API/DTO change → no `pnpm api:generate`). It is the foundational dependency for the pin +issuance write (80-04) and all three fail-closed enforcement consumers (80-06/07/08). + +The write-body wire format is **plaintext canonical JSON** (`encode_write_body` / +`encodeWriteBody`), then AEAD-sealed under the writeKey with role byte `0x01` (`seal_node`). +The parity test is a **JSON KAT** — a new `seal_vectors[1]` entry in the shared oracle +`tests/vectors/node-codec.json` — NOT a CBOR contract test. + +Purpose: server-opaque, cross-device recipient-pubkey binding that a compromised relay cannot forge. +Output: `NodeWriteBody.recipientPins` (Rust + TS), conditional-emit codec, byte-locked cross-language KAT, updated schema doc. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@docs/METADATA_EVOLUTION_PROTOCOL.md +@docs/METADATA_SCHEMAS.md +@crates/core/src/node/types.rs +@crates/core/src/node/encode.rs +@crates/core/tests/node_write_body_vectors.rs +@packages/core/src/node/encode.ts +@packages/core/src/__tests__/node-codec-vectors.test.ts +@tests/vectors/node-codec.json + + + + + + Task 1: RED — add seal_vectors[1] fixture and failing cross-language KAT for a non-empty recipientPins + tests/vectors/node-codec.json, crates/core/tests/node_write_body_vectors.rs, packages/core/src/__tests__/node-codec-vectors.test.ts + + - tests/vectors/node-codec.json (lines 94-135 — the existing seal_vectors[0] structure: node_id, kind, generation, read_key, write_key, ipns_private_key_hex, fixed_iv, expected_published_node.writeSealed) + - crates/core/tests/node_write_body_vectors.rs (full file — `write_body_seal_matches_kat` iterates ALL seal_vectors via `for v in &vectors.seal_vectors` and hardcodes `write_children: Vec::new()` with NO pin; the SealVector deserialize struct at lines 44-58) + - packages/core/src/__tests__/node-codec-vectors.test.ts (lines 201-256 — the seal_vectors[0] readSealed/writeSealed assertions index `VECTORS.seal_vectors[0]` explicitly) + - crates/crypto/tests/cross_language.rs (lines 272-320 — CONFIRM this reads `crypto/node-aad.json` NOT `node-codec.json`; its `assert_eq!(seal_vectors.len(), 1)` guard at line 310 is a DIFFERENT file's seal_vectors and must stay untouched/green) + + + - New `seal_vectors[1]` in node-codec.json: same fixed key/IV convention, `write_children: []`, plus a non-empty `recipientPins` list (2 raw compressed secp256k1 pubkeys, base64-encoded in the JSON to match the write-body's existing binary-field convention), and an `expected_published_node.writeSealed` computed for that pinned body. + - Rust `node_write_body_vectors.rs::write_body_seal_matches_kat` extended: the SealVector struct gains a `recipient_pins` field; the loop populates `NodeWriteBody.recipient_pins` from the vector (empty for [0], non-empty for [1]) so it reproduces BOTH writeSealed values byte-for-byte. + - New TS test block reads `VECTORS.seal_vectors[1]`, reconstructs the pinned write-body, seals under the fixed key/IV, and asserts `reconstructedWriteSealed === sv.expected_published_node.writeSealed`. + - Both tests FAIL initially (the codec does not yet know the field) — this is the RED state. + + + Add the `seal_vectors[1]` fixture to tests/vectors/node-codec.json using the SAME fixed key/IV + discipline as `seal_vectors[0]` (reuse the node-aad.json node_id/IV convention). Store `recipientPins` + as an array of base64 strings (raw compressed 33-byte pubkeys) to mirror the existing `base64_key` + convention for `ipnsPrivateKey`. Leave the `expected_published_node.writeSealed` value as a + placeholder to be filled once the GREEN codec exists (Task 2/3 regenerate it), OR compute it now with + a scratch script — either way the test must assert against the committed value, never skip. + Extend `node_write_body_vectors.rs`'s SealVector deserialize struct with `recipient_pins: Vec` + and populate `NodeWriteBody { ipns_private_key, write_children: vec![], recipient_pins: }` + in the loop. Add the mirrored TS `seal_vectors[1]` assertion block after the existing seal_vectors[0] + block in node-codec-vectors.test.ts. Do NOT modify the frozen seal_vectors[0] entry. Do NOT touch + crates/crypto/tests/cross_language.rs (it reads node-aad.json — verify by grep, then leave it). + + + cargo test -p cipherbox-core --test node_write_body_vectors 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed" + + + - tests/vectors/node-codec.json contains a `seal_vectors[1]` object whose `recipientPins` array is non-empty (length >= 2) + - `grep -n "recipient_pins" crates/core/tests/node_write_body_vectors.rs` shows the field wired into the loop's NodeWriteBody construction + - `grep -n "seal_vectors\[1\]\|seal_vectors[1]" packages/core/src/__tests__/node-codec-vectors.test.ts` shows a new assertion block + - The extended Rust KAT and new TS test FAIL before Task 2/3 (RED), proving they are not vacuous + - `grep -c "node-codec.json" crates/crypto/tests/cross_language.rs` returns 0 (cross_language.rs reads node-aad.json — unaffected) + + Both the Rust and TS pin KATs exist and fail against the current (pin-unaware) codec; seal_vectors[0] and cross_language.rs are untouched. + + + + Task 2: GREEN (Rust) — add recipient_pins to NodeWriteBody with conditional emission and tolerant decode + crates/core/src/node/types.rs, crates/core/src/node/encode.rs, crates/core/src/node/decode.rs, tests/vectors/node-codec.json + + - crates/core/src/node/types.rs (lines 131-145 — `NodeWriteBody` struct; note it has NO `#[serde(deny_unknown_fields)]` unlike `SealedChildRef` at line 100 — preserve that) + - crates/core/src/node/encode.rs (lines 110-124 `encode_write_body` = `serde_json::to_vec(wb)`; the `#[cfg(test)] mod write_body_tests` round-trip at lines 126-139) + - crates/core/src/node/decode.rs (lines 113-118 `decode_write_body` = `serde_json::from_slice`) + + + - `NodeWriteBody.recipient_pins: Vec>` (each entry a raw compressed pubkey), serialized to + camelCase `recipientPins` as an array of base64 strings, with `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an empty list is OMITTED from the wire (preserves seal_vectors[0]). + - `decode_write_body` on a document with no `recipientPins` yields `recipient_pins: []` (serde default), never errors. + - Round-trip unit tests cover BOTH the empty-pin (default) and non-empty-pin variants. + - After this task, seal_vectors[0] KAT still passes; seal_vectors[1] Rust KAT now passes. + + + Add `recipient_pins` to `NodeWriteBody` with a base64-list serde helper (reuse the existing + `base64_key`-style module, or add a `base64_key_list` sibling; each element is a raw pubkey byte + vector). Apply `#[serde(default, skip_serializing_if = "Vec::is_empty", rename = "recipientPins")]` + (or place `rename_all = camelCase` coverage) so the field is omitted when empty. Do NOT add + `deny_unknown_fields`. Extend the existing `write_body_tests` round-trip (encode.rs) to assert a + populated `recipient_pins` survives encode→decode AND that an empty list encodes to bytes identical + to the pre-change output (the seal_vectors[0] preservation guarantee). Regenerate the + `seal_vectors[1].expected_published_node.writeSealed` placeholder from Task 1 if it was left as a + placeholder (compute via the KAT's own fixed-key/IV seal path, commit the real value). + + + cargo test -p cipherbox-core --test node_write_body_vectors 2>&1 | grep -q "test result: ok" && cargo test -p cipherbox-core node encode decode 2>&1 | tail -5 + + + - `grep -n "recipient_pins" crates/core/src/node/types.rs` shows the field with `skip_serializing_if = "Vec::is_empty"` and no `deny_unknown_fields` anywhere on the struct + - `cargo test -p cipherbox-core --test node_write_body_vectors` passes for BOTH seal_vectors[0] and seal_vectors[1] + - A round-trip unit test in encode.rs asserts an empty `recipient_pins` encodes byte-identically to the frozen pre-change output + - `decode_write_body` on JSON lacking `recipientPins` returns an empty list (add/keep a test asserting no error) + + Rust NodeWriteBody carries recipient_pins with conditional emission; both KAT vectors and round-trip tests pass; no deny_unknown_fields added. + + + + Task 3: GREEN (TS) — mirror recipientPins in the TS codec and document the schema change + packages/core/src/node/types.ts, packages/core/src/node/encode.ts, packages/core/src/node/decode.ts, docs/METADATA_SCHEMAS.md + + - packages/core/src/node/types.ts (lines ~135-140 — `NodeWriteBody` type; `writeBody?` is optional on Node) + - packages/core/src/node/encode.ts (lines 140-155 `encodeWriteBody` builds `{ ipnsPrivateKey, writeChildren }` as an object literal — add the pin key ONLY when non-empty) + - packages/core/src/node/decode.ts (lines 317-364 `decodeWriteBody` manual validation — default the new field to [] when absent, never throw) + - packages/core/src/__tests__/node-codec-vectors.test.ts (lines 244, 340-344 — existing `{ ipnsPrivateKey, writeChildren: [] }` object literals that must still type-check → field MUST be optional) + - docs/METADATA_EVOLUTION_PROTOCOL.md (§3.1 additive-change contract, §6.2/§6.4 lockstep rule) and docs/METADATA_SCHEMAS.md (current NodeWriteBody section + version-history table) + + + - `NodeWriteBody.recipientPins?: string[]` (base64, optional) in types.ts. + - `encodeWriteBody` includes `recipientPins` in the wire object ONLY when the list is present and + non-empty (byte-preserving for seal_vectors[0]); order matches the Rust serde field order. + - `decodeWriteBody` defaults absent/empty `recipientPins` to `[]`, never throws on it. + - seal_vectors[0] TS KAT still passes; seal_vectors[1] TS KAT now passes. + - docs/METADATA_SCHEMAS.md documents the new field and gains a version-history row. + + + Add optional `recipientPins?: string[]` to the TS `NodeWriteBody` type. In `encodeWriteBody`, spread + the pin key conditionally (only when `recipientPins?.length`), matching the Rust field order so the + JSON bytes are identical across languages. In `decodeWriteBody`, read `recipientPins` with the same + manual-validation style used for `writeChildren`, defaulting to `[]`. Update docs/METADATA_SCHEMAS.md: + add `recipientPins` to the NodeWriteBody schema block and append a version-history row per + METADATA_EVOLUTION_PROTOCOL §6. Markdownlint applies to docs/ (NOT excluded like .planning/): use + `###` headings, blank lines around lists/fences. + + + pnpm --filter @cipherbox/core test node-codec-vectors 2>&1 | tail -15 + + + - `grep -n "recipientPins" packages/core/src/node/types.ts` shows an OPTIONAL field (`?:`) + - `pnpm --filter @cipherbox/core test node-codec-vectors` passes for BOTH seal_vectors[0] and seal_vectors[1] + - The pre-existing `{ ipnsPrivateKey, writeChildren: [] }` literals still type-check (no required-field break) + - `grep -rn "writeKey\|writeSealed\|NodeWriteBody\|recipientPins" apps/web/recovery-src/` returns zero matches — recovery tool never parses the write-body, so it tolerates the field by construction (D-03b verified no-op) + - docs/METADATA_SCHEMAS.md shows `recipientPins` documented plus a new version-history row; `pnpm --filter @cipherbox/core typecheck` passes + + TS NodeWriteBody mirrors the Rust field byte-for-byte; both KATs green cross-language; recovery-tool tolerance verified; schema doc updated. + + + + + +New/changed symbols and fixtures this plan introduces (consumed by 80-04/05/06/07/08): +- `NodeWriteBody.recipient_pins: Vec>` (Rust, `recipientPins` base64 on the wire) +- `NodeWriteBody.recipientPins?: string[]` (TS) +- `tests/vectors/node-codec.json` → `seal_vectors[1]` (non-empty-pin KAT fixture) +- docs/METADATA_SCHEMAS.md NodeWriteBody `recipientPins` documentation + version-history row +- Encoding convention: pins stored as base64 strings on the wire, normalized to raw pubkey bytes for the D-03d compare + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| owner device → IPFS/relay | Write-body sealed under owner writeKey; relay stores opaque bytes | +| older-schema reader → new-schema document | Phase-78 recovery tool and any pre-field reader must not fail-closed on the new field | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-01 | Tampering | seal_vectors[0] frozen KAT | high | mitigate | Conditional emission (skip when empty) keeps frozen bytes; new seal_vectors[1] locks the pinned path | +| T-80-02 | Denial of Service | decode_write_body / decodeWriteBody | medium | mitigate | Tolerant decode (default []), no deny_unknown_fields — older/newer readers never fail-closed on the field | +| T-80-03 | Information Disclosure | recipientPins content | low | accept | Pins are recipient PUBLIC keys inside an owner-sealed body — no secret material added | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `cargo test -p cipherbox-core` green (codec + both KAT vectors + round-trip) +- `pnpm --filter @cipherbox/core test` green (node-codec-vectors, both vectors) +- `pnpm --filter @cipherbox/core typecheck` green +- crates/crypto/tests/cross_language.rs untouched and still green (reads node-aad.json) +- recovery-src grep confirms zero NodeWriteBody parsing + + + +NodeWriteBody carries an optional recipientPins list with byte-identical Rust/TS wire parity, the +frozen empty-pin KAT is preserved via conditional emission, a new non-empty-pin KAT is locked on both +sides, and the schema doc reflects the additive change — with no deny_unknown_fields and no API/DB change. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md new file mode 100644 index 0000000000..d6b12677f1 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-01-SUMMARY.md @@ -0,0 +1,175 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 01 +subsystem: crypto +tags: [node-codec, serde, recipient-pins, cross-language-kat, metadata-schema, d-03b] + +# Dependency graph +requires: + - phase: 62-node-codec + provides: node/v3 NodeWriteBody codec, tests/vectors/node-codec.json seal_vectors[0] + - phase: 69-rust-node-twin + provides: crates/core node codec Rust twin + write-body seal KAT +provides: + - "NodeWriteBody.recipient_pins (Rust Vec>) / recipientPins? (TS string[]) optional pin field" + - "Conditional-emit codec (omit when empty) preserving frozen seal_vectors[0] bytes" + - "seal_vectors[1] non-empty-pin cross-language KAT (Rust + TS byte-locked)" + - "METADATA_SCHEMAS.md NodeWriteBody recipientPins documentation + version-history row" +affects: [80-04, 80-05, 80-06, 80-07, 80-08] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "base64_key_list serde helper: Vec> <-> JSON array of base64 strings" + - "Additive optional metadata field via skip_serializing_if=Vec::is_empty (Rust) + conditional spread (TS)" + +key-files: + created: [] + modified: + - crates/core/src/node/types.rs + - crates/core/src/node/encode.rs + - crates/core/src/node/seal.rs + - crates/core/tests/node_write_body_vectors.rs + - packages/core/src/node/types.ts + - packages/core/src/node/encode.ts + - packages/core/src/node/decode.ts + - packages/core/src/__tests__/node-codec-vectors.test.ts + - tests/vectors/node-codec.json + - docs/METADATA_SCHEMAS.md + +key-decisions: + - "TS decode attaches recipientPins ONLY when non-empty (symmetric with encode), keeping existing writeBody round-trip toEqual green without mutating prior test literals" + - "recipient_pins stored as raw pubkey bytes in-memory, base64 array on the wire, matching the existing ipnsPrivateKey base64 convention" + - "Field order fixed as ipnsPrivateKey, writeChildren, recipientPins in both codecs for byte-identical cross-language wire" + +patterns-established: + - "base64_key_list: sibling of base64_key for a JSON array of base64-encoded byte vectors" + - "Empty additive list is omitted from the wire on BOTH sides to preserve frozen golden vectors" + +requirements-completed: ["SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity"] + +coverage: + - id: D1 + description: "NodeWriteBody carries an optional recipientPins list that round-trips byte-identically in Rust and TS (non-empty-pin path locked by seal_vectors[1])" + requirement: "SC2 / D-03a / D-03b: recipient-pubkey pin field on NodeWriteBody with Rust/TS wire parity" + verification: + - kind: unit + ref: "crates/core/tests/node_write_body_vectors.rs#write_body_seal_matches_kat" + status: pass + - kind: unit + ref: "packages/core/src/__tests__/node-codec-vectors.test.ts#folder node writeSealed with non-empty recipientPins matches frozen vector [1] (D-03b)" + status: pass + human_judgment: false + - id: D2 + description: "Frozen empty-pin KAT seal_vectors[0] preserved byte-for-byte via conditional emission (field omitted when empty)" + verification: + - kind: unit + ref: "crates/core/src/node/encode.rs#write_body_round_trip_empty_children" + status: pass + - kind: unit + ref: "packages/core/src/__tests__/node-codec-vectors.test.ts#folder node writeSealed base64 matches frozen vector" + status: pass + human_judgment: false + - id: D3 + description: "Tolerant decode: write-body lacking recipientPins decodes to empty (Rust []) / absent (TS) and never throws" + verification: + - kind: unit + ref: "crates/core/src/node/encode.rs#decode_write_body_defaults_missing_recipient_pins_to_empty" + status: pass + human_judgment: false + - id: D4 + description: "METADATA_SCHEMAS.md documents recipientPins as an additive optional field with a version-history row" + verification: + - kind: manual_procedural + ref: "docs/METADATA_SCHEMAS.md §8 NodeWriteBody + §3 version history; markdownlint pass" + status: pass + human_judgment: false + +# Metrics +duration: 25min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 01: NodeWriteBody recipientPins Codec Field Summary + +**Additive optional `recipientPins` pin list on `NodeWriteBody` (Rust `Vec>` / TS `string[]`) with conditional-emit codec, a new byte-locked cross-language `seal_vectors[1]` KAT, and the frozen empty-pin `seal_vectors[0]` preserved unchanged.** + +## Performance + +- **Duration:** ~25 min +- **Started:** 2026-07-12T17:10:00Z +- **Completed:** 2026-07-12T17:35:30Z +- **Tasks:** 3 (RED fixture + failing KATs; GREEN Rust; GREEN TS + docs) +- **Files modified:** 10 + +## Accomplishments + +- Added `recipient_pins: Vec>` to Rust `NodeWriteBody` with a new `base64_key_list` serde helper and `#[serde(default, skip_serializing_if = "Vec::is_empty")]` so an empty list is omitted from the wire (no `deny_unknown_fields`). +- Mirrored `recipientPins?: string[]` in the TS codec: `encodeWriteBody` spreads the key only when non-empty; `decodeWriteBody` validates when present, tolerates absent, and stays symmetric with encode. +- Added `seal_vectors[1]` (two 33-byte compressed secp256k1 pins) to `tests/vectors/node-codec.json` and locked it byte-for-byte in both the Rust KAT and a new TS assertion block; `seal_vectors[0]` bytes unchanged. +- Documented the additive field in `docs/METADATA_SCHEMAS.md` (§8 NodeWriteBody) plus a §3 Node version-history row, per METADATA_EVOLUTION_PROTOCOL §3.1. + +## Task Commits + +Executed TDD-style locally (RED fixture + failing KATs observed to fail; GREEN Rust; GREEN TS + docs) and landed as a single atomic commit per orchestrator constraint 6 (SUMMARY rides with the code): + +1. **Tasks 1-3 (RED→GREEN Rust→GREEN TS + docs + SUMMARY)** - see PLAN COMPLETE hash below (feat) + +_RED was confirmed before implementing: the extended Rust KAT failed to compile against the pin-unaware struct (`E0560: NodeWriteBody has no field recipient_pins`), and the placeholder `writeSealed` forced an assertion mismatch that produced the committed ciphertext value._ + +## Files Created/Modified + +- `crates/core/src/node/types.rs` - `NodeWriteBody.recipient_pins` field + `base64_key_list` serde module +- `crates/core/src/node/encode.rs` - round-trip tests: populated pins, empty-omission byte guard, tolerant-decode default +- `crates/core/src/node/seal.rs` - `sample_write_body()` construction updated with `recipient_pins: vec![]` (blocking-compile fix) +- `crates/core/tests/node_write_body_vectors.rs` - SealVector gains `recipient_pins`; loop decodes pins into the KAT write-body +- `packages/core/src/node/types.ts` - optional `recipientPins?: string[]` +- `packages/core/src/node/encode.ts` - conditional emission of `recipientPins` (only when non-empty) +- `packages/core/src/node/decode.ts` - validate-when-present, attach-when-non-empty (symmetric with encode) +- `packages/core/src/__tests__/node-codec-vectors.test.ts` - new `seal_vectors[1]` writeSealed assertion block +- `tests/vectors/node-codec.json` - `seal_vectors[1]` non-empty-pin fixture (frozen `seal_vectors[0]` untouched) +- `docs/METADATA_SCHEMAS.md` - `recipientPins` schema row + prose + Node version-history row + +## Decisions Made + +- **TS decode is symmetric with encode (attach `recipientPins` only when non-empty).** The plan text said "default absent/empty to `[]`", but unconditionally adding `recipientPins: []` broke the existing `folder node with writeBody seal→unseal` round-trip (`toEqual` treats `{recipientPins: []}` as unequal to a literal that omits the key). Omitting on empty preserves that test with zero test-literal edits, keeps encode/decode symmetric, and still satisfies the hard requirement "tolerate absent field, never throw" (Rust still yields an empty `Vec` via `#[serde(default)]`; the Rust-`[]`-vs-TS-`undefined` asymmetry is the accepted divergence called out in METADATA_EVOLUTION_PROTOCOL §6.2). +- **Reused the fixed key/IV of `seal_vectors[0]`** for `seal_vectors[1]`; only the added `recipientPins` changes the plaintext, so the differing `writeSealed` directly demonstrates the pin bytes flow into the seal. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Updated `sample_write_body()` in seal.rs for the new required struct field** + +- **Found during:** Task 2 (Rust GREEN) +- **Issue:** Adding `recipient_pins` to `NodeWriteBody` broke compilation of an existing helper in `crates/core/src/node/seal.rs` (`E0063: missing field recipient_pins`). Not listed in `files_modified`. +- **Fix:** Added `recipient_pins: vec![]` to the `sample_write_body()` constructor. +- **Files modified:** crates/core/src/node/seal.rs +- **Verification:** `cargo test -p cipherbox-core --lib node` → 10 passed. +- **Committed in:** part of the plan commit. + +--- + +**Total deviations:** 1 auto-fixed (1 blocking). No scope creep — required for the crate to compile. + +## Issues Encountered + +- **Worktree had no installed dependencies / crypto dist.** `pnpm --filter @cipherbox/core test` failed resolving `@cipherbox/crypto`. Resolved by `pnpm install --frozen-lockfile` (workspace links present) + `pnpm --filter @cipherbox/crypto build` (dist was unbuilt). Blocking-environment setup, not a code change. + +## Notes / Verification + +- **Test pass counts:** Rust `node_write_body_vectors` = 1 passed; Rust lib `node` unit = 10 passed, 0 failed; TS `node-codec-vectors` = 24 passed; `pnpm --filter @cipherbox/core typecheck` = ok. +- **cross_language.rs untouched:** `grep -c "node-codec.json" crates/crypto/tests/cross_language.rs` = 0 (it reads `crypto/node-aad.json`; its line-310 `seal_vectors.len() == 1` guard is a different oracle and stays green). +- **Recovery-tool tolerance (D-03b no-op):** `grep -rn "writeKey|writeSealed|NodeWriteBody|recipientPins" apps/web/recovery-src/` returns a single COMMENT match (`main.ts:126`, "read-only — no writeKey argument"), not a parse. The plan AC expected literally zero matches; the intent (recovery tool never parses `NodeWriteBody`, so it tolerates the new field by construction) holds. Minor AC-literal vs actual mismatch, no behavior impact. +- **No API/DB change:** client-side owner-sealed metadata field only; `pnpm api:generate` and migrations intentionally not run. + +## Next Phase Readiness + +- `NodeWriteBody.recipientPins` (both codecs) and `seal_vectors[1]` are available for the pin-issuance write (80-04) and the fail-closed enforcement consumers (80-06/07/08). + +--- + +_Phase: 80-rotation-write-plane-and-re-mint-durability_ +_Completed: 2026-07-12_ diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md new file mode 100644 index 0000000000..70a23a900b --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-PLAN.md @@ -0,0 +1,223 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 02 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - crates/fuse/src/write_ops/rotation_deps.rs + - crates/fuse/src/replay.rs +autonomous: true +requirements: + - "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation" + - "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node" +user_setup: [] + +must_haves: + truths: + - "A scope-exit read-key rotation republishes each locally-materialized rotated node with a populated write_sealed body, not None, via reconstruct_write_body from the InodeTable re-sealed under seal_node at the NEW generation (D-01, D-01a)" + - "Unit tests pin the reconstruction round-trip and the not-materialized None fallback (D-01c)" + - "replay.rs::recover_signing_seed recovers a rotated node's signing seed after rotation+remount — the 'no write_sealed body' fail path no longer fires for a materialized rotated node (D-01 durability)" + - "A scope-exit rotation over an N-node subtree issues <=1 GET /shares/sent, not N (D-02)" + artifacts: + - "crates/fuse/src/write_ops/rotation_deps.rs — reconstruct_write_body helper + ApiClientTransport publish wiring + job-scoped collect_sent_shares cache field" + - "crates/fuse/src/replay.rs — rotation-then-replay signing-seed-recovery regression test" + - "crates/fuse/src/write_ops/rotation_deps.rs — FakeTransportInner collect_sent_shares call-counter + reconstruction/None-fallback/cache tests" + key_links: + - "ApiClientTransport::publish reads write_key + ipns_private_key + child WriteChildRefs from the in-memory InodeTable and re-seals via seal_node at the node's NEW generation (ROLE_BODY 0x01 AAD)" + - "The cache lives on ApiClientTransport (constructed once per rotation job at grant_scope.rs:488) — needs interior mutability (RefCell/OnceCell) since the struct is borrowed immutably during the walk" + prohibitions: + - "MUST NOT hard-error when a node is NOT locally materialized — fail-open to write_sealed: None (D-01b), mirroring find_ipns_private_key's Option return" + - "MUST NOT rotate or mutate the write plane — only re-seal the UNCHANGED write body at the bumped generation (write-key rotation stays a Phase-72 concern) (D-01b)" + - "MUST NOT change the existing per-share 0x-strip/hex-decode key parsing or per-share error semantics in query_grants_rooted_at — only add caching (D-02)" + - "MUST NOT use a static/global cache — the cache is job-scoped on the transport instance (D-02, Don't Hand-Roll)" +--- + + +Close the two mechanical Rust-FUSE rotation gaps that are independent of the D-03 pin work: + +- **D-01:** `ApiClientTransport::publish` currently republishes every rotated node with + `write_sealed: None` (the read-key rotation engine never populates it, and the FUSE adapter — + a Phase-72 deferral — never reconstructed it). This floods `list_folder_owned` with "owned child + has no write_sealed body" (607×/run observed) AND is a durability hole: `replay.rs` cannot recover + the node's signing seed after rotation+remount. Reconstruct the write body from the in-memory + `InodeTable` (the node's own stable write key + ipns_private_key + child WriteChildRefs rebuilt from + child inodes' write keys — all read-key-rotation-independent) and re-seal via `seal_node` at the + node's NEW generation. Fail-open to `None` for a non-materialized node. + +- **D-02:** `query_grants_rooted_at` calls `collect_sent_shares()` (a full `GET /shares/sent`) once per + rotated node → O(nodes × shares). Cache the result once per rotation job and filter by `root_node_id`. + +This plan does NOT touch the recipient-pin field (80-01/80-05) — the reconstruction here handles keys + +children; pin preservation is added in 80-05 once the field exists. + +Purpose: restore owned-walkability and signing-seed durability after rotation, and drop O(nodes) network fan-out. +Output: reconstruct-and-reseal write-body path, job-scoped sent-shares cache, and locked regression tests. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@crates/fuse/src/write_ops/rotation_deps.rs +@crates/fuse/src/replay.rs +@crates/fuse/src/inode.rs +@crates/fuse/src/write_ops/grant_scope.rs +@crates/core/src/node/seal.rs + + + + + + Task 1: RED — reconstruction round-trip, None fallback, sent-shares call-count, and rotation-then-replay regression tests + crates/fuse/src/write_ops/rotation_deps.rs, crates/fuse/src/replay.rs + + - crates/fuse/src/write_ops/rotation_deps.rs — `find_ipns_private_key` (lines 552-576) and `find_grant_root_state` (582-599) for the `inodes.inodes.values().find_map` lookup idiom; the existing `#[cfg(test)]` module and the `publish_count_for` FakeTransport call-count pattern (~lines 664-672 / 830-1270); `collect_sent_shares` (498-506) and `query_grants_rooted_at` (264-286, key parsing 270-278) + - crates/fuse/src/inode.rs — `InodeKind` (lines 119-172): Root/Folder/File each carry `read_key`, `write_key` (Zeroizing<[u8;32]>), `ipns_private_key` (Zeroizing>); `Inode.children: Option>` (line 252) + - crates/fuse/src/write_ops/grant_scope.rs (lines 485-521) — CONFIRM `FuseRotationDeps`/`ApiClientTransport` is constructed ONCE per rotation job at line 488 and `&deps` walks the whole subtree (this is why a transport-instance cache is job-scoped) + - crates/fuse/src/replay.rs — `recover_signing_seed` (~lines 261-297): the `published.write_sealed.as_ref().ok_or_else(...)` "no write_sealed body — cannot recover signing seed" fail path this proves closed + - crates/core/src/node/seal.rs (lines 48-74 `seal_node`/`unseal_node`, ROLE_BODY 0x01) + + + - Test A (reconstruct round-trip): given an InodeTable with a materialized Folder node (write_key, ipns_private_key, one child inode with its own write_key), `publish` produces a `write_sealed` that `unseal_node` under the node's write key at the NEW generation decodes back to a NodeWriteBody whose ipns_private_key and child WriteChildRef(s) match the inputs. + - Test B (None fallback): given a node NOT present in the InodeTable, `publish` yields `write_sealed: None` and returns Ok (no error). + - Test C (D-02 call-count): a rotation walk over N (>=3) rotated nodes calls the fake transport's `collect_sent_shares` at most once; `query_grants_rooted_at` still returns the correctly `root_node_id`-filtered grants per node. + - Test D (replay regression): after a rotation republish reconstructs write_sealed, `recover_signing_seed` on that PublishedNode succeeds (no "no write_sealed body" error). + - All four FAIL against current code (publish emits None; no cache; replay hits the fail path). + + + Add a `collect_sent_shares` call-counter to `FakeTransportInner` mirroring the existing + `publish_count_for` pattern. Author the four tests in the `#[cfg(test)]` module of rotation_deps.rs + (A/B/C) and a new rotation-then-replay test in replay.rs (D) that drives a reconstruction and then + calls `recover_signing_seed`. These tests define the contract; they must fail now. Do NOT implement + the reconstruction or cache yet. + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed" + + + - `grep -n "collect_sent_shares" crates/fuse/src/write_ops/rotation_deps.rs` shows a call-counter field on FakeTransportInner and an assertion of `<= 1` + - Tests A–D exist and FAIL against current code (proving non-vacuous RED) + - The replay.rs test references `recover_signing_seed` and asserts Ok after reconstruction + + Four failing tests pin the reconstruction round-trip, None fallback, <=1 sent-shares fetch, and rotation-then-replay recovery. + + + + Task 2: GREEN — reconstruct-and-reseal write body in ApiClientTransport::publish (D-01) + crates/fuse/src/write_ops/rotation_deps.rs + + - crates/fuse/src/write_ops/rotation_deps.rs — `ApiClientTransport::publish` (~lines 417-496) and its read-plane sourcing/sequencing (create_ipns_record/upload_content ~434-463); the fail-closed precedent at 426-431 + - crates/fuse/src/inode.rs — InodeKind variants and `children` list; the `apply_owned_children`/`InodeKind` match idiom + - crates/core/src/node/seal.rs — `seal_node` signature (writeKey, node_id, kind, generation, ROLE_BODY) and `WriteChildRef` shape from crates/core/src/node + + + - When `node.write_sealed` is None AND the node is materialized in the InodeTable: build a NodeWriteBody + from the node's own write_key + ipns_private_key + child WriteChildRefs (each child's `child_id` = child node_id, write key from the child inode), `encode_write_body`, seal via `seal_node` under the node's write key at the node's NEW generation, and set `write_sealed` to the result. + - When the node is NOT materialized: leave `write_sealed: None` and return Ok (D-01b). + - Child write keys are read-key-rotation-independent — do NOT re-derive or rotate them. + + + Add a `reconstruct_write_body(inodes, ipns_name, new_generation) -> Option>` helper following + the `find_ipns_private_key` `inodes.inodes.values().find_map` shape: locate the node by ipns_name, pull + its write_key + ipns_private_key, walk its `children` inode list to rebuild `WriteChildRef`s (child_id = + child.node_id, write key from the child inode), construct + `encode_write_body`, then `seal_node` at + `new_generation`. Return None when the node isn't found (fail-open). Wire it into `publish` so + `write_sealed` is populated only via this helper's Some result, preserving the existing read-plane + sequencing. Do NOT touch the write key material (no rotation). Key bytes are NEVER logged (CLAUDE.md). + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-fuse replay 2>&1 | tail -8 + + + - `grep -n "reconstruct_write_body\|seal_node" crates/fuse/src/write_ops/rotation_deps.rs` shows the helper and its seal call at the node's new generation + - Tests A, B, D from Task 1 pass (`cargo test -p cipherbox-fuse rotation_deps` and `... replay` green) + - The helper returns None (not Err) for a non-materialized node — verified by Test B + - No write-key mutation: the reconstructed body's child write keys equal the input child inode write keys (Test A) + + Rotation republish reconstructs a populated write_sealed for materialized nodes, fails open to None otherwise, and replay recovers the signing seed. + + + + Task 3: GREEN — job-scoped collect_sent_shares cache (D-02) + crates/fuse/src/write_ops/rotation_deps.rs + + - crates/fuse/src/write_ops/rotation_deps.rs — `ApiClientTransport` struct definition (~line 379, holds `api` + `&inodes` immutable borrow), `collect_sent_shares` (498-506), `query_grants_rooted_at` (264-286, filter at 268, per-share key parse/error at 270-278) + - crates/fuse/src/write_ops/grant_scope.rs (line 488) — confirms one transport instance per job + + + - `collect_sent_shares()` is invoked at most once across an entire rotation walk; subsequent + `query_grants_rooted_at` calls read the cached list and filter by `root_node_id`. + - Per-share 0x-strip/hex-decode and per-share RotateFailed error behavior are byte-for-byte unchanged. + + + Add an interior-mutable cache field to `ApiClientTransport` — a `RefCell>>` + (or `tokio::sync::OnceCell` if the fetch is async) — since the transport is borrowed immutably during + the walk. On first `query_grants_rooted_at`, populate the cache from `collect_sent_shares()`; thereafter + read from it. Keep the existing filter-by-root_node_id and per-share parsing/error path exactly as-is. + Initialize the cache empty in every construction site (grant_scope.rs:488 and the test constructors). + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8 + + + - Test C from Task 1 passes: a walk over >=3 nodes calls `collect_sent_shares` at most once + - `grep -n "RefCell\|OnceCell" crates/fuse/src/write_ops/rotation_deps.rs` shows the cache field on ApiClientTransport + - The per-share parsing block (0x strip + hex-decode + RotateFailed) is unchanged (diff shows only cache read/populate swapped for the fresh fetch) + - `cargo test -p cipherbox-fuse` passes with no regressions + + A rotation job fetches /shares/sent at most once; grant filtering and error semantics are unchanged. + + + + + +- `reconstruct_write_body(inodes, ipns_name, new_generation) -> Option>` (rotation_deps.rs) — extended in 80-05 to also carry recipient_pins +- Job-scoped sent-shares cache field on `ApiClientTransport` (rotation_deps.rs) +- `FakeTransportInner` `collect_sent_shares` call-counter (test infra reused by 80-06) +- replay.rs rotation-then-replay signing-seed-recovery regression test +- NOTE for 80-05: this reconstruction handles keys+children only; recipient_pins preservation is added in 80-05 once the D-03b field lands (NOT a scope reduction of D-01 — pins are a D-03 concern) + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| FUSE mount (in-memory InodeTable) → IPFS republish | Reconstructed write-body assembled from local plaintext key material | +| owner device → CipherBox API (/shares/sent) | Repeated relay fetches during a rotation walk | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-04 | Repudiation | replay.rs recover_signing_seed | high | mitigate | Reconstruct write_sealed so the owner retains the signing seed after rotation+remount (D-01) | +| T-80-05 | Denial of Service | list_folder_owned owned-walk | high | mitigate | Populated write_sealed removes the 607×/run "no write_sealed body" flood (D-01) | +| T-80-06 | Denial of Service | GET /shares/sent fan-out | medium | mitigate | Job-scoped cache bounds fetches to <=1 per rotation (D-02) | +| T-80-07 | Information Disclosure | reconstructed key material in logs | medium | mitigate | Key bytes never logged; only public ipns_name/child_id logged (CLAUDE.md rule 2) | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `cargo test -p cipherbox-fuse` green (rotation_deps reconstruction + None fallback + cache call-count; replay rotation-then-replay recovery) +- No write-key mutation introduced (reconstruction reseals the unchanged write plane at the new generation) +- Per-share parsing/error semantics in query_grants_rooted_at unchanged + + + +Scope-exit rotation republishes a populated write_sealed for every materialized rotated node (owned-walk +and replay signing-seed recovery survive), fails open to None for non-materialized nodes, and fetches +/shares/sent at most once per rotation job — all locked by regression tests. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md new file mode 100644 index 0000000000..8caea5da51 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md @@ -0,0 +1,165 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 02 +subsystem: infra +tags: [rust, fuse, rotation, ipns, node-v3, write-body, seal, aead, caching] + +# Dependency graph +requires: + - phase: 80-01 + provides: NodeWriteBody.recipient_pins field on the Rust/TS node codec +provides: + - reconstruct_write_body helper — rebuilds a rotated node's write-body from the in-memory InodeTable and re-seals it under the node's own write key at the NEW generation (ROLE_BODY 0x01) + - ApiClientTransport::publish now injects a populated write_sealed for materialized rotated nodes (was always None), restoring owned-walkability and replay signing-seed durability + - Job-scoped GET /shares/sent cache on FuseRotationDeps (<=1 fetch per rotation job) + - FakeTransportInner collect_sent_shares call-counter test infra (reused by 80-06) + - replay.rs rotation-then-replay signing-seed-recovery regression test +affects: [80-05, 80-06] + +# Tech tracking +tech-stack: + added: [tokio::sync::OnceCell] + patterns: + - "Reconstruct-and-reseal: rebuild a node's write plane from local InodeTable key material (read-key-rotation-independent) and re-seal at the node's new generation, never mutating the write plane" + - "Job-scoped interior-mutable cache (OnceCell) on the once-per-job FuseRotationDeps to fetch-once/reuse across an immutable-borrow walk" + +key-files: + created: + - .planning/phases/80-rotation-write-plane-and-re-mint-durability/80-02-SUMMARY.md + modified: + - crates/fuse/src/write_ops/rotation_deps.rs + - crates/fuse/src/replay.rs + - crates/sdk/src/emit.rs + - crates/sdk/src/listing.rs + - crates/fuse/src/content_ops.rs + - crates/fuse/src/journal_helpers.rs + - crates/fuse/src/fs.rs + - crates/fuse/src/write_ops/implementation/delete.rs + - crates/fuse/src/platform/windows/write_ops.rs + +key-decisions: + - "Cache lives on FuseRotationDeps (generic over T), not ApiClientTransport as the plan text stated — Test C's locked contract (the FAKE transport's collect_sent_shares called <=1) can only be satisfied by caching at the layer that wraps transport.collect_sent_shares(). FuseRotationDeps is the once-per-job instance, so it is still job-scoped, not static/global." + - "OnceCell (not RefCell) for the cache because query_grants_rooted_at is async — get_or_try_init never holds a borrow across .await and keeps the future Send." + - "Child WriteChildRef.write_key_sealed is sealed at AAD generation 0, matching the established build_folder_metadata / build_child_refs write-splice convention (child write plane is not rotated here); only the node's own write-body ROLE_BODY seal uses the new generation." + - "recipient_pins emitted empty by reconstruct_write_body — pin preservation (D-03b) is 80-05's concern once the field is populated on the inode; this reconstruction handles keys + children only." + +patterns-established: + - "reconstruct-and-reseal write body from InodeTable at a new generation, fail-open to None for non-materialized nodes" + - "job-scoped OnceCell cache for a per-node fan-out relay fetch" + +requirements-completed: + - "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation" + - "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node" + +coverage: + - id: D1 + description: "Rotation republish reconstructs a populated write_sealed for a materialized rotated node (D-01): write-body carries the node's own signing seed + child WriteChildRefs, re-sealed under the node's own write key at the new generation." + requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_round_trips_ipns_key_and_child_write_refs" + status: pass + human_judgment: false + - id: D2 + description: "reconstruct_write_body fails open to None (never Err/panic) for a node not locally materialized (D-01b)." + requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_fails_open_to_none_for_a_non_materialized_node" + status: pass + human_judgment: false + - id: D3 + description: "replay.rs::recover_signing_seed recovers a rotated node's signing seed from the reconstructed write_sealed — the 'no write_sealed body' fail path no longer fires after rotation+remount (T-80-04)." + requirement: "SC1 / D-01: rotation republish reconstructs write_sealed from InodeTable; owned-walk + replay signing-seed recovery survive rotation" + verification: + - kind: unit + ref: "crates/fuse/src/replay.rs#rotation_reconstructed_write_sealed_recovers_signing_seed" + status: pass + human_judgment: false + - id: D4 + description: "A rotation walk over N (>=3) nodes fetches GET /shares/sent at most once, with root_node_id filtering + per-share parsing unchanged (D-02, T-80-06)." + requirement: "SC2-perf / D-02: cache GET /shares/sent once per rotation job instead of once per rotated node" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#rotation_walk_fetches_sent_shares_at_most_once" + status: pass + human_judgment: false + +# Metrics +duration: 45min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 02: Rotation write-plane reconstruction + sent-shares cache Summary + +**Rotation republish reconstructs a populated write_sealed from the in-memory InodeTable (restoring owned-walkability and replay signing-seed durability) and caches GET /shares/sent to at most one fetch per rotation job.** + +## Performance + +- **Duration:** ~45 min +- **Tasks:** 3 (TDD: RED → GREEN → GREEN) +- **Files modified:** 9 (2 in-scope + 7 Rule-3 compile-unblock) + +## Accomplishments +- **D-01:** `ApiClientTransport::publish` now reconstructs the write-body from the locally-materialized `InodeTable` (own write key + `ipns_private_key` + child `WriteChildRef`s copied verbatim from child inodes) and re-seals it via `seal_node` under the node's own write key at its NEW generation, injecting a populated `write_sealed` where the rotation engine emitted `None`. Fails open to `None` for a non-materialized node; never rotates/mutates the write plane. This removes the `list_folder_owned` "no write_sealed body" flood (T-80-05) and closes the `replay.rs::recover_signing_seed` durability hole (T-80-04). +- **D-02:** Added a job-scoped `tokio::sync::OnceCell` cache on `FuseRotationDeps` so a rotation walk fetches `GET /shares/sent` at most once instead of once per rotated node — per-share `root_node_id` filter and 0x-strip/hex-decode/error parsing are byte-for-byte unchanged. +- Locked all four contracts with regression tests (reconstruct round-trip, None fallback, <=1 sent-shares fetch, rotation-then-replay recovery). + +## Task Commits + +1. **Task 1: RED tests + compile-unblock** - `eaed02937` (test) +2. **Task 2: GREEN — reconstruct-and-reseal write body (D-01)** - `5c1ee8409` (feat) +3. **Task 3: GREEN — job-scoped sent-shares cache (D-02) + SUMMARY** - this commit (perf) + +## Files Created/Modified +- `crates/fuse/src/write_ops/rotation_deps.rs` - `reconstruct_write_body` helper, `publish` wiring, job-scoped `OnceCell` sent-shares cache, `FakeTransportInner` call-counter, tests A/B/C +- `crates/fuse/src/replay.rs` - rotation-then-replay signing-seed-recovery regression test (Test D) + Rule-3 constructor fixes +- `crates/sdk/src/emit.rs`, `crates/sdk/src/listing.rs`, `crates/fuse/src/content_ops.rs`, `crates/fuse/src/journal_helpers.rs`, `crates/fuse/src/fs.rs`, `crates/fuse/src/write_ops/implementation/delete.rs`, `crates/fuse/src/platform/windows/write_ops.rs` - Rule-3 compile-unblock (`recipient_pins: Vec::new()` in downstream `NodeWriteBody` constructors) + +## Decisions Made +- **Cache placement:** The plan text said "cache on `ApiClientTransport`", but Test C (the locked acceptance contract) asserts the FAKE transport's `collect_sent_shares` is called `<= 1`. That can only hold if caching happens at the layer wrapping `transport.collect_sent_shares()` — i.e. `FuseRotationDeps::query_grants_rooted_at`. `FuseRotationDeps` is the once-per-job instance (grant_scope.rs:488), so the cache remains job-scoped and instance-local, satisfying the "not static/global" prohibition. Bonus: no construction-site literal changes at grant_scope.rs:489 (the field is initialized inside `new()`). +- **`OnceCell` over `RefCell`:** the fetch is async; `get_or_try_init` holds no borrow across `.await`, keeping the deps future `Send` (a `RefCell` field would break `Send` for the spawned rotation walk). +- **Child splice generation 0:** child `WriteChildRef.write_key_sealed` is sealed at AAD generation `0`, matching the existing `build_folder_metadata` / `build_child_refs` convention. The node's own write-body ROLE_BODY seal uses the node's NEW generation (this is what `recover_signing_seed` rebuilds). The child write plane is not rotated here. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Downstream NodeWriteBody constructors left non-compiling by 80-01** +- **Found during:** Task 1 (initial `cargo check`) +- **Issue:** Plan 80-01 added the required `recipient_pins` field to `NodeWriteBody` but only fixed its own core-crate constructor. Every downstream `NodeWriteBody` constructor in `crates/sdk` and `crates/fuse` failed to compile (`E0063: missing field recipient_pins`), so the whole workspace — including the `cargo test -p cipherbox-fuse` target this plan must run — would not build. No sibling 80-x plan lists these constructors in its `files_modified`. +- **Fix:** Added `recipient_pins: Vec::new()` to all 11 downstream constructors across 7 files. Byte-identical wire behavior (the field is `skip_serializing_if = "Vec::is_empty"`, so an empty list is omitted). This mirrors the same "blocking-compile fix" deviation 80-01 itself applied to `crates/core`. +- **Files modified:** crates/sdk/src/emit.rs, crates/sdk/src/listing.rs, crates/fuse/src/content_ops.rs, crates/fuse/src/journal_helpers.rs, crates/fuse/src/fs.rs, crates/fuse/src/write_ops/implementation/delete.rs, crates/fuse/src/platform/windows/write_ops.rs, crates/fuse/src/replay.rs (2 constructors) +- **Verification:** `cargo check -p cipherbox-fuse --features fuse` and `cargo check -p cipherbox-sdk` clean; full `cargo test -p cipherbox-fuse` green (124 passed). +- **Committed in:** `eaed02937` (Task 1) for 7 files; the two `replay.rs` constructor fixes rode the same commit as replay Test D. + +**2. [Plan-text divergence] Cache on FuseRotationDeps, not ApiClientTransport** +- **Found during:** Task 3 +- **Issue:** The plan's `key_links`/action placed the cache on `ApiClientTransport`, but the locked Test C exercises the `FakeTransport` path and asserts its `collect_sent_shares` is called `<= 1`. +- **Fix:** Implemented the cache on the generic `FuseRotationDeps` (the once-per-job instance wrapping either transport) so both the production and fake paths fetch-once/reuse. Still job-scoped and interior-mutable (`OnceCell`); satisfies every prohibition (no static/global). Grep-check for `RefCell|OnceCell` in rotation_deps.rs is satisfied. +- **Verification:** Test C passes; full fuse suite green. +- **Committed in:** this commit (Task 3). + +--- + +**Total deviations:** 2 (1 Rule-3 blocking compile-unblock; 1 plan-text divergence forced by the locked acceptance test). +**Impact on plan:** The compile-unblock was mandatory for the plan's own tests to build (same class of fix 80-01 applied). The cache-placement divergence keeps the behavior identical and the prohibitions intact. No scope creep beyond the unavoidable compile-unblock. + +## Issues Encountered +- The workspace did not compile at plan start (see Deviation 1). Resolved by the Rule-3 compile-unblock before RED. + +## Known Gaps / Notes for 80-05 +- `reconstruct_write_body` emits an empty `recipient_pins` list. Pin preservation (D-03b) is 80-05's job once the pins are cached on the inode — this is a planned handoff, NOT a scope reduction of D-01 (the plan's own note). +- `replay.rs::fetch_splice_publish_parent` re-seals the parent write-body with `recipient_pins: Vec::new()` (it decodes only `write_children` from the parent's current write-body). If a rotated/shared parent carries pins, a replay re-splice would drop them. This is out of scope for 80-02 (which owns rotation republish + the replay regression test, not replay pin preservation) and no 80-x plan currently lists `replay.rs` for pin work — flagged here for triage. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- 80-05 (wave 2, depends on 80-02) can extend `reconstruct_write_body` to thread cached `recipient_pins`; the helper signature and seal path are in place. +- 80-06 (wave 3) can reuse the `FakeTransportInner` call-counter test infra. + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md new file mode 100644 index 0000000000..7acbf0eb01 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-PLAN.md @@ -0,0 +1,177 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 03 +type: tdd +wave: 1 +depends_on: [] +files_modified: + - packages/sdk-core/src/rotation/engine.ts + - packages/sdk-core/src/__tests__/rotation/engine.test.ts + - packages/sdk/src/share/owner-reconcile.ts + - packages/sdk/src/__tests__/owner-reconcile.test.ts +autonomous: true +requirements: + - "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity" + - "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass" +user_setup: [] + +must_haves: + truths: + - "Every rotatedNodes entry's readKey is an independent 32-byte copy, non-aliased with the corresponding parentNewReadKey (D-04)" + - "A rotatedNodes readKey equals the node's expected new key after rotateReadFromNode, and is non-zero (D-04)" + - "queryGrantsFn fetches listSentGrants() at most once per runOwnerReconcile pass, filtering by rootNodeId per call (D-02 TS)" + artifacts: + - "packages/sdk-core/src/rotation/engine.ts — new Uint8Array(...) defensive copy at every rotatedNodes.set readKey" + - "packages/sdk-core/src/__tests__/rotation/engine.test.ts — non-aliasing/non-zero/correct-value regression test" + - "packages/sdk/src/share/owner-reconcile.ts — closure-scoped listSentGrants cache in buildGrantRemintCallbacks" + - "packages/sdk/src/__tests__/owner-reconcile.test.ts — single-fetch cache assertion" + key_links: + - "The defensive copy is applied at the rotatedNodes.set() collection boundary ONLY — parentNewReadKey stays the live reference the walk uses to seal children (D-04, Pattern 4)" + - "Rust already clones each key into Zeroizing<[u8;32]> — this is the TS parity fix; no Rust change" + prohibitions: + - "MUST NOT copy or alter parentNewReadKey / parentOldReadKey — only the rotatedNodes.set readKey value gets the defensive copy (D-04)" + - "MUST NOT change the Rust rotation engine — Rust is already correct (D-04)" + - "MUST NOT introduce a global/static cache for listSentGrants — the cache is scoped to a single reconcile pass (D-02)" +--- + + +Two independent TS-side mechanical mirrors of the mechanical items, both wave-1 and pin-independent: + +- **D-04 (SC3):** The TS rotation engine stores the SAME `Uint8Array` reference in `rotatedNodes` that + `ParentTrackingState.parentNewReadKey` also holds (root branch and BFS child branch). Not a live bug + today (`parentNewReadKey` is never zeroed), but a natural future D-09 zeroization tightening would + silently zero the returned `rotatedNodes` entry → the Rust FUSE consumer + (`grant_scope.rs::refresh_rotated_inode_read_keys`) would refresh an inode read key to all-zeros → + mis-decryption / data loss. Rust already clones each key into `Zeroizing<[u8;32]>`. Fix: store a + defensive 32-byte copy (`new Uint8Array(...)`) at each `rotatedNodes.set()` call, leaving + `parentNewReadKey` untouched (the live reference the walk uses to seal children). + +- **D-02 (TS mirror, SC2-perf):** `queryGrantsFn` calls `transport.listSentGrants()` fresh on every + invocation → the TS mirror of the O(nodes × shares) fetch. Cache the result for the lifetime of a + single `runOwnerReconcile` pass, filtering by `rootNodeId` per call. + +Purpose: TS/Rust parity for rotatedNodes key ownership + single-fetch sent-shares in the TS reconcile path. +Output: defensive-copy fix + non-aliasing regression test; closure-scoped listSentGrants cache + single-fetch test. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@packages/sdk-core/src/rotation/engine.ts +@packages/sdk/src/share/owner-reconcile.ts +@packages/sdk/src/__tests__/owner-reconcile.test.ts + + + + + + Task 1: RED+GREEN — defensive 32-byte copy at every rotatedNodes.set readKey (D-04) + packages/sdk-core/src/rotation/engine.ts, packages/sdk-core/src/__tests__/rotation/engine.test.ts + + - packages/sdk-core/src/rotation/engine.ts — `RotatedNodeKey` type (lines 345-350); the root-branch `rotatedNodes.set(rootNodeIpnsName, { readKey: rootResult.childReadKey, ... })` (~line 2055) and its sibling `parentNewReadKey: rootResult.childReadKey` (~:2066); the BFS child-branch `rotatedNodes.set(item.childRef.ipnsName, { readKey: result.childReadKey, ... })` (~:2226) and `parentNewReadKey: result.childReadKey` (~:2287); the existing defensive-copy idiom + comment at `parentOldReadKey: new Uint8Array(rootReadKey)` (~:2068). ALSO grep ALL `rotatedNodes.set(` sites (there is a third at ~:1815 in the resume/repair path) — apply the fix at every site whose readKey aliases a parentNewReadKey. + - packages/sdk-core/src/__tests__/rotation/engine.test.ts — existing rotateReadFromNode test setup to extend + + + - After `rotateReadFromNode`, for every entry in the returned `rotatedNodes` map: `entry.readKey` is NOT the same object as the corresponding `parentNewReadKey`, is a 32-byte non-zero array, and equals the node's expected post-rotation read key. + - A test that (RED) mutates/zeros a `parentNewReadKey` reference must NOT affect the returned `rotatedNodes` entry (proves non-aliasing). Before the fix this assertion fails; after, it passes. + + + Change each `rotatedNodes.set(...)` readKey value to `new Uint8Array()` — root: + `new Uint8Array(rootResult.childReadKey)`, child: `new Uint8Array(result.childReadKey)`, and any third + site found by grep. Leave every `parentNewReadKey: ...childReadKey` assignment untouched. Mirror the + exact "defensive copy owned by this collection, safe from a future zero-on-drop" comment style already + used at the `parentOldReadKey` idiom. Add a regression test in engine.test.ts asserting for each + rotatedNodes entry: non-aliased with parentNewReadKey (mutate-parent-does-not-affect-entry), non-zero, + and equal to the expected new key. + + + pnpm --filter @cipherbox/sdk-core test rotation/engine 2>&1 | tail -15 + + + - `grep -n "new Uint8Array(rootResult.childReadKey)\|new Uint8Array(result.childReadKey)" packages/sdk-core/src/rotation/engine.ts` shows the defensive copies at the rotatedNodes.set sites + - Every `rotatedNodes.set(` site identified by grep uses `new Uint8Array(...)` for readKey; no `parentNewReadKey` assignment was changed + - The new regression test asserts non-aliasing (mutating a parentNewReadKey does not alter the returned entry), non-zero, and correct value — and passes + - `pnpm --filter @cipherbox/sdk-core test rotation/engine` green + + rotatedNodes readKeys are independent non-zero copies matching Rust's Zeroizing-clone parity; parentNewReadKey references are untouched. + + + + Task 2: RED+GREEN — cache listSentGrants() per reconcile pass in buildGrantRemintCallbacks (D-02 TS) + packages/sdk/src/share/owner-reconcile.ts, packages/sdk/src/__tests__/owner-reconcile.test.ts + + - packages/sdk/src/share/owner-reconcile.ts — `buildGrantRemintCallbacks` (lines 66-84); `queryGrantsFn` calls `transport.listSentGrants()` fresh (line 71) and filters by rootNodeId (line 73); `runOwnerReconcile` (94-104) + - packages/sdk/src/__tests__/owner-reconcile.test.ts — existing transport mock/spy to extend with a call-count assertion + + + - Across repeated `queryGrantsFn(nodeId)` calls within one `buildGrantRemintCallbacks`/`runOwnerReconcile` pass, `transport.listSentGrants()` is invoked at most once; each call still returns the rootNodeId-filtered grant subset. + - RED: a test spying on `listSentGrants` and invoking `queryGrantsFn` N times expects call-count 1; fails before the cache. + + + In `buildGrantRemintCallbacks`, introduce a closure-scoped memo — `let cached: Promise | undefined` + (or the returned-row type) — populated on first `queryGrantsFn` call and reused thereafter. Keep the + existing filter-by-rootNodeId logic unchanged (line 73). Add an owner-reconcile.test.ts case asserting + `listSentGrants` is called at most once across multiple queryGrantsFn invocations while filtered results + stay correct. + + + pnpm --filter @cipherbox/sdk test owner-reconcile 2>&1 | tail -12 + + + - `grep -n "cached\|listSentGrants" packages/sdk/src/share/owner-reconcile.ts` shows a closure-scoped memo wrapping listSentGrants + - The new test asserts `listSentGrants` call-count <= 1 across multiple queryGrantsFn calls and correct rootNodeId filtering + - No global/module-level cache introduced (the memo lives inside buildGrantRemintCallbacks) + - `pnpm --filter @cipherbox/sdk test owner-reconcile` green + + queryGrantsFn fetches sent grants once per reconcile pass with unchanged per-node filtering. + + + + + +- Defensive-copy `new Uint8Array(...)` at every rotatedNodes.set readKey (engine.ts) +- Non-aliasing/non-zero/correct-value regression test (engine.test.ts) +- Closure-scoped listSentGrants memo in buildGrantRemintCallbacks (owner-reconcile.ts) — 80-07 later adds getPinsFn to the same callbacks builder (sequential, same file) +- Single-fetch cache assertion (owner-reconcile.test.ts) + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| rotation engine → FUSE consumer (via returned rotatedNodes map) | Returned key ownership must survive a future zeroization tightening | +| owner device → CipherBox API (listSentGrants) | Repeated relay fetches during a reconcile pass | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-08 | Tampering (self-inflicted) | rotatedNodes readKey aliasing | medium | mitigate | Defensive 32-byte copy prevents a future zero-on-drop from zeroing returned keys → no all-zeros inode refresh (D-04) | +| T-80-09 | Denial of Service | listSentGrants fan-out | low | mitigate | Per-pass memo bounds fetches to <=1 (D-02 TS) | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `pnpm --filter @cipherbox/sdk-core test rotation/engine` green (non-aliasing/non-zero/correct-value) +- `pnpm --filter @cipherbox/sdk test owner-reconcile` green (single-fetch) +- No Rust change; no parentNewReadKey/parentOldReadKey change + + + +Every returned rotatedNodes readKey is an independent, non-zero 32-byte copy equal to the expected new key +(TS/Rust parity), and the TS owner-reconcile path fetches sent grants at most once per pass. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md new file mode 100644 index 0000000000..8443fb6456 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-03-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 03 +subsystem: testing +tags: [rotation, zeroization, ts-rust-parity, owner-reconcile, memoization] + +# Dependency graph +requires: + - phase: 74-rotation-deep-scope-exit + provides: RotateReadResult.rotatedNodes deep-tree key surfacing (SC1) that this hardens +provides: + - "TS rotation engine stores a defensive 32-byte copy of every rotatedNodes readKey (non-aliased with parentNewReadKey) — Rust Zeroizing-clone parity (D-04)" + - "buildGrantRemintCallbacks memoizes listSentGrants() per reconcile pass — bounds the O(nodes × shares) fan-out to <=1 fetch (D-02 TS mirror)" +affects: [80-07-owner-reconcile-getPinsFn, rotation, fuse-inode-refresh] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Defensive Uint8Array copy at the collection boundary (rotatedNodes.set) while the live parentNewReadKey reference is left untouched for the seal walk" + - "Closure-scoped promise memo inside a callbacks-builder factory (per-pass cache, never global/static)" + +key-files: + created: [] + modified: + - packages/sdk-core/src/rotation/engine.ts + - packages/sdk-core/src/__tests__/rotation/engine.test.ts + - packages/sdk/src/share/owner-reconcile.ts + - packages/sdk/src/__tests__/owner-reconcile.test.ts + +key-decisions: + - "Defensive copy applied at the rotatedNodes.set() readKey ONLY; parentNewReadKey/parentOldReadKey left as live references (D-04, Pattern 4)" + - "Cache is a closure-scoped `let cachedGrants: Promise` populated via `??=` — scoped to one buildGrantRemintCallbacks bundle, verified to re-fetch on a fresh bundle" + +patterns-established: + - "Pattern 4 (rotatedNodes ownership): the returned map owns independent key copies so a future zero-on-drop of parentNewReadKey cannot corrupt consumer-visible keys" + +requirements-completed: + - "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity" + - "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass" + +coverage: + - id: D1 + description: "TS rotation engine stores a non-aliased, non-zero 32-byte copy of every rotatedNodes readKey (root, BFS child, dirty-resume repair), matching Rust's Zeroizing-clone (D-04)" + requirement: "SC3 / D-04: TS rotatedNodes stores a defensive 32-byte copy of readKey (no aliasing with parentNewReadKey), matching Rust parity" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/rotation/engine.test.ts#D-04: each rotatedNodes readKey is a non-aliased, non-zero copy (mutating parentNewReadKey does not affect the entry)" + status: pass + human_judgment: false + - id: D2 + description: "buildGrantRemintCallbacks caches listSentGrants() per reconcile pass — <=1 fetch across multiple queryGrantsFn calls, closure-scoped (not global), per-node rootNodeId filtering unchanged (D-02 TS)" + requirement: "SC2-perf / D-02 (TS mirror): queryGrantsFn caches listSentGrants() across calls within one reconcile pass" + verification: + - kind: unit + ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 1b: listSentGrants is fetched at most once across multiple queryGrantsFn calls, filtering stays correct per node" + status: pass + - kind: unit + ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 1c: the cache is scoped per buildGrantRemintCallbacks call — a fresh callbacks bundle re-fetches (no global/static cache)" + status: pass + human_judgment: false + +# Metrics +duration: 15min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 03: Rotation-key ownership + sent-grants memo Summary + +**TS rotation engine now stores non-aliased 32-byte defensive copies of every rotatedNodes readKey (Rust Zeroizing-clone parity, D-04), and the owner-reconcile driver memoizes listSentGrants() per pass to bound the fan-out to a single fetch (D-02 TS).** + +## Performance + +- **Duration:** ~15 min +- **Started:** 2026-07-12T20:00:00Z +- **Completed:** 2026-07-12T20:05:00Z +- **Tasks:** 2 (both TDD) +- **Files modified:** 4 + +## Accomplishments + +- Applied `new Uint8Array(...)` defensive copy at both aliasing `rotatedNodes.set()` sites — root branch (`rootResult.childReadKey`) and BFS child branch (`result.childReadKey`). The third site (dirty-resume repair, ~:1817) already copied `readKeyPrime`, so all three now own independent buffers. `parentNewReadKey`/`parentOldReadKey` left untouched. +- Added an engine regression test proving each rotatedNodes readKey is a distinct object from `result.readKey` (the retained parentNewReadKey alias), is non-zero, equals the correct new key, and survives a simulated zero-on-drop of the parent reference. +- Introduced a closure-scoped `cachedGrants` promise memo in `buildGrantRemintCallbacks` (`??=` populate-once), leaving the per-node `rootNodeId` filter unchanged. +- Added two owner-reconcile tests: single-fetch across multiple `queryGrantsFn` calls with correct per-node filtering, and a fresh-bundle-re-fetches test proving the cache is not global/static. + +## Task Commits + +Committed as a single commit per execution constraint (code + tests + SUMMARY together): + +1. **Task 1: defensive 32-byte copy at every rotatedNodes.set readKey (D-04)** — engine.ts + engine.test.ts +2. **Task 2: cache listSentGrants() per reconcile pass (D-02 TS)** — owner-reconcile.ts + owner-reconcile.test.ts + +## Files Created/Modified + +- `packages/sdk-core/src/rotation/engine.ts` — defensive `new Uint8Array(...)` copy at root (:2060) and BFS child (:2234) rotatedNodes.set readKey sites +- `packages/sdk-core/src/__tests__/rotation/engine.test.ts` — D-04 non-aliasing/non-zero/correct-value regression test +- `packages/sdk/src/share/owner-reconcile.ts` — closure-scoped `cachedGrants` memo wrapping `transport.listSentGrants()` +- `packages/sdk/src/__tests__/owner-reconcile.test.ts` — single-fetch cache assertion + per-pass-scope assertion + +## Decisions Made + +- Defensive copy at the collection boundary only; the live `parentNewReadKey` reference the walk uses to seal children is deliberately left aliasing `childReadKey` (matches the plan's Pattern 4 and Rust's structure). No Rust change — Rust already clones into `Zeroizing<[u8;32]>`. +- Memo implemented with `let cachedGrants: Promise | undefined` + `??=`, caching the promise (not the awaited value) so concurrent first-callers share one in-flight fetch. + +## Deviations from Plan + +None - plan executed exactly as written. (The dirty-resume `rotatedNodes.set` at ~:1817 flagged by the plan's grep instruction already used a `new Uint8Array(readKeyPrime)` defensive copy and required no change.) + +## Issues Encountered + +- Scoped test runs initially failed with vite `Failed to resolve entry for package "@cipherbox/core"` / `@cipherbox/api-client` — stale/absent workspace dists. Resolved as setup by building `@cipherbox/core`, `@cipherbox/api-client`, `@cipherbox/crypto`, `@cipherbox/sdk-core` (dist-staleness only; no code impact). +- Prettier flagged one wrapping in the new engine test assertion; fixed via `prettier --write` and re-verified with eslint + a re-run of the test suite. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- 80-07 will add `getPinsFn` to the same `buildGrantRemintCallbacks` builder (sequential, same file) — the memo pattern is now established there for it to extend. + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md new file mode 100644 index 0000000000..ededd33ed3 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-PLAN.md @@ -0,0 +1,224 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 04 +type: tdd +wave: 2 +depends_on: ["80-01"] +files_modified: + - packages/sdk-core/src/share/recipient-pins.ts + - packages/sdk-core/src/share/index.ts + - packages/sdk-core/src/folder/registration.ts + - packages/sdk-core/src/__tests__/share/recipient-pins.test.ts + - packages/sdk/src/client.ts +autonomous: true +requirements: + - "SC2 / D-03a: store the issuance-time recipient pubkey in the shared root node's owner-sealed NodeWriteBody (server-opaque, cross-device)" + - "SC2 / D-03c: at grant creation, append the pasted recipient pubkey to the node's write-body pin list and republish" +user_setup: [] + +must_haves: + truths: + - "A pin written via addRecipientPubkeyPin round-trips: getRecipientPubkeyPins on the republished node returns the appended recipient pubkey (D-03a/c)" + - "updateFolderMetadataAndPublish preserves existing recipientPins across any folder-metadata update and across a CAS-409 merge (pins are never silently dropped) (D-03a durability)" + - "assertRecipientPinned throws when the recipient is absent from the pin list AND when the pin list is empty/absent (D-03e no-legacy hard fail), and returns normally on a match" + artifacts: + - "packages/sdk-core/src/share/recipient-pins.ts — assertRecipientPinned, extractRecipientPins, appendRecipientPin (pure helpers)" + - "packages/sdk-core/src/folder/registration.ts — recipientPins threaded through updateFolderMetadataAndPublish seal + CAS-merge" + - "packages/sdk/src/client.ts — addRecipientPubkeyPin(itemIpnsName, recipientPublicKey) + getRecipientPubkeyPins(itemIpnsName) wrappers" + - "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts — write→read round-trip + assert-or-throw + merge-preservation tests" + key_links: + - "addRecipientPubkeyPin resolves the node, unseals its current write-body (writeKey), appends the pin (dedup), re-seals via sealNode and CAS-republishes via updateFolderMetadataAndPublish — generation UNCHANGED, sequenceNumber increments (IPNS clock)" + - "assertRecipientPinned normalizes both sides to raw pubkey bytes before comparing (no hex/base64 mismatch)" + prohibitions: + - "MUST NOT bump the node's generation or invent a pin-generation counter — the pin rides inside the existing role-0x01 write-body seal at the current generation (Anti-Pattern)" + - "MUST NOT drop existing recipientPins on a folder-metadata update or CAS merge — preserve the union (D-03a)" + - "MUST NOT bolt the pin write onto resolveShareEncryptedWriteKey (that only DERIVES a writeKey, never writes a write-body) — this is a genuine new write path (Pitfall 4)" + - "MUST NOT add an API/DTO change or call pnpm api:generate — the pin is client-side owner-sealed only (D-03f)" +--- + + +Build the TS pin storage/issuance machinery (D-03a/c): the owner-sealed `NodeWriteBody.recipientPins` +list is the server-opaque, cross-device source of truth that all three D-03d enforcement consumers +(80-06 Rust, 80-07 TS, 80-08 web) verify against. There is NO existing SDK method that mutates a node's +own write-body pin list — `resolveShareEncryptedWriteKey` only DERIVES a writeKey, it never writes back +(Pitfall 4). This plan adds: + +1. Pure sdk-core helpers: `extractRecipientPins` (from a decoded write-body), `appendRecipientPin` (dedup), + `assertRecipientPinned` (compare-or-throw, including the D-03e empty/absent hard-fail). +2. `updateFolderMetadataAndPublish` extended to carry/preserve `recipientPins` through its `sealNode` + writeBody and its CAS-409 merge — so pins survive normal folder updates and concurrent writes. +3. `@cipherbox/sdk` client wrappers `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` (issuance + write: resolve → unseal → append → re-seal → CAS-republish) and `getRecipientPubkeyPins(itemIpnsName)` + (read for enforcement). + +Depends on 80-01 (the `recipientPins` field must exist on NodeWriteBody). No API/DB change → no api:generate. + +Purpose: server-opaque, cross-device recipient-pin storage + the issuance write path. +Output: pin helpers, pin-preserving publish, and client read/write wrappers, with a round-trip test. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@packages/sdk-core/src/folder/registration.ts +@packages/sdk-core/src/share/grant.ts +@packages/sdk/src/client.ts +@packages/core/src/node/encode.ts + + + + + + Task 1: RED — round-trip, merge-preservation, and assert-or-throw tests for the pin helpers + packages/sdk-core/src/__tests__/share/recipient-pins.test.ts + + - packages/sdk-core/src/folder/registration.ts — `updateFolderMetadataAndPublish` (lines 174-400): the `sealNode` writeBody construction (~320-332), `unsealNode` remote decode (~347-348), and the CAS-409 writeChildren merge (`mergedMap`/`byChildId`, ~379-400) + - packages/sdk-core/src/share/grant.ts — `issueReadGrant` (line 80) for the issuance-time data available (recipient pubkey, root node identifiers) + - packages/core/src/node/encode.ts (encodeWriteBody) — the `recipientPins` field added by 80-01 + - packages/sdk/src/client.ts — `resolveShareEncryptedWriteKey` (~3839) and `updateFolderMetadataAndPublish` call sites (~2541/2657/2901) for the resolve→publish idiom + + + - `assertRecipientPinned(recipient, pins)`: throws when `pins` is empty/undefined (D-03e), throws when + `recipient` (normalized to raw bytes) is not a member, returns void on a match. + - `appendRecipientPin(pins, recipient)`: returns a deduped list including the recipient. + - `extractRecipientPins(writeBody)`: returns the recipientPins list (or []). + - Round-trip: after `addRecipientPubkeyPin(itemIpnsName, R)`, `getRecipientPubkeyPins(itemIpnsName)` includes R. + - Merge-preservation: an `updateFolderMetadataAndPublish` that changes only writeChildren preserves a + pre-existing recipientPins list (and a CAS-409 merge unions local+remote pins). + - All fail RED (helpers/params don't exist yet). + + + Author unit tests in packages/sdk-core/src/__tests__/share/recipient-pins.test.ts covering the pure + helpers (assert-or-throw incl. empty-list hard fail, append dedup, extract) and the merge-preservation + behavior of updateFolderMetadataAndPublish (seal a node with recipientPins, then update writeChildren, + assert pins survive; simulate a CAS-409 remote with different pins, assert union). Author a round-trip + test for the client wrappers using the existing test transport/mocks. Do NOT implement yet. + + + pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | grep -q "fail\|FAIL" && echo "RED confirmed" + + + - recipient-pins.test.ts exists and references assertRecipientPinned, appendRecipientPin, extractRecipientPins + - Tests assert: empty/absent pin list → throw (D-03e), non-member → throw, member → ok, append dedup, extract default [] + - A merge-preservation test asserts recipientPins survive a writeChildren-only update and a CAS-409 union + - All new tests FAIL against current code (non-vacuous RED) + + Failing tests pin the helper contracts, pin-preservation, and the write→read round-trip. + + + + Task 2: GREEN — pin helpers + recipientPins preservation in updateFolderMetadataAndPublish + packages/sdk-core/src/share/recipient-pins.ts, packages/sdk-core/src/share/index.ts, packages/sdk-core/src/folder/registration.ts + + - packages/sdk-core/src/folder/registration.ts (seal writeBody ~320-332, remote unseal ~347-348, CAS-merge ~379-400) + - packages/sdk-core/src/share/index.ts (export surface) + - packages/core/src/node — decoded write-body shape with `recipientPins` (from 80-01) + + + - `updateFolderMetadataAndPublish` seals the writeBody with `recipientPins` = (optional param to set/append) ∪ (current remote pins), never dropping existing pins; on CAS-409 the merged writeBody unions local+remote recipientPins alongside the existing writeChildren merge. + - Pure helpers behave per Task 1. + + + Create packages/sdk-core/src/share/recipient-pins.ts exporting `extractRecipientPins(writeBody)`, + `appendRecipientPin(pins, recipient)` (dedup by raw bytes), and `assertRecipientPinned(recipient, pins)` + (throw on empty/absent AND on non-member; normalize both sides to raw bytes; use the existing + 0x-strip/hex or base64 decode idiom from PATTERNS Shared Patterns). Export from share/index.ts. Extend + `updateFolderMetadataAndPublish` to accept an optional `recipientPins` input and thread it (union with + the remote write-body's current pins) into the sealed writeBody at seal time and in the CAS-409 merge — + generation unchanged, sequenceNumber increments as today. Do NOT add deny_unknown_fields anywhere. + + + pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | tail -12 + + + - `grep -n "assertRecipientPinned\|appendRecipientPin\|extractRecipientPins" packages/sdk-core/src/share/recipient-pins.ts` shows all three exports + - The pure-helper and merge-preservation tests from Task 1 pass + - `updateFolderMetadataAndPublish` seals writeBody with the unioned recipientPins (grep shows recipientPins threaded into the sealNode writeBody and the CAS-merge) + - `pnpm --filter @cipherbox/sdk-core typecheck` passes + + Pin helpers exist and folder publishes preserve/union recipientPins across updates and CAS merges. + + + + Task 3: GREEN — client.addRecipientPubkeyPin (issuance write) + client.getRecipientPubkeyPins (read) + packages/sdk/src/client.ts + + - packages/sdk/src/client.ts — `resolveShareEncryptedWriteKey` (~3839) for the resolve+writeKey-derivation idiom, and the `updateFolderMetadataAndPublish` call sites (~2541/2657/2901) for the resolve→seal→publish sequencing and sequenceNumber handling + - packages/sdk-core/src/share/recipient-pins.ts (Task 2 helpers) and the extended updateFolderMetadataAndPublish (Task 2) + + + - `client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)`: resolves the node, derives its writeKey (existing walk), unseals the current write-body, appends the pin (dedup), and CAS-republishes via updateFolderMetadataAndPublish with the unioned recipientPins — leaving read-body content and generation unchanged. + - `client.getRecipientPubkeyPins(itemIpnsName)`: resolves + unseals the node's write-body and returns its recipientPins (raw-byte list), used by enforcement consumers. + - The write→read round-trip test passes. + + + Add `addRecipientPubkeyPin` and `getRecipientPubkeyPins` to packages/sdk/src/client.ts as thin wrappers + over the sdk-core helpers + updateFolderMetadataAndPublish, following the existing resolve→publish + pattern (reuse walkChildWriteKey / the writeKey derivation already used by resolveShareEncryptedWriteKey). + Do NOT modify resolveShareEncryptedWriteKey. No API/DTO change; do NOT run api:generate. + + + pnpm --filter @cipherbox/sdk-core test recipient-pins 2>&1 | tail -8; pnpm --filter @cipherbox/sdk typecheck 2>&1 | tail -5 + + + - `grep -n "addRecipientPubkeyPin\|getRecipientPubkeyPins" packages/sdk/src/client.ts` shows both wrappers + - The write→read round-trip test passes: adding pin R then reading returns a list containing R + - `resolveShareEncryptedWriteKey` is unchanged (diff shows no edit to it) + - No new/changed files under packages/api-client/ (no api:generate); `pnpm --filter @cipherbox/sdk typecheck` passes + + The client can write a recipient pin at issuance and read the pin list for enforcement; round-trip green. + + + + + +Consumed by 80-06 (Rust reads via its own InodeTable path, but mirrors the compare semantics), 80-07 (TS enforcement), 80-08 (web): +- `assertRecipientPinned(recipient, pins)` — pure compare-or-throw incl. D-03e empty/absent hard fail (sdk-core/share/recipient-pins.ts) +- `extractRecipientPins`, `appendRecipientPin` (sdk-core/share/recipient-pins.ts) +- `updateFolderMetadataAndPublish` now preserves/unions `recipientPins` +- `client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` — issuance write (used by 80-08) +- `client.getRecipientPubkeyPins(itemIpnsName)` — read for enforcement (used by 80-07 seam + 80-08) + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| owner (ShareDialog paste) → owner-sealed write-body | Recipient pubkey captured out-of-band at issuance is committed to the server-opaque write-body | +| owner device → CipherBox relay | Relay stores the sealed write-body as opaque bytes; cannot read/forge pins | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-10 | Spoofing | recipient identity at issuance | high | mitigate | Pin the issuance-time pubkey inside the owner-sealed write-body (D-03a/c) — the trust anchor for all re-mint verification | +| T-80-11 | Tampering | pin loss on folder update / CAS merge | high | mitigate | updateFolderMetadataAndPublish unions/preserves recipientPins so a routine update can't silently drop the trust anchor | +| T-80-12 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | assertRecipientPinned hard-fails on empty/absent (D-03e no-legacy) | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `pnpm --filter @cipherbox/sdk-core test recipient-pins` green (helpers + merge-preservation + round-trip) +- `pnpm --filter @cipherbox/sdk-core typecheck` and `pnpm --filter @cipherbox/sdk typecheck` green +- No packages/api-client changes (no api:generate); generation never bumped for a pin write + + + +The owner can write an issuance-time recipient pubkey into a node's owner-sealed write-body and read it +back; pins survive folder updates and CAS merges; and the pure assert helper hard-fails on empty/absent or +non-member pin lists — all without any API/DB change. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md new file mode 100644 index 0000000000..b66ed8789e --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-04-SUMMARY.md @@ -0,0 +1,180 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 04 +subsystem: api +tags: [sharing, recipient-pins, write-body, ipns, cas, node-codec, secp256k1] + +# Dependency graph +requires: + - phase: 80-01 + provides: "NodeWriteBody.recipientPins (TS) / recipient_pins (Rust) codec field with round-trip encode/decode" +provides: + - "assertRecipientPinned / appendRecipientPin / extractRecipientPins pure helpers (sdk-core/share/recipient-pins.ts)" + - "updateFolderMetadataAndPublish preserves + unions recipientPins across folder updates and CAS-409 merges" + - "client.addRecipientPubkeyPin(itemIpnsName, recipientPublicKey) issuance write path" + - "client.getRecipientPubkeyPins(itemIpnsName) enforcement read path" + - "getWriteBodyParams surfaces recipientPins so routine folder updates preserve them" +affects: [80-06, 80-07, 80-08] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Owner-sealed recipient-pin list as the server-opaque cross-device re-mint trust anchor" + - "Monotonic dedup-union of pins across a CAS-409 (never pruned, unlike write-chain entries)" + - "Both-sides raw-byte normalization (Uint8Array / hex / base64) before pin compare" + +key-files: + created: + - packages/sdk-core/src/share/recipient-pins.ts + - packages/sdk-core/src/__tests__/share/recipient-pins.test.ts + modified: + - packages/sdk-core/src/share/index.ts + - packages/sdk-core/src/index.ts + - packages/sdk-core/src/folder/registration.ts + - packages/sdk/src/client.ts + - packages/sdk/src/write-body-params.ts + +key-decisions: + - "Pins are a monotonic UNION on CAS-409 (a pin is a permanent trust anchor, never pruned) — distinct from the base-aware write-chain prune" + - "getWriteBodyParams surfaces recipientPins so ALL client folder updates thread current pins through and preserve them on clean publishes (closes T-80-11 generically, not just for the issuance path)" + - "Client wrappers take a 2-arg (itemIpnsName, recipientPublicKey) signature and operate on a folder the client tracks as a FolderState — its own writeKey/ipnsKeypair seal its write-body" + +patterns-established: + - "Pure pin helpers own the D-03d compare semantics; the three enforcement consumers (80-06/07/08) verify against them" + - "encodeWriteBody omits an empty recipientPins list so the frozen empty-pin KAT is byte-preserved" + +requirements-completed: + - "SC2 / D-03a: store the issuance-time recipient pubkey in the shared root node's owner-sealed NodeWriteBody (server-opaque, cross-device)" + - "SC2 / D-03c: at grant creation, append the pasted recipient pubkey to the node's write-body pin list and republish" + +coverage: + - id: D1 + description: "assertRecipientPinned throws on empty/absent pin list (D-03e) and non-member; returns void on a raw-byte match; normalizes hex/base64/bytes" + requirement: "SC2 / D-03a" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#assertRecipientPinned" + status: pass + human_judgment: false + - id: D2 + description: "appendRecipientPin dedups by raw bytes across encodings; extractRecipientPins defaults to []" + requirement: "SC2 / D-03c" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#appendRecipientPin / extractRecipientPins" + status: pass + human_judgment: false + - id: D3 + description: "updateFolderMetadataAndPublish seals recipientPins and unions local ∪ remote pins across a CAS-409 (T-80-11 durability)" + requirement: "SC2 / D-03a" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#recipientPins durability (T-80-11)" + status: pass + human_judgment: false + - id: D4 + description: "Write→read round-trip at the sdk-core seal boundary: append pin → seal → unseal → extract returns the pin" + requirement: "SC2 / D-03c" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/share/recipient-pins.test.ts#write→read round-trip" + status: pass + human_judgment: false + - id: D5 + description: "client.addRecipientPubkeyPin (issuance write, generation unchanged) + client.getRecipientPubkeyPins (raw-byte read)" + requirement: "SC2 / D-03c" + verification: + - kind: other + ref: "pnpm --filter @cipherbox/sdk typecheck (thin wrappers over sdk-core helpers; sdk-core cannot import sdk, so runtime is proxied by the D4 seal-boundary round-trip)" + status: pass + human_judgment: false + +# Metrics +duration: 9min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 04: Recipient-Pin Storage and Issuance Write Path Summary + +**Owner-sealed `NodeWriteBody.recipientPins` machinery — pure compare/append/extract helpers, pin-preserving folder publish with CAS-409 union, and `client.addRecipientPubkeyPin`/`getRecipientPubkeyPins` wrappers — the server-opaque cross-device trust anchor for D-03d re-mint enforcement.** + +## Performance + +- **Duration:** ~9 min +- **Started:** 2026-07-12T20:11:00Z +- **Completed:** 2026-07-12T20:20:00Z +- **Tasks:** 3 +- **Files modified:** 5 (2 created, 3+2 modified) + +## Accomplishments +- Pure helpers `assertRecipientPinned` / `appendRecipientPin` / `extractRecipientPins` — `assertRecipientPinned` fails closed on an empty/absent pin list (D-03e no-legacy) and on a non-member, normalizing both sides to raw pubkey bytes. +- `updateFolderMetadataAndPublish` gains an optional `recipientPins` param, threaded into the sealed write-body and unioned with the remote write-body's pins on a CAS-409 merge — pins are never silently dropped (T-80-11). +- `client.addRecipientPubkeyPin` resolves the item, appends the recipient pin (dedup), and CAS-republishes at the UNCHANGED node generation (sequenceNumber advances); `client.getRecipientPubkeyPins` reads the pin list back as raw bytes for enforcement. +- `getWriteBodyParams` now surfaces `recipientPins`, so every routine client folder update threads current pins through the publish and preserves them on clean publishes. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: RED tests** - `7d4a1f5e8` (test) +2. **Task 2: GREEN helpers + pin-preserving publish** - `5dc6ffd21` (feat) +3. **Task 3: GREEN client wrappers** - `4ef3fd2f7` (feat) + +_Note: this is a `type: tdd` plan — RED (`test`) precedes GREEN (`feat`) in git history._ + +## Files Created/Modified +- `packages/sdk-core/src/share/recipient-pins.ts` - pure pin helpers + raw-byte normalization (created) +- `packages/sdk-core/src/__tests__/share/recipient-pins.test.ts` - helper + durability + round-trip tests (created) +- `packages/sdk-core/src/share/index.ts` - export the three helpers + `RecipientPubkey` +- `packages/sdk-core/src/index.ts` - re-export helpers from the sdk-core barrel +- `packages/sdk-core/src/folder/registration.ts` - thread `recipientPins` into the seal + CAS-409 union +- `packages/sdk/src/client.ts` - `addRecipientPubkeyPin` / `getRecipientPubkeyPins` wrappers +- `packages/sdk/src/write-body-params.ts` - surface `recipientPins` from the write-body + +## Decisions Made +- **Pins union, never prune, on CAS-409.** A recipient pin is a permanent trust anchor, so the merge is a plain dedup-union of local ∪ remote (reusing `appendRecipientPin`), unlike the base-aware write-chain prune that honors deletes. +- **`getWriteBodyParams` surfaces `recipientPins`.** This makes preservation generic: every client folder-update call site that spreads `...writeBodyParams` now threads the current pins through, so a routine rename/move/add never drops them on a clean publish — not only the issuance path. +- **2-arg client signature operating on a tracked folder.** The plan's `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` signature carries no parent, so the item is treated as a folder the client tracks (its own `FolderState` supplies the writeKey + IPNS signing key to seal its write-body). Fails closed when the item is not write-capable. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Extended `getWriteBodyParams` to return `recipientPins`** +- **Found during:** Task 3 (client wrappers) +- **Issue:** The client wrappers must read the item's CURRENT pins to append/union, but `getWriteBodyParams` returned only `{ writeKey, writeChildren }` — there was no way to read the pins without a second resolve+unseal. +- **Fix:** Added an additive optional `recipientPins?: string[]` to `getWriteBodyParams`'s return (sourced from the metadata mirror or the on-wire unseal), plus the matching private-delegate return type in `client.ts`. Beneficial side effect: all existing update call sites that spread `...writeBodyParams` now preserve pins generically (T-80-11). +- **Files modified:** packages/sdk/src/write-body-params.ts, packages/sdk/src/client.ts +- **Verification:** `pnpm --filter @cipherbox/sdk typecheck` passes; additive optional field, no wire change (empty list omitted by `encodeWriteBody`). +- **Committed in:** `4ef3fd2f7` (Task 3 commit) + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** The extension is additive and required to satisfy the plan's own key_link ("unseals its current write-body ... appends the pin"). No scope creep; no API/DB change. + +## Issues Encountered +- The `@cipherbox/sdk` typecheck reads `@cipherbox/sdk-core`'s built dist, so `@cipherbox/core` and `@cipherbox/sdk-core` dists were rebuilt after adding the new exports/param before the sdk typecheck (documented setup step). No source issues. +- The write→read round-trip is authored at the sdk-core seal boundary (append → `updateFolderMetadataAndPublish` → `unsealNode` → `extractRecipientPins`) because sdk-core cannot import `@cipherbox/sdk`; the thin client wrappers delegate to exactly this path and are covered by `@cipherbox/sdk` typecheck. + +## Prohibitions honored +- Node generation is NEVER bumped and no pin-generation counter was added — the pin rides inside the existing role-0x01 write-body seal at the current generation; only the IPNS `sequenceNumber` increments. +- `resolveShareEncryptedWriteKey` is unchanged (no pin write bolted onto the writeKey-derivation path — Pitfall 4). +- No API/DTO change and no `pnpm api:generate`; no DB migration (D-03f) — the pin is client-side owner-sealed only. +- No `deny_unknown_fields` / forward-tolerance regressions; empty pin list stays off the wire. + +## Verification +- `pnpm --filter @cipherbox/sdk-core test recipient-pins` — 17 passed (17). +- `pnpm --filter @cipherbox/sdk-core typecheck` — pass. +- `pnpm --filter @cipherbox/sdk typecheck` — pass. +- No `packages/api-client/` changes. + +## Next Phase Readiness +- Pin storage + issuance write + enforcement read are ready for the D-03d consumers: 80-06 (Rust re-mint compare), 80-07 (TS `reMintGrantsRootedAt` compare), 80-08 (web ShareDialog issuance wiring). +- The pure `assertRecipientPinned` is the shared compare semantics those consumers mirror (Rust reads via its own InodeTable path but matches the empty/absent hard-fail). + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md new file mode 100644 index 0000000000..53502d20b0 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-PLAN.md @@ -0,0 +1,202 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 05 +type: tdd +wave: 2 +depends_on: ["80-01", "80-02"] +files_modified: + - crates/sdk/src/listing.rs + - crates/fuse/src/inode.rs + - crates/fuse/src/write_ops/rotation_deps.rs +autonomous: true +requirements: + - "SC2 / D-03a: surface + cache the shared node's owner-sealed recipient pins so the FUSE re-mint can verify them offline" + - "SC1 / D-01: rotation republish must PRESERVE the recipient pins in the reconstructed write-body (else a later re-mint hard-fails D-03e)" +user_setup: [] + +must_haves: + truths: + - "When the FUSE mount materializes an owned node, the node's recipientPins (from its unsealed write-body) are cached on the inode (D-03a)" + - "reconstruct_write_body includes the cached recipientPins so a rotation republish preserves them — a subsequent re-materialize + re-mint still finds the pins (D-01 + D-03e durability)" + - "ResolvedOwnedChild carries the node's recipient pins alongside its keys (D-03a)" + artifacts: + - "crates/sdk/src/listing.rs — ResolvedOwnedChild.recipient_pins populated from the unsealed write-body" + - "crates/fuse/src/inode.rs — InodeKind recipient_pins cache field + apply_owned_children population" + - "crates/fuse/src/write_ops/rotation_deps.rs — reconstruct_write_body carries cached recipient_pins into the resealed write-body" + key_links: + - "listing.rs already unseals+decodes the write-body (write_body.ipns_private_key at ~:543-544) — recipient_pins are read from the SAME decoded write_body at the ResolvedOwnedChild construction (~:546)" + - "apply_owned_children destructures ResolvedOwnedChild and moves recipient_pins onto the materialized inode; reconstruct_write_body reads them back for republish preservation" + prohibitions: + - "MUST NOT drop recipientPins during rotation republish — reconstruction MUST carry the cached pins (otherwise D-03 self-destructs after the first rotation)" + - "MUST NOT log pin bytes as secret — recipient pins are PUBLIC keys, but keep the InodeKind Debug redaction discipline intact for read_key/write_key/ipns_private_key" + - "MUST NOT change the read plane / generation — pins live only in the write-body" +--- + + +Thread the D-03a recipient pins from the shared node's owner-sealed `NodeWriteBody` into the FUSE mount's +in-memory state so the Rust re-mint (80-06) can verify them OFFLINE (mirroring D-01's "read from the +already-mounted InodeTable" pattern), and so D-01's rotation republish PRESERVES them. + +This is the pin-plumbing prerequisite for the Rust enforcement in 80-06, and it closes a subtle D-01↔D-03 +interaction: D-01's `reconstruct_write_body` (80-02) rebuilds the write-body from InodeTable-DERIVED +material (keys, child refs). Recipient pins are NOT derivable — they are issuance data stored only in the +published write-body. So unless the pins are cached on the inode at materialization AND re-emitted by +reconstruction, a scope-exit rotation would republish the node WITHOUT pins, and the next re-mint +(after re-materialize) would hard-fail closed (D-03e). This plan caches pins at materialization and carries +them through reconstruction. + +Depends on 80-01 (the write-body pin field) and 80-02 (reconstruct_write_body already handles keys+children). + +Purpose: make the shared node's recipient pins available offline to the FUSE re-mint and durable across rotation. +Output: pins surfaced on ResolvedOwnedChild, cached on the inode, and preserved by reconstruction. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@crates/sdk/src/listing.rs +@crates/fuse/src/inode.rs +@crates/fuse/src/write_ops/rotation_deps.rs + + + + + + Task 1: RED — reconstruction-preserves-pins + materialization-caches-pins tests + crates/fuse/src/write_ops/rotation_deps.rs, crates/fuse/src/inode.rs + + - crates/sdk/src/listing.rs — `ResolvedOwnedChild` struct (lines 124-142); the write-body unseal + `decode_write_body` + `write_body.ipns_private_key` extraction (~536-544); the `Ok(ResolvedOwnedChild { ... })` construction (~546) + - crates/fuse/src/inode.rs — `InodeKind` variants (119-172) each with read_key/write_key/ipns_private_key; the Debug redaction impl (181-216); `apply_owned_children` destructure `ResolvedOwnedChild { child, node_id, read_key, write_key, ipns_private_key }` (~466); the root init construction (~299) + - crates/fuse/src/write_ops/rotation_deps.rs — `reconstruct_write_body` (added by 80-02) and its NodeWriteBody construction + - crates/core/src/node — `NodeWriteBody.recipient_pins` (from 80-01) + + + - Test A (materialization caches pins): after apply_owned_children with a ResolvedOwnedChild carrying a non-empty recipient_pins list, the materialized inode's cached recipient_pins equal the input. + - Test B (reconstruction preserves pins): reconstruct_write_body for a node whose inode caches recipient_pins produces a write_sealed that unseals to a NodeWriteBody whose recipient_pins equal the cached list (and whose keys/children still match 80-02's contract). + - Both fail RED (no recipient_pins field on ResolvedOwnedChild/InodeKind yet; reconstruction doesn't emit pins). + + + Extend the rotation_deps.rs `#[cfg(test)]` module (and inode.rs tests if that is where apply_owned_children + is exercised) with Tests A and B. Reuse 80-02's reconstruction round-trip harness, adding a cached + recipient_pins list to the fixture inode and asserting the resealed body round-trips the pins. Do NOT + implement the field/threading yet. + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed" + + + - Tests A and B exist and reference a cached recipient_pins list on the inode/ResolvedOwnedChild + - Both FAIL against current code (non-vacuous RED) + - Test B asserts the reconstructed write-body round-trips BOTH the pins AND the keys/children (no regression of 80-02) + + Failing tests pin the materialization-caches-pins and reconstruction-preserves-pins contracts. + + + + Task 2: GREEN — surface recipient_pins on ResolvedOwnedChild and cache on the inode + crates/sdk/src/listing.rs, crates/fuse/src/inode.rs + + - crates/sdk/src/listing.rs (write-body decode ~543, ResolvedOwnedChild construct ~546, Debug redaction 144-154) + - crates/fuse/src/inode.rs (InodeKind 119-172, Debug 181-216, apply_owned_children destructure ~466, root init ~299) + + + - `ResolvedOwnedChild.recipient_pins: Vec>` populated from `write_body.recipient_pins` at construction. + - `InodeKind::{Root,Folder,File}` gain a `recipient_pins: Vec>` field, populated in apply_owned_children from `owned.recipient_pins`; all construction sites (materialization, root init, test constructors) supply it (empty default where none). + - Existing `..` match arms compile unchanged; Debug shows recipient_pins via `..` or as a non-secret field (pins are public keys; keep key material redacted). + + + Add `recipient_pins: Vec>` to `ResolvedOwnedChild` and populate it from the already-decoded + `write_body.recipient_pins` (listing.rs ~:546); update its Debug impl (non-secret, may print or elide). + Add `recipient_pins: Vec>` to each `InodeKind` struct variant; populate in apply_owned_children + from the destructured `owned.recipient_pins`; default to empty at the root init and any test constructor. + Fix all construction sites the compiler flags. Do NOT weaken the key-material redaction in the Debug impl. + + + cargo test -p cipherbox-fuse inode 2>&1 | tail -8; cargo build -p cipherbox-fuse -p cipherbox-sdk 2>&1 | tail -5 + + + - `grep -n "recipient_pins" crates/sdk/src/listing.rs` shows the field on ResolvedOwnedChild populated from write_body + - `grep -c "recipient_pins" crates/fuse/src/inode.rs` shows the field on the InodeKind variants + apply_owned_children population + - `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles (all construction sites updated); Test A passes + - InodeKind Debug still redacts read_key/write_key/ipns_private_key + + Recipient pins flow from the unsealed write-body onto ResolvedOwnedChild and are cached on the materialized inode. + + + + Task 3: GREEN — reconstruct_write_body carries cached recipient_pins (D-01 durability) + crates/fuse/src/write_ops/rotation_deps.rs + + - crates/fuse/src/write_ops/rotation_deps.rs — `reconstruct_write_body` (from 80-02): its NodeWriteBody construction from InodeTable-sourced write_key + ipns_private_key + child WriteChildRefs + - crates/fuse/src/inode.rs — the inode `recipient_pins` cache added in Task 2 + + + - `reconstruct_write_body` reads the node's cached recipient_pins from the InodeTable and includes them in the reconstructed NodeWriteBody, so `seal_node` emits a write_sealed that preserves the pins. + - Round-trip (Test B) passes: unseal recovers keys + children + pins. + + + Extend `reconstruct_write_body` to pull the node's cached `recipient_pins` (from the same inode it reads + write_key/ipns_private_key from) and set `NodeWriteBody.recipient_pins` before `encode_write_body`/ + `seal_node`. Non-materialized nodes still fail open to None (unchanged from 80-02). Key bytes never logged. + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-fuse replay 2>&1 | tail -5 + + + - `grep -n "recipient_pins" crates/fuse/src/write_ops/rotation_deps.rs` shows pins threaded into the reconstructed NodeWriteBody + - Test B passes: the resealed write-body round-trips the cached recipient_pins AND the keys/children + - 80-02's None-fallback and replay tests still pass (no regression) + - `cargo test -p cipherbox-fuse` green + + Rotation republish preserves recipient pins in the reconstructed write-body; re-materialize + future re-mint still find them. + + + + + +Consumed by 80-06 (Rust enforcement seam reads pins from the InodeTable cache): +- `ResolvedOwnedChild.recipient_pins: Vec>` (listing.rs) +- `InodeKind::{Root,Folder,File}.recipient_pins: Vec>` cache + apply_owned_children population (inode.rs) +- `reconstruct_write_body` now emits recipient_pins (rotation_deps.rs) — closes the D-01↔D-03 preservation gap + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| published write-body → in-memory InodeTable | Owner-sealed pins decoded once at materialization, cached for offline verification | +| rotation republish → future re-materialize | Pins must survive republish or the next re-mint hard-fails | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-13 | Denial of Service | pin loss on rotation republish | high | mitigate | reconstruct_write_body carries cached pins so re-mint doesn't hard-fail after a rotation (D-01↔D-03e) | +| T-80-14 | Tampering | InodeTable pin cache as verification source | medium | mitigate | Pins sourced from the owner-sealed write-body at materialization — a relay cannot inject them into the sealed body | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles (all InodeKind construction sites updated) +- `cargo test -p cipherbox-fuse` green (materialization caches pins; reconstruction preserves pins; 80-02 regressions intact) +- InodeKind Debug redaction of key material intact + + + +Recipient pins flow from the shared node's owner-sealed write-body onto the materialized inode and are +preserved by rotation republish, making them available offline to the FUSE re-mint and durable across rotation. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md new file mode 100644 index 0000000000..426933e8aa --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md @@ -0,0 +1,160 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 05 +subsystem: infra +tags: [rust, fuse, ipns, rotation, recipient-pins, node-v3, zeroize] + +# Dependency graph +requires: + - phase: 80-01 + provides: NodeWriteBody.recipient_pins wire field + - phase: 80-02 + provides: reconstruct_write_body helper + job-scoped sent-shares cache in rotation_deps.rs +provides: + - "ResolvedOwnedChild.recipient_pins surfaced from the unsealed write-body (listing.rs)" + - "InodeKind::{Root,Folder,File}.recipient_pins cache field + apply_owned_children population (inode.rs)" + - "reconstruct_write_body now carries cached recipient_pins into the resealed write-body (rotation_deps.rs)" +affects: [80-06] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "D-03a pin plumbing: issuance data (recipient pins) surfaced once at materialization from the same unsealed write-body, cached on the inode for offline verification" + - "D-01↔D-03e durability: rotation republish reconstruction re-emits cached pins verbatim so a later re-mint after re-materialize still finds them" + +key-files: + created: + - .planning/phases/80-rotation-write-plane-and-re-mint-durability/80-05-SUMMARY.md + modified: + - crates/sdk/src/listing.rs + - crates/fuse/src/inode.rs + - crates/fuse/src/write_ops/rotation_deps.rs + - crates/fuse/src/fs.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/platform/windows/write_ops.rs + +key-decisions: + - "Recipient pins are PUBLIC keys, not secret material — surfaced as recipient_pins_count in Debug impls, NOT redacted like read_key/write_key/ipns_private_key" + - "Fresh nodes (mkdir, new file, root init, test fixtures) default to an empty pin list; only materialized owned nodes carry real pins" + - "reconstruct_write_body reads pins from the SAME inode it reads write_key/ipns_private_key from — copied verbatim, never rotated (read plane / generation untouched)" + +patterns-established: + - "Pin plumbing mirror of the ipns_private_key path: read from the unsealed write-body at ResolvedOwnedChild construction, moved onto InodeKind at apply_owned_children, re-emitted by reconstruction" + +requirements-completed: + - "SC2 / D-03a: surface + cache the shared node's owner-sealed recipient pins so the FUSE re-mint can verify them offline" + - "SC1 / D-01: rotation republish must PRESERVE the recipient pins in the reconstructed write-body (else a later re-mint hard-fails D-03e)" + +coverage: + - id: D1 + description: "ResolvedOwnedChild.recipient_pins populated from the already-unsealed write-body (listing.rs)" + requirement: "SC2 / D-03a" + verification: + - kind: unit + ref: "crates/fuse/src/inode.rs#apply_owned_children_caches_recipient_pins_on_the_inode (exercises pins flowing from ResolvedOwnedChild onto the inode)" + status: pass + human_judgment: false + - id: D2 + description: "InodeKind::{Root,Folder,File}.recipient_pins cache field populated in apply_owned_children; key-material Debug redaction preserved" + requirement: "SC2 / D-03a" + verification: + - kind: unit + ref: "crates/fuse/src/inode.rs#apply_owned_children_caches_recipient_pins_on_the_inode" + status: pass + human_judgment: false + - id: D3 + description: "reconstruct_write_body carries cached recipient_pins into the resealed write-body so a rotation republish preserves them (D-01↔D-03e)" + requirement: "SC1 / D-01" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_preserves_cached_recipient_pins" + status: pass + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#reconstruct_write_body_round_trips_ipns_key_and_child_write_refs (80-02 no-regression)" + status: pass + human_judgment: false + +# Metrics +duration: 30min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 05: Recipient-Pin Plumbing for Offline Re-Mint + Rotation Durability Summary + +**D-03a recipient pins now flow from the shared node's owner-sealed write-body onto the materialized inode and are preserved verbatim by rotation republish, making them available offline to the FUSE re-mint (80-06) and durable across a scope-exit rotation.** + +## Performance + +- **Duration:** ~30 min +- **Started:** 2026-07-12 +- **Completed:** 2026-07-12 +- **Tasks:** 3 (TDD RED → GREEN → GREEN) +- **Files modified:** 12 + +## Accomplishments +- `ResolvedOwnedChild.recipient_pins: Vec>` read from the SAME already-decoded `write_body` as `ipns_private_key` in `resolve_owned_child` (listing.rs) — no second unseal. +- `InodeKind::{Root,Folder,File}` gained a `recipient_pins: Vec>` cache field, populated in `apply_owned_children` by moving `owned.recipient_pins` onto the materialized inode; empty default at root init and all fresh-node/test construction sites. +- `reconstruct_write_body` (from 80-02) now reads the node's cached `recipient_pins` from the InodeTable and sets `NodeWriteBody.recipient_pins` before `seal_node`, so a scope-exit rotation republish PRESERVES the pins (closes the D-01↔D-03e self-destruct gap where a post-rotation re-mint would hard-fail). +- Debug discipline held: recipient pins (public keys) surface as `recipient_pins_count`; `read_key`/`write_key`/`ipns_private_key` remain ``. + +## Task Commits + +Single squashed commit per execution constraint (SUMMARY committed alongside code): + +1. **Task 1: RED — reconstruction-preserves-pins + materialization-caches-pins tests** (test) +2. **Task 2: GREEN — surface recipient_pins on ResolvedOwnedChild + cache on the inode** (feat) +3. **Task 3: GREEN — reconstruct_write_body carries cached recipient_pins** (feat) + +RED was confirmed as a non-vacuous compile failure (`no field recipient_pins on ResolvedOwnedChild`; `variant InodeKind::Folder/File does not have a field named recipient_pins`) before implementation. + +## Files Created/Modified +- `crates/sdk/src/listing.rs` — `ResolvedOwnedChild.recipient_pins` field + Debug + populated from `write_body.recipient_pins` at construction +- `crates/fuse/src/inode.rs` — `InodeKind` variant field + Debug (non-secret count) + `apply_owned_children` destructure/population + root init + test fixtures + Test A +- `crates/fuse/src/write_ops/rotation_deps.rs` — `reconstruct_write_body` reads cached pins into the resealed `NodeWriteBody` + doc comment + Test B + test-helper fixture +- `crates/fuse/src/{fs.rs,replay.rs,test_support.rs}` — construction sites updated (empty default) +- `crates/fuse/src/write_ops/{grant_scope.rs,implementation/{delete,file_data,mkdir,rename}.rs}` — construction sites updated (empty default) +- `crates/fuse/src/platform/windows/write_ops.rs` — winfsp construction sites updated (empty default) to keep the Windows CI build green + +## Decisions Made +- Recipient pins are public keys → shown as `recipient_pins_count` in Debug, not redacted. Key material redaction unchanged (crypto rule #2). +- Fresh/newly-created nodes and all test fixtures default to an empty pin list; only materialized owned nodes carry real pins. +- Read plane / generation untouched — pins live only in the write-body and are copied, never rotated. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Updated all InodeKind construction sites across the fuse crate (incl. winfsp)** +- **Found during:** Task 2 (adding the `recipient_pins` field to the `InodeKind` variants) +- **Issue:** Adding a required struct-variant field forces every literal construction site to supply it, or the crate (and its test build) will not compile. The plan named only listing.rs/inode.rs/rotation_deps.rs, but the compiler flagged additional lib + test construction sites in fs.rs, replay.rs, test_support.rs, grant_scope.rs, delete.rs, file_data.rs, mkdir.rs, rename.rs, and the winfsp platform module. +- **Fix:** Supplied `recipient_pins: Vec::new()` at each fresh-node/test construction site (no share grants at creation → empty pins). The winfsp `platform/windows/write_ops.rs` sites were updated by inspection to avoid breaking the Windows-only CI build (local cargo does not compile `windows/*`). +- **Files modified:** fs.rs, replay.rs, test_support.rs, grant_scope.rs, delete.rs, file_data.rs, mkdir.rs, rename.rs, platform/windows/write_ops.rs +- **Verification:** `cargo build -p cipherbox-fuse -p cipherbox-sdk` compiles; `cargo test -p cipherbox-fuse` = 126 passed / 0 failed. +- **Committed in:** same plan commit + +--- + +**Total deviations:** 1 auto-fixed (1 blocking — mechanical fan-out of a required field addition, explicitly anticipated by the plan's "Fix all construction sites the compiler flags"). +**Impact on plan:** No scope creep — all changes are the direct compile-required consequence of the specified `InodeKind` field. No behavior changed at the empty-default sites. + +## Issues Encountered +None. The winfsp sites cannot be compiled locally (macOS/CI split, per project memory), so they were updated by inspection matching the fuse-side pattern — budget a CI round-trip for the Windows build. + +## User Setup Required +None - no external service configuration required. + +## Next Phase Readiness +- 80-06 (Rust enforcement seam) can now read the recipient pins from the InodeTable cache and verify them offline; pins survive a scope-exit rotation republish. +- No API change, no DB migration, no `pnpm api:generate` (Rust-only, write-body-internal). + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md new file mode 100644 index 0000000000..3fd0c8ce92 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-PLAN.md @@ -0,0 +1,172 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 06 +type: tdd +wave: 3 +depends_on: ["80-01", "80-05"] +files_modified: + - crates/fuse/src/write_ops/rotation_deps.rs + - crates/sdk/src/rotation/engine.rs +autonomous: true +requirements: + - "SC2 / D-03d (consumer 1 of 3): Rust re-mint verifies grant.recipient_public_key against the node's owner-sealed pin before wrap_key, fail-closed on mismatch" + - "SC2 / D-03e: pin absent at re-mint is a hard fail-closed invariant violation (no-legacy, no TOFU, no backfill)" +user_setup: [] + +must_haves: + truths: + - "re_mint_grants_rooted_at fetches the node's recipient pins via a RotationDeps seam and fails the whole node's re-mint closed if grant.recipient_public_key is not pinned (D-03d)" + - "A pin-absent (empty) pin list at re-mint is a hard RotateFailed, not a skip (D-03e no-legacy)" + - "FuseRotationDeps resolves the pin list OFFLINE from the InodeTable pin cache (80-05), no extra network fetch (D-03a)" + artifacts: + - "crates/fuse/src/write_ops/rotation_deps.rs — get_recipient_pubkey_pins seam on FuseRotationDeps reading the InodeTable pin cache" + - "crates/sdk/src/rotation/engine.rs — RotationDeps::get_recipient_pubkey_pins + fail-closed compare before wrap_key in re_mint_grants_rooted_at" + - "pin-mismatch + pin-absent fail-closed tests in the rotation_deps.rs test module" + key_links: + - "The compare runs immediately before cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key) at engine.rs:610, using pins read from THIS node's own write-body (not the /shares/sent response)" + - "Pin bytes and the grant pubkey are normalized to raw bytes before comparison (no 0x/hex mismatch)" + prohibitions: + - "MUST NOT model a pin mismatch as a per-grant skip-and-continue like the is_revoked branch — it aborts the node's re-mint closed (D-03e, Pitfall 5)" + - "MUST NOT touch the 4th co-writer re-wrap site rotateWriteFromNode (crates/sdk/src/rotation/engine.rs ~:2762) — CONTEXT names exactly 3 consumers; this write-revocation site is OUT OF SCOPE and recorded as a follow-up assumption (RESEARCH Open Question 2 / A3)" + - "MUST NOT trust the /shares/sent recipient_public_key as the pin source — the pin comes only from the node's owner-sealed write-body" +--- + + +Consumer 1 of D-03d's three fail-closed enforcement sites: the Rust FUSE re-mint. Today +`re_mint_grants_rooted_at` calls `wrap_key(new_read_key, &grant.recipient_public_key)` (engine.rs:610) +where `grant.recipient_public_key` came straight back through the relay via `GET /shares/sent` — a +compromised relay could substitute it and cause the owner to ECIES-wrap the fresh post-rotation read key +TO THE ATTACKER. This plan inserts a pin comparison: before every wrap, verify `grant.recipient_public_key` +is a member of the node's OWN owner-sealed `recipientPins` (read OFFLINE from the InodeTable pin cache built +in 80-05), and fail the node's re-mint closed on mismatch OR on an absent/empty pin list (D-03e no-legacy). + +Depends on 80-05 (InodeTable pin cache + ResolvedOwnedChild pins) and 80-01 (the field). rotation_deps.rs +overlaps 80-05 → this is wave 3. + +Purpose: stop the Rust re-mint from wrapping the read key to a relay-substituted recipient. +Output: a RotationDeps pin seam + fail-closed compare + mismatch/absent regression tests. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@crates/sdk/src/rotation/engine.rs +@crates/fuse/src/write_ops/rotation_deps.rs + + + + + + Task 1: RED — pin-mismatch and pin-absent fail-closed tests for re_mint_grants_rooted_at + crates/fuse/src/write_ops/rotation_deps.rs + + - crates/sdk/src/rotation/engine.rs — `GrantRow` (line 121, `recipient_public_key: Vec` at :124); `RotationDeps` trait (146) and its `query_grants_rooted_at` default (180); `re_mint_grants_rooted_at` (597-626) with the `wrap_key(new_read_key, &grant.recipient_public_key)` call at :610 and the is_revoked skip branch; the OUT-OF-SCOPE `rotateWriteFromNode` co-writer re-wrap (~:2762) — do NOT modify + - crates/fuse/src/write_ops/rotation_deps.rs — the `#[cfg(test)]` FakeTransport/FuseRotationDeps test constructors; the InodeTable pin cache from 80-05; `find_grant_root_state`/`find_ipns_private_key` lookup idiom (552-599) + + + - Test A (mismatch): re_mint over a node whose cached pins do NOT contain grant.recipient_public_key returns Err(RotateFailed) and does NOT call update_grant/wrap for that grant. + - Test B (absent): re_mint over a node with an EMPTY pin list returns Err(RotateFailed) (D-03e hard fail), not a silent skip. + - Test C (match): re_mint over a node whose pins DO contain the grant pubkey proceeds and wraps as before (retained recipient re-minted; revoked recipient cut by absence). + - A/B fail RED (no seam/compare exists); C passes today but must still pass after the change. + + + Add a `get_recipient_pubkey_pins` fixture to the test RotationDeps/FakeTransport so tests can inject a + node's pin list. Author Tests A/B/C in the rotation_deps.rs test module (or engine.rs test module, + wherever re_mint is currently exercised). Assert the mismatch/absent cases produce RotateFailed and skip + the wrap. Do NOT implement the seam/compare yet. + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | grep -q "FAILED\|test result: FAILED" && echo "RED confirmed" + + + - Tests A/B/C exist and inject a per-node pin list via a get_recipient_pubkey_pins fixture + - A (mismatch) and B (absent/empty) FAIL against current code (non-vacuous RED) + - Assertions confirm no wrap/update_grant occurs on the fail-closed path + + Failing tests pin the mismatch and pin-absent fail-closed behavior; the match case is preserved. + + + + Task 2: GREEN — get_recipient_pubkey_pins seam + fail-closed compare before wrap_key + crates/sdk/src/rotation/engine.rs, crates/fuse/src/write_ops/rotation_deps.rs + + - crates/sdk/src/rotation/engine.rs — RotationDeps trait (146), query_grants_rooted_at default (180), re_mint_grants_rooted_at (597-626), the RotateFailed(format!(...)) error convention (610-615) + - crates/fuse/src/write_ops/rotation_deps.rs — FuseRotationDeps/ApiClientTransport (holds &inodes); Inode.node_id + the InodeKind recipient_pins cache (80-05); the find_map-by-id lookup idiom (582-599) + + + - `RotationDeps::get_recipient_pubkey_pins(&self, node_id: &str) -> Result>, RotationError>` (no permissive default that silently returns empty — implementors must provide it; the FuseRotationDeps impl reads the InodeTable pin cache for the inode whose node_id matches). + - `re_mint_grants_rooted_at` fetches the pin list once for `node_id`, then for each non-revoked grant compares grant.recipient_public_key (normalized to raw bytes) against the pins; on a non-member OR an empty pin list it returns Err(RotateFailed) BEFORE wrap_key (aborting the node's re-mint), using the existing RotateFailed message convention. + + + Add `get_recipient_pubkey_pins` to the RotationDeps trait and implement it on FuseRotationDeps by looking + up the inode whose node_id matches and returning its cached recipient_pins (mirror find_grant_root_state's + find_map). In re_mint_grants_rooted_at, fetch the pins for node_id once, then insert a fail-closed compare + immediately before the wrap_key at :610: RotateFailed on empty pins (D-03e) and on a non-member recipient. + Keep the is_revoked delete branch unchanged. Do NOT modify rotateWriteFromNode (~:2762). Normalize both + sides to raw bytes before comparing (PATTERNS 0x-strip idiom). + + + cargo test -p cipherbox-fuse rotation_deps 2>&1 | tail -8; cargo test -p cipherbox-sdk rotation 2>&1 | tail -8 + + + - `grep -n "get_recipient_pubkey_pins" crates/sdk/src/rotation/engine.rs crates/fuse/src/write_ops/rotation_deps.rs` shows the trait method + FuseRotationDeps impl + - The compare sits immediately before `wrap_key(new_read_key, &grant.recipient_public_key)` at engine.rs:610 and returns RotateFailed on empty pins and on non-member + - Tests A/B/C pass; the is_revoked branch and existing re-mint success path are unchanged + - `grep -n "rotateWriteFromNode\|2762" crates/sdk/src/rotation/engine.rs` — the co-writer re-wrap site is NOT modified (recorded as out-of-scope follow-up) + - `cargo test -p cipherbox-fuse -p cipherbox-sdk` green + + Rust re-mint verifies the recipient against the owner-sealed pin offline and fails closed on mismatch/absent, without touching the out-of-scope co-writer site. + + + + + +- `RotationDeps::get_recipient_pubkey_pins(node_id)` trait method + FuseRotationDeps impl (offline InodeTable read) +- Fail-closed pin compare before wrap_key in re_mint_grants_rooted_at (engine.rs) +- OUT-OF-SCOPE follow-up recorded: rotateWriteFromNode co-writer re-wrap (engine.rs ~:2762) still trusts the server pubkey — surface as a phase-owner assumption / follow-up todo, NOT implemented here (A3 / Open Question 2) +- Pre-ship note: tests/sdk-e2e (live client→API IPNS round-trip) must pass before ship — this is a key-lifecycle change + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| CipherBox relay → owner re-mint (GET /shares/sent) | recipient_public_key round-trips through the untrusted relay | +| owner-sealed write-body pin → re-mint wrap decision | The pin (not the relay pubkey) is the authorization anchor | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-15 | Spoofing / Tampering | relay substitutes recipient_public_key at re-mint | critical | mitigate | Fail-closed compare against the owner-sealed pin before wrap_key (D-03d consumer 1) | +| T-80-16 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | Empty pin list = hard RotateFailed (D-03e no-legacy) | +| T-80-17 | Spoofing / Tampering | co-writer re-wrap (rotateWriteFromNode) still server-trusted | medium | accept | Out of scope for Phase 80 (CONTEXT names 3 consumers); recorded as a follow-up todo for the phase owner | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `cargo test -p cipherbox-fuse -p cipherbox-sdk` green (mismatch/absent fail-closed; match preserved) +- rotateWriteFromNode co-writer site untouched (grep confirms) +- Pin sourced offline from the InodeTable cache (no extra /shares/sent fetch introduced) +- Pre-ship: tests/sdk-e2e live round-trip must be green before /gsd-verify-work + + + +The Rust FUSE re-mint binds the new read key only to a recipient pubkey pinned in the node's owner-sealed +write-body, fails closed on mismatch or absent pin (D-03e), resolves pins offline, and leaves the +out-of-scope co-writer re-wrap site untouched. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md new file mode 100644 index 0000000000..4bb1ca94dc --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-06-SUMMARY.md @@ -0,0 +1,159 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 06 +subsystem: infra +tags: [rust, fuse, rotation, ecies, recipient-pins, fail-closed, sharing] + +# Dependency graph +requires: + - phase: 80-01 + provides: NodeWriteBody.recipient_pins field (owner-sealed pin list) + - phase: 80-05 + provides: InodeTable recipient_pins cache + FuseRotationDeps pin surfacing groundwork +provides: + - "RotationDeps::get_recipient_pubkey_pins seam (required, no permissive default)" + - "FuseRotationDeps + ApiClientTransport offline pin resolution from the InodeTable cache" + - "Fail-closed recipient-pin compare before wrap_key in re_mint_grants_rooted_at (D-03d)" + - "Pin-absent hard fail-closed at re-mint (D-03e no-legacy)" +affects: [rotation, sharing, re-mint, D-03d-consumer-2-typescript, D-03d-consumer-3] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Fail-closed pin binding: verify grant.recipient_public_key against the node's OWN owner-sealed pins before ECIES-wrapping a rotated read key; abort the whole node's re-mint (not a per-grant skip) on mismatch or empty pins" + - "Offline authorization anchor via the RotationTransport seam reading the in-memory InodeTable pin cache (no extra network fetch)" + +key-files: + created: [] + modified: + - crates/sdk/src/rotation/engine.rs + - crates/fuse/src/write_ops/rotation_deps.rs + +key-decisions: + - "get_recipient_pubkey_pins is a REQUIRED trait method (no default) so a relay-substituted recipient can never slip through an implementor that forgot to wire the pin source" + - "FuseRotationDeps resolves pins through the existing RotationTransport seam (ApiClientTransport reads the InodeTable pin cache offline), mirroring query_grants_rooted_at — not by holding an InodeTable directly" + - "Empty pin list is a legitimate method return; the CALLER (re_mint) treats empty-at-re-mint as the D-03e hard fail-closed, keeping the method free of policy" + - "Raw-byte equality compare — both pins (base64-decoded) and grant key (0x-stripped + hex-decoded) are already normalized to raw ECIES pubkey bytes at their decode boundaries (PATTERNS straight-equality idiom)" + +patterns-established: + - "Pattern 1: authorization anchor = node's own owner-sealed write-body pin, never the relay-supplied /shares/sent pubkey" + - "Pattern 2: fail-closed compare aborts the whole node's re-mint (RotateFailed), never a per-grant skip-and-continue like the is_revoked branch" + +requirements-completed: + - "SC2 / D-03d (consumer 1 of 3): Rust re-mint verifies grant.recipient_public_key against the node's owner-sealed pin before wrap_key, fail-closed on mismatch" + - "SC2 / D-03e: pin absent at re-mint is a hard fail-closed invariant violation (no-legacy, no TOFU, no backfill)" + +coverage: + - id: D1 + description: "re_mint_grants_rooted_at fails the whole node's re-mint closed when grant.recipient_public_key is not among the node's owner-sealed pins (relay-substituted recipient)" + requirement: "SC2 / D-03d (consumer 1 of 3)" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_fails_closed_when_recipient_is_not_pinned" + status: pass + human_judgment: false + - id: D2 + description: "An absent/empty pin list at re-mint is a hard RotateFailed (D-03e no-legacy), not a silent skip" + requirement: "SC2 / D-03e" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_fails_closed_when_pin_list_is_empty" + status: pass + human_judgment: false + - id: D3 + description: "A pinned recipient re-mints exactly once — the pre-80-06 success path is preserved" + requirement: "SC2 / D-03d (consumer 1 of 3)" + verification: + - kind: unit + ref: "crates/fuse/src/write_ops/rotation_deps.rs#re_mint_succeeds_when_recipient_is_pinned" + status: pass + - kind: unit + ref: "crates/sdk/src/rotation/engine.rs#high3_inner_grant_at_a_child_is_re_minted_and_revoked_recipient_is_cut" + status: pass + human_judgment: false + - id: D4 + description: "get_recipient_pubkey_pins seam on FuseRotationDeps/ApiClientTransport resolves the pin list OFFLINE from the InodeTable pin cache (no extra network fetch)" + requirement: "SC2 / D-03d (consumer 1 of 3)" + verification: + - kind: unit + ref: "cargo test -p cipherbox-fuse rotation_deps (17 passed) + cargo build -p cipherbox-fuse" + status: pass + human_judgment: false + +# Metrics +duration: 18min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 06: Rust Re-Mint Fail-Closed Recipient-Pin Binding Summary + +**Rust FUSE re-mint now verifies `grant.recipient_public_key` against the node's OWN owner-sealed recipient pins (read offline from the InodeTable cache) before ECIES-wrapping the rotated read key, and fails the whole node's re-mint closed on a non-member (D-03d) or an absent/empty pin list (D-03e).** + +## Performance + +- **Duration:** 18 min +- **Started:** 2026-07-12 +- **Completed:** 2026-07-12 +- **Tasks:** 2 (TDD RED + GREEN) +- **Files modified:** 2 + +## Accomplishments +- Added `RotationDeps::get_recipient_pubkey_pins(node_id)` as a REQUIRED trait method (no permissive default), plus the matching `RotationTransport` seam method — so no implementor can silently trust a relay-substituted recipient. +- Implemented offline pin resolution: `FuseRotationDeps` delegates through the transport seam; `ApiClientTransport::get_recipient_pubkey_pins` reads the already-materialized `InodeTable` pin cache (80-05) via a new `find_recipient_pins` find_map — no extra `GET /shares/sent` or network fetch (D-03a). +- Inserted the fail-closed compare immediately before `wrap_key(new_read_key, &grant.recipient_public_key)` in `re_mint_grants_rooted_at`: a non-member recipient OR an empty/absent pin list returns `RotateFailed`, aborting the WHOLE node's re-mint (never a per-grant skip-and-continue). +- Added the two mandated negative tests (pin-mismatch fail-closed, pin-absent fail-closed) plus the positive match test; both negatives are non-vacuous RED (they failed against pre-change code) and now green. + +## Task Commits + +Single commit (per execution constraint — SUMMARY committed alongside code): + +1. **Task 1+2 (TDD RED→GREEN): fail-closed recipient-pin binding at Rust re-mint** — see commit below (feat) + +## Files Created/Modified +- `crates/sdk/src/rotation/engine.rs` — new required `RotationDeps::get_recipient_pubkey_pins` trait method; `recipient_is_pinned` helper; fail-closed compare in `re_mint_grants_rooted_at` before `wrap_key`; `FakeDeps` pin fixture (`pins_by_node` + `seed_pins` + impl); updated the existing `high3_inner_grant_...` test to pin the surviving recipient. +- `crates/fuse/src/write_ops/rotation_deps.rs` — `RotationTransport::get_recipient_pubkey_pins`; `FuseRotationDeps` delegate impl; `ApiClientTransport` offline impl + `find_recipient_pins`; `FakeTransport` pin fixture (`pins_by_node` + `seed_pins` + impl); Tests A/B/C. + +## Decisions Made +- **Required trait method, no default:** a permissive empty-returning default would defeat the entire mitigation on a mis-wired implementor (T-80-15). The empty list is a legitimate value; only the caller decides it is a hard fail (D-03e), keeping the seam policy-free. +- **Delegate through the transport seam** rather than giving `FuseRotationDeps` an `InodeTable` handle — `FuseRotationDeps` never held one, and `ApiClientTransport` already owns `&inodes`. This mirrors `query_grants_rooted_at` exactly and keeps resolution offline. +- **Raw-byte equality compare:** both sides are normalized to raw ECIES pubkey bytes at their decode boundaries, so the D-03d check is a straight `==` with no 0x/hex mismatch (PATTERNS idiom). + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Updated the existing engine re-mint success test to seed a pin** +- **Found during:** Task 2 (GREEN) +- **Issue:** Making `re_mint` fail-closed on unpinned recipients broke the pre-existing `high3_inner_grant_at_a_child_is_re_minted_and_revoked_recipient_is_cut` test, whose surviving recipient had no pin seeded (empty pins → new D-03e hard fail). +- **Fix:** Seeded the node's owner-sealed pin list with the active recipient's pubkey (`deps.seed_pins(&child_uuid(0), vec![active_pk...])`); the revoked recipient needs no pin (deleted before any pin check). This is the correct post-change behavior — the survivor IS legitimately pinned. +- **Files modified:** crates/sdk/src/rotation/engine.rs +- **Verification:** `cargo test -p cipherbox-sdk rotation` — 54 passed. +- **Committed in:** part of the plan commit. + +--- + +**Total deviations:** 1 auto-fixed (1 blocking) +**Impact on plan:** Necessary to keep the existing re-mint success path green under the new fail-closed invariant. No scope creep — same recipient, now explicitly pinned. engine.rs was already in the plan's `files_modified`. + +## Issues Encountered +None. + +## Out-of-Scope / Follow-ups +- **D-03d consumer 3 (co-writer re-wrap):** the 4th co-writer re-wrap site (TS `rotateWriteFromNode`, the write-revocation path) still trusts the server-supplied pubkey. CONTEXT names exactly 3 consumers; this write-revocation site is OUT OF SCOPE for this plan and recorded as a phase-owner follow-up (RESEARCH Open Question 2 / A3, threat T-80-17 disposition = accept). Confirmed no `rotate_write_from_node`/`rotateWriteFromNode` symbol exists in `crates/sdk/src/rotation/engine.rs` and it was not touched. +- **Pre-ship:** `tests/sdk-e2e` (live client→API IPNS round-trip) must be green before ship — this is a key-lifecycle change. NOT run here per scoped-tests constraint. + +## Verification (scoped) +- `cargo test -p cipherbox-sdk rotation` → **test result: ok. 54 passed; 0 failed; 99 filtered out** +- `cargo test -p cipherbox-fuse rotation_deps` → **test result: ok. 17 passed; 0 failed; 112 filtered out** (includes the 3 new pin tests A/B/C; pin-mismatch and pin-absent are the mandated negatives) +- `cargo build -p cipherbox-sdk -p cipherbox-fuse` → Finished (only upstream `fuser` dep warnings) +- RED proof (pre-GREEN): `re_mint_fails_closed_when_recipient_is_not_pinned` and `re_mint_fails_closed_when_pin_list_is_empty` both FAILED against pre-change code (non-vacuous). + +## Next Phase Readiness +- D-03d consumer 1 (Rust re-mint) is complete and fail-closed. Consumers 2 (TypeScript re-mint) and 3 remain for their own plans. +- Co-writer re-wrap follow-up recorded above for the phase owner. + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md new file mode 100644 index 0000000000..8bf30e2340 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-PLAN.md @@ -0,0 +1,203 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 07 +type: tdd +wave: 3 +depends_on: ["80-01", "80-03", "80-04"] +files_modified: + - packages/sdk-core/src/rotation/engine.ts + - packages/sdk/src/share/owner-reconcile.ts + - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts + - packages/sdk/src/__tests__/owner-reconcile.test.ts +autonomous: true +requirements: + - "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch" + - "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation" +user_setup: [] + +must_haves: + truths: + - "reMintGrantsRootedAt fetches the node's recipient pins via a getPinsFn seam and throws (fail-closed) if grant.recipientPublicKey is not pinned (D-03d)" + - "An absent/empty pin list at TS re-mint throws (D-03e no-legacy), it does not skip-and-continue" + - "buildGrantRemintCallbacks wires getPinsFn to the client's getRecipientPubkeyPins read path (80-04)" + artifacts: + - "packages/sdk-core/src/rotation/engine.ts — GrantRemintCallbacks.getPinsFn + fail-closed assertRecipientPinned before wrapKey" + - "packages/sdk/src/share/owner-reconcile.ts — getPinsFn wired via getRecipientPubkeyPins in buildGrantRemintCallbacks" + - "grant-remint.test.ts + owner-reconcile.test.ts — mismatch/absent fail-closed cases" + key_links: + - "assertRecipientPinned (from 80-04) is called immediately before wrapKey(newReadKey, grant.recipientPublicKey) at engine.ts:587" + - "getPinsFn resolves the node's owner-sealed recipientPins (via getRecipientPubkeyPins), NOT the /shares/sent recipientPublicKey" + prohibitions: + - "MUST NOT model a pin mismatch as a per-grant skip like the isRevoked branch — it throws and aborts the node's re-mint (D-03e, Pitfall 5)" + - "MUST NOT reimplement the compare in the web layer — reuse the sdk-core assertRecipientPinned helper (80-04); web (80-08) also reuses it" + - "MUST NOT trust the server-fed recipientPublicKey as the pin source" + - "MUST NOT add an API/DTO change or run api:generate (D-03f)" +--- + + +Consumer 2 of D-03d's three fail-closed enforcement sites: the TS re-mint. `reMintGrantsRootedAt` +(sdk-core engine.ts) calls `wrapKey(newReadKey, grant.recipientPublicKey)` at :587 where +`grant.recipientPublicKey` came back through the relay via `listSentGrants()`. Insert a fail-closed +`assertRecipientPinned` (from 80-04) before the wrap, sourcing the pin list from the node's owner-sealed +`recipientPins` via a new `getPinsFn` seam on `GrantRemintCallbacks`, wired in `buildGrantRemintCallbacks` +(owner-reconcile.ts) to the client's `getRecipientPubkeyPins` read path. Absent/empty pins throw (D-03e). + +Depends on 80-01 (field), 80-03 (engine.ts/owner-reconcile.ts sequencing), and 80-04 +(assertRecipientPinned + getRecipientPubkeyPins). engine.ts/owner-reconcile.ts overlap 80-03 and dep 80-04 +(wave 2) → wave 3. + +Purpose: stop the TS re-mint from wrapping the read key to a relay-substituted recipient. +Output: getPinsFn seam + fail-closed compare + mismatch/absent tests. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@packages/sdk-core/src/rotation/engine.ts +@packages/sdk/src/share/owner-reconcile.ts +@packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts + + + + + + Task 1: RED — pin-mismatch and pin-absent fail-closed tests for reMintGrantsRootedAt + packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts + + - packages/sdk-core/src/rotation/engine.ts — `GrantRemintCallbacks` (lines 57-84, `queryGrantsFn` grant shape at :69-72), `reMintGrantsRootedAt` (563-590) with `wrapKey(newReadKey, grant.recipientPublicKey)` at :587 and the isRevoked→deleteGrantFn branch; the throw style at ~:2764 (`new Error(..., { cause })`) + - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts — existing seam-test harness/mocks to extend + - packages/sdk-core/src/share/recipient-pins.ts — `assertRecipientPinned` (from 80-04) + + + - Test A (mismatch): reMintGrantsRootedAt with a getPinsFn returning pins that do NOT include grant.recipientPublicKey throws and does not call updateGrantFn/wrapKey for that grant. + - Test B (absent): getPinsFn returns an empty list → throws (D-03e). + - Test C (match): pins include the grant pubkey → proceeds and wraps as before. + - A/B fail RED (no getPinsFn/compare yet); C passes today and must stay passing. + + + Extend grant-remint.test.ts with a `getPinsFn` mock and Tests A/B/C, asserting the mismatch/absent cases + throw and skip the wrap. Do NOT implement the seam/compare yet. + + + pnpm --filter @cipherbox/sdk-core test grant-remint 2>&1 | grep -qi "fail" && echo "RED confirmed" + + + - grant-remint.test.ts references a getPinsFn mock and assertRecipientPinned behavior + - A (mismatch) and B (absent) FAIL against current code (non-vacuous RED) + - The match case (C) is asserted to still wrap + + Failing tests pin the TS mismatch/absent fail-closed behavior. + + + + Task 2: GREEN — getPinsFn seam + assertRecipientPinned before wrapKey (sdk-core) + packages/sdk-core/src/rotation/engine.ts + + - packages/sdk-core/src/rotation/engine.ts — GrantRemintCallbacks (57-84), reMintGrantsRootedAt (563-590), wrap at :587 + - packages/sdk-core/src/share/recipient-pins.ts — assertRecipientPinned (80-04) + + + - `GrantRemintCallbacks.getPinsFn?: (nodeId: string) => Promise` added. + - reMintGrantsRootedAt fetches pins once for nodeId, then for each non-revoked grant calls + assertRecipientPinned(grant.recipientPublicKey, pins) BEFORE wrapKey; a throw aborts the node's re-mint. + - Absent getPinsFn OR empty pins → throw (D-03e). isRevoked branch unchanged. + + + Add `getPinsFn` to `GrantRemintCallbacks`. In reMintGrantsRootedAt, resolve the pin list for nodeId once + (throw if getPinsFn is missing — this is a required seam in the enforced path), then call + assertRecipientPinned immediately before the wrapKey at :587. Reuse the file's existing throw style. Do + NOT modify the isRevoked/deleteGrantFn branch. No api:generate. + + + pnpm --filter @cipherbox/sdk-core test grant-remint 2>&1 | tail -12 + + + - `grep -n "getPinsFn\|assertRecipientPinned" packages/sdk-core/src/rotation/engine.ts` shows the seam + compare before wrapKey + - Tests A/B/C pass; the isRevoked branch is unchanged + - `pnpm --filter @cipherbox/sdk-core typecheck` passes + + TS re-mint verifies the recipient against the owner-sealed pin and fails closed on mismatch/absent. + + + + Task 3: GREEN — wire getPinsFn to getRecipientPubkeyPins in buildGrantRemintCallbacks + packages/sdk/src/share/owner-reconcile.ts, packages/sdk/src/__tests__/owner-reconcile.test.ts + + - packages/sdk/src/share/owner-reconcile.ts — `buildGrantRemintCallbacks` (66-84, the closure-scoped listSentGrants cache from 80-03), `runOwnerReconcile` (94-104) + - packages/sdk/src/client.ts — `getRecipientPubkeyPins` (from 80-04) + - packages/sdk/src/__tests__/owner-reconcile.test.ts — existing harness to extend + + + - `buildGrantRemintCallbacks` returns callbacks including `getPinsFn(nodeId)` that resolves the node's + owner-sealed recipientPins via getRecipientPubkeyPins (client/transport), so the enforced re-mint path + has a real pin source end-to-end. + - An owner-reconcile test drives a mismatch and asserts the reconcile pass throws (fail-closed). + + + Add `getPinsFn` to the callbacks built by `buildGrantRemintCallbacks`, delegating to the client's + getRecipientPubkeyPins read path (keep the 80-03 listSentGrants memo intact). Extend owner-reconcile.test.ts + with an end-to-end mismatch case asserting runOwnerReconcile fails closed. No api:generate. + + + pnpm --filter @cipherbox/sdk test owner-reconcile 2>&1 | tail -12 + + + - `grep -n "getPinsFn\|getRecipientPubkeyPins" packages/sdk/src/share/owner-reconcile.ts` shows the seam wired to the read path + - The 80-03 listSentGrants memo is preserved (grep shows the cached closure still present) + - owner-reconcile.test.ts asserts a mismatch fails the reconcile pass closed + - `pnpm --filter @cipherbox/sdk test owner-reconcile` green + + The TS owner-reconcile re-mint path has an end-to-end pin source and fails closed on relay substitution. + + + + + +- `GrantRemintCallbacks.getPinsFn` seam (engine.ts) +- Fail-closed assertRecipientPinned compare before wrapKey in reMintGrantsRootedAt +- getPinsFn wired via getRecipientPubkeyPins in buildGrantRemintCallbacks (owner-reconcile.ts) +- Pre-ship note: tests/sdk-e2e must pass before ship (key-lifecycle change) + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| CipherBox relay → TS owner re-mint (listSentGrants) | recipientPublicKey round-trips through the untrusted relay | +| owner-sealed write-body pin → re-mint wrap decision | The pin (not the relay pubkey) authorizes the wrap | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-18 | Spoofing / Tampering | relay substitutes recipientPublicKey at TS re-mint | critical | mitigate | Fail-closed assertRecipientPinned before wrapKey (D-03d consumer 2) | +| T-80-19 | Elevation of Privilege | empty/absent pin treated as pass | high | mitigate | Empty/absent pin list throws (D-03e no-legacy) | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `pnpm --filter @cipherbox/sdk-core test grant-remint` green (mismatch/absent fail-closed; match preserved) +- `pnpm --filter @cipherbox/sdk test owner-reconcile` green (end-to-end fail-closed; 80-03 memo intact) +- `pnpm --filter @cipherbox/sdk-core typecheck` green; no api-client changes +- Pre-ship: tests/sdk-e2e live round-trip green before /gsd-verify-work + + + +The TS re-mint binds the new read key only to a recipient pubkey pinned in the node's owner-sealed +write-body, fails closed on mismatch or absent pin (D-03e), and sources pins via the client read path — with +no API/DB change. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md new file mode 100644 index 0000000000..a345cb000e --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-07-SUMMARY.md @@ -0,0 +1,154 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 07 +subsystem: crypto +tags: [rotation, re-mint, recipient-pins, fail-closed, sdk-core, owner-reconcile, ECIES] + +# Dependency graph +requires: + - phase: 80-01 + provides: recipientPins field on NodeWriteBody wire codec + - phase: 80-03 + provides: engine.ts/owner-reconcile.ts sequencing + closure-scoped listSentGrants memo + - phase: 80-04 + provides: assertRecipientPinned helper + client getRecipientPubkeyPins read path +provides: + - GrantRemintCallbacks.getPinsFn seam on reMintGrantsRootedAt (sdk-core) + - Fail-closed assertRecipientPinned verification before wrapKey in the TS re-mint + - getPinsFn wired via transport.getRecipientPubkeyPins in buildGrantRemintCallbacks (sdk) +affects: [80-08, ship, verify-work] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Fail-closed recipient-pin verification at a wrap site (D-03d consumer 2 of 3)" + - "Reuse the shared sdk-core assertRecipientPinned helper across all enforcement consumers" + +key-files: + created: [] + modified: + - packages/sdk-core/src/rotation/engine.ts + - packages/sdk/src/share/owner-reconcile.ts + - packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts + - packages/sdk/src/__tests__/owner-reconcile.test.ts + +key-decisions: + - "getPinsFn is optional on GrantRemintCallbacks but REQUIRED on the enforced (surviving-grant) path — absent seam throws (D-03e)" + - "Pins fetched once per node, only when at least one surviving grant exists — an all-revoked node needs no pin source, preserving existing revoked-only callers" + - "transport.getRecipientPubkeyPins kept OPTIONAL on OwnerReconcileTransport so the web wrapper (80-08) wires it separately; absent method fails closed, not open" + +patterns-established: + - "Normalize Uint8Array pins to base64 before assertRecipientPinned (its stored-pin encoding)" + - "Enforcement is a hard throw that aborts the node's re-mint — NOT a per-grant skip like isRevoked" + +requirements-completed: + - "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch" + - "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation" + +coverage: + - id: D1 + description: "TS re-mint fails closed when the relay-fed recipientPublicKey is not in the node's owner-sealed pin list (D-03d, T-80-18)" + requirement: "SC2 / D-03d (consumer 2 of 3): TS re-mint verifies grant.recipientPublicKey against the node's owner-sealed pin before wrapKey, fail-closed on mismatch" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test A (D-03d mismatch): throws and does NOT wrap when getPinsFn omits the grant recipient" + status: pass + - kind: unit + ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 5 (D-03d mismatch): reconcile fails closed when the pin list omits the surviving grant recipient" + status: pass + human_judgment: false + - id: D2 + description: "Absent/empty pin list at TS re-mint is a hard fail-closed error, never a skip (D-03e, T-80-19)" + requirement: "SC2 / D-03e: pin absent at TS re-mint is a hard fail-closed invariant violation" + verification: + - kind: unit + ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test B (D-03e absent): throws when getPinsFn returns an empty pin list" + status: pass + - kind: unit + ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test B2 (D-03e absent seam): throws when getPinsFn is missing for a surviving grant" + status: pass + - kind: unit + ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 6 (D-03e absent): reconcile fails closed when the pin list is empty" + status: pass + human_judgment: false + - id: D3 + description: "getPinsFn seam sources pins from the client read path (getRecipientPubkeyPins); a pinned recipient wraps as before, 80-03 listSentGrants memo intact" + verification: + - kind: unit + ref: "packages/sdk/src/__tests__/owner-reconcile.test.ts#Test 7 (pin source): getPinsFn resolves via getRecipientPubkeyPins, matching pin wraps as before" + status: pass + - kind: unit + ref: "packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts#Test C (match): proceeds and wraps when getPinsFn includes the grant recipient" + status: pass + human_judgment: false + +# Metrics +duration: 18min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 07: TS Re-mint Recipient-Pin Fail-Closed Enforcement Summary + +**The TS owner re-mint 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) before wrapKey, and fails closed on mismatch or absent pins.** + +## Performance + +- **Duration:** ~18 min +- **Started:** 2026-07-12T20:47Z +- **Completed:** 2026-07-12T20:52Z +- **Tasks:** 3 +- **Files modified:** 4 + +## Accomplishments +- Added `GrantRemintCallbacks.getPinsFn` seam to `reMintGrantsRootedAt` (sdk-core engine.ts) that resolves the node's owner-sealed pins. +- Inserted a fail-closed `assertRecipientPinned` (reused from 80-04) immediately before `wrapKey(newReadKey, grant.recipientPublicKey)` — a mismatch or absent/empty pin list throws and aborts the node's re-mint (NOT a per-grant skip like isRevoked). +- Wired `getPinsFn` to the client `getRecipientPubkeyPins` read path via `transport.getRecipientPubkeyPins` in `buildGrantRemintCallbacks` (sdk owner-reconcile.ts), preserving the 80-03 closure-scoped `listSentGrants` memo. +- Added mismatch + absent negative tests at both layers (sdk-core unit and sdk end-to-end). + +## Task Commits + +Executed as a single atomic commit per D-03d consumer-2 scope (TDD RED→GREEN across two packages): + +1. **Tasks 1-3: RED tests + getPinsFn seam + owner-reconcile wiring** - see plan metadata commit below + +**Plan metadata + code:** committed together (feat) + +_All four files (engine, owner-reconcile, and both test files) plus this SUMMARY landed in one commit._ + +## Files Created/Modified +- `packages/sdk-core/src/rotation/engine.ts` - `GrantRemintCallbacks.getPinsFn` seam + fail-closed `assertRecipientPinned` before `wrapKey`; pins fetched once per node only when a surviving grant exists. +- `packages/sdk/src/share/owner-reconcile.ts` - optional `getRecipientPubkeyPins` on `OwnerReconcileTransport` + `getPinsFn` delegating to it (fail-closed if absent); 80-03 memo untouched. +- `packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts` - Tests A (mismatch), B (empty), B2 (missing seam), C (match); Tests 1/3 updated to supply a matching `getPinsFn`. +- `packages/sdk/src/__tests__/owner-reconcile.test.ts` - crypto mock switched to `importOriginal` (keeps real base64/hex codecs for `assertRecipientPinned`); `makeTransport` supplies `getRecipientPubkeyPins`; Tests 5 (mismatch), 6 (empty), 7 (pin source) added. + +## Decisions Made +- **getPinsFn optional in the type, required on the enforced path:** the type stays optional so all-revoked callers and the no-callbacks no-op keep compiling, but any surviving grant with a missing seam throws (D-03e). This keeps the existing revoked-only test (Test 2) green without a getPinsFn. +- **Pins fetched once, gated on `grants.some(!isRevoked)`:** an all-revoked node performs no wrap and needs no pin source, so the seam is only demanded when enforcement actually applies. +- **`transport.getRecipientPubkeyPins` kept optional:** the concrete web wrapper (`apps/web/.../owner-reconcile.service.ts`) is wired in 80-08; leaving it optional keeps the web package compiling now and makes the web re-mint fail closed (throw, caught+logged) until 80-08 completes the wiring — the safe direction. + +## Deviations from Plan +None - plan executed exactly as written. Added one extra sdk-core test (B2: missing-seam throws) beyond the plan's A/B/C to explicitly cover the "absent getPinsFn" D-03e path, and one extra sdk test (Test 7: pin-source-resolves) to assert the getRecipientPubkeyPins wiring — both strengthen coverage without changing scope. + +## Issues Encountered +- Bash cwd drifted to the primary checkout (main) between calls; re-targeted every command at the worktree path explicitly. The initial "4 tests passed" was the primary checkout running stale code — re-running inside the worktree correctly showed 4 failing RED tests. +- The sdk test resolves `@cipherbox/sdk-core` to its built dist, so `assertRecipientPinned` needs the real base64/hex codecs. Switched the owner-reconcile crypto mock to `importOriginal` (keeping only the ECIES/randomness stubs) and rebuilt sdk-core dist before running the sdk suite. + +## User Setup Required +None - no external service configuration required. No API/DTO change, no api:generate, no DB migration. + +## Next Phase Readiness +- 80-08 (web consumer 3 of 3) wires `getRecipientPubkeyPins` on the concrete web `OwnerReconcileTransport` (delegating to `client.getRecipientPubkeyPins`) and reuses the same `assertRecipientPinned` compare — the seam and helper are in place. +- Pre-ship: run the full sdk-core/sdk suites + sdk-e2e live round-trip before `/gsd-verify-work` (key-lifecycle change). + +## Verification Results +- `pnpm --filter @cipherbox/sdk-core test grant-remint` → 8 passed (8) +- `pnpm --filter @cipherbox/sdk test owner-reconcile` → 9 passed (9) +- `pnpm --filter @cipherbox/sdk-core typecheck` → clean +- `pnpm --filter @cipherbox/sdk typecheck` → clean +- eslint + prettier on all 4 touched files → clean + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md new file mode 100644 index 0000000000..e2f1e55b10 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-PLAN.md @@ -0,0 +1,186 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 08 +type: execute +wave: 4 +depends_on: ["80-04", "80-07"] +files_modified: + - apps/web/src/components/file-browser/ShareDialog.tsx + - apps/web/src/services/owner-reconcile.service.ts +autonomous: true +requirements: + - "SC2 / D-03c (web issuance): ShareDialog writes the pasted recipient pubkey into the shared node's owner-sealed write-body pin list at grant creation" + - "SC2 / D-03d (consumer 3 of 3): the web upgrade path verifies the server-fed recipient pubkey against the pin before re-wrapping, and the web owner-reconcile path delegates to the enforced runOwnerReconcile" +user_setup: [] + +must_haves: + truths: + - "On share creation, ShareDialog.handleShare calls client.addRecipientPubkeyPin so the recipient pubkey is committed to the node's owner-sealed write-body (D-03c)" + - "ShareDialog.handleUpgrade calls assertRecipientPinned(server-fed recipientPublicKey, getRecipientPubkeyPins) before resolveShareEncryptedWriteKey re-wraps — fail-closed on mismatch/absent (D-03d)" + - "owner-reconcile.service.ts's runOwnerReconcile path carries the pin enforcement from 80-07 (getPinsFn), verified end-to-end" + artifacts: + - "apps/web/src/components/file-browser/ShareDialog.tsx — issuance pin write (handleShare) + upgrade-path fail-closed compare (handleUpgrade)" + - "apps/web/src/services/owner-reconcile.service.ts — pin enforcement flows through runOwnerReconcile (80-07); wiring verified" + key_links: + - "Issuance-time wrapKey at :184/:205 stays EXEMPT (the pin does not exist until handleShare writes it); the compare belongs only at the upgrade/reconcile re-wrap (:306) and the runOwnerReconcile path" + - "Web reuses sdk-core assertRecipientPinned + client.getRecipientPubkeyPins/addRecipientPubkeyPin (80-04) — no compare reimplemented in the web layer" + prohibitions: + - "MUST NOT reimplement the pin compare in the web layer — call the sdk-core assertRecipientPinned helper (80-04)" + - "MUST NOT pin-check the issuance-time wrap (handleShare :184/:205) — that is where the pin is first written (D-03c)" + - "MUST NOT add an API/DTO change or run api:generate (D-03f)" + - "MUST NOT trust share.recipientPublicKey (server-fed store) at the upgrade/reconcile re-wrap without a pin compare" +--- + + +Consumer 3 of D-03d plus the D-03c web issuance write. `ShareDialog.tsx` today (a) creates a share and +ECIES-wraps the read/name/write key to the pasted recipient pubkey at issuance (handleShare :184/:205), +and (b) on a read→write upgrade re-wraps to `share.recipientPublicKey` read straight from the server-fed +store (handleUpgrade :297-306) with NO pin check. `owner-reconcile.service.ts` re-mints via +`runOwnerReconcile` (enforced by 80-07's getPinsFn seam). + +This plan: +1. **D-03c issuance:** in handleShare, after the share is created, write the recipient pubkey into the + node's owner-sealed write-body via `client.addRecipientPubkeyPin` (80-04) — for both read and write shares. +2. **D-03d enforcement:** in handleUpgrade, verify the server-fed recipientPublicKey against the node's pin + (via `client.getRecipientPubkeyPins` + `assertRecipientPinned`, 80-04) BEFORE the :306 re-wrap; fail + closed on mismatch/absent (D-03e). +3. **owner-reconcile.service.ts:** verify its `runOwnerReconcile` path carries the 80-07 enforcement; add + any wiring needed so getPinsFn resolves real pins (no direct wrap reimplementation). + +Web is NOT unit-tested (logic lives in sdk-core; UI covered by Playwright web-e2e). Verify via typecheck + +build + Puppeteer MCP (if available) / manual steps. Depends on 80-04 (issuance/read/assert helpers) and +80-07 (enforced runOwnerReconcile) → wave 4. + +Purpose: close the web issuance write and the third fail-closed enforcement consumer. +Output: ShareDialog issuance pin write + upgrade-path fail-closed compare; verified reconcile enforcement. + + + +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/workflows/execute-plan.md +@/Users/myankelev/Code/random/cipher-box/.claude/gsd-core/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/STATE.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-RESEARCH.md +@.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md +@apps/web/src/components/file-browser/ShareDialog.tsx +@apps/web/src/services/owner-reconcile.service.ts + + + + + + Task 1: D-03c issuance write in ShareDialog.handleShare + apps/web/src/components/file-browser/ShareDialog.tsx + + - apps/web/src/components/file-browser/ShareDialog.tsx — `handleShare` (162-245): recipient pubkey decode (171-179), issuance wrap of itemReadKey (:184), resolveShareEncryptedWriteKey for write shares (:195), name wrap (:205), share-create result (:222-240); the item's ipnsName in scope + - packages/sdk/src/client.ts — `addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` (80-04) + + + After a share is successfully created in handleShare (both read-only and read-write branches), call + `getSdkClient().addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` to commit the pasted recipient + pubkey to the shared node's owner-sealed write-body pin list. Do NOT alter the issuance-time wrapKey + calls (:184/:205) — those remain exempt (the pin is being written here for the first time). Handle the + pin-write error path consistently with the existing share-create error handling (surface a user-facing + error; do not leave a share created without its pin silently — log + set error). Use the item's ipnsName + already in scope. No api:generate. + + + pnpm --filter @cipherbox/web typecheck 2>&1 | tail -5 + + + - `grep -n "addRecipientPubkeyPin" apps/web/src/components/file-browser/ShareDialog.tsx` shows the issuance pin write in handleShare, for both read and write share branches + - The issuance-time wrapKey calls (:184/:205) are unchanged (no pin compare added there) + - `pnpm --filter @cipherbox/web typecheck` passes + - No files changed under packages/api-client/ (no api:generate) + + Creating a share writes the recipient pubkey into the node's owner-sealed write-body pin list. + + + + Task 2: D-03d fail-closed compare in the upgrade path + verify reconcile enforcement + apps/web/src/components/file-browser/ShareDialog.tsx, apps/web/src/services/owner-reconcile.service.ts + + - apps/web/src/components/file-browser/ShareDialog.tsx — `handleUpgrade` (275-323): the server-fed recipientPublicKey decode (:297-300) and the resolveShareEncryptedWriteKey re-wrap (:303-306) + - apps/web/src/services/owner-reconcile.service.ts — the GrantRow decode (:57-84) and the `runOwnerReconcile` calls (:188, :244); CONFIRM enforcement is carried by 80-07's getPinsFn in buildGrantRemintCallbacks (does the service need to pass a pin-resolver/transport, or is it self-wired in sdk?) + - packages/sdk/src/client.ts — `getRecipientPubkeyPins` (80-04); packages/sdk-core/src/share/recipient-pins.ts — `assertRecipientPinned` (80-04) + + + In handleUpgrade, BEFORE the resolveShareEncryptedWriteKey re-wrap at :303-306, fetch the node's pins via + `getSdkClient().getRecipientPubkeyPins(itemIpnsName)` and call `assertRecipientPinned(recipientPublicKey, pins)`; + on throw, surface the existing upgrade-failure error path (fail closed — do NOT proceed to re-wrap). + For owner-reconcile.service.ts: verify the runOwnerReconcile path is enforced by 80-07 (getPinsFn wired + in buildGrantRemintCallbacks). If the service must supply a pin-resolver or a getRecipientPubkeyPins-capable + client to runOwnerReconcile for the seam to resolve real pins, add that wiring; otherwise leave the direct + re-wrap logic to sdk (do NOT reimplement the compare here). Do NOT weaken the server-fed decode; only gate + the re-wrap behind the pin check. + + + pnpm --filter @cipherbox/web typecheck 2>&1 | tail -5; pnpm --filter @cipherbox/web build 2>&1 | tail -5 + + + - `grep -n "assertRecipientPinned\|getRecipientPubkeyPins" apps/web/src/components/file-browser/ShareDialog.tsx` shows the compare BEFORE the upgrade re-wrap at :306 + - The upgrade path fails closed (does not call resolveShareEncryptedWriteKey) when the pin is absent or mismatched + - owner-reconcile.service.ts enforcement is confirmed to flow through runOwnerReconcile (80-07) — either self-wired (no change) or the service supplies the pin resolver; the compare is NOT reimplemented in the web layer + - `pnpm --filter @cipherbox/web typecheck` and `pnpm --filter @cipherbox/web build` pass + + The web upgrade/reconcile re-wrap binds only to a pinned recipient, failing closed on relay substitution, without duplicating the compare in the web layer. + + + + + +Web is not unit-tested (logic in sdk-core; UI via Playwright web-e2e which only runs on main push). After +typecheck/build pass, verify at runtime with Puppeteer MCP if available (per CLAUDE.md), else manually: +1. Create a share to a recipient pubkey → confirm the share succeeds and (via a follow-up read) the node's + pin list includes that pubkey. +2. Attempt a read→write upgrade with a tampered/mismatched recipientPublicKey → confirm the UI shows the + fail-closed upgrade error and no re-wrap occurs. +The authoritative pre-ship gate is `tests/sdk-e2e` (live client→API IPNS round-trip); web-e2e runs on main push. + + + +- ShareDialog issuance pin write (addRecipientPubkeyPin) + upgrade-path fail-closed compare (assertRecipientPinned) +- owner-reconcile.service.ts enforcement verified to flow through runOwnerReconcile (80-07) +- Recovery-tool no-op tolerance is verified in 80-01 (apps/web/recovery-src never parses the write-body) +- Pre-ship note: tests/sdk-e2e must pass before ship (key-lifecycle change) + + + +## Trust Boundaries + +| Boundary | Description | +|----------|-------------| +| server-fed store (share.recipientPublicKey) → web upgrade re-wrap | Untrusted relay value re-wrapped to at upgrade time | +| owner (ShareDialog paste) → owner-sealed write-body | Issuance-time recipient pubkey committed as the pin | + +## STRIDE Threat Register + +| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | +|-----------|----------|-----------|----------|-------------|-----------------| +| T-80-20 | Spoofing / Tampering | web upgrade re-wraps to server-fed recipientPublicKey | high | mitigate | assertRecipientPinned before the :306 re-wrap (D-03d consumer 3) | +| T-80-21 | Spoofing | issuance binds the wrong recipient | high | mitigate | Issuance writes the pasted pubkey as the pin (D-03c); re-mint/upgrade later verify against it | +| T-80-22 | Elevation of Privilege | empty/absent pin at upgrade treated as pass | high | mitigate | assertRecipientPinned hard-fails on empty/absent (D-03e) | + +No external packages added — no supply-chain (T-*-SC) threat for this plan. + + + +- `pnpm --filter @cipherbox/web typecheck` and `pnpm --filter @cipherbox/web build` green +- Issuance wrap sites (:184/:205) unchanged; compare added only at the upgrade re-wrap +- No api-client changes (no api:generate) +- Manual/Puppeteer runtime check per manual_verification; tests/sdk-e2e is the pre-ship gate + + + +Share creation commits the recipient pubkey to the node's owner-sealed write-body, and the web upgrade and +reconcile re-wraps bind the read/write key only to a pinned recipient (fail-closed on relay substitution or +absent pin), reusing the sdk-core helpers with no API/DB change. + + + +Create `.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md` when done. + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md new file mode 100644 index 0000000000..ec9d817b6f --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-08-SUMMARY.md @@ -0,0 +1,177 @@ +--- +phase: 80-rotation-write-plane-and-re-mint-durability +plan: 08 +subsystem: ui +tags: [share, recipient-pins, ecies, react, sdk-facade, fail-closed] + +# Dependency graph +requires: + - phase: 80-04 + provides: "client.addRecipientPubkeyPin / client.getRecipientPubkeyPins + sdk-core assertRecipientPinned" + - phase: 80-07 + provides: "runOwnerReconcile getPinsFn enforcement wired through buildGrantRemintCallbacks" +provides: + - "ShareDialog issuance-time recipient-pin write (D-03c) on share creation" + - "ShareDialog fail-closed upgrade-path pin compare (D-03d consumer 3) before re-wrap" + - "web owner-reconcile.service.ts transport getRecipientPubkeyPins wiring — getPinsFn resolves real pins end-to-end" + - "@cipherbox/sdk facade re-export of assertRecipientPinned (D-07-compliant web access)" +affects: [share, rotation, owner-reconcile, sdk-e2e] + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Web reuses sdk-core pure pin helpers via the @cipherbox/sdk facade re-export (D-07 boundary), never importing @cipherbox/sdk-core directly" + - "Per-reconcile-pass transport factory closes over the root's shareRootIpnsName to resolve the ipnsName-keyed client pin read from the nodeId-keyed seam" + +key-files: + created: [] + modified: + - apps/web/src/components/file-browser/ShareDialog.tsx + - apps/web/src/services/owner-reconcile.service.ts + - packages/sdk/src/index.ts + +key-decisions: + - "Placed addRecipientPubkeyPin immediately after sharesControllerCreateShare (unconditional, covering both read and write shares) so a pin-write failure throws into the existing catch and surfaces a user error rather than leaving a share silently un-pinned" + - "Re-exported assertRecipientPinned from the @cipherbox/sdk facade (mirrors the existing selectEncryptionMode re-export) to satisfy the D-07 no-restricted-imports boundary without reimplementing the compare in the web layer" + - "Built a per-pass makeWebOwnerReconcileTransport factory that closes over the root's shareRootIpnsName — the sdk-core seam threads rootNodeId, but client.getRecipientPubkeyPins is keyed by ipnsName, and each reconcile pass is scoped 1:1 to a single root" + +patterns-established: + - "Pattern 1: D-07-compliant reuse of a pure sdk-core helper in apps/web = re-export from the @cipherbox/sdk facade, then import from the facade" + - "Pattern 2: getPinsFn seam (nodeId-keyed) → web transport resolves via the reconcile pass's fixed shareRootIpnsName to the ipnsName-keyed client read" + +requirements-completed: + - "SC2 / D-03c (web issuance): ShareDialog writes the pasted recipient pubkey into the shared node's owner-sealed write-body pin list at grant creation" + - "SC2 / D-03d (consumer 3 of 3): the web upgrade path verifies the server-fed recipient pubkey against the pin before re-wrapping, and the web owner-reconcile path delegates to the enforced runOwnerReconcile" + +coverage: + - id: D1 + description: "ShareDialog.handleShare commits the pasted recipient pubkey to the node's owner-sealed write-body pin list on share creation (both read and write shares) — D-03c issuance write" + requirement: "SC2 / D-03c (web issuance)" + verification: + - kind: manual_procedural + ref: "Create a share to a recipient pubkey, then read back the node's pins (getRecipientPubkeyPins) to confirm the pubkey is present — requires a running web + API + IPFS stack" + status: unknown + human_judgment: true + rationale: "apps/web has no unit tests (logic lives in sdk-core, UI covered by main-push web-e2e); runtime confirmation needs the full dev stack + a real recipient pubkey, out of scope for this scoped-verification executor" + - id: D2 + description: "ShareDialog.handleUpgrade fails closed on relay substitution — assertRecipientPinned against getRecipientPubkeyPins BEFORE resolveShareEncryptedWriteKey re-wrap (D-03d consumer 3)" + requirement: "SC2 / D-03d (consumer 3 of 3)" + verification: + - kind: manual_procedural + ref: "Attempt a read→write upgrade with a tampered recipientPublicKey → confirm the UI shows the upgrade-failure error and no re-wrap occurs" + status: unknown + human_judgment: true + rationale: "Same as D1 — no web unit tests; the fail-closed path is exercised by tests/sdk-e2e (the pre-ship gate) and web-e2e on main push, neither run here per scoped-verification constraints" + - id: D3 + description: "web owner-reconcile.service.ts transport supplies getRecipientPubkeyPins so runOwnerReconcile's 80-07 getPinsFn enforcement resolves real pins end-to-end (no more fail-closed-on-absent-seam)" + requirement: "SC2 / D-03d (consumer 3 of 3)" + verification: + - kind: unit + ref: "packages/sdk/src/share/owner-reconcile.ts buildGrantRemintCallbacks getPinsFn seam (unit-tested in sdk); web wrapper now satisfies the required transport method — typecheck confirms the OwnerReconcileTransport contract is met" + status: pass + human_judgment: false + - id: D4 + description: "@cipherbox/sdk facade re-exports assertRecipientPinned so the web upgrade path reuses the sdk-core compare without violating the D-07 import boundary" + requirement: "SC2 / D-03d (consumer 3 of 3)" + verification: + - kind: unit + ref: "pnpm --filter @cipherbox/web exec tsc -b (pass) + eslint no-restricted-imports (pass) — the facade import resolves and satisfies D-07" + status: pass + human_judgment: false + +# Metrics +duration: 20min +completed: 2026-07-12 +status: complete +--- + +# Phase 80 Plan 08: Web recipient-pin issuance write + D-03d consumer 3 Summary + +**ShareDialog now writes the recipient pin at share creation (D-03c) and fail-closed-verifies the server-fed recipient against it before the upgrade re-wrap (D-03d), and the web owner-reconcile transport supplies getRecipientPubkeyPins so the 80-07 getPinsFn enforcement resolves real pins end-to-end.** + +## Performance + +- **Duration:** ~20 min +- **Started:** 2026-07-12 +- **Completed:** 2026-07-12 +- **Tasks:** 2 +- **Files modified:** 3 + +## Accomplishments + +- **D-03c issuance write:** `ShareDialog.handleShare` calls `getSdkClient().addRecipientPubkeyPin(item.ipnsName, recipientPublicKey)` immediately after `sharesControllerCreateShare`, committing the pasted recipient pubkey to the node's owner-sealed write-body pin list for both read and write shares. The issuance-time wraps (:184/:205) remain untouched — the pin is first written here, so they stay exempt. +- **D-03d consumer 3 (upgrade path):** `ShareDialog.handleUpgrade` fetches the node's pins via `getRecipientPubkeyPins(item.ipnsName)` and calls the shared sdk-core `assertRecipientPinned` (reused through the facade — not reimplemented) BEFORE `resolveShareEncryptedWriteKey`. On mismatch/absent pin it throws into the existing upgrade-failure catch (fail-closed, no re-wrap). The server-fed `share.recipientPublicKey` decode is unchanged; only the re-wrap is gated. +- **owner-reconcile transport wiring:** replaced the module-level `webOwnerReconcileTransport` with a `makeWebOwnerReconcileTransport(shareRootIpnsName)` factory that adds `getRecipientPubkeyPins`, so `runOwnerReconcile`'s 80-07 `getPinsFn` seam resolves real pins instead of failing closed on the previously-absent optional method. + +## Task Commits + +Both tasks committed together in a single commit (the two ShareDialog edits + the transport wiring + the facade re-export are one cohesive fail-closed enforcement change), with the SUMMARY in the same commit per plan constraint 4. + +1. **Task 1 + Task 2: D-03c issuance write + D-03d upgrade compare + reconcile transport wiring** — see commit below (feat) + +## Files Created/Modified + +- `apps/web/src/components/file-browser/ShareDialog.tsx` — issuance pin write in `handleShare`; fail-closed `getRecipientPubkeyPins` + `assertRecipientPinned` compare in `handleUpgrade` before the re-wrap; added `bytesToBase64` (crypto) and `assertRecipientPinned` (sdk facade) imports. +- `apps/web/src/services/owner-reconcile.service.ts` — replaced the static transport with a per-pass `makeWebOwnerReconcileTransport(shareRootIpnsName)` factory that wires `getRecipientPubkeyPins`; updated both call sites (eager login sweep + opportunistic per-folder). +- `packages/sdk/src/index.ts` — D-07-compliant facade re-export of `assertRecipientPinned` from `@cipherbox/sdk-core`. + +## Decisions Made + +- Unconditional `addRecipientPubkeyPin` after `createShare` (covers both read and write branches with one call) — placed inside the existing `try` so a failure surfaces the user-facing error instead of a silently un-pinned share. +- Reused `assertRecipientPinned` via a new `@cipherbox/sdk` facade re-export rather than importing `@cipherbox/sdk-core` directly (blocked by the D-07 `no-restricted-imports` eslint rule) and rather than reimplementing the compare (prohibited by the plan). +- Per-pass transport factory closing over `shareRootIpnsName` to bridge the seam's `nodeId` key to the client's `ipnsName` key — each reconcile pass is scoped to exactly one root, making the mapping 1:1. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Reused assertRecipientPinned via a @cipherbox/sdk facade re-export** +- **Found during:** Task 2 (upgrade-path compare) +- **Issue:** The plan directed importing `assertRecipientPinned` for the web compare, but `apps/web/src` is blocked from importing `@cipherbox/sdk-core` directly by the D-07 `no-restricted-imports` eslint rule, and the facade did not yet re-export it. +- **Fix:** Added `export { assertRecipientPinned } from '@cipherbox/sdk-core';` to `packages/sdk/src/index.ts` (mirroring the existing `selectEncryptionMode` re-export) and imported it from `@cipherbox/sdk` in ShareDialog. No compare logic reimplemented. +- **Files modified:** packages/sdk/src/index.ts, apps/web/src/components/file-browser/ShareDialog.tsx +- **Verification:** `tsc -b` and `eslint` (incl. no-restricted-imports) pass on the touched web files; sdk facade builds clean. +- **Committed in:** part of the plan commit. + +--- + +**Total deviations:** 1 auto-fixed (1 blocking — necessary D-07-compliant wiring to reuse the helper). +**Impact on plan:** The facade re-export is a third modified file beyond the two in `files_modified`, but it is the minimal, precedent-following way to satisfy both "reuse the sdk-core helper" and the D-07 boundary. No API/DTO/DB change; no scope creep. + +## Issues Encountered + +- **Type bridging:** `client.getRecipientPubkeyPins` returns `Uint8Array[]` while `assertRecipientPinned` expects base64 `string[]` (its stored-pin encoding). Resolved by `pins.map(bytesToBase64)` in ShareDialog — the same normalization sdk-core's engine applies to `getPinsFn` output. The reconcile transport returns raw bytes as the seam expects (sdk-core normalizes internally). +- **No `typecheck` script on apps/web:** web typechecks via `tsc -b` (inside `build`). Ran `pnpm --filter @cipherbox/web exec tsc -b` directly for the type gate. + +## Verification Results + +- `pnpm --filter @cipherbox/web exec tsc -b` → exit 0 (clean) +- `eslint` on `ShareDialog.tsx` + `owner-reconcile.service.ts` → 0 problems (after the facade fix) +- `eslint` on `packages/sdk/src/index.ts` → 0 problems +- Dependency dists rebuilt (`@cipherbox/core`, `@cipherbox/sdk-core`, `@cipherbox/sdk`, `@cipherbox/api-client`) so the web typecheck sees the new facade export +- No `packages/api-client/` changes (no api:generate); no DB migration + +## Human Verification Required + +Per constraint 1 (apps/web has no unit tests; web-e2e is a main-push gate and was NOT run; sdk-e2e NOT run here), runtime confirmation is deferred: + +1. Create a share to a recipient pubkey → confirm the share succeeds and the node's pin list (via `getRecipientPubkeyPins`) includes that pubkey (D1). +2. Attempt a read→write upgrade with a tampered/mismatched `recipientPublicKey` → confirm the UI shows the fail-closed upgrade error and no re-wrap occurs (D2). + +The authoritative pre-ship gate is `tests/sdk-e2e` (live client→API IPNS round-trip); web-e2e runs on main push. + +## D-03d Wiring Confirmation (all three points in place) + +1. **Issuance pin write** — `ShareDialog.handleShare` → `addRecipientPubkeyPin(item.ipnsName, recipientPublicKey)` after createShare. ✅ +2. **Upgrade-path assertRecipientPinned** — `ShareDialog.handleUpgrade` → `getRecipientPubkeyPins` + `assertRecipientPinned` before `resolveShareEncryptedWriteKey`, fail-closed. ✅ +3. **web owner-reconcile getRecipientPubkeyPins transport** — `makeWebOwnerReconcileTransport(shareRootIpnsName).getRecipientPubkeyPins` → `client.getRecipientPubkeyPins`, satisfying the 80-07 `getPinsFn` seam end-to-end. ✅ + +## Next Phase Readiness + +- D-03d now has all three enforcement consumers wired (80-06 Rust, 80-07 TS re-mint, 80-08 web). The web issuance write (D-03c) and the third fail-closed consumer are complete. +- Pre-ship: `tests/sdk-e2e` must pass (key-lifecycle change) before this branch ships. + +--- +*Phase: 80-rotation-write-plane-and-re-mint-durability* +*Completed: 2026-07-12* diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md new file mode 100644 index 0000000000..e5bc55105b --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-CONTEXT.md @@ -0,0 +1,82 @@ +# Phase 80: Rotation Write-Plane and Re-Mint Durability - Context + +**Gathered:** 2026-07-12 +**Status:** Ready for planning + + +## Phase Boundary + +Close 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. + +Bounded by the four ROADMAP source todos: + +- `2026-07-11-rotation-republish-drops-write-sealed-body` (HIGH) — SC1 +- `2026-07-11-remint-refetches-sent-shares-per-rotated-node` — SC2 (perf half) +- `2026-07-11-remint-trusts-server-recipient-pubkey-binding` (MED) — SC2 (binding half) +- `2026-07-11-ts-rotatednodes-defensive-copy-parity` (LOW) — SC3 + +**Scope note:** The recipient-pubkey binding decision (D-03) deliberately expands this phase from a "closeout straggler" into a genuine sharing-crypto phase. This was chosen knowingly during discuss-phase (the alternative — documenting server-trusted recipient binding as an accepted risk — was declined). All other items are mechanical fixes, one with a verified prototype. + +**Depends on:** Phase 74 (made the FUSE re-mint path reachable), Phase 70.1. + + + + +## Implementation Decisions + +### SC1 — Rotation republish drops `write_sealed` body +- **D-01:** A scope-exit read-key rotation currently republishes every rotated node with `write_sealed: None` (the engine never populates it — read-key rotation is a read-plane op — and the FUSE adapter, a documented Phase-72 deferral, doesn't reconstruct it). This breaks `list_folder_owned` ("owned child … has no write_sealed body", observed 607× per run on macOS → the owner's background folder-metadata refresh permanently fails for any scope-exit-rotated shared subtree) AND is a **durability hole** (`replay.rs` can't recover the node's signing seed from the write body → after rotation + remount the owner may lose the ability to sign updates to the rotated subtree). +- **D-01a (fix):** In `ApiClientTransport::publish` (`crates/fuse/src/write_ops/rotation_deps.rs`), when `node.write_sealed` is `None`, **reconstruct** `NodeWriteBody` from the mount's in-memory `InodeTable` — the node's own **stable write key** + `ipns_private_key` + child `WriteChildRef`s rebuilt from the child inodes (child write keys are **read-key-rotation-independent**) — and re-seal under the node's write key at the node's **NEW generation** via `seal_node` (which shares the `ROLE_BODY` AAD with `seal_published_node`'s write-body path). Round-trip: unseal under the write key at the new generation recovers the write body + child refs. +- **D-01b (fallback):** Fail-open to `None` for a node **not locally materialized** (matches the existing signing-seed fail-closed lookup). Write-key *rotation* remains a separate Phase-72 concern — this only re-seals the **unchanged** write plane at the bumped generation. +- **D-01c (tests):** Unit tests for the reconstruction round-trip + the `None` fallback (were authored in the prototype). Prototype verified locally: the "no write_sealed body" flood drops **607→0**. + +### SC2 (perf) — Re-mint refetches `/shares/sent` per node +- **D-02:** `re_mint_grants_rooted_at` runs after **each** per-node commit during a rotation walk, and `query_grants_rooted_at` calls `collect_sent_shares()` (a full `GET /shares/sent`) every time → O(nodes × shares) network work. **Cache** the `collect_sent_shares()` result for the lifetime of a single rotation job and filter the cached list by `root_node_id` per node. Preserve the existing 0x-strip / hex-decode key parsing and per-share error handling. Mirror the optimization in the TS owner-reconcile `queryGrantsFn` for parity. +- **D-02a (acceptance):** A scope-exit rotation over an N-node subtree performs **≤1** `/shares/sent` fetch (not N); re-mint results unchanged (retained recipients re-minted, revoked recipients cut by **absence** — revoked shares are hard-deleted server-side). + +### SC2 (binding) — Re-mint trusts the ZK relay for recipient-pubkey identity +- **D-03:** **Pin the recipient pubkey end-to-end.** The recipient pubkey is authentic only at **issuance** (the owner pastes it out-of-band into ShareDialog; the server merely confirms a user exists via `lookupUser` and stores it). It becomes **server-trusted** whenever it round-trips back through the relay via `GET /shares/sent` — used by **three** consumers, all of which re-wrap the read key to the server-returned key without re-checking it: + 1. **Rust re-mint** — `rotation_deps.rs::query_grants_rooted_at` → `engine.rs::re_mint_grants_rooted_at` → `wrap_key(new_read_key, &grant.recipient_public_key)`. + 2. **TS re-mint** — `owner-reconcile.ts` `queryGrantsFn` → `sdk-core/rotation/engine.ts` wrap site. + 3. **Web upgrade/reconcile** — `owner-reconcile.service.ts` + the ShareDialog upgrade/downgrade path both read `share.recipientPublicKey` straight from the server-fed store and re-wrap to it. + + A compromised relay that substitutes the pubkey in any of these responses causes the owner to ECIES-wrap the fresh post-rotation read key **to the attacker** — a confidentiality break against the exact adversary the zero-knowledge model names as untrusted. This trust is **inherited** (initial issuance already trusts the relay for recipient identity binding); pinning only re-mint would be incoherent, so the fix must cover **all three** consumers. + +- **D-03a (storage):** Store the issuance-time recipient pubkey(s) in the **shared root node's owner-sealed `NodeWriteBody`** — already sealed + AAD-bound under the owner's write key and IPNS-published, so it is **server-opaque** and **cross-device** by construction (a re-mint on a different owner device than the issuing one can still verify). A node shared to N recipients holds N pins (a list). The existing wrapped `encryptedReadKey` can't help: ECIES doesn't let the owner recover/verify the recipient pubkey from the blob without the recipient's private key, so the pubkey must be stored owner-side at issuance. + +- **D-03b (schema):** Adding the pin field to `NodeWriteBody` is a **metadata-schema change** — follow `METADATA_EVOLUTION_PROTOCOL` + update `METADATA_SCHEMAS`, and maintain **Rust/TS CBOR parity** for the new field (this repo's cross-language contract-test discipline applies; see `[[project-cross-language-verification-parity-gotchas]]`). The Phase-78 offline recovery tool must **tolerate** the new `NodeWriteBody` field (ignore-unknown) — verify it does not fail-closed on the added field. + +- **D-03c (issuance write):** At grant creation, write the pasted recipient pubkey into the shared root node's `NodeWriteBody` pin list (alongside the existing server-side create-share call). + +- **D-03d (enforcement):** On **all three** round-trip consumers, compare the `/shares/sent`-returned pubkey against the pin and **fail closed on mismatch**. + +- **D-03e (no legacy):** There are **no legacy shares** — the staging env is reset to a clean slate at milestone completion / deployment, so only the forward-looking case exists. Therefore **a pin absent at re-mint/upgrade is an invariant violation → hard fail-closed** (not a migration case). No TOFU, no backfill, no migration versioning. + +- **D-03f (server untouched):** The pin is purely client-side owner-sealed. The server still stores/returns `recipient_public_key` for its own `lookupUser`/response path — we just stop *trusting* it. **No API/DTO change → no `pnpm api:generate`.** + +- **D-03g (file-share carve-out — post-hoc, added during phase-80 CI hardening):** D-03c/d/e as written **implicitly assumed folder/root shares**. A shared **FILE** is a leaf node with **no `NodeWriteBody`**, so it structurally **cannot carry an owner-sealed pin** — issuance already skips pinning files (`addRecipientPubkeyPin` is folder-only). The gap: a scope-exit rotation of a folder that merely **contains** a separately-shared file rotates that file node and runs `re_mint_grants_rooted_at` for it → `query_grants_rooted_at` returns the file's grant → the (structurally empty) pin list → **D-03e "0 pinned" hard fail → the whole rotation aborts** (partial rotation → AES-GCM cascade). **Resolution:** the pin-enforcement layer (`re_mint_grants_rooted_at` in Rust `engine.rs` + `reMintGrantsRootedAt` in TS `engine.ts`) **exempts `nodeKind === 'file'` from the pin check** and re-mints the file grant directly. This is **not** a downgrade of the folder-share guard (folders/roots stay fully fail-closed); it accepts the **pre-existing** file-share recipient-substitution limitation (files were never pinned at issuance either — tracked in `todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md` §5). D-03e should therefore read: *a pin absent at re-mint/upgrade for a **folder/root** grant is a hard fail-closed; **file-rooted grants are exempt** (no pin is structurally possible).* + +### SC3 — TS `rotatedNodes` defensive-copy parity +- **D-04:** The Rust engine `.clone()`s each node's key into an independent `Zeroizing<[u8;32]>` in `rotated_nodes`; the TS engine stores the **same `Uint8Array` reference** (`engine.ts:2064` root, `:2235` child), also aliased into `ParentTrackingState.parentNewReadKey`. Not a live bug today (`parentNewReadKey` is never zeroed), but a natural future D-09 tightening that zeroes it would silently zero the returned `rotatedNodes` entry → the FUSE consumer (`grant_scope.rs::refresh_rotated_inode_read_keys`) would refresh an inode read key to **all-zeros** → mis-decryption / data loss. Store a **defensive 32-byte copy**: `readKey: new Uint8Array(rootResult.childReadKey)` (root) and `new Uint8Array(result.childReadKey)` (child). Add a TS regression test asserting every `rotatedNodes` value's `readKey` is non-aliased with `parentNewReadKey`, non-zero, and equals the node's expected new key after `rotateReadFromNode`. + + + + +## Success Criteria (from ROADMAP) + +1. Rotation republish no longer emits `write_sealed: None` for rotated nodes — owned-walks and replay signing-seed recovery survive a read-key rotation, locked by a regression test. **(D-01)** +2. Scope-exit re-mint binds the new read key to a **verified** recipient public key (pinned/verified rather than blindly server-supplied) across all three round-trip consumers, and refetches `/shares/sent` once per rotation job (cached), not once per rotated node. **(D-02, D-03)** +3. TS `rotatedNodes` stores a defensive 32-byte copy of `readKey` (no aliasing with `parentNewReadKey`), matching Rust parity. **(D-04)** + + + + +## Relevant Memories / Prior Art + +- `[[project-fuse-scope-exit-rotation-stale-refresh-clobber]]` — the Part-D fix this bug was found orthogonally alongside (this is NOT the Part-D cause). +- `[[project-write-plane-keyed-by-uuid-read-plane-by-ipnsname]]` — write-plane (UUID) vs read-plane (ipnsName) threading discipline. +- `[[project-zeroization-callee-must-not-zero-reused-buffer]]` — zeroization ownership rules relevant to D-04. +- `[[project-cross-language-verification-parity-gotchas]]` — CBOR Rust/TS parity gotchas relevant to D-03b. +- `[[project-sdk-e2e-only-cross-package-publish-gate]]` — the gate to run before shipping IPNS/key-lifecycle changes. + + diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md new file mode 100644 index 0000000000..214b1d31f0 --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-PATTERNS.md @@ -0,0 +1,285 @@ +# Phase 80: Rotation Write-Plane and Re-Mint Durability - Pattern Map + +**Mapped:** 2026-07-12 +**Files analyzed:** 10 +**Analogs found:** 10 / 10 + +## Correction to phase brief + +The write-body wire format is **plaintext canonical JSON, not CBOR** +(`crates/core/src/node/encode.rs:110-124` `encode_write_body`, mirrored by +`packages/core/src/node/encode.ts:140-155` `encodeWriteBody`). It is then +AEAD-sealed as opaque bytes (`seal_node`/`seal_published_node`, ROLE_BODY +0x01). The "CBOR integer-key dup-key/float" gotcha in +`[[project-cross-language-verification-parity-gotchas]]` applies to a +*different* wire structure (IPNS records), not `NodeWriteBody`. The D-03b +cross-language parity test for the new pin field should mirror the +**existing JSON KAT pattern** below (`node_write_body_vectors.rs` / +`node-codec.json`), not a CBOR contract test. Plan accordingly — don't invent +a CBOR encoder for this field. + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|--------------------|------|-----------|-----------------|----------------| +| `crates/core/src/node/types.rs` (`NodeWriteBody`) | model | transform | itself (add field) | exact | +| `crates/core/src/node/encode.rs` / `decode.rs` | transform | transform | itself (add field, extend KAT) | exact | +| `crates/core/tests/node_write_body_vectors.rs` | test | transform | itself (extend vector) | exact | +| `packages/core/src/node/types.ts` / `encode.ts` / `decode.ts` | model/transform | transform | itself (add field) | exact | +| `tests/vectors/node-codec.json` | fixture | transform | itself (add `pins`/pin-list vector) | exact | +| `crates/fuse/src/write_ops/rotation_deps.rs::ApiClientTransport::publish` | service | request-response | `crates/fuse/src/write_ops/replay.rs` (write-body reconstruct + `seal_node`) | exact | +| `crates/fuse/src/write_ops/rotation_deps.rs::query_grants_rooted_at` | service | CRUD | itself (add caching) | exact | +| `crates/sdk/src/rotation/engine.rs::re_mint_grants_rooted_at` | service | event-driven | itself (add compare) | exact | +| `packages/sdk-core/src/rotation/engine.ts::reMintGrantsRootedAt` | service | event-driven | itself (add compare) | exact | +| `packages/sdk/src/share/owner-reconcile.ts::buildGrantRemintCallbacks` | service | CRUD | itself (add cache wrapper) | exact | +| `apps/web/src/services/owner-reconcile.service.ts` | service | request-response | itself (decode pattern reused) | exact | +| `apps/web/src/components/file-browser/ShareDialog.tsx` (issuance write) | component | request-response | itself (existing share-create call site) | exact | +| `apps/web/recovery-src/walk.ts` | utility | file-I/O | itself — consumes `@cipherbox/core` `unsealNode`, inherits parity automatically | exact | +| `crates/fuse/src/write_ops/grant_scope.rs::refresh_rotated_inode_read_keys` | service | event-driven | itself (D-04 consumer, no change needed — just verify) | exact | + +## Pattern Assignments + +### `crates/core/src/node/types.rs`, `encode.rs`, `decode.rs` (D-03b schema) + +**Analog:** itself — `NodeWriteBody` (`crates/core/src/node/types.rs:131-145`) + +```rust +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct NodeWriteBody { + #[serde(with = "base64_key")] + pub ipns_private_key: Vec, + pub write_children: Vec, +} +``` + +Add the pin list as a new field, e.g. `pub recipient_pins: Vec>` (or +hex/base64-encoded `String`s to match `recipient_public_key` handling +elsewhere — see `rotation_deps.rs:270-272` which strips `0x` and hex-decodes +server-supplied keys; store pins in the **same encoding convention** so the +compare in D-03d is a direct byte/hex comparison with no re-encoding step). + +Note `SealedChildRef` uses `#[serde(deny_unknown_fields)]` +(`types.rs:100`) but `NodeWriteBody` does **not** — this is intentional and +must be preserved: D-03 depends on `NodeWriteBody` tolerating unknown fields +so `apps/web/recovery-src` (Phase-78, pinned to an older schema) doesn't +fail-closed on the new field. Do not add `deny_unknown_fields` to +`NodeWriteBody`. + +**Encode pattern** (`crates/core/src/node/encode.rs:110-124`): + +```rust +/// FIXED field order (`ipnsPrivateKey` then `writeChildren`) so the output is +/// deterministic and, once sealed under the writeKey, byte-identical to the +/// frozen cross-language KAT... +pub fn encode_write_body(wb: &NodeWriteBody) -> Result, NodeError> { + serde_json::to_vec(wb).map_err(|_| NodeError::SerializationFailed) +} +``` + +Appending the new field to the struct changes the FIXED field order the KAT +depends on — the existing `write_body_seal_matches_kat` test +(`crates/core/tests/node_write_body_vectors.rs`) will need its oracle vector +in `tests/vectors/node-codec.json` regenerated/extended (add +`recipientPins` to `seal_vectors[].expected_published_node` or add a new +vector), not silently left stale. + +**TS mirror** (`packages/core/src/node/encode.ts:140-155`, +`decode.ts:317-345`, `types.ts:135-140`) — same field, same camelCase name, +same base64/hex convention. `decodeWriteBody` currently manually validates +`ipnsPrivateKey`/`writeChildren` shape (throwing `CryptoError` with code +`DECRYPTION_FAILED` on malformed input) — extend with the same +manual-validation style for the new field, defaulting to `[]` if absent +(never throwing) so older-schema documents (Phase-78 recovery tool consumer, +D-03e "no legacy" only applies to *shares*, not to bytes-on-disk written +before this phase) don't fail-closed on read. Fail-closed only applies at +the D-03d **compare** sites, not at decode. + +### Cross-language JSON KAT (D-03b test structure) + +**Analog:** `crates/core/tests/node_write_body_vectors.rs` (full file read, +53-123) + `tests/vectors/node-codec.json` `seal_vectors[]` + +Pattern to replicate for the new field's parity test: +- Load the same shared oracle `tests/vectors/node-codec.json` (`vectors_path()` helper, lines 17-21). +- Deserialize a `SealVector` struct mirroring the JSON shape (`#[serde(rename = "...")]` for camelCase JSON keys). +- Build a `NodeWriteBody` in Rust with the new field populated from the vector, call `encode_write_body`, seal with `encrypt_aes_gcm_aad` under the vector's `fixed_iv`/`write_key`, and assert byte-identical to `expected_published_node.write_sealed` (lines 97-121). +- Guard `!vectors.seal_vectors.is_empty()` — no vacuous pass (line 76-79). +- TS counterpart: `packages/core/src/__tests__/node-codec-vectors.test.ts` — same oracle file, asserts `encodeWriteBody`/`decodeWriteBody` byte-parity. Read that file's existing `write_sealed`/round-trip assertions before extending (not yet excerpted here — same vectors_path pattern as Rust, adjusted for `import.meta` / repo-root resolution). +- Also add a **round-trip unit test** in the `#[cfg(test)] mod write_body_tests` block already in `encode.rs:126-` (existing example: `write_body_round_trip_populated`, lines 132-139) — extend it to cover the new field non-empty AND empty (mirrors this repo's convention of testing both populated and default-empty variants). + +### `crates/fuse/src/write_ops/rotation_deps.rs::ApiClientTransport::publish` (D-01a reconstruct) + +**Analog:** same file, `FuseRotationDeps::publish` doc comment +(`rotation_deps.rs:371-378`) documents the `InodeTable` signing-key sourcing +pattern already used by `publish` (lines 417-496) for the **read** plane; +extend the same function for the **write** plane reconstruction. + +Fail-closed precedent to copy verbatim (lines 426-431): + +```rust +let signing_seed = find_ipns_private_key(self.inodes, ipns_name).ok_or_else(|| { + RotationError::RotateFailed(format!( + "publish: no locally-cached IPNS signing key for {ipns_name} \ + (node not materialized in the local inode table)" + )) +})?; +``` + +D-01b (fallback to `None` for a non-materialized node) is the **inverse** — +when the node/children aren't locally available, do NOT error; set +`node.write_sealed = None` and proceed (matches current behavior, so this is +an explicit opt-out path, not a new error). Use `InodeKind::Root { .. } | +InodeKind::Folder { .. }`'s `children` map (see `grant_scope.rs:613-628` for +the `InodeKind` match-arm idiom) to rebuild `WriteChildRef`s from child +inodes' write keys. + +**Seal call** — use `cipherbox_core::node::seal_node` (`crates/core/src/node/seal.rs:48`, shares `ROLE_BODY = 0x01` AAD with `seal_published_node`'s write arm at `seal.rs:169-192`) at the node's **new generation**, mirroring `publish`'s existing `create_ipns_record`/`upload_content` sequencing (lines 434-463) — reseal happens before `upload_content`, same as the read body. + +**D-01c tests** — add unit tests beside the existing `ApiClientTransport` tests in this module (check bottom of `rotation_deps.rs` for existing `#[cfg(test)]`) for: (1) reconstruct round-trip (unseal under write key at new generation recovers the write body/children), (2) `None` fallback for a non-materialized node. + +### `query_grants_rooted_at` caching (D-02) + TS `queryGrantsFn` (D-02a) + +**Analog:** `rotation_deps.rs:264-286` (current per-call fetch) — add a +`OnceCell`/`tokio::sync::OnceCell` or a plain `Option>` +field on `ApiClientTransport` (constructed once per rotation job — check the +job-scoped constructor, likely near `ApiClientTransport::new`/struct +definition ~line 379) so `collect_sent_shares()` (line 498-506) is called at +most once per job and `query_grants_rooted_at` filters the cached list by +`root_node_id` (existing filter logic at line 268 is unchanged — just swap +the fresh fetch for a cache read/populate). + +Preserve the existing per-share error handling exactly (lines 270-278: `0x` +strip, hex-decode, per-share `RotateFailed` on bad key) — do not change +error semantics, only add caching. + +**TS mirror:** `packages/sdk/src/share/owner-reconcile.ts::buildGrantRemintCallbacks` (lines 66-84) — `queryGrantsFn` currently calls `transport.listSentGrants()` fresh every invocation (line 71). Cache the `listSentGrants()` promise/result for the lifetime of the `runOwnerReconcile` call (function at lines 94-104) — e.g. lazily populate a closure-scoped variable in `buildGrantRemintCallbacks` shared across repeated `queryGrantsFn` calls within one reconcile pass. Same filter-by-`rootNodeId` logic stays (line 73). + +### Fail-closed pubkey pin compare (D-03d) — three consumers + +**Rust site 1 — `crates/sdk/src/rotation/engine.rs::re_mint_grants_rooted_at`** (lines 597-620): + +```rust +async fn re_mint_grants_rooted_at(...) { + ... + let wrapped = cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key) + .map_err(|e| RotationError::RotateFailed(format!( + "re_mint_grants_rooted_at: wrap_key failed for share {}: {e}", ... + )))?; +``` + +Insert the pin compare immediately before this `wrap_key` call: fetch the +root node's `NodeWriteBody.recipient_pins` (already unsealed as part of the +rotation walk — thread it through the same way `deps` already carries other +node state) and `RotateFailed` (same error type/format style) on mismatch or +absent-pin (D-03e: absent = hard fail, not TOFU). + +**Rust site 2 — `rotation_deps.rs::query_grants_rooted_at`** doesn't wrap +keys itself (that's the engine's job) — no compare needed there; the compare +belongs in the engine per D-03d ("all three round-trip consumers" = the +three **wrap** call sites, not the query call site). + +**TS site — `packages/sdk-core/src/rotation/engine.ts::reMintGrantsRootedAt`** +(lines 563-590, wrap call at line 587): + +```typescript +const wrappedBytes = await wrapKey(newReadKey, grant.recipientPublicKey); +``` + +Same insertion point — compare against the pin list before this call, throw +(mirror this file's existing `Error` construction style, e.g. line 2764's +`throw new Error('rotateWriteFromNode: wrapKey for co-writer failed', { cause: err })` pattern) on mismatch/absent. + +**Web site — `apps/web/src/components/file-browser/ShareDialog.tsx`** +(wrap calls at lines 184, 205) and **`owner-reconcile.service.ts`** (decode +at lines 57-69) — these are thin wrappers over the sdk-core/sdk functions +above; the compare should live in sdk-core/sdk (D-03d's "three consumers"), +not duplicated in the web layer, UNLESS the web ShareDialog upgrade/downgrade +path calls `wrapKey` directly without routing through `reMintGrantsRootedAt` +— confirm at the plan stage which of ShareDialog's two `wrapKey` call sites +(184, 205) are issuance-time (trusted, no pin exists yet) vs +reconcile/upgrade-time (must compare). Issuance-time calls are exempt (the +pin doesn't exist until this call writes it — D-03c). + +### Issuance write (D-03c) — where to write the pin + +**Site:** `apps/web/src/components/file-browser/ShareDialog.tsx` around the +existing share-create call (near line 184-222, where `recipientPublicKey` +is ECIES-wrapped and the share row is POSTed). Add a client-side step here +that updates the shared **root node's** `NodeWriteBody.recipient_pins` +(append this recipient's raw pubkey), then re-seals and re-publishes that +node's write body — reuse whatever "update this node's write body and +republish" helper `packages/sdk-core/src/folder/registration.ts` already +exposes for write-chain mutation (it already threads `WriteChildRef[]` +updates through a merge+republish flow, e.g. `registration.ts:379-397` +`mergedMap`/`byChildId` merge pattern) rather than hand-rolling a new +publish path. + +### D-04 — TS `rotatedNodes` defensive copy + +**Analog:** the fix is already spec'd exactly by CONTEXT.md D-04, and the +Rust side to mirror is `crates/sdk/src/rotation/engine.rs`'s +`Zeroizing<[u8;32]>` clone-per-node pattern (search `rotated_nodes` insert +sites in that file — same function family as `re_mint_grants_rooted_at`). + +**Current TS (to fix)** — `packages/sdk-core/src/rotation/engine.ts:2056-2059` (root): + +```typescript +rotatedNodes.set(rootNodeIpnsName, { + ipnsName: rootNodeIpnsName, + readKey: rootResult.childReadKey, + ... +}); +``` + +and the child-branch equivalent at `:2227-2231` (`result.childReadKey` +directly). Fix per D-04: `readKey: new Uint8Array(rootResult.childReadKey)` +/ `new Uint8Array(result.childReadKey)`. The file already has the exact +"defensive copy, not zeroed here" idiom to copy at line ~2068-2070 +(`parentOldReadKey: new Uint8Array(rootReadKey)` with the comment "a +defensive copy of the caller-owned rootReadKey... owned by this tracking +state so it can be safely zeroed on teardown below without touching the +caller's buffer") — replicate that exact comment style for the `rotatedNodes` +fix. + +**Regression test:** add near existing rotation-engine tests asserting +`rotatedNodes` values are non-aliased with `parentNewReadKey`, non-zero, and +equal to the node's expected post-rotation key (per D-04's spec). + +**Consumer — `crates/fuse/src/write_ops/grant_scope.rs::refresh_rotated_inode_read_keys`** +(lines 613-628) is the Rust consumer of the *Rust* `rotated_nodes` map +(already independently cloned via `Zeroizing`, no bug there) — this file +needs no change for D-04 itself; it's cited in scope only as the FUSE +consumer that a *future* TS-side zero-on-drop tightening would have broken. +Confirm no Rust changes needed here beyond an optional comment/test noting +the parity guarantee. + +## Shared Patterns + +### Fail-closed error style + +**Source:** `rotation_deps.rs:426-431`, `re_mint_grants_rooted_at` (engine.rs:610-615) + +All new fail-closed compares should use the same `RotateFailed(format!(": for : "))` message convention (Rust) and `throw new Error(': ', { cause })` (TS) already used throughout these two engines — do not introduce a new error type. + +### 0x-strip / hex-decode convention for recipient keys + +**Source:** `rotation_deps.rs:270-272`, `owner-reconcile.service.ts:57-59`, `ShareDialog.tsx:297-299` + +```typescript +const bareHex = share.recipientPublicKey.startsWith('0x') + ? share.recipientPublicKey.slice(2) + : share.recipientPublicKey; +``` + +Reuse this exact idiom (already duplicated 3x in TS, once in Rust) if the pin list is stored/compared as hex — apply consistently so the D-03d compare is a straight equality check with no encoding mismatch. + +## No Analog Found + +None — all 10 files have a strong same-file or same-role exact match (this phase is entirely modifications to existing rotation/write-plane machinery, no genuinely new subsystem). + +## Metadata + +**Analog search scope:** `crates/core/src/node/`, `crates/core/tests/`, `crates/fuse/src/write_ops/`, `crates/sdk/src/rotation/`, `packages/core/src/node/`, `packages/sdk-core/src/rotation/`, `packages/sdk/src/share/`, `apps/web/src/services/`, `apps/web/src/components/file-browser/`, `apps/web/recovery-src/` +**Files scanned:** ~20 (targeted reads/greps, no full-repo scan) +**Pattern extraction date:** 2026-07-12 diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md new file mode 100644 index 0000000000..fff7a0f8fc --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-RESEARCH.md @@ -0,0 +1,716 @@ +# Phase 80: Rotation Write-Plane and Re-Mint Durability - Research + +**Researched:** 2026-07-12 +**Domain:** Rust/TS cross-language sharing-crypto — NodeWriteBody re-sealing, ECIES re-mint, CBOR/JSON wire parity +**Confidence:** HIGH (all code sites read directly; no framework-selection ambiguity — this is a closed-codebase surgical phase) + +## Summary + +This phase touches four narrow, already-located code sites (D-01 through D-04) inside an +existing, well-tested `node/v3` codec and rotation-engine architecture. Three of the four +(D-01, D-02, D-04) are mechanical fixes to functions that already exist and already have +test scaffolding to extend. D-03 (the recipient-pubkey pin) is the one genuine net-new +design surface: it requires (a) a new optional field on `NodeWriteBody` with matching +Rust/TS wire-tolerance, (b) a **new SDK write path** to mutate-and-republish a shared +node's own write-body pin list at share-issuance time (no such mutation path currently +exists — `resolveShareEncryptedWriteKey` only *derives* the item's writeKey, it never +writes back to the write-body), and (c) three independent fail-closed comparison sites +threaded with access to that pin list. + +**Primary recommendation:** Sequence the four items D-01 → D-02 → D-04 → D-03, in that +order. D-01/D-02/D-04 are additive, low-risk, and unblock the D-03 cross-language vector +work (D-03's schema change is easiest to reason about once the write-body reconstruction +path (D-01) is already flowing real write-body content through `rotation_deps.rs`). D-03 +is the only item requiring new cross-language KAT vectors and a net-new SDK method +(`addRecipientPubkeyPin` or equivalent) — budget it as its own plan/wave. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Write-body reconstruction on rotation republish (D-01) | API/Backend (FUSE transport adapter, in-process) | — | `ApiClientTransport::publish` is the FUSE-mount-local write-plane assembly point; no server involvement | +| Sent-shares fetch caching (D-02) | API/Backend (SDK/rotation-engine callers) | — | Both Rust `FuseRotationDeps` and TS `owner-reconcile.ts` are the two callers issuing the redundant `GET /shares/sent` | +| Recipient-pubkey pin storage (D-03a) | Database/Storage (IPFS-sealed `NodeWriteBody`) | — | Owner-sealed, IPNS-published — server-opaque by construction, not a DB column | +| Recipient-pubkey pin enforcement (D-03d) | API/Backend (3 independent consumers: Rust FUSE, TS SDK, web service) | Browser/Client (ShareDialog upgrade/downgrade UI) | Each consumer independently re-wraps a key to a server-returned pubkey; each must independently verify against the pin before wrapping | +| TS `rotatedNodes` defensive copy (D-04) | API/Backend (`packages/sdk-core` rotation engine, pure in-memory) | — | No I/O; a buffer-aliasing correctness fix inside the TS rotation walk | + +## Standard Stack + +No new dependencies. This phase is 100% internal crypto/codec/engine surgery inside the +existing `node/v3` stack (`packages/core`, `crates/core`, `packages/sdk-core`, +`crates/sdk`, `crates/fuse`). No package installs, so `## Package Legitimacy Audit` is +not applicable — skipped. + +**Existing primitives this phase composes (verified in this session):** + +| Primitive | Location | Purpose | +|-----------|----------|---------| +| `seal_node` / `unseal_node` | `crates/core/src/node/seal.rs:48-74` | AES-256-GCM + AAD role `0x01` body seal, used by D-01's re-seal | +| `seal_published_node` | `crates/core/src/node/seal.rs:169-209` | Seals BOTH read+write bodies into a `PublishedNode`; explicit `write_body: Option<&NodeWriteBody>` param (never a `Node` field — Landmine 2 in the module doc, deliberate to avoid a D-02/D-07 core/sdk split) | +| `sealNode` / `unsealNode` (TS twin) | `packages/core/src/node/seal.ts:78-150` (approx; `unsealNode` at :121) | Same role, `writeKey` param is **optional** (line 124: `writeKey?: Uint8Array`) — write-body unseal is skipped entirely when omitted | +| `encodeWriteBody`/`decodeWriteBody` | `packages/core/src/node/encode.ts:140-155`, `packages/core/src/node/decode.ts:317-364` | Manual (non-schema-validated) JSON encode/decode of `NodeWriteBody` | +| `encode_write_body`/`decode_write_body` (Rust twin) | `crates/core/src/node/encode.rs:110-124`, `crates/core/src/node/decode.rs:113-118` | `serde_json::to_vec`/`from_slice` directly on the `NodeWriteBody` struct (no `deny_unknown_fields`) | +| `wrap_key`/`unwrap_key` (ECIES) | `cipherbox_crypto` (Rust), `@cipherbox/crypto` (TS) | The re-mint/re-wrap primitive at every D-03 enforcement site — never hand-roll | + +## Package Legitimacy Audit + +Not applicable — no external packages are added by this phase. + +## Architecture Patterns + +### System Architecture Diagram + +```text + ┌─────────────────────────────────────────┐ + │ Scope-exit read-key rotation walk │ + │ (crates/sdk/rotation/engine.rs / │ + │ packages/sdk-core/rotation/engine.ts) │ + └───────────────┬─────────────────────────┘ + │ per-node commit + ▼ + ┌────────────────────────────────────────────────────────┐ + │ rotate_one / rotateOne: mint readKey', reseal SealedChildRef│ + └───────────────┬──────────────────────┬───────────────┘ + │ │ + (D-01) publish path │ (D-02/D-03) re_mint_grants_rooted_at + ▼ ▼ + ┌────────────────────────────┐ ┌──────────────────────────────┐ + │ ApiClientTransport::publish │ │ query_grants_rooted_at() │ + │ (rotation_deps.rs) │ │ -> collect_sent_shares() │ + │ │ │ GET /shares/sent │ + │ if write_sealed==None: │ │ (D-02: cache once per job, │ + │ reconstruct NodeWriteBody │ │ filter by root_node_id) │ + │ from InodeTable, re-seal │ │ │ + │ via seal_node at NEW gen │ │ for each non-revoked grant: │ + │ (ROLE_BODY=0x01 AAD) │ │ (D-03) verify grant.recipient │ + │ fail-open->None if node │ │ _public_key against the pin │ + │ not locally materialized │ │ list read from THIS node's │ + └──────────────┬───────────────┘ │ own NodeWriteBody, THEN │ + │ │ wrap_key(new_read_key, pk) │ + ▼ └──────────────────────────────┘ + ┌────────────────────────────┐ + │ PublishedNode.write_sealed │ + │ (now populated) │ + └──────────────┬───────────────┘ + ▼ + ┌────────────────────────────────────────┐ + │ replay.rs::recover_signing_seed() │ + │ unseals write_sealed -> ipns_private_key │ + │ (D-01 durability consumer — the fix │ + │ closes the "cannot recover signing seed"│ + │ fail path) │ + └────────────────────────────────────────┘ + + ┌────────────────────────────────────────────────────────────┐ + │ D-04 (TS-only, in-memory, no I/O): │ + │ rotateReadFromNode's rotatedNodes.set(ipnsName, { │ + │ readKey: }) │ + │ engine.ts:2057 (root) / :2228 (child) — aliasing bug │ + │ Fix: wrap in `new Uint8Array(...)` at the .set() call only │ + └────────────────────────────────────────────────────────────┘ + + ┌────────────────────────────────────────────────────────────┐ + │ D-03 issuance write (NEW — no existing code path): │ + │ ShareDialog.tsx::handleShare() (apps/web) pastes recipient │ + │ pubkey -> MUST also write it into the shared root NODE's own │ + │ write-body pin list (a new SDK mutate+republish call, │ + │ sibling to the existing resolveShareEncryptedWriteKey which │ + │ only DERIVES, never WRITES, to a write-body) │ + └────────────────────────────────────────────────────────────┘ +``` + +### Recommended Project Structure + +No new files/folders — every change is inside existing modules: + +```text +crates/core/src/node/ +├── types.rs # D-03b: add recipient_pubkey_pins field to NodeWriteBody (Option>>, #[serde(default)]) +├── encode.rs # D-03b: encode_write_body — conditionally emit pin field +├── decode.rs # D-03b: decode_write_body — tolerate absent field (already tolerant: no deny_unknown_fields) +├── seal.rs # unchanged — seal_published_node already takes write_body: Option<&NodeWriteBody> + +crates/fuse/src/write_ops/ +├── rotation_deps.rs # D-01 fix (ApiClientTransport::publish), D-02 fix (cache collect_sent_shares), D-03 enforcement (query_grants_rooted_at) +├── grant_scope.rs # D-04 downstream consumer (refresh_rotated_inode_read_keys) — READ ONLY, Rust side already correct (Zeroizing clone) + +crates/fuse/src/replay.rs # D-01 durability consumer (recover_signing_seed) — no code change, just a regression test target + +crates/sdk/src/rotation/engine.rs # D-02/D-03 fix (re_mint_grants_rooted_at, query_grants_rooted_at trait default) + +packages/core/src/node/ +├── types.ts # D-03b: add recipientPubkeyPins?: string[] (or Uint8Array[]) to NodeWriteBody +├── encode.ts # D-03b: encodeWriteBody — conditionally emit pin field +├── decode.ts # D-03b: decodeWriteBody — tolerate absent field (already manual/tolerant) + +packages/sdk-core/src/rotation/engine.ts # D-02 fix (queryGrantsFn caching contract), D-03 enforcement (reMintGrantsRootedAt), D-04 fix (rotatedNodes.set defensive copy at :2057/:2228) + +packages/sdk/src/share/owner-reconcile.ts # D-02 mirror (buildGrantRemintCallbacks caching), D-03 enforcement +packages/sdk/src/client.ts # D-03c: NEW method to write the pin into a node's own write-body (sibling to resolveShareEncryptedWriteKey ~:3839) + +apps/web/src/services/owner-reconcile.service.ts # D-03 enforcement (3rd consumer) +apps/web/src/components/file-browser/ShareDialog.tsx # D-03c issuance write (handleShare ~:162) + D-03d enforcement (upgrade path ~:286-327) + +tests/vectors/node-codec.json # D-03b: NEW seal_vector entry with a non-empty pin list (lockstep discipline) +docs/METADATA_SCHEMAS.md # D-03b: document the new NodeWriteBody field, bump version-history table +``` + +### Pattern 1: Fail-open reconstruction with an explicit "not materialized" boundary (D-01) + +**What:** `ApiClientTransport::publish` must reconstruct `NodeWriteBody` only from data the +FUSE mount already has plaintext access to in-memory (`InodeTable`), and return `None` for +`write_sealed` rather than erroring when the node isn't locally materialized. + +**When to use:** Any time a republish needs write-plane data the current transport layer +wasn't designed to carry, and a graceful degradation (not a hard failure) is the existing +project convention for "node not in local cache" (see `find_ipns_private_key`, +`crates/fuse/src/write_ops/rotation_deps.rs:555-576`, which already returns `Option` for +exactly this reason). + +**Example — the existing sibling helper to mirror for reconstruction:** +```rust +// Source: crates/fuse/src/write_ops/rotation_deps.rs:552-576 +fn find_ipns_private_key(inodes: &InodeTable, ipns_name: &str) -> Option>> { + inodes.inodes.values().find_map(|inode| { + let (candidate_name, key) = match &inode.kind { + InodeKind::Root { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key), + InodeKind::Folder { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key), + InodeKind::File { ipns_name, ipns_private_key, .. } => (ipns_name, ipns_private_key), + }; + (candidate_name == ipns_name && !key.is_empty()).then(|| Zeroizing::new(key.to_vec())) + }) +} +``` +D-01's reconstruction helper should follow the identical `inodes.inodes.values().find_map` +shape, additionally pulling the node's own **stable write key** and rebuilding each child's +`WriteChildRef` from the child inodes' cached write keys (documented as +"read-key-rotation-independent" in CONTEXT D-01a). The `InodeKind::{Root,Folder,File}` +variants already carry `ipns_private_key`; confirm during planning whether they also cache +a `write_key` field and child write-key material — if not, this is the actual scope +boundary of D-01a (grep `InodeKind` definition in `crates/fuse/src/inode.rs` before +planning the exact reconstruction fields). + +### Pattern 2: Single-fetch-per-job caching via an owned cache field, not a static/global (D-02) + +**What:** `collect_sent_shares()` (a full `GET /shares/sent`) is currently called once +**per rotated node** from both `query_grants_rooted_at` (Rust, `rotation_deps.rs:264-286`) +and `queryGrantsFn` (TS, `owner-reconcile.ts:66-84` calling `transport.listSentGrants()` +fresh every invocation). Both call sites are simple pass-throughs with no caching layer. + +**When to use:** Any per-job (not per-request) invariant data set that a walk re-fetches on +every per-node callback. + +**Fix shape:** Add a cache field to `FuseRotationDeps` (Rust) — e.g. an +`Arc>>` or a simple `RefCell>>` +scoped to the lifetime of a single `rotate_read_from_node` call — populated on first +`query_grants_rooted_at` call, reused thereafter, filtered by `root_node_id` per call. For +TS, `buildGrantRemintCallbacks` (owner-reconcile.ts:66) needs the equivalent: the returned +`queryGrantsFn` closure should memoize `transport.listSentGrants()`'s promise on first +call (a simple `let cached: Promise | undefined` closed over by the returned +function object satisfies this — no new dependency needed). + +**Landmine:** `FuseRotationDeps` is constructed once per rotation call site in the current +test suite (`FuseRotationDeps::new(transport, owner_pub, owner_priv, floor_store)`, +`rotation_deps.rs:177-191`) — confirm whether a single `FuseRotationDeps` instance is reused +across an ENTIRE rotation job (good — cache lives for the job) or reconstructed per-node +(bad — cache would reset every node, defeating D-02). Grep the call site in +`crates/fuse/src/write_ops/` that constructs `FuseRotationDeps` before assuming instance +lifetime; if it's per-call, the cache must be threaded through the injected `deps` at a +higher scope or invalidated per rotation-job-id, not per-instance. + +### Pattern 3: Fail-closed pin comparison at three independent consumer sites (D-03d) + +**What:** Every site currently does `wrapKey(newReadKey, grant.recipientPublicKey)` (or the +Rust `wrap_key(new_read_key, &grant.recipient_public_key)`) with NO verification that +`recipientPublicKey` is the one the owner actually issued the share to. D-03 requires +inserting a pin comparison immediately before each wrap, using the pin list read from the +node's OWN `NodeWriteBody` (not from the server response). + +**Confirmed exact wrap call sites (verified this session):** + +| # | Location | Line | Trust boundary crossed | +|---|----------|------|------------------------| +| 1 | `crates/sdk/src/rotation/engine.rs` `re_mint_grants_rooted_at` | :610 | `cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key)` | +| 2 | `packages/sdk-core/src/rotation/engine.ts` `reMintGrantsRootedAt` | :587 | `await wrapKey(newReadKey, grant.recipientPublicKey)` | +| 3 | `apps/web/src/components/file-browser/ShareDialog.tsx` upgrade path | :306 | `wrapKey(rootWriteKey??..., recipientPublicKey)` — recipientPublicKey decoded straight from `share.recipientPublicKey` (server-fed store, line 297-300) with **no pin check** | + +**Also relevant (write-plane co-writer re-wrap, same trust pattern, different function):** +`crates/sdk/src/rotation/engine.rs:2762` (`rotateWriteFromNode`'s co-writer re-wrap) — +confirm during planning whether D-03's scope includes this write-revocation co-writer +re-wrap or is strictly the read-rotation re-mint path; CONTEXT's three named consumers +(rotation_deps.rs/engine.rs Rust, owner-reconcile.ts/sdk-core engine.ts TS, web +owner-reconcile.service.ts + ShareDialog.tsx) do not explicitly name this write-plane +site — flag as an Open Question below. + +**Where the pin list must come from:** the pin lives on **the shared root node's own +`NodeWriteBody`** (D-03a) — i.e. the SAME node currently being processed by +`re_mint_grants_rooted_at(node_id, ...)`/`reMintGrantsRootedAt`. Today, neither function +receives that node's write-body content — they only receive `node_id`, `new_read_key`, +`new_generation`, and (via the grant query) the grant rows. **This is the one non-mechanical +design gap in the phase** — see Open Questions. + +### Pattern 4: Defensive copy at the collection boundary, not the mutation boundary (D-04) + +**What:** The bug is specifically that `rotatedNodes.set(...)` stores the SAME +`Uint8Array` object reference that `ParentTrackingState.parentNewReadKey` also holds — not +that the key is computed wrong. The fix is narrowly scoped to the `.set()` call, not to +where `childReadKey`/`parentNewReadKey` is minted or consumed elsewhere in the 2700-line +engine. + +**Confirmed exact lines (verified this session, corrects CONTEXT's approximate line +numbers):** + +```typescript +// Source: packages/sdk-core/src/rotation/engine.ts:2055-2060 (root branch) +rotatedNodes.set(rootNodeIpnsName, { + ipnsName: rootNodeIpnsName, + readKey: rootResult.childReadKey, // <-- D-04 bug: same ref as line 2066 + generation: rootResult.newGeneration, + sequenceNumber: rootResult.newSequenceNumber, +}); +// ... +rootParentState = { + parentNewReadKey: rootResult.childReadKey, // :2066 — SAME object + ... +}; +``` +```typescript +// Source: packages/sdk-core/src/rotation/engine.ts:2226-2231 (BFS child branch) +rotatedNodes.set(item.childRef.ipnsName, { + ipnsName: item.childRef.ipnsName, + readKey: result.childReadKey, // <-- D-04 bug: same ref as line 2287 + generation: result.newGeneration, + sequenceNumber: result.newSequenceNumber, +}); +// ... +thisNodeParentState = { + parentNewReadKey: result.childReadKey, // :2287 — SAME object + ... +}; +``` + +**Fix:** `readKey: new Uint8Array(rootResult.childReadKey)` and +`readKey: new Uint8Array(result.childReadKey)` at the two `.set()` calls ONLY — leave +`parentNewReadKey: rootResult.childReadKey` / `parentNewReadKey: result.childReadKey` +untouched (that live reference is what the walk actively uses to seal children; D-09's +terminal-owner rule means only the RETURNED map should be independently owned). + +**`RotatedNodeKey` type** (`packages/sdk-core/src/rotation/engine.ts:345-350`): +```typescript +export type RotatedNodeKey = { + ipnsName: string; + readKey: Uint8Array; + generation: number; + sequenceNumber: bigint; +}; +``` +Rust twin: `crates/sdk/src/rotation/engine.rs` (search `RotatedNodeKey`) — already +`.clone()`s into `Zeroizing<[u8;32]>` per CONTEXT D-04, confirmed correct; no Rust change +needed. + +**Regression test target:** `refresh_rotated_inode_read_keys` (Rust, +`crates/fuse/src/write_ops/grant_scope.rs:613-638`) is the FUSE consumer that would +mis-decrypt on a future TS-side zero-write bug — but it's Rust-only, so the actual D-04 +regression test is TS-only (per CONTEXT: "Add a TS regression test asserting every +`rotatedNodes` value's `readKey` is non-aliased with `parentNewReadKey`"). + +### Anti-Patterns to Avoid + +- **Bumping `generation` to update the write-body pin list (D-03c):** `generation` is the + READ-KEY rotation clock (`docs/METADATA_SCHEMAS.md` §10 invariants table). Writing a new + pin into `NodeWriteBody` does NOT require touching the read-body or its generation — the + read-body and write-body are independently sealed under the SAME AAD generation value + (`seal_published_node` seals both under `node.generation()`), but re-sealing the + write-body alone with the CURRENT generation, republishing an updated `PublishedNode` + with an unchanged `readSealed` and a new `writeSealed`, is a valid, self-consistent + operation. Do not invent a "pin generation" counter. +- **Re-implementing AEAD or hand-rolling the pin's AAD binding:** the pin list is just + another field inside the existing `NodeWriteBody` JSON body, sealed by the EXISTING + `seal_node`/`seal_aes_gcm_aad(..., ROLE_BODY)` call — no new role byte, no ADR 0003 + amendment needed (confirmed: adding a field to an already-role-`0x01`-sealed body does + not require a new AAD role, unlike adding a NEW independently-sealed sub-object). +- **`deny_unknown_fields` on `NodeWriteBody` (Rust):** confirmed absent today + (`crates/core/src/node/types.rs:136-145` has no `#[serde(deny_unknown_fields)]`, unlike + `SealedChildRef` at `:100` which explicitly has it). Do NOT add `deny_unknown_fields` to + `NodeWriteBody` when adding the pin field — that would make the type forward-INcompatible + and contradict the additive-change contract in `METADATA_EVOLUTION_PROTOCOL.md` §3.1. + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| ECIES key wrap for the re-minted read key | A custom secp256k1/AES hybrid wrap | `cipherbox_crypto::wrap_key` / `@cipherbox/crypto`'s `wrapKey` | Already the mandated primitive at all 3+ existing call sites (T-64-04c parity comment explicit in `engine.rs:594-596`) | +| AEAD sealing of the new pin field | A separate encryption pass for just the pin list | The EXISTING `seal_node`/`sealNode` role-`0x01` body seal (the pin rides inside the same `NodeWriteBody` JSON that's already sealed) | AAD/role-byte table is FROZEN (ADR 0003) — adding a new role for a field-level change is unnecessary and would require a KAT extension for no benefit | +| Sent-shares fetch caching | A new global/static cache singleton | A per-job-scoped field on `FuseRotationDeps`/the `queryGrantsFn` closure | Global caches leak stale data across rotation jobs and complicate testing (the codebase already prefers injectable seams — see `RotationTransport`, `RotationDeps` traits) | + +**Key insight:** every primitive this phase needs (AEAD seal, ECIES wrap, InodeTable +lookup-by-ipns-name) already exists and is exercised by directly adjacent code in the same +files. The only genuinely new code is (1) the reconstruction logic inside D-01, (2) the +cache field inside D-02, (3) the pin-list plumbing + a new SDK write method inside D-03, +and (4) a one-line `new Uint8Array(...)` wrap for D-04. + +## Common Pitfalls + +### Pitfall 1: Adding the pin field breaks the FULL-SEAL cross-language vector silently + +**What goes wrong:** `tests/vectors/node-codec.json`'s single `seal_vectors[0]` entry +locks the EXACT byte output of `encodeWriteBody`/`encode_write_body` for a write-body with +`writeChildren: []` and no pin field. If the pin field is added to the wire JSON +unconditionally (even as `[]`), the frozen `expected_published_node.writeSealed` base64 +string changes and BOTH the TS test (`packages/core/src/__tests__/node-codec-vectors.test.ts:229-257`) +and the Rust `cross_language.rs` seal-vector assertion break. + +**Why it happens:** `encodeWriteBody` (`packages/core/src/node/encode.ts:140-155`) +currently builds `wireBody = { ipnsPrivateKey, writeChildren }` as a plain object literal — +adding a third key unconditionally changes every output, even when the pin list is empty. + +**How to avoid:** Only include the pin field in the wire object when non-empty/present +(mirror the existing `skip_serializing_if` convention used elsewhere in this codebase for +optional array fields), so the EXISTING zero-pin fixture's bytes are preserved exactly, and +add a SECOND `seal_vectors[1]` entry (with a non-empty pin) as the new lockstep vector both +TS and Rust must assert against — per `METADATA_EVOLUTION_PROTOCOL.md` §6.2/§6.4's +explicit lockstep rule ("Extending one without the other is forbidden"). + +**Warning signs:** `cargo test -p cipherbox-crypto` or `pnpm test` in `packages/core` +failing on the EXISTING `folder node writeSealed base64 matches frozen vector` test with no +apparent code change to seal.ts/seal.rs — check `encodeWriteBody`'s field-inclusion logic +first. + +### Pitfall 2: `NodeWriteBody`'s TS type change breaks the existing literal-object test fixtures + +**What goes wrong:** `packages/core/src/__tests__/node-codec-vectors.test.ts:244` and +`:340-344` construct `writeBody: { ipnsPrivateKey, writeChildren: [] }` object literals +directly. If the new pin field is added as a REQUIRED TS field +(`recipientPubkeyPins: string[]`), these literals fail to type-check. + +**Why it happens:** TS structural typing enforces every field on an object literal +assigned to a typed variable. + +**How to avoid:** Make the field optional in the TS type (`recipientPubkeyPins?: string[]`) +— consistent with `writeBody?` itself being optional on `Node`, and with the +additive-field convention. Existing test literals then continue to compile unchanged. + +### Pitfall 3: The Phase-78 recovery tool concern (D-03b) is likely a non-issue — verify, don't assume work is needed + +**What goes wrong:** CONTEXT flags "the Phase-78 offline recovery tool must tolerate the +new NodeWriteBody field" as a verification item. Investigation this session +(`apps/web/recovery-src/walk.ts:22,162`) shows the recovery tool calls the SAME production +`unsealNode(published, childReadKey)` from `@cipherbox/core` with **only the readKey +argument** — `writeKey` is optional (`packages/core/src/node/seal.ts:124`, +`writeKey?: Uint8Array`) and when omitted, `unsealNode` skips write-body unsealing +entirely (`seal.ts:142`, `if (published.writeSealed && writeKey)`). The recovery tool +never has write-key material (by design — it's a read-only disaster-recovery path) and +therefore never parses `NodeWriteBody` — with or without the pin field. + +**How to avoid:** Do not add speculative recovery-tool changes. Confirm this finding with a +direct grep for `writeKey`/`writeSealed`/`NodeWriteBody` in `apps/web/recovery-src/` during +planning (already done this session — zero matches), then close this checklist item as +"verified no-op, not applicable" rather than writing dead code. + +### Pitfall 4: D-03's issuance write (D-03c) has no existing SDK mutation path — this is new surface, not a wire-up + +**What goes wrong:** `ShareDialog.tsx::handleShare` (`apps/web/src/components/file-browser/ShareDialog.tsx:162-218`) +currently only calls `resolveShareEncryptedWriteKey` (`packages/sdk/src/client.ts:3839`), +which **derives** the item's writeKey by walking the parent's write-chain +(`walkChildWriteKey`) — it never mutates or republishes the item's own write-body. There is +currently **no SDK method that appends to a node's own `NodeWriteBody.recipientPubkeyPins` +and republishes it**. Planning this as "wire the pin into the existing create-share call" +will underestimate the work — it requires: unsealing the item's CURRENT write-body (if any +— items shared for the first time may have no `writeBody` at all yet, since it's `Option` +in Rust / optional in TS), appending the new pin, re-sealing via `seal_published_node` +(Rust) / `sealNode` (TS) at the item's CURRENT generation, and CAS-publishing the updated +`PublishedNode`. + +**Why it happens:** The existing write-chain code (`getWriteBodyParams`, +`walkChildWriteKey` in `packages/sdk/src/write-body-params.ts`) is read-oriented (resolve a +descendant's writeKey), not a write-body mutation API. + +**How to avoid:** Budget D-03c as a genuinely new SDK method +(e.g. `client.ts::addRecipientPubkeyPin(itemIpnsName, recipientPublicKey)` or fold it into +a parameter on a new share-creation SDK wrapper), following the SAME CAS-publish pattern +already used by `updateFolderMetadataAndPublish` / the rotation engine's own republish +calls — not a bolt-on to `resolveShareEncryptedWriteKey`. Flag as its own plan/task, not a +one-line change alongside D-03d's read-side enforcement. + +### Pitfall 5: `is_revoked` is always `false` from `GET /shares/sent` — the pin-mismatch fail-closed path must not be conflated with revocation + +**What goes wrong:** Both `query_grants_rooted_at` (Rust) and `queryGrantsFn` (TS) source +`GrantRow.isRevoked`/`is_revoked` from `GET /shares/sent`, which — per the project's +hard-delete revocation convention (confirmed in `rotation_deps.rs:262-263` comment and +`owner-reconcile.service.ts:38-42` comment) — NEVER returns a revoked row at all (revoked = +row deleted server-side). A pin MISMATCH is a DIFFERENT failure mode (compromised-relay +substitution, not owner-initiated revocation) and must fail the ENTIRE rotation/re-mint +operation closed (per D-03e: "hard fail-closed invariant violation"), not silently skip +that one grant the way `is_revoked` skip does. + +**How to avoid:** Model the pin check as a hard `Err`/`throw` that aborts +`re_mint_grants_rooted_at`/`reMintGrantsRootedAt` for the WHOLE node (or even the whole +job, per planner discretion — CONTEXT doesn't specify granularity), never as a per-grant +skip-and-continue like the `is_revoked` branch. + +## Code Examples + +### Verified pattern: fail-closed InodeTable lookup returning `Option` + +```rust +// Source: crates/fuse/src/write_ops/rotation_deps.rs:582-599 (find_grant_root_state) +// Mirrors the shape D-01's write-body reconstruction lookup should follow. +pub(crate) fn find_grant_root_state( + inodes: &InodeTable, + ipns_name: &str, +) -> Option<(String, Zeroizing<[u8; 32]>)> { + inodes.inodes.values().find_map(|inode| match &inode.kind { + InodeKind::Root { ipns_name: n, read_key, .. } if n == ipns_name => + Some((inode.node_id.clone(), Zeroizing::new(**read_key))), + InodeKind::Folder { ipns_name: n, read_key, .. } if n == ipns_name => + Some((inode.node_id.clone(), Zeroizing::new(**read_key))), + _ => None, + }) +} +``` + +### Verified pattern: the exact D-01 durability failure this phase closes + +```rust +// Source: crates/fuse/src/replay.rs (recover_signing_seed, ~:261-297) +fn recover_signing_seed( + published: &PublishedNode, + write_key: &[u8; 32], + kind: NodeKind, +) -> Result { + let write_sealed = published.write_sealed.as_ref().ok_or_else(|| { + /* error: "node {} has no write_sealed body — cannot recover signing seed + — retaining entry" */ + })?; + // ... unseal + decode_write_body(...) -> Zeroizing::new(write_body.ipns_private_key) +} +``` +This is the EXACT error path the 607→0 prototype fix (D-01) eliminates. + +### Verified pattern: the D-03d wrap call sites needing a pin check inserted + +```rust +// Source: crates/sdk/src/rotation/engine.rs:597-626 (re_mint_grants_rooted_at) +async fn re_mint_grants_rooted_at( + deps: &D, node_id: &str, new_read_key: &[u8; 32], new_generation: u32, +) -> Result<(), RotationError> { + let grants = deps.query_grants_rooted_at(node_id).await?; + for grant in grants { + if grant.is_revoked { + deps.delete_grant(&grant.share_id).await?; + } else { + // D-03d insertion point: verify grant.recipient_public_key against + // the pin list BEFORE this wrap_key call. + let wrapped = cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key)?; + let encrypted_read_key = hex::encode(&wrapped); + deps.update_grant(&grant.share_id, &encrypted_read_key, new_generation).await?; + } + } + Ok(()) +} +``` + +```typescript +// Source: packages/sdk-core/src/rotation/engine.ts (reMintGrantsRootedAt, ~:563-590) +export async function reMintGrantsRootedAt( + nodeId, newReadKey, newGeneration, job, ctx, callbacks?: GrantRemintCallbacks +) { + const grants = await callbacks.queryGrantsFn(nodeId); + for (const grant of grants) { + if (grant.isRevoked) { + await callbacks.deleteGrantFn(grant.shareId); + } else { + // D-03d insertion point. + const wrappedBytes = await wrapKey(newReadKey, grant.recipientPublicKey); + // ... + await callbacks.updateGrantFn(grant.shareId, encryptedReadKey, newGeneration); + } + } +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|---------------|--------| +| Rotation republish drops `write_sealed` | D-01 reconstructs it from `InodeTable` | This phase | Fixes the 607×/run `list_folder_owned` flood AND the signing-seed-recovery durability hole | +| `GET /shares/sent` per rotated node (O(nodes×shares)) | D-02 caches once per rotation job | This phase | O(1) network fetch per job instead of O(nodes) | +| Server-trusted `recipient_public_key` at re-mint | D-03 pins the issuance-time pubkey inside the owner-sealed `NodeWriteBody` | This phase | Closes a confidentiality break where a compromised relay substitutes the recipient at re-mint time | +| TS `rotatedNodes` aliases `parentNewReadKey` | D-04 defensive 32-byte copy | This phase | Prevents a FUTURE zeroization tightening from silently zeroing the returned map | + +**Deprecated/outdated:** None — this phase does not retire any prior schema or API; it is +purely additive (new optional `NodeWriteBody` field) plus internal correctness fixes. + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | `re_mint_grants_rooted_at`/`reMintGrantsRootedAt` need a NEW parameter or seam method to receive the current node's pin list (no existing plumbing carries write-body content into these functions) | Pattern 3, Open Questions | If the planner instead threads pins via a different mechanism (e.g., the caller pre-filters grants before calling), the exact function signature change described here would be wrong — but the underlying gap (no current pin access) is verified fact, not assumption | +| A2 | `InodeKind::{Root,Folder,File}` variants may not currently cache a per-node `write_key`/child write-key material needed for D-01's full reconstruction — flagged, not confirmed by reading `inode.rs` this session | Pattern 1 | If they DO already cache this (likely, since `ipns_private_key` is cached), D-01 is simpler than described; if not, D-01 needs an additional InodeTable field, expanding scope | +| A3 | D-03's three named consumers do NOT include `rotateWriteFromNode`'s co-writer re-wrap (`engine.rs:2762`), based on CONTEXT's explicit enumeration of exactly 3 consumers (Rust re-mint, TS re-mint, web upgrade/reconcile) | Pattern 3 | If write-revocation co-writer re-wrap is actually in scope, a 4th enforcement site is needed — see Open Questions | + +**None of these are HIGH-risk to the phase's core mechanics** — all are scoping-boundary +questions for the planner to resolve with the CONTEXT author, not open technical unknowns. + +## Open Questions + +1. **How does `re_mint_grants_rooted_at`/`reMintGrantsRootedAt` obtain the pin list for + the node it's currently processing?** + - What we know: the pin lives in "the shared root node's owner-sealed `NodeWriteBody`" + (D-03a) — i.e., the pin for a given `share_id`'s grants is stored on the node whose + `id == root_node_id` for that grant (the same node `re_mint_grants_rooted_at(node_id, ...)` + is invoked for, since `query_grants_rooted_at` filters `root_node_id == node_id`). + - What's unclear: neither function currently receives that node's write-body content — + only `node_id: &str`/`nodeId: string`, `new_read_key`, `new_generation`. The caller + (`rotate_one`/`rotateOne`) DOES have (or can obtain) the node's writeKey during a + rotation walk (the owner always holds it for owned nodes), but the write-body + content itself isn't currently threaded to this call. + - Recommendation: the cleanest fix is a new `RotationDeps`/`RotationTransport` method + (mirroring the D-01 pattern of "read from the already-mounted `InodeTable`") — e.g. + `get_recipient_pubkey_pins(node_id) -> Vec>` on the Rust side, resolved by + `FuseRotationDeps` from the InodeTable's in-memory write-body cache (same source D-01's + reconstruction reads from). For TS, thread an equivalent optional parameter/callback + into `reMintGrantsRootedAt`'s `GrantRemintCallbacks` shape (a new + `getPinsFn?: (nodeId: string) => Promise`). Confirm this shape with the user + during `/gsd-discuss-phase` if not already settled — this is the one place CONTEXT's + locked decisions don't fully specify a call-site mechanism. + +2. **Is `rotateWriteFromNode`'s co-writer re-wrap (`crates/sdk/src/rotation/engine.rs:2762`) + in scope for D-03d's fail-closed pin check?** + - What we know: it has the IDENTICAL trust pattern (`wrap_key(rootResult.newWriteKey, + grant.recipient_public_key)` with no pin verification) but operates on the WRITE + chain (write-revocation), not the READ chain (scope-exit rotation) this phase's SC1-3 + bullets describe. + - What's unclear: CONTEXT names exactly 3 consumers and doesn't mention this 4th site. + - Recommendation: treat as out of scope for Phase 80 unless the planner/user confirms + otherwise — flag it as a follow-up todo (mirrors the phase's own "closeout straggler" + todo-sourcing convention) rather than silently expanding D-03's surface. + +3. **Does `FuseRotationDeps` get reconstructed once per rotation job or once per node?** + (D-02 cache-lifetime correctness — see Pattern 2's Landmine.) Grep the call site in + `crates/fuse/src/write_ops/` (search for `FuseRotationDeps::new`) before finalizing the + D-02 cache-field design. + +## Environment Availability + +Not applicable — no external tools/services/runtimes are newly required by this phase. +All work is inside the existing Rust workspace (`cargo test -p cipherbox-core -p +cipherbox-crypto -p cipherbox-fuse -p cipherbox-sdk`) and TS workspace (`pnpm test` in +`packages/core`, `packages/sdk-core`, `packages/sdk`, `apps/web`) plus the existing +`tests/sdk-e2e` suite. + +## Validation Architecture + +### Test Framework + +| Property | Value | +|----------|-------| +| Framework (Rust) | `cargo test` (workspace crates: `cipherbox-core`, `cipherbox-crypto`, `cipherbox-fuse`, `cipherbox-sdk`) | +| Framework (TS) | Vitest (`packages/core`, `packages/sdk-core`, `packages/sdk`) | +| Framework (cross-package) | `tests/sdk-e2e` (Vitest, live API — the only real client→API IPNS round-trip gate) | +| Config files | Standard `Cargo.toml` workspace + each package's `vitest.config.ts` (no new config needed) | +| Quick run command | `cargo test -p cipherbox-core -p cipherbox-crypto` (unit-level, D-01/D-03b codec changes); `pnpm --filter @cipherbox/core test` / `pnpm --filter @cipherbox/sdk-core test` | +| Full suite command | `cargo test --workspace` + `pnpm test` (root) + `tests/sdk-e2e` live-API run (per `project-sdk-e2e-only-cross-package-publish-gate` memory — run before shipping IPNS/key-lifecycle changes) | + +### Phase Requirements → Test Map + +| SC | Behavior | Test Type | Automated Command | File Exists? | +|----|----------|-----------|--------------------|--------------| +| SC1 (D-01) | Rotation republish reconstructs `write_sealed`; owned-walk + replay signing-seed recovery survive rotation | unit + regression | `cargo test -p cipherbox-fuse rotation_deps` (extend existing test module at `crates/fuse/src/write_ops/rotation_deps.rs:601`) | ✅ (module + `#[cfg(test)]` scaffold exists — prototype tests already authored per CONTEXT D-01c) | +| SC1 (D-01) | `replay.rs::recover_signing_seed` no longer hits the "no write_sealed body" fail path for a rotated node | regression | `cargo test -p cipherbox-fuse replay` | ✅ file exists (`crates/fuse/src/replay.rs`), needs a NEW test exercising a rotation-then-replay sequence — ❌ Wave 0 gap | +| SC2 perf (D-02) | A scope-exit rotation over N nodes performs ≤1 `/shares/sent` fetch | unit (call-count assertion) | `cargo test -p cipherbox-fuse query_grants_rooted_at` (extend the existing `FakeTransport` call-count pattern already used at `rotation_deps.rs:664-672`, `publish_count_for`) | ✅ pattern exists, needs a new `collect_sent_shares` call-counter added to `FakeTransportInner` | +| SC2 perf (D-02, TS mirror) | `queryGrantsFn` caches `listSentGrants()` across repeated calls | unit | `pnpm --filter @cipherbox/sdk test owner-reconcile` (extend `packages/sdk/src/__tests__/owner-reconcile.test.ts`) | ✅ test file exists | +| SC2 binding (D-03) | Re-mint fails closed on a pin mismatch (simulated compromised-relay substitution) | unit | New test in `rotation_deps.rs` test module + `packages/sdk-core` engine tests | ❌ Wave 0 gap — new test cases needed on both sides | +| SC2 binding (D-03) | No-legacy-share invariant: pin absent at re-mint = hard fail-closed | unit | Same test modules as above, negative case | ❌ Wave 0 gap | +| SC2 binding (D-03b) | Cross-language wire parity for the new `NodeWriteBody` pin field | KAT | `cargo test -p cipherbox-crypto node_aad_cross_language` (extend `crates/crypto/tests/cross_language.rs:272`) + `pnpm --filter @cipherbox/core test node-codec-vectors` | ✅ file/harness exists, needs a NEW `seal_vectors[1]` fixture entry with a non-empty pin | +| SC3 (D-04) | `rotatedNodes` values are non-aliased, non-zero copies | unit | `pnpm --filter @cipherbox/sdk-core test rotation/engine` | ❌ Wave 0 gap — new assertion, existing engine test file to extend (search `packages/sdk-core/src/rotation/__tests__/` or co-located `engine.test.ts`) | +| Full round-trip | End-to-end scope-exit rotation + re-mint against a live API | e2e | `tests/sdk-e2e` (see README for run instructions; requires local API stack per `project-sdk-e2e-worktree-live-checkpoint-run` memory) | ✅ suite exists — the mandatory pre-ship gate | + +### Sampling Rate + +- **Per task commit:** the relevant crate/package's quick unit-test command (Rust: + `cargo test -p `; TS: `pnpm --filter test`) +- **Per wave merge:** `cargo test --workspace` + `pnpm test` (root) +- **Phase gate:** `tests/sdk-e2e` full live-API round-trip must be green before + `/gsd-verify-work` — this is the ONLY suite that exercises a real client→API IPNS + resolve/publish cycle, and IPNS/key-lifecycle changes (D-01, D-03) are exactly the class + of change that suite exists to gate (per `project-sdk-e2e-only-cross-package-publish-gate` + memory). + +### Wave 0 Gaps + +- [ ] `crates/fuse/src/replay.rs` — no existing test exercises a rotation-then-replay + signing-seed-recovery sequence; needed to prove D-01 closes the durability hole (not + just the flood). +- [ ] `crates/fuse/src/write_ops/rotation_deps.rs` — new `FakeTransportInner` call-counter + for `collect_sent_shares` (D-02 perf assertion) and new pin-mismatch fixtures (D-03 + fail-closed assertion). +- [ ] `packages/sdk-core/src/rotation/__tests__/` (or co-located engine test file) — new + assertion that `rotatedNodes` entries are non-aliased with `parentNewReadKey` (D-04). +- [ ] `packages/core/src/__tests__/node-codec-vectors.test.ts` + `tests/vectors/node-codec.json` + — new `seal_vectors[1]` entry with a non-empty `recipientPubkeyPins` (D-03b lockstep). +- [ ] `crates/crypto/tests/cross_language.rs` — extend the `NodeSealVector`-driven assertion + loop (currently hardcoded to `seal_vectors.len() == 1`, `cross_language.rs:310`) to + accept 2 vectors once the new one is added — **this length-guard assertion will need + updating or it will hard-fail on the new fixture**. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|----------------|---------|-------------------| +| V6 Cryptography | Yes | AES-256-GCM + AAD (`seal_aes_gcm_aad`/`encryptAesGcmAad`) for the write-body; ECIES (`wrap_key`/`wrapKey`) for the re-mint — both existing, never hand-rolled | +| V4 Access Control | Yes | The pin comparison IS an access-control check: it verifies the re-mint target is the ORIGINALLY authorized recipient, not merely "a key the server currently associates with this share" | + +### Known Threat Patterns for this stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|----------------------| +| Compromised relay substitutes `recipient_public_key` in `GET /shares/sent` at re-mint time | Spoofing / Tampering | D-03's owner-sealed pin, verified client-side before every re-wrap (this phase's core deliverable) | +| A future TS zeroization tightening zeros `rotatedNodes` entries via the `parentNewReadKey` alias | Tampering (self-inflicted) | D-04's defensive copy | +| Stale/missing `write_sealed` silently degrading owned-walk + losing signing-seed recoverability | Denial of Service / Repudiation (owner loses ability to sign) | D-01's reconstruct-and-reseal fix | + +## Sources + +### Primary (HIGH confidence — direct code reads this session) + +- `crates/fuse/src/write_ops/rotation_deps.rs` (full file, 1270 lines) — D-01/D-02/D-03 Rust FUSE transport adapter +- `crates/core/src/node/types.rs`, `encode.rs`, `decode.rs`, `seal.rs` — `NodeWriteBody` schema + codec, both Rust and TS twins +- `packages/core/src/node/types.ts`, `encode.ts`, `decode.ts` — TS `NodeWriteBody` twin +- `crates/sdk/src/rotation/engine.rs` (`re_mint_grants_rooted_at`, `RotationDeps` trait, `GrantRow`) — Rust rotation engine +- `packages/sdk-core/src/rotation/engine.ts` (`reMintGrantsRootedAt`, `RotatedNodeKey`, `RotateReadResult`, the D-04 aliasing bug) — TS rotation engine +- `packages/sdk/src/share/owner-reconcile.ts`, `apps/web/src/services/owner-reconcile.service.ts` — the TS/web re-mint consumers +- `apps/web/src/components/file-browser/ShareDialog.tsx` — issuance + upgrade/downgrade UI, D-03c/D-03d web sites +- `apps/web/recovery-src/walk.ts` — confirmed Phase-78 recovery tool never parses write-bodies (Pitfall 3) +- `crates/fuse/src/replay.rs` (`recover_signing_seed`) — D-01 durability consumer +- `crates/fuse/src/write_ops/grant_scope.rs` (`refresh_rotated_inode_read_keys`) — D-04 downstream Rust consumer (already correct) +- `packages/core/src/__tests__/node-codec-vectors.test.ts`, `tests/vectors/node-codec.json`, `crates/crypto/tests/cross_language.rs` — cross-language KAT discipline and exact vector structure +- `packages/sdk/src/client.ts` (`resolveShareEncryptedWriteKey`, ~:3839-3899) — closest existing pattern for D-03c's needed new write-body mutation method +- `docs/METADATA_EVOLUTION_PROTOCOL.md`, `docs/METADATA_SCHEMAS.md` — schema evolution rules and current `NodeWriteBody` documentation + +### Secondary (MEDIUM confidence) + +- None — every claim in this document traces to a direct file read this session; no + WebSearch or external documentation was needed (closed-codebase surgical phase). + +### Tertiary (LOW confidence) + +- None. + +## Metadata + +**Confidence breakdown:** + +- Standard stack: HIGH — no new dependencies; all primitives directly verified in-repo +- Architecture: HIGH — every function/line cited was read directly this session +- Pitfalls: HIGH — each pitfall is backed by a specific file:line contradiction risk found by reading the actual test fixtures and encode/decode logic +- D-03's SDK-mutation-path gap (Pitfall 4, Open Question 1): MEDIUM — the GAP itself is + verified fact (no such method exists), but the RECOMMENDED shape of the fix is a design + proposal, not a located pattern + +**Research date:** 2026-07-12 +**Valid until:** No expiry driver — this is closed-codebase internal research, not +dependent on external library versions or ecosystem state. Re-verify only if the +`node/v3` codec, rotation engine, or share-grant API surface changes before this phase is +planned/executed. diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VALIDATION.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VALIDATION.md new file mode 100644 index 0000000000..985342c6ff --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VALIDATION.md @@ -0,0 +1,84 @@ +--- +phase: 80 +slug: rotation-write-plane-and-re-mint-durability +status: draft +nyquist_compliant: false +wave_0_complete: false +created: 2026-07-12 +--- + +# Phase 80 — Validation Strategy + +> Per-phase validation contract for feedback sampling during execution. Derived from 80-RESEARCH.md `## Validation Architecture`. + +--- + +## Test Infrastructure + +| Property | Value | +|----------|-------| +| **Framework (Rust)** | `cargo test` (workspace crates: `cipherbox-core`, `cipherbox-crypto`, `cipherbox-fuse`, `cipherbox-sdk`) | +| **Framework (TS)** | Vitest (`packages/core`, `packages/sdk-core`, `packages/sdk`) | +| **Framework (cross-package)** | `tests/sdk-e2e` (Vitest, live API — the only real client→API IPNS round-trip gate) | +| **Config file** | Standard `Cargo.toml` workspace + each package's `vitest.config.ts` (no new config needed) | +| **Quick run command** | `cargo test -p cipherbox-core -p cipherbox-crypto` / `pnpm --filter @cipherbox/core test` / `pnpm --filter @cipherbox/sdk-core test` | +| **Full suite command** | `cargo test --workspace` + `pnpm test` (root) + `tests/sdk-e2e` live-API run | +| **Estimated runtime** | ~120 seconds (unit); sdk-e2e several minutes (live stack) | + +--- + +## Sampling Rate + +- **After every task commit:** Run the relevant crate/package quick command (Rust: `cargo test -p `; TS: `pnpm --filter test`) +- **After every plan wave:** Run `cargo test --workspace` + `pnpm test` (root) +- **Before `/gsd-verify-work`:** `tests/sdk-e2e` full live-API round-trip must be green — the ONLY suite exercising a real client→API IPNS resolve/publish cycle, and the class of change (D-01, D-03 key-lifecycle/IPNS) this suite exists to gate +- **Max feedback latency:** ~120 seconds (unit tiers) + +--- + +## Per-Task Verification Map + +> Populated by the planner / gsd-nyquist-auditor from the SC→test map below. Each task's `` must map to one automated command. + +| SC | Behavior | Test Type | Automated Command | File Exists | +|----|----------|-----------|-------------------|-------------| +| SC1 (D-01) | Rotation republish reconstructs `write_sealed`; owned-walk survives rotation | unit + regression | `cargo test -p cipherbox-fuse rotation_deps` | ✅ module + `#[cfg(test)]` scaffold | +| SC1 (D-01) | `replay.rs::recover_signing_seed` no longer hits "no write_sealed body" for a rotated node | regression | `cargo test -p cipherbox-fuse replay` | ❌ W0 (new rotation-then-replay test) | +| SC2 perf (D-02) | Scope-exit rotation over N nodes performs ≤1 `/shares/sent` fetch | unit (call-count) | `cargo test -p cipherbox-fuse query_grants_rooted_at` | ❌ W0 (new `collect_sent_shares` call-counter on `FakeTransportInner`) | +| SC2 perf (D-02 TS) | `queryGrantsFn` caches `listSentGrants()` across calls | unit | `pnpm --filter @cipherbox/sdk test owner-reconcile` | ✅ test file exists | +| SC2 binding (D-03) | Re-mint fails closed on pin mismatch (simulated relay substitution) | unit | new cases in `rotation_deps.rs` + `packages/sdk-core` engine tests | ❌ W0 | +| SC2 binding (D-03e) | Pin absent at re-mint = hard fail-closed (no-legacy invariant) | unit | same modules, negative case | ❌ W0 | +| SC2 binding (D-03b) | Cross-language wire parity for new `NodeWriteBody` pin field (JSON KAT, NOT CBOR) | KAT | `cargo test -p cipherbox-core node_write_body_vectors` + `pnpm --filter @cipherbox/core test node-codec-vectors` | ✅ harness exists; add `seal_vectors[1]` fixture to `tests/vectors/node-codec.json` (unrelated `crypto/cross_language.rs` guard stays untouched) | +| SC3 (D-04) | `rotatedNodes` values non-aliased with `parentNewReadKey`, non-zero copies | unit | `pnpm --filter @cipherbox/sdk-core test rotation/engine` | ❌ W0 | +| Full round-trip | E2E scope-exit rotation + re-mint against live API | e2e | `tests/sdk-e2e` | ✅ suite exists — mandatory pre-ship gate | + +--- + +## Wave 0 Requirements + +- [ ] `crates/fuse/src/replay.rs` — new test exercising a rotation-then-replay signing-seed-recovery sequence (proves D-01 closes the durability hole, not just the flood) +- [ ] `crates/fuse/src/write_ops/rotation_deps.rs` — new `FakeTransportInner` call-counter for `collect_sent_shares` (D-02) + new pin-mismatch / pin-absent fixtures (D-03 fail-closed) +- [ ] `packages/sdk-core/src/rotation/__tests__/` (or co-located engine test) — new assertion that `rotatedNodes` entries are non-aliased with `parentNewReadKey` (D-04) +- [ ] `tests/vectors/node-codec.json` + `packages/core/src/__tests__/node-codec-vectors.test.ts` + `crates/core/tests/node_write_body_vectors.rs` — new `seal_vectors[1]` entry with a non-empty recipient-pin list (D-03b lockstep); pin field conditionally emitted so frozen `seal_vectors[0]` KAT is preserved +- Note: `crates/crypto/tests/cross_language.rs:310` (`seal_vectors.len() == 1`) reads a DIFFERENT oracle (`crypto/node-aad.json`), NOT `tests/vectors/node-codec.json` — it is unrelated to the new pin fixture and must stay untouched/green (locked by an 80-01 acceptance criterion) + +--- + +## Manual-Only Verifications + +| Behavior | Requirement | Why Manual | Test Instructions | +|----------|-------------|------------|-------------------| +| Live scope-exit rotation + re-mint IPNS round-trip | SC1, SC2 | Requires live API + IPNS stack (Kubo/someguy) not available in unit tiers | Run `tests/sdk-e2e` against a local stack per `project-sdk-e2e-worktree-live-checkpoint-run` (copy gitignored `.env`, `SDK_E2E_SECRET` == API `TEST_LOGIN_SECRET`, reset DB + restart API from current code) | + +--- + +## Validation Sign-Off + +- [ ] All tasks have `` verify or Wave 0 dependencies +- [ ] Sampling continuity: no 3 consecutive tasks without automated verify +- [ ] Wave 0 covers all MISSING references +- [ ] No watch-mode flags +- [ ] Feedback latency < 120s (unit tiers) +- [ ] `nyquist_compliant: true` set in frontmatter + +**Approval:** pending diff --git a/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VERIFICATION.md b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VERIFICATION.md new file mode 100644 index 0000000000..a08969814b --- /dev/null +++ b/.planning/phases/80-rotation-write-plane-and-re-mint-durability/80-VERIFICATION.md @@ -0,0 +1,50 @@ +--- +phase: 80 +slug: rotation-write-plane-and-re-mint-durability +status: PASS +verified: 2026-07-12 +--- + +# Phase 80 — Verification + +## Verdict: PASS + +All three success criteria delivered and verified, plus a HIGH-severity +pin-durability gap (surfaced during ship review) closed. + +## Success criteria + +1. **D-01 — rotation republish no longer emits `write_sealed: None`.** FUSE + rotation adapter reconstructs `NodeWriteBody` from the InodeTable and re-seals + at the new generation; `replay::recover_signing_seed` succeeds on the + reconstructed body. Verified by `rotation_reconstructed_write_sealed_recovers_signing_seed` + and the full fuse suite (130 passed). +2. **D-02/D-03 — verified recipient binding + `/shares/sent` cached once per + rotation.** Three-consumer fail-closed pin verification (Rust/TS/web); sent + shares cached per rotation job. Verified by SDK-E2E share suites (106/106) and + sdk-core rotation/grant-remint tests. +3. **D-04 — TS `rotatedNodes` defensive 32-byte copy.** Confirmed non-aliased in + `rotation/engine.ts`; regression test asserts non-aliasing with + `parentNewReadKey`. + +## Ship-review additions + +- **FLAG 1** (`replay.rs::fetch_splice_publish_parent` empty pins): real + durability gap, FIXED (`3e3ec2a3d`). +- **HIGH — routine reseals dropped pins** (crypto review + CodeRabbit): FIXED + across all routine paths (`ddb7082e6`) with Rust + TS regression tests. +- **CodeRabbit — `addRecipientPubkeyPin` missing ROT-07 reconcile**: FIXED. +- **FLAG 2** (winfsp): updated by inspection; confirmed via CI Windows job. + +## Gate evidence + +| Gate | Result | +|------|--------| +| Rust `cipherbox-fuse` (fuse) | 130 passed, 0 failed | +| SDK-E2E (client→API IPNS round-trip, TEE 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 | + +See `.planning/security/REVIEW-80.md` for the crypto/security review dispositions. diff --git a/.planning/research/grant-delivery-rotation-research-goals.md b/.planning/research/grant-delivery-rotation-research-goals.md new file mode 100644 index 0000000000..d8b9bf4ad3 --- /dev/null +++ b/.planning/research/grant-delivery-rotation-research-goals.md @@ -0,0 +1,463 @@ +# Research Goals — Grant Delivery, Rotation, and the Role of the Relay + +Status: research/prototyping sprint charter (pre-decision) +Owner: Michael +Created: 2026-07-16 + +## 1. Purpose & how to use this document + +CipherBox's sharing model has grown to the point where **where grant key-material +lives**, **how it is delivered and updated on rotation**, and **how much the +API/relay is trusted to make sharing work** are entangled decisions that are +currently made implicitly. Recent work (Phase 80 recipient-pins; the file-share +re-mint gap) surfaced concrete bugs that are *symptoms* of those implicit +decisions rather than isolated defects. + +This document defines a focused research + prototyping sprint to decide, on +evidence, the target architecture for grant delivery and rotation. It is meant to +stand alone: a researcher who has not seen the originating discussion should be +able to run the sprint from this document plus the code references in Appendix C. + +How to use it: + +- Sections 2–6 are grounding: the current model, the core tension, the concrete + evidence, and the invariants/subtleties any solution must respect. +- Sections 7–10 are the actual work: research questions, hypotheses, + prototyping tracks, and the decision framework. +- The appendices give a **canonical test scenario** and **flow traces** every + prototype must validate against, so results are comparable. + +The sprint's output is a recommendation (an ADR) backed by working prototypes and +measurements — not a production implementation. + +## 2. Background — the current architecture (grounding) + +### 2.1 The v3 node model (two encryption planes) + +Every folder/file is a `PublishedNode` on IPFS, addressed by an IPNS name, with +two independently-sealed bodies: + +- **Read-body** (sealed under the node's `readKey`, AES-256-GCM): for folders, + `children: SealedChildRef[]`; for files, `content` (fileKey, size, versions). +- **Write-body** (`NodeWriteBody`, sealed under the node's `writeKey`): + `{ ipnsPrivateKey, writeChildren: WriteChildRef[], recipientPins: string[] }`. + +Per-node keys: + +- `readKey` (AES-256) — decrypts the read-body. +- `writeKey` (AES-256) — decrypts the write-body. +- `ipnsPrivateKey` (Ed25519 seed) — signs IPNS records; its public key **is** the + IPNS name (`deriveIpnsName(pub)`). It lives **inside** the write-body, so it is + recoverable only via the `writeKey`. + +Two derivation chains let a single grant cover a whole subtree: + +- **Read-chain:** `SealedChildRef.readKeySealed = sealChildReadKey(rk_child, rk_parent, …)`. + A reader with a parent `readKey` derives every descendant `readKey` on demand. +- **Write-chain:** `WriteChildRef.writeKeySealed = sealChildWriteKey(wk_child, wk_parent, …)`. + A writer with a parent `writeKey` derives every descendant `writeKey` — and hence + each descendant's `ipnsPrivateKey` (from that node's write-body). + +Key relationship (important): the `writeKey` is the **shareable, subtree-scoped +envelope**; the `ipnsPrivateKey` is the write-specific signing secret carried +*inside* it. You never distribute the signing key directly — you distribute the +`writeKey` that unlocks it. This is the write-plane analog of `readKey` + read-chain. + +### 2.2 How sharing works today + +A share grants an entire subtree with a single ECIES wrap of the subtree root's key: + +- **Read share:** `encryptedReadKey = hex(ECIES_wrap(rk_root, recipientPub))`. +- **Write share:** additionally `encryptedWriteKey = hex(ECIES_wrap(wk_root, recipientPub))`. +- The grant is stored **in the relay** (`/shares` table): + `{ shareId, recipientPublicKey, encryptedReadKey, encryptedWriteKey?, rootNodeId, shareRootIpnsName, rootGeneration }`. +- For **folder** shares, the owner also seals the recipient's pubkey into the shared + node's write-body `recipientPins` (anti-relay-substitution defense, Phase 80), + pin-FIRST then grant. **File** shares are pin-exempt (a file leaf's write-body is + not reachable via the folder-only pin API — the accepted carve-out). + +So authorization *authority* (pins) already lives in metadata; grant *key material* +lives only in the relay. The relay is also the **discovery** channel +(`GET /shares/received`). The relay is zero-knowledge w.r.t. key material (all +blobs are ECIES to the recipient) but sees the sharing graph and can attempt +recipient substitution (which pins defend against on re-mint). + +### 2.3 Rotation and re-mint + +- **Read rotation** (`rotateReadFromNode`, scope-exit trigger `maybeRotateOnScopeExit`): + fires on covered scope-exit mutations (rename/delete/move/createSubfolder) on a + grant-root folder. It rekeys the folder **and its whole subtree** (BFS), keeping + every `writeKey`, `ipnsPrivateKey`, and IPNS **name** stable. Then it **re-mints** + grants rooted at each rotated node — re-wrapping the new `readKey` for surviving + recipients (`PATCH /shares/:id/grant`) and deleting revoked ones. +- **Write rotation** (`rotateWriteFromNode`): mints a **new** `ipnsPrivateKey` + (→ new name) + new `writeKey` per node, republishes under new names, and + **tombstones** the old names. Required to truly revoke a writer (who may have + already extracted the stable signing key). +- **Revocation is lazy** (ADR 0002): a pure revoke deletes the grant row; the + actual read-key cut is deferred to the next covered mutation's rotation. + +## 3. The core tension (problem statement) + +The relay has drifted from an intended "temporary key transport" into a +**load-bearing store of the sharing graph and grant key-material**. Two forces are +in tension: + +1. **Grants-in-relay (status quo).** Grant key-material lives in `/shares`; + rotation must reach back out and `PATCH` each grant. This makes re-mint a + separate, out-of-band write that must be kept in sync with the metadata rotation, + and it makes the relay integral to every share and every rotation. + +2. **Grants-in-metadata (original intent).** Owner-sealed grant material lives in + node metadata (extending `recipientPins`), so rotation's re-seal carries the + re-minted grants **for free**, atomically, for files and folders alike — and the + relay shrinks toward a **swappable, integrity-untrusted pointer/notification bus**. + +Cutting across both: **key delivery and discovery are separable.** Delivery of key +material can plausibly move into metadata, while notification/discovery ("you have a +new share; here is where it is") is the genuinely hard, IPFS-unfriendly part — the +"inbox" problem the relay currently solves and that any decentralization goal must +confront. + +And a third axis: **hygiene vs revoking rotations.** A non-revoking (hygiene) rekey +can plausibly be delivered purely in metadata (chain the new key under the old key, +readable forward by any current holder). A revoking rotation cannot (the revoked +party holds the old key), and must re-deliver per surviving recipient out-of-band. + +The sprint must decide where CipherBox should land on these axes and prove it works. + +## 4. Evidence — concrete gaps that motivate this + +These are real, code-confirmed issues that are *symptoms* of the grants-in-relay +model (see Appendix C for exact locations): + +- **Gap C — re-mint encoding mismatch (fixed on branch, but instructive).** + `reMintGrantsRootedAt` emitted base64 while `PATCH /shares/:id/grant` requires + hex, so **every** re-mint PATCH 400'd — including the folder-grant reconcile + sweep, which had therefore silently never worked. It existed only because re-mint + is an out-of-band relay write with its own wire format; unit tests mocked the + transport, so no test caught it. (Rust already emitted hex — a cross-language + parity divergence.) + +- **Gap B — file leaves cannot be read-rotated on web.** The rotation BFS enqueues + every child including files, keyed only via web's `nodeKeySource`, which reads only + `folderTree` (folders). A file leaf has no `ipnsPrivateKey`/`writeKey` available → + `rotateOne` fail-closes → on web, scope-exit rotation of *any shared folder + containing files* throws. Latent only because v2.0 web rotation isn't fully live. + Desktop/FUSE works because its host (`RotationDeps`) resolves any node's key by + name from the mounted tree. This is a host-data-model divergence, not a protocol + one; the intended "Phase 65 write-body key derivation" landed for folders but not + file leaves in the walk. + +- **Write-plane sibling.** `rotateWriteFromNode` re-wraps co-writer keys with the + same base64-vs-hex shape (engine.ts:2833) and the same relay-PATCH dependency — + likely the same class of latent bug on the write plane. + +- **Whole-subtree blast radius.** Because scope-exit rotation rekeys the entire + subtree, an **independently-shared descendant** (e.g. a file shared to a different + set of recipients) is rekeyed by an unrelated action on its ancestor folder, and + its grants *must* be re-minted or those recipients silently lose access. + +## 5. Invariants & constraints (non-negotiable) + +Any candidate architecture MUST preserve these unless the sprint explicitly argues +to change one (with justification): + +- **Zero-knowledge server.** The relay/API never sees plaintext keys or content. + All grant material is ECIES to the recipient; all content is AES-256-GCM. +- **Primitives.** ECIES (secp256k1) for key wrapping; AES-256-GCM (+AAD) for + content and body sealing. No hand-rolled crypto. +- **No plaintext signing keys at rest.** `ipnsPrivateKey` is only ever stored + sealed inside a write-body. +- **IPNS name = f(Ed25519 pub).** Read rotation keeps names stable; only write + rotation changes names (with tombstones). +- **Lazy-revocation stance (ADR 0002).** Ciphertext already published under an old + key is presumed leaked; rotation revokes *future* derivation, not the past. +- **Cross-language parity.** Rust (desktop/FUSE) and TypeScript (web/sdk) engines + must produce byte-compatible published records and grant encodings. +- **Recipient-pin anti-substitution defense.** The owner-sealed authorization must + remain the authority a re-mint verifies against — the relay-fed recipient is never + trusted blindly. +- **Two independent planes.** Read and write revocation stay separable (read + rotation must not force a write-plane/name change). +- **Forward-only migration is acceptable.** Staging is reset to a clean slate at + milestone completion; the sprint may assume no legacy shares to migrate (but must + still describe the cutover for a future production migration). + +## 6. Hard-won subtleties any solution must handle (failure modes) + +These are the traps discovered so far; a candidate that ignores one is disqualified: + +1. **Bootstrap chicken-egg.** An initial grant cannot be sealed under the node's own + `readKey` — a brand-new recipient has no key to open it. Initial delivery is + irreducibly ECIES-to-pubkey through a channel reachable without the node key. +2. **Revocation-under-old-key leak.** For a *revoking* rotation, the new key cannot + be sealed under the old key (the revoked party holds it too) — it must be + re-wrapped per surviving recipient. +3. **Hygiene rekeys are different.** A non-revoking rekey *can* chain new-under-old + in metadata, avoiding the relay and any public exposure. Distinguishing the two + cases correctly is itself a research question. +4. **Name stability on read rotation.** Grant *pointers* (`shareRootIpnsName`) + survive read rotation; only the wrapped key must update. Solutions must not + accidentally require pointer churn on read rotation. +5. **File-leaf key recovery.** Rotating/republishing a file leaf needs its + `ipnsPrivateKey` (to sign) and `writeKey` (to reseal), recoverable via the + write-chain: parent `writeKey` → `WriteChildRef.writeKeySealed` → child `writeKey` + → child write-body → child `ipnsPrivateKey`. +6. **Whole-subtree blast radius.** Independently-shared descendants are always in the + blast radius of an ancestor rotation; re-mint must reach them or they lose access. +7. **File pin carve-out.** Files structurally can't carry `recipientPins` today, so + file-share grants are unprotected against relay substitution. Any solution should + either extend protection to files or make the exposure explicit. +8. **Sharing-graph privacy.** Depending on where grants live, the relay, arbitrary + IPFS observers, or recipients may learn who-shares-what-with-whom. This is a + design axis, not an afterthought. +9. **Cross-platform key sourcing.** Web (`folderTree`, folder-only, no parent chain) + and desktop (full mounted tree) have different data models; a solution should + converge them rather than deepen the divergence. + +## 7. Research questions + +Answer these with evidence (prototypes, measurements, threat models), not opinion. + +- **RQ1 — Grant locus.** Where should grant key-material live: relay table, + owner-sealed node metadata, per-recipient inbox, or a hybrid? Evaluate each + against re-mint atomicity, privacy, availability, and complexity. + +- **RQ2 — Delivery vs discovery.** Can key *delivery* move to metadata while + *notification/discovery* remains a swappable, integrity-untrusted relay/inbox? + What is the minimal irreducible relay role that remains? + +- **RQ3 — Rotation re-mint mechanics.** For hygiene rekeys, can new-key-under-old-key + metadata chaining replace the relay `PATCH`? How should the system classify a + rotation as hygiene vs revoking, and handle each? Does this eliminate Gap B/C for + the common case? + +- **RQ4 — File-leaf key sourcing / parity.** Should the rotation engine derive child + (file) keys from the write-chain (engine-side, host-agnostic) rather than rely on a + host callback? What design unifies web + desktop and produces identical output? + (Directly resolves Gap B.) + +- **RQ5 — Rotation scope.** Is whole-subtree read rotation on every covered + scope-exit mutation necessary for correctness, or can it be scoped/incremental/lazy + without weakening revocation? What is the exact correctness boundary? + +- **RQ6 — Decentralized inbox.** Is a viable IPFS/IPNS/libp2p-native owner→recipient + delivery mechanism feasible (append-only log, per-pair rendezvous, pubsub, etc.)? + What are its write-authority, persistence, availability, and privacy properties? + Can it replace the relay's discovery role, and at what cost? + +- **RQ7 — Sharing-graph privacy.** For each candidate, precisely who learns the + sharing graph (relay, IPFS observers, recipients)? Can exposure be minimized (e.g. + grants in encrypted metadata rather than plaintext, unlinkable inbox addresses)? + +- **RQ8 — Write-plane unification.** Do the write-plane re-mint issues (encoding at + engine.ts:2833, relay dependency, name churn) have the same root, and should the + chosen solution cover both planes uniformly? + +- **RQ9 — Migration & cutover.** What is the forward-only cutover for the chosen + target, and what would a future production migration (with legacy shares) require? + What is the cross-language (Rust/TS) implementation surface? + +## 8. Hypotheses to test (falsifiable) + +- **H1.** Sealing owner-encrypted grant blobs into node metadata makes re-mint a + byproduct of the rotation re-seal, eliminating Gap C entirely and removing the + sweep/inline split — at the cost of O(recipients) metadata that re-publishes on + rotation. + +- **H2.** For non-revoking rotations, new-key-under-old-key chaining in the read-body + lets current holders (including independently-shared descendants) recover the new + key with **zero** relay interaction and no public exposure; only revoking rotations + need out-of-band per-survivor delivery. + +- **H3.** Engine-side write-chain key derivation makes file-leaf rotation work + identically on web and desktop with byte-compatible output, removing the + `nodeKeySource` divergence — and it is the smaller long-term surface than making + `nodeKeySource` async + giving web a parent-chain lookup. + +- **H4.** A minimal relay reduced to "notify + point" (no key material) preserves all + current functionality with strictly less trust, provided an acceptable discovery + mechanism exists. + +- **H5.** Whole-subtree rotation can be replaced by root-cut + lazy per-node rekey on + next access without weakening revocation, materially reducing rotation cost. + (This one may well be *falsified* — testing the correctness boundary is the point.) + +## 9. Prototyping tracks + +Each prototype is a throwaway spike validated against the Appendix A scenario and +Appendix B flows. Prefer the smallest artifact that answers its question. + +- **P1 — Metadata-sealed grants.** Store ECIES grant blobs in owner-sealed node + metadata; make rotation re-seal them. Measure: does re-mint disappear as a separate + step? Metadata size/churn per rotation? Privacy (who can enumerate recipients)? + Does it cover file leaves for free? + +- **P2 — Hygiene-rekey chaining.** Implement new-key-under-old-key delivery in the + read-body for non-revoking rotations. Verify current holders (incl. Darren/Eugene + on `b.txt`) recover the new key with no relay call; verify a *revoking* rotation + correctly falls back to per-survivor ECIES. Measure relay-call elimination rate. + +- **P3 — Engine-side write-chain key derivation.** Make the TS rotation walk derive + file-leaf `writeKey`/`ipnsPrivateKey` from the write-chain (fix Gap B host-agnostically). + Prove byte-parity of published output with the Rust path; benchmark added + fetch/unseal cost per file leaf. (This is the one track that could also ship as the + interim Gap B fix if the sprint decides to keep grants-in-relay.) + +- **P4 — Decentralized inbox spike.** Evaluate 2–3 owner→recipient delivery + mechanisms on IPFS/IPNS/libp2p against a written threat model. Deliverable is a + feasibility memo + one working proof-of-concept for the most promising option, not + production code. + +- **P5 — Rotation-scope experiment.** Prototype root-cut + lazy per-node rekey and + compare against whole-subtree rotation on the scenario. Produce a correctness + argument (or counterexample) for revocation completeness, plus a cost comparison. + +## 10. Evaluation framework / decision matrix + +Score every candidate architecture across these dimensions (define a rubric per +dimension before scoring; keep evidence, not vibes): + +| Dimension | What to measure | +| --- | --- | +| Correctness | Revocation completeness; no silent access loss; no key leak to revoked parties; handles all Appendix B flows | +| Zero-knowledge / privacy | Who learns the sharing graph (relay / IPFS observers / recipients); metadata leakage | +| Decentralization alignment | Residual relay trust; is the relay swappable and integrity-untrusted | +| Complexity | Engine surface; cross-language duplication; cognitive load; number of moving parts | +| Cross-platform parity | Web / desktop-FUSE / Windows behave identically; single source of truth for key sourcing | +| Performance | Rotation cost; metadata churn/size; delivery/poll latency; network round-trips | +| Migration cost & risk | Forward-only cutover effort; future production-migration path; blast radius | + +The sprint produces a scored matrix and a single recommended target with rationale. + +## 11. Sprint deliverables + +1. An **ADR** recommending the target architecture for grant delivery + rotation, + with the scored decision matrix and explicit tradeoffs. +2. The **prototypes** (P1–P5) with their measurements and threat models. +3. A **de-risked implementation plan** for the recommendation, including the + cross-language (Rust/TS) surface and the forward-only cutover. +4. A decision on the **interim question**: keep grants-in-relay and ship the Gap B/C + fixes on the current PR, or freeze that work pending the target. (P3 informs this.) + +## 12. Out of scope for this sprint + +- Production implementation of the chosen target (that follows the ADR). +- Billing, mobile, real-time collaboration, team accounts (milestone-out-of-scope). +- Changing the content-encryption scheme (AES-256-GCM) or the ECIES key-wrap choice. +- The TEE republishing mechanism (unaffected by grant locus). + +## 13. Open questions / unknowns + +- Does the "hygiene vs revoking" classification have a clean, tamper-proof definition + the client can compute, or is it owner-asserted (and thus abusable)? +- Can metadata-sealed grants avoid O(recipients) republish cost via a per-recipient + side-index that is still owner-sealed and self-certifying? +- Is there an unlinkable inbox address scheme (per owner-recipient pair) that hides + the sharing graph from the relay without a trusted setup? +- How does file-share pin protection (currently carved out) fit the chosen target — + does grants-in-metadata make file pins natural? +- What is the interaction with versioning and the version-floor anti-rollback gate + when keys/fileKeys rotate? + +--- + +## Appendix A — Canonical test scenario + +All prototypes validate against this exact setup so results are comparable. + +Alice's private vault: + +```text +root +├─ folderA +│ ├─ a.txt +│ └─ b.txt +└─ folderB + ├─ c.txt + └─ d.txt +``` + +Shares Alice creates: + +- folderA → **Bob** (read-only) +- folderA → **Charlie** (read + write) +- folderA/b.txt → **Darren** (read-only) +- folderA/b.txt → **Eugene** (read + write) + +Resulting relay `/shares` rows and metadata state: + +| # | recipient | encryptedReadKey | encryptedWriteKey | rootNodeId | shareRootIpnsName | +| --- | --- | --- | --- | --- | --- | +| 1 | Bob | wrap(rk_A) | — | id_A | name_A | +| 2 | Charlie | wrap(rk_A) | wrap(wk_A) | id_A | name_A | +| 3 | Darren | wrap(rk_b) | — | id_b | name_b | +| 4 | Eugene | wrap(rk_b) | wrap(wk_b) | id_b | name_b | + +Metadata changes: folderA write-body `recipientPins = [Bob.pub, Charlie.pub]` +(folderA republished per pin). `b.txt` unchanged (file shares add no pins/metadata). +Everything else untouched. + +Two structural facts this bakes in: + +1. `b.txt` is reachable by **two independent key paths** — Bob/Charlie derive `rk_b` + from `rk_A` down the read-chain (no b.txt grant), while Darren/Eugene hold `rk_b` + directly (rows 3/4). folderA's metadata knows nothing about Darren/Eugene. +2. **Asymmetric substitution protection** — folderA grants are pinned; b.txt grants + are not (file carve-out). + +## Appendix B — Reference flow traces + +Candidates must produce correct behavior for each. + +- **Content edit (no rotation).** Charlie edits `a.txt` content → new version under + `name_a`; does not touch folderA (a file-content publish never rewrites the parent). + No rotation. + +- **Covered scope-exit mutation (the main event).** Charlie deletes `a.txt` from + folderA → `rotateReadFromNode(folderA)` rekeys the remaining subtree + `{folderA, b.txt}`: `rk_A→rk_A'`, `rk_b→rk_b'` (+ fresh `fileKey_b'`); write plane, + `ik`s, and names unchanged. Re-mint: folderA grants (Bob/Charlie, pin-verified, + hex) and b.txt grants (Darren/Eugene, file-exempt, hex). **This is where Gap B + (file-leaf `ik_b`/`wk_b` recovery) and Gap C (hex PATCH) bite, and where Darren/ + Eugene silently lose access to `b.txt` if re-mint doesn't reach them.** + +- **Lazy revocation.** Alice revokes Bob → delete row 1; `rk_A` unchanged; the actual + key cut is deferred to the next covered mutation, which re-mints only survivors. + +- **Write revocation (contrast).** Revoking Charlie's write access needs + `rotateWriteFromNode`: new `ik`/names + tombstones + pointer rewrites in root's refs + and every affected grant — a much larger cascade than read rotation. + +- **Shared-write on a file.** Eugene edits `b.txt` → publishes a new version under + `name_b` (recovers `ik_b` via `wk_b`); no rotation, does not touch folderA. + +## Appendix C — Key code references (as of 2026-07-16) + +- `packages/core/src/node/types.ts` — `SealedChildRef`, `WriteChildRef`, + `NodeWriteBody { ipnsPrivateKey, writeChildren, recipientPins }`. +- `packages/sdk-core/src/rotation/engine.ts` + - `reMintGrantsRootedAt` (~586) — read-grant re-mint; encoding at ~648 (Gap C, + fixed base64→hex); file pin carve-out via `nodeKind`. + - `rotateOne` D-01 IPNS-key guard (~1090) — where file leaves fail closed (Gap B). + - child enqueue keyed by `nodeKeySource` (~1959); walk driver `rotateReadFromNode` + (~1323). + - `rotateWriteFromNode` co-writer re-wrap encoding (~2833) — write-plane sibling. + - `mintFileKeyOnRotate` (~546); write rotation new keypair/name (~2632). +- `packages/sdk/src/client.ts` + - `performScopeExitRotation` (~2065) and `nodeKeySource` (folderTree-only, ~2115). + - `getRecipientPubkeyPins` (~4021), `addRecipientPubkeyPin` (~3969). + - `resolveChildIdentity` (file share key resolution), `resolveShareEncryptedWriteKey`. + - file-leaf key recovery pattern (`updateSharedFile`, ~5584) — parent write-body → + `WriteChildRef` → child `writeKey` → child write-body → `ipnsPrivateKey`. +- `crates/sdk/src/rotation/engine.rs` + - `re_mint_grants_rooted_at` hex encode (~704, "must be hex, NOT base64"). + - `rotate_one_inner` (~428), `enqueue_child` (~2321), `seal_and_publish` — Rust + delegates per-node key resolution to `RotationDeps` (host), unlike TS. +- `apps/api/src/shares/shares.controller.ts` — `POST /shares`, `PATCH /shares/:id/grant` + (`UpdateGrantDto` requires even-length hex), `DELETE /shares/:id`, `GET /shares/{sent,received}`. +- `apps/web/src/services/owner-reconcile.service.ts`, + `apps/web/src/services/rotation-driver.service.ts` — web re-mint wiring. +- `docs/METADATA_SCHEMAS.md`, `docs/FILESYSTEM_SPECIFICATION.md`, + `docs/AUTHENTICATION_ARCHITECTURE.md` — canonical model docs. diff --git a/.planning/security/REVIEW-80.md b/.planning/security/REVIEW-80.md new file mode 100644 index 0000000000..babd3e1068 --- /dev/null +++ b/.planning/security/REVIEW-80.md @@ -0,0 +1,82 @@ +# Phase 80 — Security & Crypto Review + +**Date:** 2026-07-12 +**Scope:** `git diff origin/main...HEAD` (rotation write-plane + re-mint durability; D-03 recipient-pubkey pinning). +**Passes:** crypto/privacy review, general security vulnerability sweep, CodeRabbit CLI. + +## Verdict on D-03 (the crux) + +The three-consumer fail-closed recipient-pin binding (Rust re-mint, TS re-mint, +web upgrade/reconcile) is **cryptographically sound** against the +substituted-relay-pubkey threat, conditional on the pins being present at +re-mint time: + +- Every consumer verifies the relay-fed `recipientPublicKey` against the + owner-sealed pin list read from an owner-authoritative source (Rust + `InodeTable` cache populated from the unsealed write-body; TS/web the sealed + write-body itself) — never from the server share record. +- The compare is raw-byte equality (Rust `pin == recipient_public_key`; TS + length-guarded XOR `bytesEqual`). No loose/substring compare. +- A missing/empty pin on a surviving grant is a HARD fail everywhere (D-03e); no + TOFU/backfill/legacy path. The wrap uses the relay pubkey only AFTER the + byte-equality assertion, so it is equivalent to wrapping to the pin. +- `recipientPins` lives inside the AES-256-GCM–sealed write-body (server-opaque); + the seal AAD (ROLE_BODY 0x01) still binds id/kind/generation. Frozen empty-pin + KAT `seal_vectors[0]` byte-preserved; new `seal_vectors[1]` locks the + non-empty path across Rust/TS. +- D-04 defensive copy of `rotatedNodes[].readKey` confirmed non-aliased (Rust + `Zeroizing` clone; TS `new Uint8Array(...)`), so a future zeroize of + `parentNewReadKey` cannot zero the returned key. + +## Findings & dispositions + +### HIGH — Routine write-body reseals dropped recipientPins — FIXED + +The pin preservation wired into rotation-republish + journal-replay parent +re-splice was NOT wired into the routine mutation reseal paths +(`build_folder_metadata`, `publish_file_node`, 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 (D-03e), +defeating revocation/rotation by ordinary usage. Independently surfaced by the +crypto review and CodeRabbit. **Fixed** in commit `ddb7082e6` across all routine +paths with Rust + TS regression tests; SDK-E2E stays 106/106. + +FLAG 1 (`replay.rs::fetch_splice_publish_parent` empty pins) was the same class, +fixed earlier in commit `3e3ec2a3d`. + +### MEDIUM — Pin lifecycle (pruning-on-revoke, growth, atomic issuance) — TODO + +Pins are never pruned on revoke (a malicious relay could re-inject a +revoked-but-still-pinned recipient — a defense-in-depth gap, since revocation +already trusts relay grant-row honesty), grow unbounded (O(n²) union, never +pruned), and issuance is non-atomic (share row created before the pin CAS-write; +a failed pin-write strands an unpinned share that blocks whole-node rotation). +All fail-closed-safe (no key leak). Deferred to +`.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md`. + +### CodeRabbit — `addRecipientPubkeyPin` missing reconcile-before-publish — FIXED + +The pin-issuance publish skipped the ROT-07 durable anti-rollback +`reconcileFolderSequence` gate every other publish path uses. **Fixed** in +`ddb7082e6`. + +### Dismissed (nits / no material impact) + +- Pin domain type `string[]` (base64) vs `Uint8Array[]` (CodeRabbit): style + refactor across the whole pin API, internally consistent (Rust `Vec>`, + TS base64 wire), no correctness impact. +- `decode.ts` base64 validation of pins (CodeRabbit minor): input is + AEAD-authenticated (relay cannot inject), fails safely at compare. +- `wb_bytes` not zeroized in `reconstruct_write_body` (security L3): mirrors the + accepted `build_folder_metadata` pattern; freed-not-zeroed residue, low value. +- "compressed" vs uncompressed pin comment (I4): doc-only; raw-byte compare works + regardless of encoding. + +## Gate results + +- Rust `cipherbox-fuse` (fuse): 130 passed. +- SDK-E2E (client→API IPNS round-trip, TEE worker up): 106/106. +- sdk unit: 423 passed / 3 skipped; sdk-core: 417; core (incl. KAT): 204. + +winfsp Windows sites updated by inspection only (macOS/CI split) — confirmed via +the CI `Cargo Check & Test (Windows)` job on the PR. diff --git a/.planning/todos/pending/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md b/.planning/todos/completed/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md similarity index 88% rename from .planning/todos/pending/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md rename to .planning/todos/completed/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md index 7eefce2a1b..6fcb62cadb 100644 --- a/.planning/todos/pending/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md +++ b/.planning/todos/completed/2026-07-11-remint-refetches-sent-shares-per-rotated-node.md @@ -37,3 +37,7 @@ owner-reconcile `queryGrantsFn` for parity. A scope-exit rotation over an N-node subtree performs at most ONE `/shares/sent` fetch (not N), and re-mint results are unchanged (retained recipients re-minted, revoked recipients cut by absence). + +## Resolution + +Resolved by Phase 80 (rotation-write-plane-and-re-mint-durability), shipped on branch `feat/rotation-write-plane-and-re-mint-durability`. D-01/D-02/D-03/D-04 implemented and verified (SDK-E2E 106/106, fuse 130). diff --git a/.planning/todos/pending/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md b/.planning/todos/completed/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md similarity index 90% rename from .planning/todos/pending/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md rename to .planning/todos/completed/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md index 7a186c39fc..cc3615f7e6 100644 --- a/.planning/todos/pending/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md +++ b/.planning/todos/completed/2026-07-11-remint-trusts-server-recipient-pubkey-binding.md @@ -49,3 +49,7 @@ Either (a) re-mint compares the server-returned recipient pubkey against a client-pinned value and fails closed on mismatch, or (b) the sharing threat model documents server-trusted recipient-identity binding as an accepted risk with rationale. + +## Resolution + +Resolved by Phase 80 (rotation-write-plane-and-re-mint-durability), shipped on branch `feat/rotation-write-plane-and-re-mint-durability`. D-01/D-02/D-03/D-04 implemented and verified (SDK-E2E 106/106, fuse 130). diff --git a/.planning/todos/pending/2026-07-11-rotation-republish-drops-write-sealed-body.md b/.planning/todos/completed/2026-07-11-rotation-republish-drops-write-sealed-body.md similarity index 92% rename from .planning/todos/pending/2026-07-11-rotation-republish-drops-write-sealed-body.md rename to .planning/todos/completed/2026-07-11-rotation-republish-drops-write-sealed-body.md index 4db81c990e..83fab68c6d 100644 --- a/.planning/todos/pending/2026-07-11-rotation-republish-drops-write-sealed-body.md +++ b/.planning/todos/completed/2026-07-11-rotation-republish-drops-write-sealed-body.md @@ -48,3 +48,7 @@ Verified locally: the "no write_sealed body" flood drops 607→0. Write-key *rotation* remains a separate Phase-72 concern; this only re-seals the UNCHANGED write plane at the bumped generation. Add unit tests for the reconstruction round-trip + the None fallback (were written in the prototype). + +## Resolution + +Resolved by Phase 80 (rotation-write-plane-and-re-mint-durability), shipped on branch `feat/rotation-write-plane-and-re-mint-durability`. D-01/D-02/D-03/D-04 implemented and verified (SDK-E2E 106/106, fuse 130). diff --git a/.planning/todos/pending/2026-07-11-ts-rotatednodes-defensive-copy-parity.md b/.planning/todos/completed/2026-07-11-ts-rotatednodes-defensive-copy-parity.md similarity index 88% rename from .planning/todos/pending/2026-07-11-ts-rotatednodes-defensive-copy-parity.md rename to .planning/todos/completed/2026-07-11-ts-rotatednodes-defensive-copy-parity.md index 688ee025ce..0d6ee954ea 100644 --- a/.planning/todos/pending/2026-07-11-ts-rotatednodes-defensive-copy-parity.md +++ b/.planning/todos/completed/2026-07-11-ts-rotatednodes-defensive-copy-parity.md @@ -44,3 +44,7 @@ Store a defensive copy for robustness + Rust parity (cheap, 32 bytes): `parentNewReadKey`; add a TS regression test asserting every `rotatedNodes` value's `readKey` is non-zero and equals the node's expected new key after `rotateReadFromNode`. + +## Resolution + +Resolved by Phase 80 (rotation-write-plane-and-re-mint-durability), shipped on branch `feat/rotation-write-plane-and-re-mint-durability`. D-01/D-02/D-03/D-04 implemented and verified (SDK-E2E 106/106, fuse 130). diff --git a/.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md b/.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md new file mode 100644 index 0000000000..324a339df5 --- /dev/null +++ b/.planning/todos/pending/2026-07-12-recipient-pin-lifecycle-hardening.md @@ -0,0 +1,141 @@ +--- +created: 2026-07-12T00:00:00.000Z +title: Recipient-pin lifecycle hardening — pruning on revoke, growth bound, atomic issuance +area: sharing-rotation-crypto +severity: medium +source: Phase 80 crypto-privacy-review + security review (2026-07-12) — MEDIUM findings +files: + - packages/sdk-core/src/folder/registration.ts + - packages/sdk-core/src/share/recipient-pins.ts + - packages/sdk/src/rotation/engine.ts + - crates/sdk/src/rotation/engine.rs + - apps/web/src/components/file-browser/ShareDialog.tsx + - docs/METADATA_SCHEMAS.md +resolves_phase: null +--- + +## Context + +Phase 80 (D-03) pins each share recipient's pubkey in the owner-sealed +`NodeWriteBody.recipientPins` and fail-closes re-mint on a server-fed pubkey that +is not pinned. That closes the substituted-relay-pubkey confidentiality break. +Three residual lifecycle gaps remain (all MEDIUM, none a fresh confidentiality +break — the fail-closed direction is always safe): + +## 1. Pins are never pruned on revoke (revocation integrity gap) + +`registration.ts` makes `recipientPins` a monotonically-growing union that is +never pruned. Revocation deletes the grant row but leaves the revoked +recipient's pin. Because the D-03d check only tests pin membership and grant +rows come from the untrusted relay (`GET /shares/sent`), a malicious relay can +re-inject a grant row for a previously-revoked-but-still-pinned recipient +(`isRevoked=false`); the pin check passes and the owner re-wraps the freshly +rotated read key to the revoked recipient — defeating rotation-based revocation. +Caveat: revocation already relies on relay grant-row honesty, so this is a +defense-in-depth gap, not a fresh break. + +Fix: prune the recipient's pin at revocation time (accepting that a genuinely +concurrent re-share re-adds it), OR explicitly document in +`docs/METADATA_SCHEMAS.md` that the pin list does not enforce revocation and +revocation integrity still rests on relay grant-row honesty. + +## 2. Unbounded, non-prunable pin-list growth (DoS / write-body bloat) + +The CAS-409 merge in `registration.ts` unions local ∪ remote pins on every +publish; `appendRecipientPin` is O(current) per pin → O(n²) per retry, and pins +are never removed. A write-capable co-tenant or accumulation over many +share/revoke cycles grows the sealed write-body without bound; every re-mint +then iterates it. Not an escalation (a junk pin grants nothing without a matching +grant row), but an unbounded-allocation / permanent-bloat vector. Pruning on +revoke (item 1) largely resolves this; otherwise add a length cap. + +## 3. Non-atomic share-create → pin-write (revocation liveness) — RESOLVED + +RESOLVED during Phase 80 ship (greptile P1 / thread review): `ShareDialog.tsx` +now commits the pin BEFORE creating the server grant (pin-first), so a partial +failure leaves at most a harmless orphan pin, never an unpinned share that blocks +rotation. Residual (still deferred): a cross-client window where a pin added on +web is absent from a FUSE mount's offline `InodeTable` cache until re-resolve — +reconciliation (`owner-reconcile`) could backfill a missing pin for an existing +grant rather than only fail-closed. + +## 4. Crash-replay pin preservation for a journaled shared-node write + +The routine (non-crash) reseal paths now preserve `recipientPins` +(`build_folder_metadata`, `publish_file_node`, all TS `client.ts` sites). Two +crash-recovery sites still seal pin-less: + +- `crates/fuse/src/journal_helpers.rs` `build_upload_journal_entry` (~:328/:456) + seals the journaled file placeholder with `recipient_pins: Vec::new()`. +- `crates/fuse/src/replay.rs` `replay_upload_entry` (~:1113) re-seals the file + node with empty pins on replay (it has no `InodeTable`, and its input — the + journaled placeholder — is already pin-less). + +So a shared FILE that is overwritten, journaled, and crash-replayed before its +first publish could republish pin-less. Note this is narrow and likely largely +unreachable in practice: for an already-published (existing) shared file, +`replay::publish_child_node`'s idempotency check skips re-publishing a node that +still resolves, so it does not clobber the pinned record. The failure direction +is fail-closed-safe (a later re-mint hard-fails, requiring reconciliation — no +key leak). A complete fix is coordinated: `build_upload_journal_entry` sources +the file inode's pins into the placeholder (it has `self.inodes` access), and +`replay_upload_entry` decodes + threads them from the placeholder write-body +(mirroring `fetch_splice_publish_parent`). Deferred because it is a +crash-recovery path that cannot be integration-tested locally and the routine +paths already cover ordinary usage. + +## 5. File-share recipient pinning is not wired (folder-only issuance) + +`client.ts::addRecipientPubkeyPin` reseals the shared node's OWN folder +write-body via `requireFolder` → `ensureFolderLoaded` → `dfsFindFolder`, which +walks the FOLDER tree only. A shared FILE is a leaf child (not a folder-tree +entry), so `addRecipientPubkeyPin(fileIpnsName)` throws "Shared item not loaded". +Consequently file shares are issued WITHOUT an owner-sealed recipient pin (the +`ShareDialog` issuance skips the pin for `kind === 'file'` to avoid blocking +file sharing — greptile P1 regression). The read/ENFORCE side is symmetric: the +`handleUpgrade` path in `ShareDialog.tsx` also gates the folder-only pin reader +`getRecipientPubkeyPins` → `assertRecipientPinned` on `kind === 'folder'` +(commit 9978a85ca), so a file-share read→write upgrade no longer throws +"Shared item not loaded" — but it also carries NO substitution protection. This +is a pre-existing D-03 limitation (the folder-only helper never pinned files), +not introduced here. + +Impact: if a file share is ever scope-exit re-minted, the D-03e fail-closed pin +check would reject it (no pin) — file-share revocation-rotation would fail +closed. Fix: extend pin issuance to load a file node's own write-body and reseal +its `recipientPins` (or route file-share re-mint to tolerate an absent pin with +an explicit file-share policy). Add a file-share pin round-trip test. + +## 6. Routine mount republish can clobber an out-of-band pin (stale-cache window) + +The desktop mount's inode `recipient_pins` cache is materialized lazily (mount +init / the ~30s periodic metadata refresh). A pin written OUT OF BAND — the +owner sharing a folder from the web client, which reseals that folder's +write-body with the new pin — is not reflected on the desktop mount until its +next refresh. The scope-exit ROTATION path is now safe: `rotate_read_on_scope_exit` +refreshes the grant root's pins from the published write-body before reading +them (`refresh_grant_root_recipient_pins`, commit da393f99d). But a ROUTINE +folder mutation on the desktop mount in that same stale window +(`fs.rs::build_folder_metadata` reseals the cached — empty — pin list, D-03 +ddb7082e6) would republish the folder PIN-LESS, dropping the web-written pin on +the wire until the next refresh re-reads it (and if the wire is already empty, +the refresh reads empty too). + +Impact: narrow window, but a web-issued pin can be silently lost by an +interleaved desktop write before the mount's first post-share refresh — later +re-mint/upgrade then fails closed. Fix options: (a) refresh the folder's pins +from the published write-body before a routine reseal too (costly on the hot +publish path — prefer only when the folder is a known sent-grant root), or +(b) have the mount eagerly refresh a folder's pins when it first observes a new +sent-share rooted there (piggyback on the periodic `/shares/sent` refresh). +Add a share-then-routine-write pin-survival test. + +## Acceptance + +- Revoking a share prunes the recipient's pin (or the residual relay-trust is + documented in `METADATA_SCHEMAS.md`), closing the re-inject path. +- Pin-list growth is bounded (via pruning or an explicit cap). +- Share issuance is atomic (pin committed before/with the share row), so a + partial failure never strands an unpinned share that blocks rotation. +- A routine desktop write in the post-share stale-cache window does not drop an + out-of-band (web-issued) recipient pin (item 6). diff --git a/apps/desktop/src-tauri/src/commands/vault.rs b/apps/desktop/src-tauri/src/commands/vault.rs index 781aed1954..44cb616e10 100644 --- a/apps/desktop/src-tauri/src/commands/vault.rs +++ b/apps/desktop/src-tauri/src/commands/vault.rs @@ -34,6 +34,8 @@ fn build_empty_root_published_node( let write_body = cipherbox_core::node::NodeWriteBody { ipns_private_key: root_ipns_private_key.to_vec(), write_children: Vec::new(), + // Fresh empty root at vault init — no shares yet (D-03). + recipient_pins: Vec::new(), }; let published = cipherbox_core::node::seal::seal_published_node( &root_node, diff --git a/apps/desktop/src-tauri/src/fuse/mod.rs b/apps/desktop/src-tauri/src/fuse/mod.rs index 2bd1ee2b71..0cc861155c 100644 --- a/apps/desktop/src-tauri/src/fuse/mod.rs +++ b/apps/desktop/src-tauri/src/fuse/mod.rs @@ -227,6 +227,9 @@ pub async fn mount_filesystem( read_key: root_read_key.clone(), write_key: root_write_key.clone(), ipns_private_key: Zeroizing::new(root_ipns_private_key.clone().unwrap_or_default()), + // Fresh root inode at mount; recipient pins (D-03) are surfaced onto + // inodes later during owned-listing materialization, not here. + recipient_pins: Vec::new(), }; } diff --git a/apps/desktop/src-tauri/src/fuse/windows/mod.rs b/apps/desktop/src-tauri/src/fuse/windows/mod.rs index 9c98565f1b..df517e35e8 100644 --- a/apps/desktop/src-tauri/src/fuse/windows/mod.rs +++ b/apps/desktop/src-tauri/src/fuse/windows/mod.rs @@ -116,6 +116,9 @@ mod mount_impl { read_key: root_read_key.clone(), write_key: root_write_key.clone(), ipns_private_key: Zeroizing::new(root_ipns_private_key.clone().unwrap_or_default()), + // Fresh root inode at mount; recipient pins (D-03) are surfaced + // onto inodes later during owned-listing materialization. + recipient_pins: Vec::new(), }; } diff --git a/apps/web/src/components/file-browser/ShareDialog.tsx b/apps/web/src/components/file-browser/ShareDialog.tsx index 5fff3999c1..f79377a7ef 100644 --- a/apps/web/src/components/file-browser/ShareDialog.tsx +++ b/apps/web/src/components/file-browser/ShareDialog.tsx @@ -7,7 +7,8 @@ import { sharesControllerGetSentShares, sharesControllerUpdateGrant, } from '@cipherbox/api-client'; -import { wrapKey, hexToBytes, bytesToHex } from '@cipherbox/crypto'; +import { wrapKey, hexToBytes, bytesToHex, bytesToBase64 } from '@cipherbox/crypto'; +import { assertRecipientPinned } from '@cipherbox/sdk'; import { useShareStore } from '../../stores/share.store'; import type { SentShare } from '../../stores/share.store'; import { resolveChildNodeIdentity } from '../../lib/crypto/key-wrapping'; @@ -207,6 +208,38 @@ export function ShareDialog({ logger.warn('[Share] Failed to wrap item name, continuing without it:', err); } + // D-03c issuance write (pin-FIRST for atomicity, FOLDER shares only): + // commit the pasted recipient pubkey to the shared node's owner-sealed + // write-body pin list BEFORE creating the server grant. Ordering is + // load-bearing — a partial failure must never strand a server grant with no + // owner-sealed pin, or the D-03d fail-closed checks would permanently block + // re-mint/upgrade for the whole share root (a revocation-liveness foot-gun). + // Pin-first inverts the failure mode: if the grant create below fails, at + // most a HARMLESS orphan pin remains — a pin grants nothing without a + // matching server grant, and the append is idempotent on retry. This is + // where the pin is FIRST written, so the issuance wraps above (:184/:205) + // stay exempt from a pin compare; later re-mint/upgrade paths (D-03d) verify + // the server-fed recipient against this pin. A failure here throws into the + // shared catch below (surfaced to the user) rather than creating an + // un-pinnable grant. + // + // FOLDERS only: addRecipientPubkeyPin reseals the shared node's OWN folder + // write-body via requireFolder, so a FILE item (a leaf child, not a + // folder-tree entry) would throw "not loaded" and block the share entirely. + // File-share recipient pinning is not yet wired (tracked in the + // recipient-pin-lifecycle todo); skip the pin for files and create the grant + // as before, preserving file sharing without regressing the folder path. + // + // Gate on identity.kind (the child's own PublishedNode envelope, unsealed + // above) — NOT the `kind` prop: the prop falls back to 'folder' when the + // browser's resolvedByIpnsName listing hasn't caught up yet, which would + // route a real FILE into requireFolder and fail the whole share. Skipping + // the pin on an unknown kind instead is NOT safe — an unpinned folder + // grant would be permanently blocked by the D-03d fail-closed checks. + if (identity.kind === 'folder') { + await getSdkClient().addRecipientPubkeyPin(item.ipnsName, recipientPublicKey); + } + const result = await sharesControllerCreateShare({ recipientPublicKey: trimmed.startsWith('0x') ? trimmed : `0x${trimmed}`, encryptedReadKey, @@ -299,6 +332,38 @@ export function ShareDialog({ : share.recipientPublicKey; recipientPublicKey = hexToBytes(bareHex); + // D-03d consumer 3 (fail-closed): the recipientPublicKey above comes + // from the server-fed sent-share store and MUST NOT be trusted for the + // re-wrap. Verify it against the node's owner-sealed recipientPins + // (D-03c issuance write) BEFORE re-wrapping. `getRecipientPubkeyPins` + // returns raw pubkey bytes; normalize to base64 for the shared sdk-core + // helper (its stored-pin encoding). A mismatch or absent/empty pin list + // throws — aborting the upgrade before resolveShareEncryptedWriteKey + // (D-03e no-legacy hard fail); the compare is NOT reimplemented here. + // + // FOLDERS only (symmetric with the issuance gate in handleShare): the pin + // READER `getRecipientPubkeyPins` -> requireFolder resolves the shared + // node's OWN folder write-body, so a FILE item (a leaf child, not a + // folder-tree entry) would throw "not loaded" and block the upgrade + // entirely (greptile P1). File-share recipient pinning is not yet wired + // (tracked in the recipient-pin-lifecycle todo), so a file share carries + // no owner-sealed pin to verify against; skip the pin enforce for files, + // mirroring the write path, rather than fail-closing an unpinnable file + // upgrade. + // + // Gate on the child's own unsealed envelope kind, NOT the `kind` prop — + // the prop falls back to 'folder' while the browser's listing is still + // resolving, which would over-enforce the pin check for a real FILE and + // fail the upgrade until the dialog is reopened. The transient readKey + // from the identity resolve is zeroed immediately (D-09). + const identity = await resolveChildNodeIdentity(item, folderKey); + const itemKind = identity.kind; + identity.readKey.fill(0); + if (itemKind === 'folder') { + const pins = await getSdkClient().getRecipientPubkeyPins(item.ipnsName); + assertRecipientPinned(recipientPublicKey, pins.map(bytesToBase64)); + } + const parentIpnsName = resolveParentIpnsName(parentFolderId); const encryptedWriteKey = await getSdkClient().resolveShareEncryptedWriteKey( parentIpnsName, @@ -327,7 +392,7 @@ export function ShareDialog({ recipientPublicKey?.fill(0); } }, - [item, parentFolderId] + [item, folderKey, parentFolderId] ); const handleDowngradeConfirm = useCallback(async (share: SentShare) => { diff --git a/apps/web/src/services/owner-reconcile.service.ts b/apps/web/src/services/owner-reconcile.service.ts index d51192ad4e..a058307739 100644 --- a/apps/web/src/services/owner-reconcile.service.ts +++ b/apps/web/src/services/owner-reconcile.service.ts @@ -102,11 +102,27 @@ async function deleteGrant(shareId: string): Promise { await sharesControllerRevokeShare(shareId); } -const webOwnerReconcileTransport: OwnerReconcileTransport = { - listSentGrants, - updateGrant, - deleteGrant, -}; +/** + * Build the concrete web owner-reconcile transport for a SINGLE reconcile pass, + * scoped to the root whose `shareRootIpnsName` is closed over here. + * + * The `getRecipientPubkeyPins` seam (D-03d consumer 3, 80-08) supplies the + * node's owner-sealed `recipientPins` so runOwnerReconcile's 80-07 `getPinsFn` + * enforcement can fail-closed verify each surviving grant's recipient BEFORE + * re-wrapping (never trusting the relay-fed `/shares/sent` recipientPublicKey). + * The sdk-core seam threads the `rootNodeId`, but `client.getRecipientPubkeyPins` + * is keyed by ipnsName; each reconcile pass is scoped to exactly one root, so + * this root's `shareRootIpnsName` is the correct 1:1 lookup key. Returns raw + * pubkey bytes; sdk-core normalizes them to base64 for `assertRecipientPinned`. + */ +function makeWebOwnerReconcileTransport(shareRootIpnsName: string): OwnerReconcileTransport { + return { + listSentGrants, + updateGrant, + deleteGrant, + getRecipientPubkeyPins: () => getSdkClient().getRecipientPubkeyPins(shareRootIpnsName), + }; +} /** * Reduce a caught error to name+message before logging: raw Axios/SDK errors @@ -191,7 +207,7 @@ export async function triggerOwnerReconcileOnLogin(): Promise { folderState.nodeGeneration, makeReconcileJob(rootNodeId), buildReconcileCtx(), - webOwnerReconcileTransport + makeWebOwnerReconcileTransport(shareRootIpnsName) ); } catch (error) { logger.error( @@ -247,7 +263,7 @@ export async function runOwnerReconcileForFolder(shareRootIpnsName: string): Pro folderState.nodeGeneration, makeReconcileJob(grant.rootNodeId), buildReconcileCtx(), - webOwnerReconcileTransport + makeWebOwnerReconcileTransport(grant.shareRootIpnsName) ); } catch (error) { logger.error( diff --git a/crates/core/src/node/encode.rs b/crates/core/src/node/encode.rs index 9c3cf8c32d..80d3be99e4 100644 --- a/crates/core/src/node/encode.rs +++ b/crates/core/src/node/encode.rs @@ -137,6 +137,7 @@ mod write_body_tests { child_id: "660e8400-e29b-41d4-a716-446655440001".to_string(), write_key_sealed: "c2VhbGVkLXdyaXRlLWtleQ==".to_string(), }], + recipient_pins: vec![], }; let encoded = encode_write_body(&wb).expect("encode ok"); @@ -149,18 +150,56 @@ mod write_body_tests { let wb = NodeWriteBody { ipns_private_key: vec![0x11u8; 32], write_children: vec![], + recipient_pins: vec![], }; let encoded = encode_write_body(&wb).expect("encode ok"); - // FIXED field order: ipnsPrivateKey then writeChildren. + // FIXED field order: ipnsPrivateKey then writeChildren. An EMPTY + // recipient_pins is omitted from the wire (skip_serializing_if), so the + // encoded bytes are byte-identical to the pre-D-03b output — this is the + // seal_vectors[0] preservation guarantee (D-03b, Pitfall 1). let text = std::str::from_utf8(&encoded).unwrap(); assert!(text.starts_with(r#"{"ipnsPrivateKey":"#)); assert!(text.ends_with(r#""writeChildren":[]}"#)); + assert!(!text.contains("recipientPins")); let decoded = decode_write_body(&encoded).expect("decode ok"); assert_eq!(decoded, wb); } + #[test] + fn write_body_round_trip_with_recipient_pins() { + // Two raw compressed secp256k1 pubkeys (33 bytes each). + let mut pin1 = vec![0x02u8]; + pin1.extend_from_slice(&[0x11u8; 32]); + let mut pin2 = vec![0x03u8]; + pin2.extend_from_slice(&[0x22u8; 32]); + + let wb = NodeWriteBody { + ipns_private_key: vec![0x44u8; 32], + write_children: vec![], + recipient_pins: vec![pin1, pin2], + }; + + let encoded = encode_write_body(&wb).expect("encode ok"); + // FIXED field order: ipnsPrivateKey, writeChildren, then recipientPins + // (matches the TS encodeWriteBody order for cross-language byte parity). + let text = std::str::from_utf8(&encoded).unwrap(); + assert!(text.contains(r#""writeChildren":[],"recipientPins":["#)); + + let decoded = decode_write_body(&encoded).expect("decode ok"); + assert_eq!(decoded, wb); + } + + #[test] + fn decode_write_body_defaults_missing_recipient_pins_to_empty() { + // Old-format JSON with no recipientPins field must decode to an empty + // list (serde default) and NEVER error (forward tolerance, D-03b). + let old_format = br#"{"ipnsPrivateKey":"ERERERERERERERERERERERERERERERERERERERERERE=","writeChildren":[]}"#; + let decoded = decode_write_body(old_format).expect("decode ok"); + assert!(decoded.recipient_pins.is_empty()); + } + #[test] fn decode_write_body_malformed_bytes_fail_closed() { let result = decode_write_body(b"not json at all {{{"); diff --git a/crates/core/src/node/seal.rs b/crates/core/src/node/seal.rs index 4a5c4120fa..2e691ff18d 100644 --- a/crates/core/src/node/seal.rs +++ b/crates/core/src/node/seal.rs @@ -231,6 +231,7 @@ mod seal_published_node_tests { child_id: "660e8400-e29b-41d4-a716-446655440001".to_string(), write_key_sealed: "c2VhbGVkLXdyaXRlLWtleQ==".to_string(), }], + recipient_pins: vec![], } } diff --git a/crates/core/src/node/types.rs b/crates/core/src/node/types.rs index dcbb770544..671f869649 100644 --- a/crates/core/src/node/types.rs +++ b/crates/core/src/node/types.rs @@ -142,6 +142,17 @@ pub struct NodeWriteBody { pub ipns_private_key: Vec, /// Write chain to child nodes; mirrors the read chain in `SealedChildRef`. pub write_children: Vec, + /// Recipient-pubkey pins bound at share/re-mint (D-03b) — each entry a raw + /// compressed secp256k1 public key, base64-encoded on the wire. + /// + /// Additive optional field (METADATA_EVOLUTION_PROTOCOL §3.1): omitted from + /// the wire when empty (`skip_serializing_if`) so the frozen empty-pin KAT + /// (`seal_vectors[0]`) is preserved byte-for-byte, and defaulted to empty + /// on decode so older/newer readers never fail-closed on it. Note the + /// enclosing struct intentionally carries NO `deny_unknown_fields` (forward + /// tolerance, unlike `SealedChildRef`). + #[serde(default, skip_serializing_if = "Vec::is_empty", with = "base64_key_list")] + pub recipient_pins: Vec>, } /// The unified in-memory Node shape (decrypted, plaintext). Mirrors TS `Node`. @@ -250,6 +261,36 @@ mod base64_key { } } +/// Base64 (standard alphabet) serde helper for a LIST of raw key/pubkey byte +/// vectors on the JSON wire (each element base64, the outer value a JSON array). +/// +/// Used by `NodeWriteBody.recipient_pins`; mirrors the TS `string[]` (base64) +/// wire convention so the two codecs are byte-identical (D-03b). +mod base64_key_list { + use base64::Engine as _; + use serde::ser::SerializeSeq; + use serde::{Deserialize, Deserializer, Serializer}; + + pub fn serialize(items: &[Vec], s: S) -> Result { + let mut seq = s.serialize_seq(Some(items.len()))?; + for item in items { + seq.serialize_element(&base64::engine::general_purpose::STANDARD.encode(item))?; + } + seq.end() + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let raw = Vec::::deserialize(d)?; + raw.into_iter() + .map(|s| { + base64::engine::general_purpose::STANDARD + .decode(&s) + .map_err(serde::de::Error::custom) + }) + .collect() + } +} + /// Decimal-string serde helper for `versionFloor` (bigint on the TS wire; /// bigint is not JSON-serializable, so it is a decimal string, D-04). mod u64_as_string { diff --git a/crates/core/tests/node_write_body_vectors.rs b/crates/core/tests/node_write_body_vectors.rs index 9550da7b57..d2cd85645e 100644 --- a/crates/core/tests/node_write_body_vectors.rs +++ b/crates/core/tests/node_write_body_vectors.rs @@ -51,6 +51,11 @@ struct SealVector { write_key: String, ipns_private_key_hex: String, fixed_iv: String, + /// Recipient-pubkey pins (raw compressed secp256k1, base64) sealed inside + /// the write-body (D-03b). Absent (defaults to empty) for the frozen + /// seal_vectors[0] empty-pin KAT. + #[serde(default)] + recipient_pins: Vec, expected_published_node: ExpectedPublishedNode, } @@ -94,9 +99,21 @@ fn write_body_seal_matches_kat() { let ipns_private_key = hex::decode(&v.ipns_private_key_hex) .unwrap_or_else(|_| panic!("Bad hex ipns_private_key_hex in: {}", v.description)); + // Decode the recipient pins (base64 → raw compressed pubkey bytes). + // Empty for seal_vectors[0]; non-empty for seal_vectors[1]. + let recipient_pins: Vec> = v + .recipient_pins + .iter() + .map(|p| { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, p) + .unwrap_or_else(|_| panic!("Bad base64 recipient_pin in: {}", v.description)) + }) + .collect(); + let wb = NodeWriteBody { ipns_private_key, write_children: Vec::::new(), + recipient_pins, }; let wb_bytes = encode_write_body(&wb) .unwrap_or_else(|e| panic!("encode_write_body failed for {}: {:?}", v.description, e)); diff --git a/crates/fuse/src/content_ops.rs b/crates/fuse/src/content_ops.rs index 2b295db694..26cc48c006 100644 --- a/crates/fuse/src/content_ops.rs +++ b/crates/fuse/src/content_ops.rs @@ -218,6 +218,7 @@ pub async fn publish_file_node( read_key: &[u8; 32], write_key: &[u8; 32], ipns_private_key: &zeroize::Zeroizing>, + recipient_pins: &[Vec], coordinator: &crate::PublishCoordinator, encrypted_ipns_for_tee: Option<&str>, tee_key_epoch: Option, @@ -259,9 +260,16 @@ pub async fn publish_file_node( modified_at: now_ms, content: node_content, }; + // D-03 (Plan 80): thread the file's CACHED owner-sealed recipient pins + // (surfaced onto the inode at materialization, 80-05) VERBATIM into the + // re-sealed write-body. A file that has been shared carries recipient pins + // (public ECIES keys, NOT derivable from key material); sealing `Vec::new()` + // on a routine overwrite would republish it WITHOUT pins, hard-failing a + // later re-mint after re-materialize (D-03e). Pins are copied, never rotated. let mut write_body = cipherbox_core::node::NodeWriteBody { ipns_private_key: ipns_private_key.to_vec(), write_children: Vec::new(), + recipient_pins: recipient_pins.to_vec(), }; let seal_result = cipherbox_core::node::seal::seal_published_node( &file_node, diff --git a/crates/fuse/src/fs.rs b/crates/fuse/src/fs.rs index 757979c15d..950fb6a7f1 100644 --- a/crates/fuse/src/fs.rs +++ b/crates/fuse/src/fs.rs @@ -182,6 +182,7 @@ impl CipherBoxFS { ipns_name, is_root, folder_node_id, + recipient_pins, child_inos, ) = { let inode = self @@ -200,6 +201,7 @@ impl CipherBoxFS { write_key, ipns_private_key, ipns_name, + recipient_pins, .. } => ( **read_key, @@ -208,6 +210,7 @@ impl CipherBoxFS { ipns_name.clone(), true, folder_node_id, + recipient_pins.clone(), children, ), crate::inode::InodeKind::Folder { @@ -215,6 +218,7 @@ impl CipherBoxFS { write_key, ipns_private_key, ipns_name, + recipient_pins, .. } => ( **read_key, @@ -223,6 +227,7 @@ impl CipherBoxFS { ipns_name.clone(), false, folder_node_id, + recipient_pins.clone(), children, ), _ => return Err("Cannot update metadata for non-folder inode".to_string()), @@ -303,9 +308,17 @@ impl CipherBoxFS { children: sealed_children, } }; + // D-03 (Plan 80): thread the folder's CACHED owner-sealed recipient pins + // (surfaced onto the inode at materialization, 80-05) VERBATIM into the + // re-sealed write-body. Recipient pins are issuance data (public ECIES + // keys) that are NOT derivable from InodeTable key material — sealing + // `Vec::new()` here would republish a shared node WITHOUT its pins, hard- + // failing a later re-mint after re-materialize (D-03e). Pins are copied, + // never rotated. Mirrors `reconstruct_write_body` (rotation republish). let mut write_body = NodeWriteBody { ipns_private_key: ipns_private_key.to_vec(), write_children, + recipient_pins, }; let published = seal_published_node( &node, @@ -833,6 +846,7 @@ mod drain_refresh_completions_tests { read_key: Zeroizing::new([7u8; 32]), write_key: Zeroizing::new([8u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }, attr: FileAttrs { ino, @@ -871,6 +885,7 @@ mod drain_refresh_completions_tests { read_key: Zeroizing::new([7u8; 32]), write_key: Zeroizing::new([8u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }] } @@ -894,6 +909,7 @@ mod drain_refresh_completions_tests { read_key: Zeroizing::new([7u8; 32]), write_key: Zeroizing::new([8u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }, attr: FileAttrs { ino, @@ -933,6 +949,7 @@ mod drain_refresh_completions_tests { read_key: Zeroizing::new([7u8; 32]), write_key: Zeroizing::new([8u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }) .collect() } @@ -1277,6 +1294,7 @@ mod d07_write_plane_pairing_tests { let write_body = NodeWriteBody { ipns_private_key: ipns_private_key.to_vec(), write_children: Vec::new(), + recipient_pins: Vec::new(), }; let published = seal_published_node(&node, read_key, write_key, Some(&write_body)).unwrap(); encode_published_node(&published).unwrap() @@ -1324,6 +1342,7 @@ mod d07_write_plane_pairing_tests { read_key: Zeroizing::new(child_read_key), write_key: Zeroizing::new(child_write_key), ipns_private_key: Zeroizing::new(child_ipns_private_key.clone()), + recipient_pins: Vec::new(), }, attr: attrs(child_local_ino, false), children: None, @@ -1340,6 +1359,7 @@ mod d07_write_plane_pairing_tests { read_key: Zeroizing::new(parent_read_key), write_key: Zeroizing::new(parent_write_key), ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: Vec::new(), children_loaded: true, }, attr: attrs(parent_ino, true), @@ -1409,3 +1429,105 @@ mod d07_write_plane_pairing_tests { ); } } + +/// D-03 (Plan 80) recipient-pin preservation regression: a routine folder +/// republish through `build_folder_metadata` must carry the folder inode's +/// cached owner-sealed `recipient_pins` VERBATIM into the sealed write-body. +/// +/// Before the fix, `build_folder_metadata` sealed `recipient_pins: Vec::new()`, +/// so any ordinary write to a shared folder republished it PIN-LESS — a later +/// re-mint (after re-materialize) then read empty pins and hard fail-closed +/// (D-03e), silently defeating revocation/rotation. Pins are PUBLIC ECIES keys: +/// copied, never rotated. Models `reconstruct_write_body_preserves_cached_recipient_pins`. +#[cfg(all(test, feature = "fuse"))] +mod recipient_pin_preservation_tests { + use crate::inode::{FileAttrs, InodeData, InodeKind, ROOT_INO}; + use crate::test_support::make_test_fs; + use base64::Engine as _; + use cipherbox_core::node::seal::unseal_node; + use cipherbox_core::node::{decode_published_node, decode_write_body, NodeKind}; + use std::time::SystemTime; + use zeroize::Zeroizing; + + fn dir_attrs(ino: u64) -> FileAttrs { + let now = SystemTime::now(); + FileAttrs { + ino, + size: 0, + blocks: 0, + atime: now, + mtime: now, + ctime: now, + crtime: now, + is_dir: true, + perm: 0o777, + nlink: 2, + } + } + + #[tokio::test] + async fn build_folder_metadata_preserves_cached_recipient_pins() { + let mut fs = make_test_fs(); + + let folder_ino = fs.inodes.allocate_ino(); + let folder_write_key = [22u8; 32]; + const FOLDER_IPNS: &str = "k51-folder-pins"; + // Two distinct owner-sealed recipient pins (public ECIES keys — arbitrary + // bytes here; a 33-byte compressed key plus a short marker). + let pins = vec![vec![0x04u8; 33], vec![0x04u8, 0x11, 0x22, 0x33]]; + let folder_node_id = crate::fs::uuid_from_ino(folder_ino); + + fs.inodes.insert(InodeData { + ino: folder_ino, + node_id: folder_node_id.clone(), + parent_ino: ROOT_INO, + name: "shared".to_string(), + kind: InodeKind::Folder { + ipns_name: FOLDER_IPNS.to_string(), + read_key: Zeroizing::new([21u8; 32]), + write_key: Zeroizing::new(folder_write_key), + ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: pins.clone(), + children_loaded: true, + }, + attr: dir_attrs(folder_ino), + children: Some(vec![]), + write_generation: 0, + }); + if let Some(root) = fs.inodes.get_mut(ROOT_INO) { + root.children.get_or_insert_with(Vec::new).push(folder_ino); + } + + // The REAL routine republish path. + let (published_bytes, _ipk, ipns_name, _old_cid) = fs + .build_folder_metadata(folder_ino) + .expect("build_folder_metadata should succeed"); + assert_eq!(ipns_name, FOLDER_IPNS); + + // Decode the sealed node and recover its write-body (sealed under the + // folder's OWN write key at generation 0, ROLE_BODY 0x01). + let published = + decode_published_node(&published_bytes).expect("decode the published node"); + let write_sealed_b64 = published + .write_sealed + .expect("a routine folder republish must populate write_sealed"); + let write_sealed_bytes = base64::engine::general_purpose::STANDARD + .decode(write_sealed_b64) + .expect("write_sealed is valid base64"); + let wb_bytes = unseal_node( + &write_sealed_bytes, + &folder_write_key, + &folder_node_id, + NodeKind::Folder, + 0, + ) + .expect("unseal the write-body under the folder write key"); + let wb = decode_write_body(&wb_bytes).expect("decode the write-body"); + + assert_eq!( + wb.recipient_pins, pins, + "a routine folder republish MUST preserve the cached recipient pins \ + (D-03e durability) — sealing Vec::new() here silently defeats revocation" + ); + } +} diff --git a/crates/fuse/src/inode.rs b/crates/fuse/src/inode.rs index 02dec8068f..655f20496e 100644 --- a/crates/fuse/src/inode.rs +++ b/crates/fuse/src/inode.rs @@ -133,6 +133,11 @@ pub enum InodeKind { /// Decrypted Ed25519 IPNS private key (signing seed) for this folder. /// Wrapped in `Zeroizing` for automatic zeroization on drop. ipns_private_key: Zeroizing>, + /// D-03a: the node's owner-sealed recipient pins (PUBLIC ECIES keys), + /// cached from the unsealed write-body at materialization so the Rust + /// re-mint (80-06) can verify them offline and a rotation republish can + /// PRESERVE them (D-01↔D-03e). Not secret material — never redacted. + recipient_pins: Vec>, }, /// Subfolder within the vault. @@ -146,6 +151,11 @@ pub enum InodeKind { /// Decrypted Ed25519 IPNS private key for signing this folder's records. /// Wrapped in `Zeroizing` for automatic zeroization on drop. ipns_private_key: Zeroizing>, + /// D-03a: the node's owner-sealed recipient pins (PUBLIC ECIES keys), + /// cached from the unsealed write-body at materialization so the Rust + /// re-mint (80-06) can verify them offline and a rotation republish can + /// PRESERVE them (D-01↔D-03e). Not secret material — never redacted. + recipient_pins: Vec>, /// Whether children have been loaded from node/v3 metadata. children_loaded: bool, }, @@ -169,6 +179,11 @@ pub enum InodeKind { /// Decrypted Ed25519 IPNS private key for signing this file's record. /// Wrapped in `Zeroizing` for automatic zeroization on drop. ipns_private_key: Zeroizing>, + /// D-03a: the node's owner-sealed recipient pins (PUBLIC ECIES keys), + /// cached from the unsealed write-body at materialization so the Rust + /// re-mint (80-06) can verify them offline and a rotation republish can + /// PRESERVE them (D-01↔D-03e). Not secret material — never redacted. + recipient_pins: Vec>, }, } @@ -178,16 +193,24 @@ impl std::fmt::Debug for InodeKind { /// material can never reach logs or panic output (crypto rule #2). fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - InodeKind::Root { ipns_name, .. } => f + InodeKind::Root { + ipns_name, + recipient_pins, + .. + } => f .debug_struct("Root") .field("ipns_name", ipns_name) .field("read_key", &"") .field("write_key", &"") .field("ipns_private_key", &"") + // Recipient pins are PUBLIC keys, not secret material -- shown + // as a count (non-secret) while key material stays redacted. + .field("recipient_pins_count", &recipient_pins.len()) .finish(), InodeKind::Folder { ipns_name, children_loaded, + recipient_pins, .. } => f .debug_struct("Folder") @@ -196,6 +219,7 @@ impl std::fmt::Debug for InodeKind { .field("read_key", &"") .field("write_key", &"") .field("ipns_private_key", &"") + .field("recipient_pins_count", &recipient_pins.len()) .finish(), InodeKind::File { ipns_name, @@ -203,6 +227,7 @@ impl std::fmt::Debug for InodeKind { size, encryption_mode, iv, + recipient_pins, .. } => f .debug_struct("File") @@ -214,6 +239,7 @@ impl std::fmt::Debug for InodeKind { .field("read_key", &"") .field("write_key", &"") .field("ipns_private_key", &"") + .field("recipient_pins_count", &recipient_pins.len()) .finish(), } } @@ -301,6 +327,7 @@ impl InodeTable { read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(Vec::new()), + recipient_pins: Vec::new(), }, attr: root_attr, children: Some(vec![]), @@ -499,6 +526,7 @@ impl InodeTable { read_key, write_key, ipns_private_key, + recipient_pins, } = owned; // Reuse existing ino: prefer stable ipns_name, fall back to display name. @@ -582,6 +610,9 @@ impl InodeTable { read_key, write_key, ipns_private_key, + // D-03a: cache the node's owner-sealed recipient pins + // for offline re-mint + rotation preservation. + recipient_pins, children_loaded: was_loaded, }, attr, @@ -625,6 +656,9 @@ impl InodeTable { read_key, write_key, ipns_private_key, + // D-03a: cache the node's owner-sealed recipient pins + // for offline re-mint + rotation preservation. + recipient_pins, }, attr, children: None, @@ -849,6 +883,7 @@ mod tests { read_key: Zeroizing::new([0x11u8; 32]), write_key: Zeroizing::new([0x22u8; 32]), ipns_private_key: Zeroizing::new(vec![0x33u8; 32]), + recipient_pins: Vec::new(), } } @@ -866,6 +901,7 @@ mod tests { read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(vec![0u8; 32]), + recipient_pins: Vec::new(), children_loaded: false, }, attr: FileAttrs { @@ -1024,6 +1060,49 @@ mod tests { assert_eq!(unresolved[0].0, file_ino); } + // D-03a (80-05): a materialized owned node's recipient pins (from its + // unsealed write-body) are cached on the inode so the Rust re-mint (80-06) + // can verify them offline and reconstruction (rotation republish) preserves + // them. + #[test] + fn apply_owned_children_caches_recipient_pins_on_the_inode() { + let mut table = InodeTable::new(); + let pins = vec![vec![0x04u8; 33], vec![0x04u8, 0x99, 0xAB]]; + + let mut folder_child = owned_child("k51pinnedfolder", "pinnedfolder", NodeKind::Folder, None); + folder_child.recipient_pins = pins.clone(); + let mut file_child = owned_child("k51pinnedfile", "pinned.txt", NodeKind::File, Some(7)); + file_child.recipient_pins = pins.clone(); + + table.apply_owned_children(ROOT_INO, vec![folder_child, file_child], false); + + let folder_ino = table + .find_child(ROOT_INO, "pinnedfolder") + .expect("folder linked"); + match &table.get(folder_ino).unwrap().kind { + InodeKind::Folder { recipient_pins, .. } => { + assert_eq!( + *recipient_pins, pins, + "the folder inode caches the write-body recipient pins" + ); + } + other => panic!("expected Folder, got {:?}", other), + } + + let file_ino = table + .find_child(ROOT_INO, "pinned.txt") + .expect("file linked"); + match &table.get(file_ino).unwrap().kind { + InodeKind::File { recipient_pins, .. } => { + assert_eq!( + *recipient_pins, pins, + "the file inode caches the write-body recipient pins" + ); + } + other => panic!("expected File, got {:?}", other), + } + } + #[test] fn apply_owned_children_reuses_ino_on_rename_by_ipns_name() { let mut table = InodeTable::new(); diff --git a/crates/fuse/src/journal_helpers.rs b/crates/fuse/src/journal_helpers.rs index e30acae286..c837b7d009 100644 --- a/crates/fuse/src/journal_helpers.rs +++ b/crates/fuse/src/journal_helpers.rs @@ -325,6 +325,7 @@ impl crate::CipherBoxFS { let write_body = NodeWriteBody { ipns_private_key: file_ipns_private_key.to_vec(), write_children: Vec::new(), + recipient_pins: Vec::new(), }; let published = seal_published_node( &file_node, @@ -452,6 +453,7 @@ impl crate::CipherBoxFS { let child_write_body = NodeWriteBody { ipns_private_key: child_ipns_private_key.to_vec(), write_children: Vec::new(), + recipient_pins: Vec::new(), }; let child_published = seal_published_node( &child_node, diff --git a/crates/fuse/src/platform/windows/write_ops.rs b/crates/fuse/src/platform/windows/write_ops.rs index dab853a639..c937f2a9b5 100644 --- a/crates/fuse/src/platform/windows/write_ops.rs +++ b/crates/fuse/src/platform/windows/write_ops.rs @@ -125,6 +125,8 @@ pub mod implementation { read_key: zeroize::Zeroizing::new(read_key), write_key: zeroize::Zeroizing::new(write_key), ipns_private_key: zeroize::Zeroizing::new(ipns_private_key.to_vec()), + // Freshly created node: no share grants yet, no recipient pins. + recipient_pins: Vec::new(), children_loaded: true, }, attr: attr.clone(), @@ -362,6 +364,8 @@ pub mod implementation { read_key: zeroize::Zeroizing::new(read_key), write_key: zeroize::Zeroizing::new(write_key), ipns_private_key: zeroize::Zeroizing::new(file_ipns_private_key), + // Freshly created node: no share grants yet, no recipient pins. + recipient_pins: Vec::new(), }, attr: attr.clone(), children: None, @@ -964,6 +968,21 @@ pub mod implementation { .get(ino) .map(|i| i.node_id.clone()) .unwrap_or_else(|| crate::fs::uuid_from_ino(ino)); + // D-03 (Plan 80): source the file's CACHED owner-sealed + // recipient pins from the inode so a routine overwrite + // re-publish PRESERVES them (a shared file republished + // pin-less would hard-fail a later re-mint, D-03e). Pins + // are public ECIES keys — copied verbatim, never rotated. + let file_recipient_pins = fs + .inodes + .get(ino) + .map(|i| match &i.kind { + crate::inode::InodeKind::File { recipient_pins, .. } => { + recipient_pins.clone() + } + _ => Vec::new(), + }) + .unwrap_or_default(); let crate::journal_helpers::UploadJournalResult { ciphertext, @@ -1035,6 +1054,7 @@ pub mod implementation { &file_read_key, &file_write_key, &file_ipns_private_key, + &file_recipient_pins, &coordinator, encrypted_ipns_for_tee.as_deref(), tee_key_epoch, @@ -1469,6 +1489,7 @@ pub mod implementation { read_key: Zeroizing::new([1u8; 32]), write_key: Zeroizing::new([6u8; 32]), ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: Vec::new(), children_loaded: true, }, attr: FileAttrs { @@ -1528,6 +1549,7 @@ pub mod implementation { read_key: Zeroizing::new([2u8; 32]), write_key: Zeroizing::new([4u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }, attr: FileAttrs { ino, @@ -1745,6 +1767,7 @@ pub mod implementation { let write_body = NodeWriteBody { ipns_private_key: vec![0u8; 32], write_children: vec![write_child_ref.clone()], + recipient_pins: Vec::new(), }; let published = seal_published_node( &parent_node, diff --git a/crates/fuse/src/read_ops.rs b/crates/fuse/src/read_ops.rs index 05be8936b2..5b4ed89b6c 100644 --- a/crates/fuse/src/read_ops.rs +++ b/crates/fuse/src/read_ops.rs @@ -856,6 +856,21 @@ pub(crate) mod implementation { .get(spawn_ino) .map(|i| i.node_id.clone()) .unwrap_or_else(|| crate::fs::uuid_from_ino(spawn_ino)); + // D-03 (Plan 80): source the file's CACHED owner-sealed + // recipient pins from the inode so a routine overwrite + // re-publish PRESERVES them (a shared file republished + // pin-less would hard-fail a later re-mint, D-03e). Pins + // are public ECIES keys — copied verbatim, never rotated. + let file_recipient_pins = fs + .inodes + .get(spawn_ino) + .map(|i| match &i.kind { + crate::inode::InodeKind::File { recipient_pins, .. } => { + recipient_pins.clone() + } + _ => Vec::new(), + }) + .unwrap_or_default(); let api = fs.api.clone(); let rt = fs.rt.clone(); @@ -904,6 +919,7 @@ pub(crate) mod implementation { &file_read_key, &file_write_key, &file_ipns_private_key, + &file_recipient_pins, &coordinator, encrypted_ipns_for_tee.as_deref(), tee_key_epoch, diff --git a/crates/fuse/src/replay.rs b/crates/fuse/src/replay.rs index fbd4b8cdd1..64e6fba81a 100644 --- a/crates/fuse/src/replay.rs +++ b/crates/fuse/src/replay.rs @@ -494,14 +494,19 @@ where ) })?, ); - let mut write_children = decode_write_body(&write_body_bytes) - .map_err(|e| { - format!( - "decode parent write-body {}: {} — retaining entry", - parent_ipns_name, e - ) - })? - .write_children; + // Preserve the parent's OWN owner-sealed recipient pins (D-03) verbatim: a + // parent folder/root with active shares carries pins in its write-body, and + // a re-splice must NOT drop them, or a later re-mint would read empty pins + // from the remounted InodeTable and hard fail-closed (D-03e). Only the + // write-children plane changes on this path; pins pass through unchanged. + let decoded_write_body = decode_write_body(&write_body_bytes).map_err(|e| { + format!( + "decode parent write-body {}: {} — retaining entry", + parent_ipns_name, e + ) + })?; + let parent_recipient_pins = decoded_write_body.recipient_pins; + let mut write_children = decoded_write_body.write_children; // Extract read-plane children + node identity, preserving the variant. let (id, generation, created_at, modified_at, mut children, is_root) = match node { @@ -575,6 +580,7 @@ where let new_write_body = NodeWriteBody { ipns_private_key: parent_signing_seed.to_vec(), write_children, + recipient_pins: parent_recipient_pins, }; let new_published = seal_published_node( &new_node, @@ -1104,9 +1110,16 @@ where modified_at, content, }; + // D-03: empty recipient pins is CORRECT here. This re-seals a JOURNALED file + // PLACEHOLDER (child_published_node_b64), which `build_upload_journal_entry` + // always seals with no pins (journal_helpers.rs), and replay has no InodeTable + // to source pins from — there is nothing to preserve at this site. Contrast + // the parent re-splice (`fetch_splice_publish_parent`), which DECODES and + // preserves the parent's existing `recipient_pins` from its current write-body. let write_body = NodeWriteBody { ipns_private_key: file_signing_seed.to_vec(), write_children: Vec::new(), + recipient_pins: Vec::new(), }; let resealed_published = seal_published_node( &resealed_node, @@ -1452,4 +1465,81 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + + // D-01 durability regression (Plan 80-02): after a scope-exit read-key + // rotation reconstructs a rotated node's `write_sealed` from the local + // InodeTable (via the FUSE rotation adapter's `reconstruct_write_body`), + // `recover_signing_seed` on that PublishedNode succeeds — the "no + // write_sealed body — cannot recover signing seed" fail path no longer + // fires for a materialized rotated node (T-80-04). + #[cfg(any(feature = "fuse", feature = "winfsp"))] + #[test] + fn rotation_reconstructed_write_sealed_recovers_signing_seed() { + use crate::inode::{FileAttrs, InodeData, InodeKind, InodeTable, ROOT_INO}; + use crate::write_ops::rotation_deps::reconstruct_write_body; + use base64::Engine as _; + use cipherbox_core::node::{NodeKind, PublishedNode}; + use zeroize::Zeroizing; + + const NODE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let ipns_name = "k51replayrecon"; + let node_write_key = [44u8; 32]; + let node_ipns_private_key = vec![55u8; 32]; + let new_generation = 9u32; + + // A materialized rotated folder node in the local inode table. + let mut table = InodeTable::new(); + let ino = table.allocate_ino(); + let now = std::time::SystemTime::now(); + table.insert(InodeData { + ino, + node_id: NODE_ID.to_string(), + parent_ino: ROOT_INO, + name: "rotated".to_string(), + kind: InodeKind::Folder { + ipns_name: ipns_name.to_string(), + read_key: Zeroizing::new([1u8; 32]), + write_key: Zeroizing::new(node_write_key), + ipns_private_key: Zeroizing::new(node_ipns_private_key.clone()), + recipient_pins: Vec::new(), + children_loaded: true, + }, + attr: FileAttrs { + ino, + size: 0, + blocks: 0, + atime: now, + mtime: now, + ctime: now, + crtime: now, + is_dir: true, + perm: 0o755, + nlink: 2, + }, + children: Some(vec![]), + write_generation: 0, + }); + + // The rotation republish reconstructs the write plane at the NEW generation. + let sealed = reconstruct_write_body(&table, ipns_name, new_generation) + .expect("a materialized rotated node reconstructs a write-body"); + + let published = PublishedNode { + schema: "node/v3".to_string(), + kind: "folder".to_string(), + id: NODE_ID.to_string(), + generation: new_generation, + aead_version: 1, + read_sealed: String::new(), + write_sealed: Some(base64::engine::general_purpose::STANDARD.encode(&sealed)), + }; + + let seed = super::recover_signing_seed(&published, &node_write_key, NodeKind::Folder) + .expect("recover_signing_seed must succeed on a reconstructed write_sealed"); + assert_eq!( + seed.to_vec(), + node_ipns_private_key, + "the recovered signing seed matches the rotated node's ipns_private_key" + ); + } } diff --git a/crates/fuse/src/test_support.rs b/crates/fuse/src/test_support.rs index 4c97966ed4..31d0ead3d9 100644 --- a/crates/fuse/src/test_support.rs +++ b/crates/fuse/src/test_support.rs @@ -91,6 +91,7 @@ pub(crate) fn make_test_fs_with_keypair( read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(vec![7u8; 32]), + recipient_pins: Vec::new(), }; } diff --git a/crates/fuse/src/write_ops/grant_scope.rs b/crates/fuse/src/write_ops/grant_scope.rs index f8db8a994c..df7a125303 100644 --- a/crates/fuse/src/write_ops/grant_scope.rs +++ b/crates/fuse/src/write_ops/grant_scope.rs @@ -471,6 +471,17 @@ pub async fn rotate_read_on_scope_exit( deleted_child_id: &str, root_children_override: Option>, ) -> Result<(), RotationError> { + // D-03a share→rotate race fix: refresh the grant root's cached recipient + // pins from its CURRENT published write-body BEFORE the rotation reads them. + // A pin written out of band (e.g. the owner sharing this folder from the web + // client) may not yet be materialized on this mount's inode cache; reading + // the stale/empty cache would fail-close the retained recipient's re-mint + // ("0 pinned", D-03e) AND republish the node pin-less (reconstruct_write_body), + // aborting the rotation and cascading into AES-GCM refresh failures on the + // slower FUSE-T/WinFsp mounts. Best-effort (never fails the rotation itself). + crate::write_ops::rotation_deps::refresh_grant_root_recipient_pins(fs, grant_root_ipns_name) + .await; + // ipns_name (read plane) and child UUID (write plane) are PUBLIC // identifiers, not key material — safe to log (CLAUDE.md rule 2). let Some((root_node_id, root_read_key)) = @@ -861,6 +872,7 @@ mod tests { read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(vec![0u8; 32]), + recipient_pins: Vec::new(), children_loaded: false, }, attr: make_attrs(ino, true), @@ -892,6 +904,7 @@ mod tests { read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(vec![0u8; 32]), + recipient_pins: Vec::new(), }, attr: make_attrs(ino, false), children: None, @@ -909,6 +922,7 @@ mod tests { read_key: Zeroizing::new([0u8; 32]), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: Zeroizing::new(Vec::new()), + recipient_pins: Vec::new(), }; } let folder_a = table.allocate_ino(); diff --git a/crates/fuse/src/write_ops/implementation/delete.rs b/crates/fuse/src/write_ops/implementation/delete.rs index 09a5984b29..317b817bda 100644 --- a/crates/fuse/src/write_ops/implementation/delete.rs +++ b/crates/fuse/src/write_ops/implementation/delete.rs @@ -588,6 +588,7 @@ mod tests { read_key: Zeroizing::new([2u8; 32]), write_key: Zeroizing::new([4u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }, attr: FileAttrs { ino, @@ -624,6 +625,7 @@ mod tests { read_key: Zeroizing::new([1u8; 32]), write_key: Zeroizing::new([6u8; 32]), ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: Vec::new(), children_loaded: true, }, attr: FileAttrs { @@ -889,6 +891,7 @@ mod tests { read_key: Zeroizing::new(root_read_key), write_key: Zeroizing::new([0u8; 32]), ipns_private_key: root_priv.clone(), + recipient_pins: Vec::new(), }; } @@ -1061,6 +1064,7 @@ mod tests { let write_body = NodeWriteBody { ipns_private_key: vec![0u8; 32], write_children: vec![write_child_ref.clone()], + recipient_pins: Vec::new(), }; let published = seal_published_node( &parent_node, diff --git a/crates/fuse/src/write_ops/implementation/file_data.rs b/crates/fuse/src/write_ops/implementation/file_data.rs index b6a2df25dc..16b74ba560 100644 --- a/crates/fuse/src/write_ops/implementation/file_data.rs +++ b/crates/fuse/src/write_ops/implementation/file_data.rs @@ -237,6 +237,8 @@ pub fn handle_create( read_key: zeroize::Zeroizing::new(read_key), write_key: zeroize::Zeroizing::new(write_key), ipns_private_key: zeroize::Zeroizing::new(file_ipns_private_key), + // Freshly created node: no share grants yet, so no recipient pins. + recipient_pins: Vec::new(), }, attr, children: None, @@ -340,6 +342,7 @@ mod tests { read_key: zeroize::Zeroizing::new([0u8; 32]), write_key: zeroize::Zeroizing::new([0u8; 32]), ipns_private_key: zeroize::Zeroizing::new(vec![0u8; 32]), + recipient_pins: Vec::new(), children_loaded: false, }, attr, diff --git a/crates/fuse/src/write_ops/implementation/mkdir.rs b/crates/fuse/src/write_ops/implementation/mkdir.rs index 8c56ac8a51..840f0191c0 100644 --- a/crates/fuse/src/write_ops/implementation/mkdir.rs +++ b/crates/fuse/src/write_ops/implementation/mkdir.rs @@ -88,6 +88,8 @@ pub fn handle_mkdir(fs: &mut CipherBoxFS, parent: u64, name: &OsStr, reply: Repl read_key: zeroize::Zeroizing::new(read_key), write_key: zeroize::Zeroizing::new(write_key), ipns_private_key: zeroize::Zeroizing::new(ipns_private_key.to_vec()), + // Freshly created node: no share grants yet, so no recipient pins. + recipient_pins: Vec::new(), children_loaded: true, }, attr, diff --git a/crates/fuse/src/write_ops/implementation/rename.rs b/crates/fuse/src/write_ops/implementation/rename.rs index 0086b94b82..aff294cd4a 100644 --- a/crates/fuse/src/write_ops/implementation/rename.rs +++ b/crates/fuse/src/write_ops/implementation/rename.rs @@ -322,6 +322,7 @@ mod tests { read_key: Zeroizing::new([1u8; 32]), write_key: Zeroizing::new([6u8; 32]), ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: Vec::new(), children_loaded: true, }, attr: FileAttrs { @@ -379,6 +380,7 @@ mod tests { read_key: Zeroizing::new([2u8; 32]), write_key: Zeroizing::new([4u8; 32]), ipns_private_key: Zeroizing::new(vec![3u8; 32]), + recipient_pins: Vec::new(), }, attr: FileAttrs { ino, diff --git a/crates/fuse/src/write_ops/rotation_deps.rs b/crates/fuse/src/write_ops/rotation_deps.rs index 40f69091c6..ccccd41a69 100644 --- a/crates/fuse/src/write_ops/rotation_deps.rs +++ b/crates/fuse/src/write_ops/rotation_deps.rs @@ -64,7 +64,11 @@ use zeroize::Zeroizing; use cipherbox_api_client::ipns::{resolve_ipns_verified, VerifyError}; use cipherbox_api_client::shares::SentShareResponse; use cipherbox_api_client::{ApiClient, ApiError, IpnsPublishRequest, PublishResult}; -use cipherbox_core::node::{decode_published_node, encode_published_node, PublishedNode}; +use cipherbox_core::node::seal::{seal_child_write_key, seal_node, unseal_node}; +use cipherbox_core::node::{ + decode_published_node, decode_write_body, encode_published_node, encode_write_body, NodeKind, + NodeWriteBody, PublishedNode, WriteChildRef, +}; use cipherbox_sdk::rotation::{GrantRow, PublishAttempt}; use cipherbox_sdk::{ JsonSidecarFloorStore, PublishOutcome, ResolvedRecord, RotationDeps, RotationError, @@ -144,6 +148,17 @@ pub trait RotationTransport { /// Hard-revoke a single share/invite grant by ID — `DELETE /shares/:shareId`. async fn revoke_share(&self, share_id: &str) -> Result<(), RotationError>; + + /// D-03d/D-03e (Plan 80-06): the node's OWN owner-sealed recipient pin list + /// (raw ECIES pubkey bytes), read OFFLINE. Production ([`ApiClientTransport`]) + /// resolves it from the already-materialized `InodeTable` pin cache (80-05) + /// — NO extra `GET /shares/sent` or network fetch; tests seed it in-memory. + /// An absent/non-materialized node yields an empty list (the caller treats + /// empty-at-re-mint as a hard fail-closed, D-03e). + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError>; } // --------------------------------------------------------------------------- @@ -172,6 +187,16 @@ pub struct FuseRotationDeps { /// Combined per-nodeId sidecar (Plan 70.1-03) backing /// `persist_wrapped_key`/`get_wrapped_key`/`delete_wrapped_key`. floor_store: JsonSidecarFloorStore, + /// D-02 job-scoped cache of `GET /shares/sent`. `query_grants_rooted_at` is + /// called once per rotated node during re-mint; without this each call + /// re-fetches the full sent-share list (O(nodes × shares), 607×/run + /// observed). This `FuseRotationDeps` is constructed ONCE per rotation job + /// (grant_scope.rs:488) and walked via `&deps`, so an interior-mutable + /// `OnceCell` (populated on the first query, reused thereafter) bounds the + /// fetch to `<= 1` per job — job-scoped, not static/global. `OnceCell` + /// (not `RefCell`) because the fetch is `async` and must not hold a borrow + /// across the `.await`. + sent_shares_cache: tokio::sync::OnceCell>, } impl FuseRotationDeps { @@ -186,6 +211,7 @@ impl FuseRotationDeps { owner_public_key, owner_private_key: Zeroizing::new(owner_private_key), floor_store, + sent_shares_cache: tokio::sync::OnceCell::new(), } } } @@ -262,9 +288,16 @@ impl RotationDeps for FuseRotationDeps { /// they never appear in this query result (Pitfall 2 / T-74-14 — a /// revoked recipient is cut by ABSENCE, not a flag). async fn query_grants_rooted_at(&self, node_id: &str) -> Result, RotationError> { - let shares = self.transport.collect_sent_shares().await?; + // D-02: fetch `GET /shares/sent` once per rotation job, then filter the + // cached rows by `root_node_id` on every subsequent node. The per-share + // 0x-strip/hex-decode + per-share RotateFailed error path below is + // unchanged — only the source (cached slice) differs. + let shares = self + .sent_shares_cache + .get_or_try_init(|| self.transport.collect_sent_shares()) + .await?; shares - .into_iter() + .iter() .filter(|s| s.root_node_id == node_id) .map(|s| { let recipient_public_key = cipherbox_crypto::utils::hex_to_bytes( @@ -277,7 +310,7 @@ impl RotationDeps for FuseRotationDeps { )) })?; Ok(GrantRow { - share_id: s.share_id, + share_id: s.share_id.clone(), recipient_public_key, is_revoked: false, }) @@ -309,6 +342,19 @@ impl RotationDeps for FuseRotationDeps { self.transport.revoke_share(share_id).await } + /// D-03d/D-03e (Plan 80-06): resolves the node's OWN owner-sealed recipient + /// pins OFFLINE via the transport seam (production: the `InodeTable` pin + /// cache surfaced in 80-05; no extra network fetch — D-03a). The engine's + /// `re_mint_grants_rooted_at` fails the node's re-mint closed if + /// `grant.recipient_public_key` is not among these pins (T-80-15) or the + /// list is empty (D-03e no-legacy, T-80-16). + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError> { + self.transport.get_recipient_pubkey_pins(node_id).await + } + /// D-01/D-03: ECIES-wraps `wrapped_b64`'s raw key material under the /// owner's OWN public key before persisting ciphertext-only to the /// combined floor store (Plan 70.1-03). Fails closed (D-08) on any @@ -431,6 +477,28 @@ impl RotationTransport for ApiClientTransport<'_> { })?; let seed_arr = to_key32(&signing_seed, "IPNS signing seed")?; + // D-01: the read-key rotation engine hands us a node with + // `write_sealed: None`. Reconstruct + inject the write-body from the + // locally-materialized InodeTable, re-sealed under the node's OWN write + // key at its NEW generation (ROLE_BODY 0x01) — restoring owned-walkability + // and `replay.rs` signing-seed durability. Fail-open to the unchanged + // (None) node for a non-materialized node (D-01b). Never rotates/mutates + // the write plane — the child write keys are copied verbatim. + let reconstructed_node; + let node = if node.write_sealed.is_none() { + match reconstruct_write_body(self.inodes, ipns_name, node.generation) { + Some(sealed) => { + let mut cloned = node.clone(); + cloned.write_sealed = Some(STANDARD.encode(sealed)); + reconstructed_node = cloned; + &reconstructed_node + } + None => node, + } + } else { + node + }; + let node_bytes = encode_published_node(node).map_err(|e| { RotationError::RotateFailed(format!( "publish: encode_published_node failed for {ipns_name}: {e}" @@ -532,6 +600,187 @@ impl RotationTransport for ApiClientTransport<'_> { )) }) } + + /// D-03d/D-03e (Plan 80-06): reads the node's cached recipient pins OFFLINE + /// from the already-materialized `InodeTable` (80-05) — NO network fetch. + /// A node not locally materialized returns an empty list, so a later + /// re-mint fails closed (D-03e), rather than trusting the relay pubkey. + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError> { + Ok(find_recipient_pins(self.inodes, node_id)) + } +} + +/// Scans the locally-mounted `InodeTable` for the inode whose stable `node_id` +/// matches, returning its cached (owner-sealed, surfaced in 80-05) recipient +/// pin list — the D-03d authorization anchor for the re-mint wrap. Returns an +/// empty list when the node is not locally materialized (fail-closed at the +/// caller, D-03e). Mirrors `find_grant_root_state`'s `find_map` idiom, keyed on +/// `node_id` (the value `re_mint_grants_rooted_at` passes) rather than +/// `ipns_name`. +fn find_recipient_pins(inodes: &InodeTable, node_id: &str) -> Vec> { + inodes + .inodes + .values() + .find_map(|inode| { + if inode.node_id != node_id { + return None; + } + let pins = match &inode.kind { + InodeKind::Root { recipient_pins, .. } => recipient_pins, + InodeKind::Folder { recipient_pins, .. } => recipient_pins, + InodeKind::File { recipient_pins, .. } => recipient_pins, + }; + Some(pins.clone()) + }) + .unwrap_or_default() +} + +/// Refresh a grant root inode's cached `recipient_pins` from its CURRENT +/// published write-body — the share→rotate race fix (D-03a/D-03d/D-03e). +/// +/// The scope-exit re-mint (`re_mint_grants_rooted_at` → `get_recipient_pubkey_pins` +/// → [`find_recipient_pins`]) AND the rotation republish ([`reconstruct_write_body`]) +/// both read the grant root's owner-sealed recipient pins from THIS mount's +/// in-memory inode cache. That cache is materialized lazily (mount init / the +/// periodic metadata refresh), so a pin written OUT OF BAND — e.g. the owner +/// sharing the folder from the web client, which reseals the folder write-body +/// with the new pin and bumps its IPNS record — may not yet be reflected here +/// when the owner immediately deletes inside that folder on their desktop mount. +/// +/// Reading the STALE (empty) cache then does two harmful things: (a) the retained +/// recipient's re-mint fails closed ("0 pinned", D-03e), and (b) the rotation +/// republishes the node PIN-LESS (`reconstruct_write_body` reseals the empty +/// cache), clobbering the real published pin. The rotation then aborts mid-flight +/// and every later metadata refresh AES-GCM-fails against the new-generation +/// record (the mount's stale read key cannot open it) — the exact macOS/WinFsp +/// desktop-e2e cascade this closes. +/// +/// This resolves + unseals the grant root's OWN write-body ONCE (under its +/// unchanged write key — write keys never rotate, only the read plane does) and +/// overwrites the inode's cached pins with the authoritative published list +/// BEFORE the rotation reads them, closing the race deterministically for BOTH +/// the product (share-then-rotate) and the desktop e2e. Best-effort: a +/// resolve/fetch/unseal failure leaves the cache untouched and returns `Ok` (the +/// rotation then proceeds on the cached pins — this refresh must never itself be +/// the thing that fails a rotation that would otherwise have had fresh pins). +#[cfg(any(feature = "fuse", feature = "winfsp"))] +pub(crate) async fn refresh_grant_root_recipient_pins( + fs: &mut crate::CipherBoxFS, + grant_root_ipns_name: &str, +) { + // Snapshot the grant root's identity + write key from the inode. The + // immutable borrow is dropped before the network round trip below. + let Some((ino, node_id, node_kind, write_key)) = + fs.inodes.inodes.iter().find_map(|(ino, inode)| { + let (candidate, kind, write_key) = match &inode.kind { + InodeKind::Root { + ipns_name, + write_key, + .. + } => (ipns_name, NodeKind::Root, write_key), + InodeKind::Folder { + ipns_name, + write_key, + .. + } => (ipns_name, NodeKind::Folder, write_key), + InodeKind::File { + ipns_name, + write_key, + .. + } => (ipns_name, NodeKind::File, write_key), + }; + (candidate == grant_root_ipns_name) + .then(|| (*ino, inode.node_id.clone(), kind, Zeroizing::new(**write_key))) + }) + else { + return; // not locally materialized — nothing to refresh + }; + + // Resolve + fetch + unseal the CURRENT published write-body (network; no + // inode borrow held). Any failure is swallowed to a warn — see the + // best-effort contract in the doc comment. + let api = fs.api.clone(); + let fresh_pins = match refresh_pins_inner(&api, grant_root_ipns_name, &node_id, node_kind, &write_key) + .await + { + Ok(Some(pins)) => pins, + Ok(None) => return, // no write-body published — leave the cache as-is + Err(e) => { + log::warn!( + "refresh_grant_root_recipient_pins: could not refresh pins for {grant_root_ipns_name} \ + (proceeding with cached pins): {e}" + ); + return; + } + }; + + // Overwrite the cached pins (mut borrow, no network held). + if let Some(inode) = fs.inodes.get_mut(ino) { + match &mut inode.kind { + InodeKind::Root { recipient_pins, .. } + | InodeKind::Folder { recipient_pins, .. } + | InodeKind::File { recipient_pins, .. } => { + *recipient_pins = fresh_pins; + } + } + } +} + +/// Network + crypto half of [`refresh_grant_root_recipient_pins`], split out so +/// the caller holds NO inode borrow across the `.await`. Returns `Ok(None)` when +/// the published node carries no write-body (nothing to refresh from). +#[cfg(any(feature = "fuse", feature = "winfsp"))] +async fn refresh_pins_inner( + api: &ApiClient, + ipns_name: &str, + node_id: &str, + node_kind: NodeKind, + write_key: &[u8; 32], +) -> Result>>, RotationError> { + // sc6-allow: verified fail-closed resolve chokepoint (D-08), not a read-plane bypass. + let resolved = resolve_ipns_verified(api, ipns_name).await.map_err(|e| { + RotationError::RotateFailed(format!("refresh_pins_inner: resolve failed for {ipns_name}: {e}")) + })?; + let bytes = cipherbox_api_client::ipfs::fetch_content(api, &resolved.cid) + .await + .map_err(|e| { + RotationError::RotateFailed(format!( + "refresh_pins_inner: fetch_content failed for {ipns_name}: {e}" + )) + })?; + let published = decode_published_node(&bytes).map_err(|e| { + RotationError::RotateFailed(format!( + "refresh_pins_inner: decode_published_node failed for {ipns_name}: {e}" + )) + })?; + let Some(write_sealed_b64) = published.write_sealed.as_ref() else { + return Ok(None); + }; + let write_sealed = STANDARD.decode(write_sealed_b64).map_err(|e| { + RotationError::RotateFailed(format!( + "refresh_pins_inner: base64 decode failed for {ipns_name}: {e}" + )) + })?; + // The write-body is sealed under the node's OWN write key at its published + // generation (ROLE_BODY 0x01) — the exact AAD `list_folder_owned` unseals with. + let wb_bytes = Zeroizing::new( + unseal_node(&write_sealed, write_key, node_id, node_kind, published.generation).map_err( + |e| { + RotationError::RotateFailed(format!( + "refresh_pins_inner: unseal_node failed for {ipns_name}: {e}" + )) + }, + )?, + ); + let write_body = decode_write_body(&wb_bytes).map_err(|e| { + RotationError::RotateFailed(format!( + "refresh_pins_inner: decode_write_body failed for {ipns_name}: {e}" + )) + })?; + Ok(Some(write_body.recipient_pins)) } // --------------------------------------------------------------------------- @@ -575,6 +824,161 @@ fn find_ipns_private_key(inodes: &InodeTable, ipns_name: &str) -> Option Option> { + use zeroize::Zeroize as _; + + // Locate the node by its OWN ipns_name (mirrors `find_ipns_private_key`), + // pulling its stable node_id, kind, write key, signing seed, cached + // recipient pins (D-03a), and child inos. + let (node_id, node_kind, node_write_key, ipns_private_key, recipient_pins, child_inos) = + inodes.inodes.values().find_map(|inode| { + let (candidate_name, kind, write_key, ipns_priv, pins) = match &inode.kind { + InodeKind::Root { + ipns_name, + write_key, + ipns_private_key, + recipient_pins, + .. + } => ( + ipns_name, + NodeKind::Root, + write_key, + ipns_private_key, + recipient_pins, + ), + InodeKind::Folder { + ipns_name, + write_key, + ipns_private_key, + recipient_pins, + .. + } => ( + ipns_name, + NodeKind::Folder, + write_key, + ipns_private_key, + recipient_pins, + ), + InodeKind::File { + ipns_name, + write_key, + ipns_private_key, + recipient_pins, + .. + } => ( + ipns_name, + NodeKind::File, + write_key, + ipns_private_key, + recipient_pins, + ), + }; + (candidate_name == ipns_name && !ipns_priv.is_empty()).then(|| { + ( + inode.node_id.clone(), + kind, + Zeroizing::new(**write_key), + Zeroizing::new(ipns_priv.to_vec()), + pins.clone(), + inode.children.clone().unwrap_or_default(), + ) + }) + })?; + + // Rebuild the child write-chain from each child inode's OWN write key — + // read-key-rotation-independent, copied verbatim (never re-derived/rotated). + // Children with no IPNS identity yet (freshly created, never published) are + // skipped, mirroring `build_folder_metadata`. + let mut write_children: Vec = Vec::new(); + for child_ino in child_inos { + let Some(child) = inodes.inodes.get(&child_ino) else { + continue; + }; + let (child_kind, child_ipns, child_write_key) = match &child.kind { + InodeKind::Folder { + ipns_name, + write_key, + .. + } => (NodeKind::Folder, ipns_name, Zeroizing::new(**write_key)), + InodeKind::File { + ipns_name, + write_key, + .. + } => (NodeKind::File, ipns_name, Zeroizing::new(**write_key)), + InodeKind::Root { .. } => continue, + }; + if child_ipns.is_empty() { + continue; + } + let sealed = seal_child_write_key( + &child_write_key, + &node_write_key, + &child.node_id, + child_kind, + 0, + ) + .ok()?; + write_children.push(WriteChildRef { + child_id: child.node_id.clone(), + write_key_sealed: STANDARD.encode(sealed), + }); + } + + // Assemble + seal the write-body under the node's OWN write key at the NEW + // generation (ROLE_BODY 0x01) — the exact AAD `recover_signing_seed` rebuilds. + // D-01↔D-03e: carry the node's CACHED recipient pins verbatim so a rotation + // republish PRESERVES them (recipient pins are issuance data, not derivable + // from InodeTable material — dropping them here would hard-fail a later + // re-mint after re-materialize). Pins are PUBLIC keys, copied not rotated. + let mut write_body = NodeWriteBody { + ipns_private_key: ipns_private_key.to_vec(), + write_children, + recipient_pins, + }; + let wb_bytes = encode_write_body(&write_body).ok()?; + // Scrub the bare signing-seed copy inside the (non-Zeroizing) write body once + // it has been encoded (crypto rule #6; mirrors `build_folder_metadata`). + write_body.ipns_private_key.zeroize(); + + seal_node(&wb_bytes, &node_write_key, &node_id, node_kind, new_generation).ok() +} + /// Scans the locally-mounted `InodeTable` for the grant-root inode matching /// `ipns_name`, returning its stable `node_id` + current `read_key` — the /// two inputs `rotate_read_on_scope_exit`'s stub lacked (RESEARCH Sharp @@ -635,6 +1039,11 @@ mod tests { /// In-memory `GET /shares/sent` fixture rows (Task 1: grant-seam /// tests), consumed verbatim by `collect_sent_shares`. sent_shares: Vec, + /// D-02 call-counter: how many times `collect_sent_shares` has been + /// invoked. A job-scoped cache bounds this to `<= 1` per rotation walk, + /// regardless of the number of rotated nodes (mirrors the + /// `publish_log`/`publish_count_for` pattern). + collect_sent_shares_calls: usize, /// Ordered log of every `update_grant` call: /// `(share_id, encrypted_read_key, new_generation)`. updated_grants: Vec<(String, String, u32)>, @@ -646,6 +1055,11 @@ mod tests { /// If set, the NEXT `revoke_share` call returns this error message /// wrapped in `RotationError::RotateFailed`, then clears. fail_next_revoke_share: Option, + /// node_id -> the node's owner-sealed recipient pin list (raw ECIES + /// pubkey bytes), returned verbatim by `get_recipient_pubkey_pins` + /// (Plan 80-06, D-03d/D-03e). An absent entry yields an empty list — + /// the pin-absent hard fail-closed case (D-03e). + pins_by_node: HashMap>>, } /// In-memory `RotationTransport` fake — no live IPNS/IPFS round trip. @@ -685,6 +1099,12 @@ mod tests { self.0.lock().unwrap().sent_shares = shares; } + /// How many times `collect_sent_shares` has been called so far (D-02 + /// call-count assertion — a job-scoped cache must keep this `<= 1`). + fn collect_sent_shares_count(&self) -> usize { + self.0.lock().unwrap().collect_sent_shares_calls + } + /// Every `update_grant` call captured so far, in order. fn updated_grants(&self) -> Vec<(String, String, u32)> { self.0.lock().unwrap().updated_grants.clone() @@ -704,6 +1124,17 @@ mod tests { fn fail_next_revoke_share(&self, message: &str) { self.0.lock().unwrap().fail_next_revoke_share = Some(message.to_string()); } + + /// Seeds the owner-sealed recipient pin list returned by + /// `get_recipient_pubkey_pins` for `node_id` (Plan 80-06). Not seeding + /// a node leaves its pin list empty (the D-03e pin-absent case). + fn seed_pins(&self, node_id: &str, pins: Vec>) { + self.0 + .lock() + .unwrap() + .pins_by_node + .insert(node_id.to_string(), pins); + } } impl RotationTransport for FakeTransport { @@ -760,7 +1191,9 @@ mod tests { } async fn collect_sent_shares(&self) -> Result, RotationError> { - Ok(self.0.lock().unwrap().sent_shares.clone()) + let mut inner = self.0.lock().unwrap(); + inner.collect_sent_shares_calls += 1; + Ok(inner.sent_shares.clone()) } async fn update_grant( @@ -789,6 +1222,20 @@ mod tests { inner.revoked_shares.push(share_id.to_string()); Ok(()) } + + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError> { + Ok(self + .0 + .lock() + .unwrap() + .pins_by_node + .get(node_id) + .cloned() + .unwrap_or_default()) + } } /// Fresh secp256k1 ECIES owner keypair (compressed pubkey / raw scalar), @@ -1267,4 +1714,445 @@ mod tests { .expect_err("a transport failure must surface as an error"); assert!(matches!(err, RotationError::RotateFailed(_))); } + + // ----------------------------------------------------------------------- + // D-01 reconstruction + D-02 sent-shares cache (Plan 80-02) + // ----------------------------------------------------------------------- + + const RECON_FOLDER_NODE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + const RECON_CHILD_NODE_ID: &str = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; + + /// Minimal directory `FileAttrs` for a test inode. + fn recon_dir_attrs(ino: u64) -> crate::inode::FileAttrs { + let now = std::time::SystemTime::now(); + crate::inode::FileAttrs { + ino, + size: 0, + blocks: 0, + atime: now, + mtime: now, + ctime: now, + crtime: now, + is_dir: true, + perm: 0o755, + nlink: 2, + } + } + + /// Build an `InodeTable` with a materialized Folder node (own write_key + + /// ipns_private_key) holding one materialized child folder (own write_key). + /// Returns `(table, folder_ipns, folder_write_key, folder_ipns_private_key, + /// child_write_key)`. + fn table_with_materialized_folder() -> (InodeTable, String, [u8; 32], Vec, [u8; 32]) { + use crate::inode::{InodeData, ROOT_INO}; + + let mut table = InodeTable::new(); + let folder_ino = table.allocate_ino(); + let child_ino = table.allocate_ino(); + + let folder_write_key = [21u8; 32]; + let folder_ipns_private_key = vec![31u8; 32]; + let child_write_key = [22u8; 32]; + + table.insert(InodeData { + ino: folder_ino, + node_id: RECON_FOLDER_NODE_ID.to_string(), + parent_ino: ROOT_INO, + name: "folder".to_string(), + kind: InodeKind::Folder { + ipns_name: "k51recon-folder".to_string(), + read_key: Zeroizing::new([11u8; 32]), + write_key: Zeroizing::new(folder_write_key), + ipns_private_key: Zeroizing::new(folder_ipns_private_key.clone()), + recipient_pins: Vec::new(), + children_loaded: true, + }, + attr: recon_dir_attrs(folder_ino), + children: Some(vec![child_ino]), + write_generation: 0, + }); + table.insert(InodeData { + ino: child_ino, + node_id: RECON_CHILD_NODE_ID.to_string(), + parent_ino: folder_ino, + name: "child".to_string(), + kind: InodeKind::Folder { + ipns_name: "k51recon-child".to_string(), + read_key: Zeroizing::new([12u8; 32]), + write_key: Zeroizing::new(child_write_key), + ipns_private_key: Zeroizing::new(vec![32u8; 32]), + recipient_pins: Vec::new(), + children_loaded: true, + }, + attr: recon_dir_attrs(child_ino), + children: Some(vec![]), + write_generation: 0, + }); + + ( + table, + "k51recon-folder".to_string(), + folder_write_key, + folder_ipns_private_key, + child_write_key, + ) + } + + /// Test A (D-01): `reconstruct_write_body` for a materialized folder returns + /// a write-body that `unseal_node` (under the node's OWN write key, at the + /// NEW generation, ROLE_BODY 0x01) decodes back to a `NodeWriteBody` whose + /// `ipns_private_key` and child `WriteChildRef`(s) match the InodeTable + /// inputs — and whose child write key is copied verbatim (no rotation). + #[test] + fn reconstruct_write_body_round_trips_ipns_key_and_child_write_refs() { + use cipherbox_core::node::seal::{unseal_child_write_key, unseal_node}; + use cipherbox_core::node::{decode_write_body, NodeKind}; + + let (table, folder_ipns, folder_write_key, folder_ipns_private_key, child_write_key) = + table_with_materialized_folder(); + let new_generation = 7u32; + + let sealed = reconstruct_write_body(&table, &folder_ipns, new_generation) + .expect("a materialized node reconstructs Some"); + + let wb_bytes = unseal_node( + &sealed, + &folder_write_key, + RECON_FOLDER_NODE_ID, + NodeKind::Folder, + new_generation, + ) + .expect("unseal the reconstructed write-body under the node write key at the new generation"); + let wb = decode_write_body(&wb_bytes).expect("decode the reconstructed write-body"); + + assert_eq!( + wb.ipns_private_key, folder_ipns_private_key, + "the reconstructed write-body carries the node's own signing seed" + ); + assert_eq!( + wb.write_children.len(), + 1, + "one materialized child -> exactly one WriteChildRef" + ); + let wcr = &wb.write_children[0]; + assert_eq!( + wcr.child_id, RECON_CHILD_NODE_ID, + "the WriteChildRef is keyed by the child's stable node_id" + ); + + let sealed_child = STANDARD + .decode(&wcr.write_key_sealed) + .expect("child write_key_sealed is valid base64"); + let recovered_child_write_key = unseal_child_write_key( + &sealed_child, + &folder_write_key, + RECON_CHILD_NODE_ID, + NodeKind::Folder, + 0, + ) + .expect("unseal the child write key under the parent write key"); + assert_eq!( + recovered_child_write_key, + child_write_key.to_vec(), + "the child write key is copied verbatim (read-key-rotation-independent, never rotated)" + ); + } + + /// Test B (D-03a/D-01↔D-03e, 80-05): `reconstruct_write_body` carries the + /// node's CACHED recipient pins into the resealed write-body, so a rotation + /// republish PRESERVES them (a subsequent re-materialize + re-mint still + /// finds the pins). The reconstructed body must round-trip BOTH the pins + /// AND the keys/children (no regression of 80-02's reconstruction contract). + #[test] + fn reconstruct_write_body_preserves_cached_recipient_pins() { + use cipherbox_core::node::seal::unseal_node; + use cipherbox_core::node::{decode_write_body, NodeKind}; + + let (mut table, folder_ipns, folder_write_key, folder_ipns_private_key, _child_write_key) = + table_with_materialized_folder(); + let new_generation = 7u32; + let pins = vec![vec![0x04u8; 33], vec![0x04u8, 0x11, 0x22, 0x33]]; + + // Cache recipient pins on the materialized folder inode (D-03a). + let folder_ino = table + .find_child(crate::inode::ROOT_INO, "folder") + .expect("materialized folder linked under root"); + match &mut table.get_mut(folder_ino).unwrap().kind { + InodeKind::Folder { recipient_pins, .. } => *recipient_pins = pins.clone(), + other => panic!("expected Folder, got {:?}", other), + } + + let sealed = reconstruct_write_body(&table, &folder_ipns, new_generation) + .expect("a materialized node reconstructs Some"); + let wb_bytes = unseal_node( + &sealed, + &folder_write_key, + RECON_FOLDER_NODE_ID, + NodeKind::Folder, + new_generation, + ) + .expect("unseal the reconstructed write-body under the node write key"); + let wb = decode_write_body(&wb_bytes).expect("decode the reconstructed write-body"); + + assert_eq!( + wb.recipient_pins, pins, + "reconstruction MUST preserve the cached recipient pins (D-01↔D-03e durability)" + ); + // 80-02 contract intact: keys + children still round-trip. + assert_eq!( + wb.ipns_private_key, folder_ipns_private_key, + "the reconstructed write-body still carries the node's own signing seed" + ); + assert_eq!( + wb.write_children.len(), + 1, + "one materialized child -> exactly one WriteChildRef" + ); + assert_eq!( + wb.write_children[0].child_id, RECON_CHILD_NODE_ID, + "the WriteChildRef is still keyed by the child's stable node_id" + ); + } + + /// Test B (D-01b): a node NOT present in the InodeTable fails open to + /// `None` (never a panic/Err), mirroring `find_ipns_private_key`. + #[test] + fn reconstruct_write_body_fails_open_to_none_for_a_non_materialized_node() { + let table = InodeTable::new(); + assert!( + reconstruct_write_body(&table, "k51-not-materialized", 3).is_none(), + "a non-materialized node must fail open to None, not hard-error" + ); + } + + /// Test C (D-02): a rotation walk that queries grants once per rotated node + /// (>= 3 nodes here) fetches `GET /shares/sent` at most once, while the + /// per-node `root_node_id` filter still returns exactly the in-scope grant. + #[tokio::test] + async fn rotation_walk_fetches_sent_shares_at_most_once() { + const OTHER_NODE_ID: &str = "33333333-3333-3333-3333-333333333333"; + let transport = FakeTransport::default(); + transport.seed_sent_shares(vec![ + sent_share_fixture( + "share-in-scope", + ROOT_ID, + "0x04aabbccdd00112233445566778899aabbccddeeff001122334455667788990011", + ), + sent_share_fixture("share-other-root", OTHER_NODE_ID, "0x04ff"), + ]); + let (owner_pub, owner_priv) = owner_keypair(); + let deps = + FuseRotationDeps::new(transport.clone(), owner_pub, owner_priv, temp_floor_store()); + + for _ in 0..4 { + let rows = deps + .query_grants_rooted_at(ROOT_ID) + .await + .expect("query_grants_rooted_at must succeed"); + assert_eq!( + rows.len(), + 1, + "the root_node_id filter still returns exactly the in-scope grant per node" + ); + assert_eq!(rows[0].share_id, "share-in-scope"); + } + + assert!( + transport.collect_sent_shares_count() <= 1, + "a rotation job must fetch GET /shares/sent at most once (got {})", + transport.collect_sent_shares_count() + ); + } + + // ----------------------------------------------------------------------- + // D-03d / D-03e (Plan 80-06): fail-closed recipient-pin binding at re-mint + // + // A compromised relay could substitute the `recipient_public_key` that + // round-trips through `GET /shares/sent`, causing the owner to ECIES-wrap + // the fresh post-rotation read key TO THE ATTACKER. Before every wrap, + // `re_mint_grants_rooted_at` must verify the recipient is a member of the + // node's OWN owner-sealed `recipient_pins` (read offline from the InodeTable + // cache) and fail the WHOLE node's re-mint closed on a non-member (D-03d) OR + // an absent/empty pin list (D-03e no-legacy) — never a per-grant skip. + // ----------------------------------------------------------------------- + + /// Wires a childless grant-root at seq 1 plus a single NON-revoked grant + /// rooted at `ROOT_ID` whose recipient is `recipient_pub`, so a + /// `rotate_read_from_node` walk reaches the re-mint wrap for that recipient. + fn seed_remint_pin_harness(transport: &FakeTransport, recipient_pub: &[u8]) { + let read_key = [7u8; 32]; + transport.seed( + "k51root", + "cid-root-v1", + 1, + seal_for_seed(&folder_fixture(0), &read_key), + ); + transport.seed_sent_shares(vec![sent_share_fixture( + "share-active", + ROOT_ID, + &format!( + "0x{}", + cipherbox_crypto::utils::bytes_to_hex(recipient_pub) + ), + )]); + } + + /// Test A (D-03d mismatch): the grant's recipient is NOT among the node's + /// owner-sealed pins (a relay-substituted pubkey) — the node's re-mint + /// fails closed and NO grant is wrapped/updated. + #[tokio::test] + async fn re_mint_fails_closed_when_recipient_is_not_pinned() { + let transport = FakeTransport::default(); + let (_sk, pk) = ecies::utils::generate_keypair(); + seed_remint_pin_harness(&transport, &pk.serialize()); + + // Pin a DIFFERENT recipient — the grant's recipient is not a member. + let (_other_sk, other_pk) = ecies::utils::generate_keypair(); + transport.seed_pins(ROOT_ID, vec![other_pk.serialize().to_vec()]); + + let (owner_pub, owner_priv) = owner_keypair(); + let deps = + FuseRotationDeps::new(transport.clone(), owner_pub, owner_priv, temp_floor_store()); + let read_key = [7u8; 32]; + let mut job = RotationJobRecord::new(ROOT_ID); + + let result = + cipherbox_sdk::rotate_read_from_node(&deps, ROOT_ID, "k51root", &read_key, &mut job) + .await; + + assert!( + result.is_err(), + "a relay-substituted (unpinned) recipient must fail the node's re-mint closed (D-03d), got {result:?}" + ); + assert!( + transport.updated_grants().is_empty(), + "no grant may be re-minted/wrapped on the fail-closed path, got {:?}", + transport.updated_grants() + ); + } + + /// Test B (D-03e absent): the node has an EMPTY pin list at re-mint — a + /// hard fail-closed (no-legacy, no-TOFU), NOT a silent skip. No grant is + /// wrapped/updated. + #[tokio::test] + async fn re_mint_fails_closed_when_pin_list_is_empty() { + let transport = FakeTransport::default(); + let (_sk, pk) = ecies::utils::generate_keypair(); + seed_remint_pin_harness(&transport, &pk.serialize()); + // No pins seeded for ROOT_ID -> empty/absent pin list (D-03e). + + let (owner_pub, owner_priv) = owner_keypair(); + let deps = + FuseRotationDeps::new(transport.clone(), owner_pub, owner_priv, temp_floor_store()); + let read_key = [7u8; 32]; + let mut job = RotationJobRecord::new(ROOT_ID); + + let result = + cipherbox_sdk::rotate_read_from_node(&deps, ROOT_ID, "k51root", &read_key, &mut job) + .await; + + assert!( + result.is_err(), + "an absent/empty pin list at re-mint is a hard fail-closed (D-03e), got {result:?}" + ); + assert!( + transport.updated_grants().is_empty(), + "no grant may be re-minted on the pin-absent path, got {:?}", + transport.updated_grants() + ); + } + + /// Test C (match): the grant's recipient IS pinned — the node re-mints + /// exactly that recipient (the pre-80-06 success path is preserved). + #[tokio::test] + async fn re_mint_succeeds_when_recipient_is_pinned() { + let transport = FakeTransport::default(); + let (_sk, pk) = ecies::utils::generate_keypair(); + let recipient_pub = pk.serialize().to_vec(); + seed_remint_pin_harness(&transport, &recipient_pub); + transport.seed_pins(ROOT_ID, vec![recipient_pub.clone()]); + + let (owner_pub, owner_priv) = owner_keypair(); + let deps = + FuseRotationDeps::new(transport.clone(), owner_pub, owner_priv, temp_floor_store()); + let read_key = [7u8; 32]; + let mut job = RotationJobRecord::new(ROOT_ID); + + let result = + cipherbox_sdk::rotate_read_from_node(&deps, ROOT_ID, "k51root", &read_key, &mut job) + .await; + + assert!( + result.is_ok(), + "a pinned recipient must re-mint successfully: {:?}", + result.err() + ); + let updated = transport.updated_grants(); + assert_eq!( + updated.len(), + 1, + "exactly the pinned recipient is re-minted, got {updated:?}" + ); + assert_eq!(updated[0].0, "share-active"); + } + + /// Best-effort contract of [`refresh_grant_root_recipient_pins`]: when the + /// grant root cannot be resolved/fetched (here: `make_test_fs`'s dead + /// `127.0.0.1:1` API endpoint), the refresh must NOT panic and must leave + /// the inode's cached pins UNTOUCHED — the rotation then proceeds on the + /// cached pins rather than the refresh becoming the thing that fails a + /// rotation. A missing/unmaterialized grant root is likewise a no-op. + #[tokio::test] + async fn refresh_grant_root_recipient_pins_is_a_noop_on_resolve_failure() { + use crate::inode::{FileAttrs, InodeData, InodeKind, ROOT_INO}; + use crate::test_support::make_test_fs; + use std::time::SystemTime; + + let mut fs = make_test_fs(); + let folder_ino = fs.inodes.allocate_ino(); + let cached_pins = vec![vec![0x02u8; 33], vec![0x03u8, 0x11, 0x22]]; + let now = SystemTime::now(); + fs.inodes.insert(InodeData { + ino: folder_ino, + node_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".to_string(), + parent_ino: ROOT_INO, + name: "shared".to_string(), + kind: InodeKind::Folder { + ipns_name: "k51-refresh-target".to_string(), + read_key: Zeroizing::new([11u8; 32]), + write_key: Zeroizing::new([22u8; 32]), + ipns_private_key: Zeroizing::new(vec![5u8; 32]), + recipient_pins: cached_pins.clone(), + children_loaded: true, + }, + attr: FileAttrs { + ino: folder_ino, + size: 0, + blocks: 0, + atime: now, + mtime: now, + ctime: now, + crtime: now, + is_dir: true, + perm: 0o755, + nlink: 2, + }, + children: Some(vec![]), + write_generation: 0, + }); + + // Resolve hits the dead endpoint and fails; the refresh swallows it. + refresh_grant_root_recipient_pins(&mut fs, "k51-refresh-target").await; + let pins_after = match &fs.inodes.get(folder_ino).unwrap().kind { + InodeKind::Folder { recipient_pins, .. } => recipient_pins.clone(), + _ => unreachable!(), + }; + assert_eq!( + pins_after, cached_pins, + "a resolve failure must leave the cached pins untouched (best-effort, never clobbers)" + ); + + // A grant root not present in the inode table is a silent no-op. + refresh_grant_root_recipient_pins(&mut fs, "k51-does-not-exist").await; + } } diff --git a/crates/sdk/src/emit.rs b/crates/sdk/src/emit.rs index c4a74cd932..1f2ea0b9e4 100644 --- a/crates/sdk/src/emit.rs +++ b/crates/sdk/src/emit.rs @@ -193,6 +193,7 @@ pub fn build_folder_emission( let write_body = NodeWriteBody { ipns_private_key: ipns_private_key.clone(), write_children, + recipient_pins: Vec::new(), }; let published = @@ -250,6 +251,7 @@ pub fn build_file_emission(content: NodeContent) -> Result>, + /// D-03a: the shared node's owner-sealed recipient pins, read from the SAME + /// already-unsealed write-body as `ipns_private_key`. These are PUBLIC keys + /// (recipient ECIES pubkeys), NOT secret key material — they are cached on + /// the materialized inode so the Rust re-mint (80-06) can verify them + /// OFFLINE and a rotation republish can PRESERVE them (D-01↔D-03e). Empty + /// for a node with no active share grants. + pub recipient_pins: Vec>, } impl std::fmt::Debug for ResolvedOwnedChild { @@ -149,6 +156,10 @@ impl std::fmt::Debug for ResolvedOwnedChild { .field("read_key", &"[REDACTED]") .field("write_key", &"[REDACTED]") .field("ipns_private_key", &"[REDACTED]") + // Recipient pins are PUBLIC keys, not secret material -- shown as a + // count rather than redacted (mirrors the non-secret discipline + // while keeping log lines terse). + .field("recipient_pins_count", &self.recipient_pins.len()) .finish() } } @@ -541,6 +552,12 @@ where published.generation, )?); let write_body = decode_write_body(&write_body_bytes)?; + // D-03a: the recipient pins are read from the SAME already-unsealed + // write-body as the signing seed -- issuance data (public ECIES keys), not + // derivable from InodeTable material, so they must be surfaced here and + // cached on the inode for the offline re-mint (80-06) + rotation + // preservation (D-01). + let recipient_pins = write_body.recipient_pins; let ipns_private_key = Zeroizing::new(write_body.ipns_private_key); Ok(ResolvedOwnedChild { @@ -559,6 +576,7 @@ where read_key, write_key, ipns_private_key, + recipient_pins, }) } @@ -1302,6 +1320,7 @@ mod tests { let write_body = NodeWriteBody { ipns_private_key: parent_stub.ipns_private_key.clone(), write_children, + recipient_pins: Vec::new(), }; let published = seal::seal_published_node( &parent_node, diff --git a/crates/sdk/src/rotation/engine.rs b/crates/sdk/src/rotation/engine.rs index b2759a5906..895315401d 100644 --- a/crates/sdk/src/rotation/engine.rs +++ b/crates/sdk/src/rotation/engine.rs @@ -198,6 +198,24 @@ pub trait RotationDeps { Ok(()) } + /// D-03d/D-03e (SC2, Plan 80-06): returns the node's OWN owner-sealed + /// recipient pin list (raw ECIES pubkey bytes) taken from the node's + /// write-body — the authorization anchor [`re_mint_grants_rooted_at`] + /// checks `grant.recipient_public_key` against BEFORE ECIES-wrapping the + /// fresh post-rotation read key. + /// + /// Intentionally has NO default: `grant.recipient_public_key` round-trips + /// through the untrusted relay (`GET /shares/sent`), so a permissive + /// default that silently returned an empty list would let a + /// relay-substituted recipient slip through on any implementor that forgot + /// to wire the pin source. An EMPTY list is a legitimate return (the node + /// was shared to nobody); the CALLER treats empty-at-re-mint as a hard + /// fail-closed (D-03e no-legacy) — this method never fabricates a pass. + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError>; + /// ECIES key-checkpoint seam (D-01/D-03, T-70.1-19, 70.1-08): persists a /// durable, recoverable checkpoint of a freshly minted `read_key_prime` /// BEFORE its owning node's publish lands, closing the @@ -525,9 +543,14 @@ async fn rotate_one_inner( // HIGH-3 (T-69-12-02): re-mint grants rooted at THIS node // BEFORE marking it completed — D-07 parity: a failure here // must not silently skip the node on resume. - if let Err(e) = - re_mint_grants_rooted_at(deps, &resolved_node_id, &read_key_prime, new_generation) - .await + if let Err(e) = re_mint_grants_rooted_at( + deps, + &resolved_node_id, + kind, + &read_key_prime, + new_generation, + ) + .await { read_key_prime.zeroize(); return Err(e); @@ -594,19 +617,80 @@ fn mint_file_key_on_rotate() -> Zeroizing<[u8; 32]> { /// @security ECIES-wraps the new readKey via `cipherbox_crypto::wrap_key` /// — never hand-rolled key wrapping (T-64-04c parity). Does NOT zero /// `new_read_key` — caller is terminal owner (D-09). +/// D-03d (Plan 80-06): raw-byte membership test for the fail-closed pin +/// compare. BOTH sides are already normalized to raw ECIES pubkey bytes at +/// their respective decode boundaries — the pin list is +/// `NodeWriteBody.recipient_pins` (base64-decoded to raw bytes) and +/// `recipient_public_key` is 0x-stripped + hex-decoded to raw bytes by the +/// production `query_grants_rooted_at`. So this is the straight equality check +/// PATTERNS prescribes, with no 0x/hex encoding mismatch. +fn recipient_is_pinned(pins: &[Vec], recipient_public_key: &[u8]) -> bool { + pins.iter().any(|pin| pin.as_slice() == recipient_public_key) +} + async fn re_mint_grants_rooted_at( deps: &D, node_id: &str, + node_kind: NodeKind, new_read_key: &[u8; 32], new_generation: u32, ) -> Result<(), RotationError> { let grants = deps.query_grants_rooted_at(node_id).await?; + // D-03d/D-03e (SC2, Plan 80-06): fetch THIS node's OWN owner-sealed + // recipient pins ONCE. The pin — never the relay-supplied + // `grant.recipient_public_key` (which round-trips through the untrusted + // `GET /shares/sent` relay) — is the authorization anchor for the ECIES + // wrap below. A compromised relay could substitute the pubkey and cause + // the owner to wrap the fresh post-rotation read key TO THE ATTACKER + // (T-80-15); the pin binding closes that. + // + // FILE-ROOTED GRANTS ARE EXEMPT (Plan 80 file-share carve-out): a FILE node + // structurally cannot carry an owner-sealed recipient pin — the pin lives in + // `NodeWriteBody.recipientPins`, and only folder/root shares reseal a + // write-body at issuance (`addRecipientPubkeyPin` is folder-only). D-03e's + // "empty pins is a hard fail" implicitly assumed folder shares. Enforcing it + // on a file would fail-close EVERY scope-exit rotation of a folder that + // merely CONTAINS a separately-shared file (the walk rotates that file node, + // `query_grants_rooted_at` returns its grant, and `get_recipient_pubkey_pins` + // is structurally empty → "0 pinned"), aborting the whole rotation. So a + // file-rooted grant is re-minted WITHOUT the pin check — the accepted, + // pre-existing file-share recipient-substitution limitation (never pinned at + // issuance either; see the recipient-pin-lifecycle todo), NOT a downgrade of + // the folder-share guard, which stays fully fail-closed below. The pin fetch + // itself is skipped for files (some `get_recipient_pubkey_pins` impls resolve + // a folder write-body and would error on a leaf). + let recipient_pins = if node_kind == NodeKind::File { + Vec::new() + } else { + deps.get_recipient_pubkey_pins(node_id).await? + }; for grant in grants { if grant.is_revoked { // T-64-04b parity: re-minting a revoked recipient's encrypted // key would defeat revocation — delete the row instead. deps.delete_grant(&grant.share_id).await?; } else { + // FAIL CLOSED (D-03d/D-03e): the recipient we are about to wrap the + // fresh read key TO must be a member of the node's OWN owner-sealed + // pins. A non-member (possibly 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 — NOT a per-grant skip-and-continue like the + // `is_revoked` branch (a partial re-mint would silently drop the + // surviving recipients while the node's key advanced). File-rooted + // grants are exempt (see the file-share carve-out above): a file has + // no pin to verify against, so it is re-minted directly. + if node_kind != NodeKind::File + && !recipient_is_pinned(&recipient_pins, &grant.recipient_public_key) + { + return Err(RotationError::RotateFailed(format!( + "re_mint_grants_rooted_at: recipient for share {} is not among node {}'s \ + owner-sealed recipient pins ({} pinned) — refusing to wrap the rotated read \ + key to an unpinned (possibly relay-substituted) recipient (D-03d/D-03e)", + grant.share_id, + node_id, + recipient_pins.len() + ))); + } let wrapped = cipherbox_crypto::wrap_key(new_read_key, &grant.recipient_public_key) .map_err(|e| { RotationError::RotateFailed(format!( @@ -2589,6 +2673,11 @@ mod test_support { pub updated_grants: Mutex>, /// Ordered log of every `delete_grant` call's `share_id`. pub deleted_grants: Mutex>, + /// node_id -> the node's owner-sealed recipient pin list (raw ECIES + /// pubkey bytes) returned by `get_recipient_pubkey_pins` (D-03d/D-03e, + /// Plan 80-06). An absent entry returns an empty list — the pin-absent + /// hard fail-closed case (D-03e). + pub pins_by_node: Mutex>>>, /// node_id -> base64(ECIES ciphertext) — the ECIES key-checkpoint /// store (D-01/D-03, 70.1-08). Wrapped here (not by the engine, /// which stays key-material-free per RESEARCH option (b)) using @@ -2619,6 +2708,7 @@ mod test_support { grants_by_node: Mutex::new(HashMap::new()), updated_grants: Mutex::new(Vec::new()), deleted_grants: Mutex::new(Vec::new()), + pins_by_node: Mutex::new(HashMap::new()), checkpoints: Mutex::new(HashMap::new()), call_log: Mutex::new(Vec::new()), owner_sk, @@ -2644,6 +2734,17 @@ mod test_support { .insert(node_id.to_string(), grants); } + /// Seeds the owner-sealed recipient pin list returned by + /// `get_recipient_pubkey_pins` for `node_id` (D-03d/D-03e, Plan 80-06). + /// Not seeding a node leaves its pin list empty (the D-03e pin-absent + /// case). + pub fn seed_pins(&self, node_id: &str, pins: Vec>) { + self.pins_by_node + .lock() + .unwrap() + .insert(node_id.to_string(), pins); + } + pub fn resolve_call_count(&self) -> usize { self.resolve_log.lock().unwrap().len() } @@ -2782,6 +2883,19 @@ mod test_support { .unwrap_or_default()) } + async fn get_recipient_pubkey_pins( + &self, + node_id: &str, + ) -> Result>, RotationError> { + Ok(self + .pins_by_node + .lock() + .unwrap() + .get(node_id) + .cloned() + .unwrap_or_default()) + } + async fn update_grant( &self, share_id: &str, @@ -4060,6 +4174,11 @@ mod rotate_read_from_node { }, ], ); + // D-03d/D-03e (Plan 80-06): the non-revoked recipient must be pinned in + // the node's owner-sealed pin list for its re-mint wrap to be permitted. + // (The revoked recipient is deleted before any pin check, so it needs no + // pin.) + deps.seed_pins(&child_uuid(0), vec![active_pk.serialize().to_vec()]); let mut job = RotationJobRecord::new(ROOT_ID); let result = rotate_read_from_node(&deps, ROOT_ID, "k51/root", &root_read_key, &mut job) @@ -4134,6 +4253,79 @@ mod rotate_read_from_node { ); } + // ----------------------------------------------------------------------- + // Plan 80 file-share carve-out: a FILE-rooted grant is EXEMPT from the + // D-03e 0-pins fail-closed check (a file has no NodeWriteBody, so it can + // NEVER carry an owner-sealed pin). Without the exemption, a scope-exit + // rotation of a folder that merely CONTAINS a separately-shared file would + // fail-close the file node's re-mint ("0 pinned") and abort the WHOLE + // rotation. Folder/root grants stay fully fail-closed. + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn file_rooted_grant_is_re_minted_without_a_pin_while_folder_still_fails_closed() { + const FILE_NODE_ID: &str = "ffffffff-1111-2222-3333-444444444444"; + let new_read_key = [9u8; 32]; + + // A single non-revoked file share, and NO pins seeded for the file node + // (files are never pinned at issuance — the structural D-03e case). + let (recipient_sk, recipient_pk) = ecies::utils::generate_keypair(); + let seed_file_grant = |deps: &FakeDeps| { + deps.seed_grants( + FILE_NODE_ID, + vec![GrantRow { + share_id: "file-share".to_string(), + recipient_public_key: recipient_pk.serialize().to_vec(), + is_revoked: false, + }], + ); + }; + + // FILE kind → exempt: the re-mint SUCCEEDS and wraps the new read key to + // the (unpinned) recipient, so a shared file inside a rotated folder is + // not cut off and the rotation is not aborted. + let file_deps = FakeDeps::new(); + seed_file_grant(&file_deps); + re_mint_grants_rooted_at( + &file_deps, + FILE_NODE_ID, + NodeKind::File, + &new_read_key, + 1, + ) + .await + .expect("a file-rooted grant must re-mint WITHOUT a pin (file-share carve-out)"); + let updated = file_deps.updated_grants.lock().unwrap().clone(); + assert_eq!(updated.len(), 1, "the file share is re-minted, got: {updated:?}"); + assert_eq!(updated[0].0, "file-share"); + // Sanity: the wrapped key really is the new read key for this recipient. + let wrapped = hex::decode(&updated[0].1).unwrap(); + let unwrapped = + cipherbox_crypto::unwrap_key(&wrapped, &recipient_sk.serialize()).unwrap(); + assert_eq!(unwrapped.as_slice(), &new_read_key); + + // FOLDER kind, identical unpinned grant → still FAILS CLOSED (the guard + // is unchanged for folders; the carve-out is file-only). + let folder_deps = FakeDeps::new(); + seed_file_grant(&folder_deps); // same rows, but rooted-node treated as a folder + let folder_result = re_mint_grants_rooted_at( + &folder_deps, + FILE_NODE_ID, + NodeKind::Folder, + &new_read_key, + 1, + ) + .await; + assert!( + folder_result.is_err(), + "a FOLDER grant with no pin must still fail closed (D-03e) — the carve-out is file-only" + ); + assert!( + folder_deps.updated_grants.lock().unwrap().is_empty(), + "a fail-closed folder re-mint must not wrap anything" + ); + } + // ----------------------------------------------------------------------- // HIGH-4 (T-69-12-03): CAS-409 concurrent-add re-fetch + re-merge. // ----------------------------------------------------------------------- diff --git a/docs/METADATA_SCHEMAS.md b/docs/METADATA_SCHEMAS.md index a8ea64b77c..2902d5b1a1 100644 --- a/docs/METADATA_SCHEMAS.md +++ b/docs/METADATA_SCHEMAS.md @@ -136,9 +136,10 @@ Encoding rules for the read-body JSON: **Version history:** -| Change | Phase | Description | -| -------------------- | ----- | ---------------------------------------------------------------------------- | -| `node/v3` introduced | 62 | Unified Node replaces FolderMetadata, FileMetadata, FilePointer, FolderEntry | +| Change | Phase | Description | +| ----------------------------- | ----- | ------------------------------------------------------------------------------------------------------------ | +| `node/v3` introduced | 62 | Unified Node replaces FolderMetadata, FileMetadata, FilePointer, FolderEntry | +| `NodeWriteBody.recipientPins` | 80 | Additive optional recipient-pubkey pin list (D-03b); omitted when empty, `schema` unchanged, tolerant decode | --- @@ -303,10 +304,31 @@ read-only nodes (when only `readKey` is held). ### NodeWriteBody -| Field | Type | Encoding | Description | -| ---------------- | ----------------- | ------------- | ---------------------------------------------------- | -| `ipnsPrivateKey` | `Uint8Array` | base64 (wire) | Raw Ed25519 signing seed for this node's IPNS record | -| `writeChildren` | `WriteChildRef[]` | -- | Write-chain references to child nodes | +| Field | Type | Encoding | Description | +| ---------------- | ----------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ipnsPrivateKey` | `Uint8Array` | base64 (wire) | Raw Ed25519 signing seed for this node's IPNS record | +| `writeChildren` | `WriteChildRef[]` | -- | Write-chain references to child nodes | +| `recipientPins` | `string[]` | base64 array (wire) | Optional. Recipient-pubkey pins bound at share/re-mint (D-03b); each entry a raw compressed secp256k1 public key. Omitted from the wire when empty | + +**`recipientPins` (additive, optional):** Introduced for the D-03 re-mint recipient-identity +check. It is an additive optional field per +[METADATA_EVOLUTION_PROTOCOL §3.1](METADATA_EVOLUTION_PROTOCOL.md#31-additive-non-breaking-changes) — +the `schema` discriminator is NOT bumped. Both codecs OMIT the field from the wire when the list +is empty (TS conditional spread; Rust `#[serde(skip_serializing_if = "Vec::is_empty")]`), which +preserves the frozen empty-pin golden vector (`seal_vectors[0]`) byte-for-byte. Decoders tolerate +an absent field (TS: field stays absent; Rust: `#[serde(default)]` yields an empty `Vec`) and never +fail-closed on it — `NodeWriteBody` intentionally carries NO `deny_unknown_fields`. A non-empty-pin +golden vector (`seal_vectors[1]`) locks the pinned wire path across Rust and TypeScript. + +**Folder/root only (file-share carve-out, D-03g):** `recipientPins` is meaningful only for +**folder and root** nodes. A shared **file** is a leaf whose `NodeWriteBody` (when present) never +carries pins — pin issuance (`addRecipientPubkeyPin`) is folder-only, since a file leaf is not a +tracked folder-tree entry. Consequently the D-03d/D-03e re-mint pin enforcement +(`re_mint_grants_rooted_at` / `reMintGrantsRootedAt`) **exempts file-rooted grants** from the +"pin absent → hard fail-closed" rule: a file grant is re-minted without a pin check (otherwise a +scope-exit rotation of any folder that merely contains a separately-shared file would fail-closed +and abort). Folder/root grants remain fully fail-closed. File-share recipient-substitution +protection is a known, tracked limitation (`recipient-pin-lifecycle-hardening` todo §5). ### WriteChildRef diff --git a/packages/core/src/__tests__/node-codec-vectors.test.ts b/packages/core/src/__tests__/node-codec-vectors.test.ts index 59cdb0b0db..4816bbff43 100644 --- a/packages/core/src/__tests__/node-codec-vectors.test.ts +++ b/packages/core/src/__tests__/node-codec-vectors.test.ts @@ -255,6 +255,40 @@ describe('Node Codec — FULL-SEAL LOCK (D-04, T-62-06)', () => { expect(reconstructedWriteSealed).toBe(sv.expected_published_node.writeSealed); }); + + it('folder node writeSealed with non-empty recipientPins matches frozen vector [1] (D-03b)', async () => { + const sv = VECTORS.seal_vectors[1]; + const writeKey = fromHex(sv.write_key); + const fixedIv = fromHex(sv.fixed_iv); + const ipnsPrivateKey = fromHex(sv.ipns_private_key_hex); + const recipientPins = (sv as { recipient_pins: string[] }).recipient_pins; + + // Non-vacuous guard: this KAT must exercise a populated pin list. + expect(recipientPins.length).toBeGreaterThanOrEqual(2); + + // Reconstruct the folder node with a pinned writeBody to produce writeSealed + const folderNode: Node = { + schema: 'node/v3', + kind: 'folder', + id: sv.node_id, + generation: sv.generation, + children: [], + createdAt: 1719532800000, + modifiedAt: 1719532800000, + writeBody: { ipnsPrivateKey, writeChildren: [], recipientPins }, + }; + + const bodyBytes = encodeWriteBody(folderNode); + const aad = buildNodeAad(sv.node_id, sv.kind, sv.generation, 0x01 /* body */); + const ciphertext = await encryptAesGcmAad(bodyBytes, writeKey, fixedIv, aad); + + const sealedBlob = new Uint8Array(fixedIv.length + ciphertext.length); + sealedBlob.set(fixedIv); + sealedBlob.set(ciphertext, fixedIv.length); + const reconstructedWriteSealed = uint8ArrayToBase64(sealedBlob); + + expect(reconstructedWriteSealed).toBe(sv.expected_published_node.writeSealed); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/node/decode.ts b/packages/core/src/node/decode.ts index 6a71e79e2f..6ba0674365 100644 --- a/packages/core/src/node/decode.ts +++ b/packages/core/src/node/decode.ts @@ -360,5 +360,34 @@ export function decodeWriteBody(bytes: Uint8Array): NodeWriteBody { }; }); - return { ipnsPrivateKey, writeChildren }; + // recipientPins is an ADDITIVE optional field (D-03b). Forward tolerance: + // an absent (or empty) field is fine — never fail-closed on it. To keep the + // codec symmetric with encodeWriteBody (which OMITS an empty pin list from the + // wire, preserving seal_vectors[0]), the field is attached to the returned + // object ONLY when non-empty; an empty/absent list decodes to `undefined` + // (the optional-field contract). When present it must be an array of base64 + // strings. + const base: NodeWriteBody = { ipnsPrivateKey, writeChildren }; + if (raw.recipientPins !== undefined) { + if (!Array.isArray(raw.recipientPins)) { + throw new CryptoError( + 'Invalid write-body format: recipientPins must be an array', + 'DECRYPTION_FAILED' + ); + } + const recipientPins = (raw.recipientPins as unknown[]).map((pin, idx) => { + if (typeof pin !== 'string') { + throw new CryptoError( + `Invalid write-body format: recipientPins[${idx}] must be a base64 string`, + 'DECRYPTION_FAILED' + ); + } + return pin; + }); + if (recipientPins.length > 0) { + base.recipientPins = recipientPins; + } + } + + return base; } diff --git a/packages/core/src/node/encode.ts b/packages/core/src/node/encode.ts index e8c09eecf3..8c16314f7d 100644 --- a/packages/core/src/node/encode.ts +++ b/packages/core/src/node/encode.ts @@ -143,7 +143,16 @@ export function encodeWriteBody(node: Node): Uint8Array { } const wb: NodeWriteBody = node.writeBody; - const wireBody = { + // FIXED field order (ipnsPrivateKey, writeChildren, then recipientPins) so the + // JSON bytes are byte-identical to the Rust serde field order (D-03b). The + // recipientPins key is emitted ONLY when the list is present and non-empty — + // mirroring the Rust `skip_serializing_if = "Vec::is_empty"` so the frozen + // empty-pin KAT (seal_vectors[0]) is preserved byte-for-byte (Pitfall 1). + const wireBody: { + ipnsPrivateKey: string; + writeChildren: { childId: string; writeKeySealed: string }[]; + recipientPins?: string[]; + } = { ipnsPrivateKey: bytesToBase64(wb.ipnsPrivateKey), writeChildren: wb.writeChildren.map((wc) => ({ childId: wc.childId, @@ -151,5 +160,9 @@ export function encodeWriteBody(node: Node): Uint8Array { })), }; + if (wb.recipientPins && wb.recipientPins.length > 0) { + wireBody.recipientPins = wb.recipientPins; + } + return new TextEncoder().encode(JSON.stringify(wireBody)); } diff --git a/packages/core/src/node/types.ts b/packages/core/src/node/types.ts index 4f99c48533..f42fdcaa75 100644 --- a/packages/core/src/node/types.ts +++ b/packages/core/src/node/types.ts @@ -137,6 +137,22 @@ export type NodeWriteBody = { ipnsPrivateKey: Uint8Array; /** Write chain to child nodes; mirrors the read chain in SealedChildRef. */ writeChildren: WriteChildRef[]; + /** + * Recipient-pubkey pins bound at share/re-mint (D-03b) — each entry the raw + * secp256k1 public-key bytes as issued by the owner (currently the 65-byte + * uncompressed `0x04` form; compared as raw bytes, so the encoding is not + * normalized), base64-encoded on the wire. + * + * Additive OPTIONAL field (METADATA_EVOLUTION_PROTOCOL §3.1): omitted from the + * wire when absent or empty so the frozen empty-pin KAT (seal_vectors[0]) is + * preserved byte-for-byte. On decode the field is LEFT ABSENT (`undefined`), + * not normalized to `[]` — the property is optional and every consumer treats + * `undefined` and `[]` equivalently (an empty/absent pin list). Twin of the + * Rust `NodeWriteBody.recipient_pins` (`Vec>`, base64 `recipientPins` + * wire), which materializes an empty `Vec` on decode; the wire bytes are + * identical (empty omitted) so the two codecs stay byte-compatible. + */ + recipientPins?: string[]; }; // --------------------------------------------------------------------------- diff --git a/packages/sdk-core/scripts/edit-filepointer.mts b/packages/sdk-core/scripts/edit-filepointer.mts index d1448a3f50..d20b3d6948 100644 --- a/packages/sdk-core/scripts/edit-filepointer.mts +++ b/packages/sdk-core/scripts/edit-filepointer.mts @@ -224,6 +224,9 @@ async function main(): Promise { readKey: rootReadKey, writeKey: rootWriteKey, writeChildren: rootNode.writeBody.writeChildren, + // Preserve the root's owner-sealed recipient pins (D-03): an omitted snapshot + // fail-closes, and sealing pin-less would erase pins for a shared root. + recipientPins: rootNode.writeBody.recipientPins ?? [], ipnsPrivateKey: rootNode.writeBody.ipnsPrivateKey, ipnsName: rootIpnsName, sequenceNumber: rootSequenceNumber, diff --git a/packages/sdk-core/scripts/rename-folder.mts b/packages/sdk-core/scripts/rename-folder.mts index 53375d1309..0a30f4c64a 100644 --- a/packages/sdk-core/scripts/rename-folder.mts +++ b/packages/sdk-core/scripts/rename-folder.mts @@ -139,6 +139,9 @@ async function main(): Promise { readKey: rootReadKey, writeKey: rootWriteKey, writeChildren: rootNode.writeBody.writeChildren, + // Preserve the root's owner-sealed recipient pins (D-03): an omitted + // snapshot fail-closes, and sealing pin-less would erase pins on a shared root. + recipientPins: rootNode.writeBody.recipientPins ?? [], ipnsPrivateKey: rootNode.writeBody.ipnsPrivateKey, ipnsName: rootIpnsName, sequenceNumber: rootSequenceNumber, diff --git a/packages/sdk-core/src/__tests__/folder/registration.test.ts b/packages/sdk-core/src/__tests__/folder/registration.test.ts index 0f1996f2d7..686c0b21b0 100644 --- a/packages/sdk-core/src/__tests__/folder/registration.test.ts +++ b/packages/sdk-core/src/__tests__/folder/registration.test.ts @@ -297,6 +297,7 @@ describe('updateFolderMetadataAndPublish — base-aware write-body CAS-merge (SC readKey: READ_KEY, writeKey: WRITE_KEY, writeChildren: localWriteChildren, + recipientPins: [], baseWriteChildren, ipnsPrivateKey: IPNS_PRIVATE_KEY, ipnsName: 'k51-write-merge-a', @@ -345,6 +346,7 @@ describe('updateFolderMetadataAndPublish — base-aware write-body CAS-merge (SC readKey: READ_KEY, writeKey: WRITE_KEY, writeChildren: localWriteChildren, + recipientPins: [], baseWriteChildren, ipnsPrivateKey: IPNS_PRIVATE_KEY, ipnsName: 'k51-write-merge-b', @@ -393,6 +395,7 @@ describe('updateFolderMetadataAndPublish — base-aware write-body CAS-merge (SC readKey: READ_KEY, writeKey: WRITE_KEY, writeChildren: localWriteChildren, + recipientPins: [], baseWriteChildren, ipnsPrivateKey: IPNS_PRIVATE_KEY, ipnsName: 'k51-write-merge-c', @@ -444,6 +447,7 @@ describe('updateFolderMetadataAndPublish — base-aware write-body CAS-merge (SC readKey: READ_KEY, writeKey: WRITE_KEY, writeChildren: localWriteChildren, + recipientPins: [], baseWriteChildren, ipnsPrivateKey: IPNS_PRIVATE_KEY, ipnsName: 'k51-write-merge-d', @@ -459,3 +463,93 @@ describe('updateFolderMetadataAndPublish — base-aware write-body CAS-merge (SC expect(publishedWriteChildren).toEqual([{ childId: 'Z', writeKeySealed: 'seal-z' }]); }); }); + +describe('updateFolderMetadataAndPublish — recipient-pin snapshot fail-closed (thread-80-4)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => ({ + cid: 'QmPinCid', + size: data.length, + recorded: true, + })); + }); + + it('rejects an OMITTED recipientPins snapshot when a write-body is sealed (writeKey present)', async () => { + const ctx = createMockContext(); + mockFns.createAndPublishIpnsRecord.mockResolvedValue({ success: true, sequenceNumber: 2n }); + + await expect( + updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + // recipientPins deliberately OMITTED — must fail closed, not silently + // seal [] and erase existing owner-sealed pins on a clean publish. + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pin-omitted', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }) + ).rejects.toThrow(/recipientPins snapshot is required/); + + // Fail-closed BEFORE any upload/publish I/O. + expect(mockFns.addToIpfs).not.toHaveBeenCalled(); + expect(mockFns.createAndPublishIpnsRecord).not.toHaveBeenCalled(); + }); + + it('preserves an explicit recipientPins snapshot into the sealed write-body on a clean (no-409) publish', async () => { + const ctx = createMockContext(); + let capturedNode: PublishedNode | undefined; + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => { + capturedNode = JSON.parse(new TextDecoder().decode(data)) as PublishedNode; + return { cid: 'QmPinCid', size: data.length, recorded: true }; + }); + mockFns.createAndPublishIpnsRecord.mockResolvedValue({ success: true, sequenceNumber: 2n }); + + const pins = ['cGluLWE=', 'cGluLWI=']; // two base64 pins + + await updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + recipientPins: pins, + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pin-preserve', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }); + + expect(capturedNode).toBeDefined(); + // Unseal the write-body under the write key and assert the pins survived the + // clean publish (no CAS-409 union ran — this is the routine happy path). + const unsealed = await unsealNode(capturedNode!, READ_KEY, WRITE_KEY); + expect(unsealed.writeBody?.recipientPins).toEqual(pins); + }); + + it('allows an explicit empty [] snapshot for a genuinely unpinned node', async () => { + const ctx = createMockContext(); + mockFns.createAndPublishIpnsRecord.mockResolvedValue({ success: true, sequenceNumber: 2n }); + + await expect( + updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + recipientPins: [], + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pin-empty-ok', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/sdk-core/src/__tests__/folder/write-body.test.ts b/packages/sdk-core/src/__tests__/folder/write-body.test.ts index 59be58853a..01274b7d13 100644 --- a/packages/sdk-core/src/__tests__/folder/write-body.test.ts +++ b/packages/sdk-core/src/__tests__/folder/write-body.test.ts @@ -80,6 +80,7 @@ describe('owned write-body model (D-03)', () => { readKey: READ_KEY, writeKey: WRITE_KEY, writeChildren, + recipientPins: [], ipnsPrivateKey: IPNS_PRIVATE_KEY, ipnsName: 'k51-write-body', sequenceNumber: 1n, diff --git a/packages/sdk-core/src/__tests__/rotation/engine.test.ts b/packages/sdk-core/src/__tests__/rotation/engine.test.ts index 4ab61141d3..78096b0856 100644 --- a/packages/sdk-core/src/__tests__/rotation/engine.test.ts +++ b/packages/sdk-core/src/__tests__/rotation/engine.test.ts @@ -3726,4 +3726,135 @@ describe('rotateReadFromNode — rotatedNodes deep-tree parity with Rust (Plan 7 expect(rotatedNodes.size).toBe(3); }); + + // D-04 (Plan 80-03, SC3 / T-80-08): every rotatedNodes entry's readKey must + // be an INDEPENDENT 32-byte copy, non-aliased with the corresponding + // parentNewReadKey the walk retains. Not a live bug today (parentNewReadKey + // is never zeroed), but a future D-09 zero-on-drop tightening would otherwise + // silently zero the returned map entry → the Rust FUSE consumer + // (grant_scope.rs::refresh_rotated_inode_read_keys) refreshes an inode key to + // all-zeros → mis-decryption / data loss. Mirrors Rust's Zeroizing-clone. + it('D-04: each rotatedNodes readKey is a non-aliased, non-zero copy (mutating parentNewReadKey does not affect the entry)', async () => { + const rootNode = makeFolderNode({ + id: NODE_ID, + generation: 0, + children: [ + { + name: 'folder-b', + ipnsName: P7402_FOLDER_B_IPNS, + generation: 0, + versionFloor: 0n, + readKeySealed: 'folder-b-sealed==', + }, + ], + }); + const folderBNode = makeFolderNode({ + id: P7402_FOLDER_B_ID, + generation: 0, + children: [ + { + name: 'file-c', + ipnsName: P7402_FILE_C_IPNS, + generation: 0, + versionFloor: 0n, + readKeySealed: 'file-c-sealed==', + }, + ], + }); + const fileCNode: import('@cipherbox/core').Node = { + schema: 'node/v3', + kind: 'file', + id: P7402_FILE_C_ID, + generation: 0, + createdAt: 3000, + modifiedAt: 3000, + children: [], + } as import('@cipherbox/core').Node; + + mockFns.resolveIpnsRecord.mockImplementation(async (ipnsName: string) => { + const cidByIpns: Record = { + [NODE_IPNS]: 'bafy-7402-root', + [P7402_FOLDER_B_IPNS]: 'bafy-7402-folder-b', + [P7402_FILE_C_IPNS]: 'bafy-7402-file-c', + }; + const cid = cidByIpns[ipnsName]; + if (!cid) return null; + return { cid, sequenceNumber: 1n, signatureVerified: true }; + }); + mockFns.fetchFromIpfs.mockImplementation(async (_ctx: unknown, cid: string) => { + if (cid === 'bafy-7402-root') + return new TextEncoder().encode(JSON.stringify(makePublishedNode(NODE_ID, 0, 'folder'))); + if (cid === 'bafy-7402-folder-b') + return new TextEncoder().encode( + JSON.stringify(makePublishedNode(P7402_FOLDER_B_ID, 0, 'folder')) + ); + return new TextEncoder().encode( + JSON.stringify(makePublishedNode(P7402_FILE_C_ID, 0, 'file')) + ); + }); + mockFns.unsealNode.mockImplementation( + async (published: import('@cipherbox/core').PublishedNode) => { + if (published.id === NODE_ID) return rootNode; + if (published.id === P7402_FOLDER_B_ID) return folderBNode; + if (published.id === P7402_FILE_C_ID) return fileCNode; + throw new Error(`unexpected unsealNode call for ${published.id}`); + } + ); + mockFns.sealNode.mockImplementation(async (node: import('@cipherbox/core').Node) => + makePublishedNode(node.id, node.generation + 1, node.kind as 'folder' | 'file') + ); + mockFns.sealChildReadKey.mockResolvedValue('7402-resealed=='); + mockFns.unsealChildReadKey.mockResolvedValue(new Uint8Array(32).fill(0x99)); + mockFns.publishWithCas.mockResolvedValue({ + cid: 'bafy-7402-new', + newSequenceNumber: 2n, + publishedData: [], + prunedCids: [], + }); + + const jobRecord = makeJobRecord({ rootNodeId: NODE_ID }); + const result = await rotateReadFromNode({ + rootNodeId: NODE_ID, + rootNodeIpnsName: NODE_IPNS, + rootReadKey: P7402_ROOT_READ_KEY, + rootIpnsPrivateKey: P7402_ROOT_IPNS_KEY, + nodeKeySource: (ipnsName: string) => { + if (ipnsName === P7402_FOLDER_B_IPNS) + return { privateKey: P7402_FOLDER_B_IPNS_KEY, publicKey: P7402_STUB_PUBLIC_KEY }; + if (ipnsName === P7402_FILE_C_IPNS) + return { privateKey: P7402_FILE_C_IPNS_KEY, publicKey: P7402_STUB_PUBLIC_KEY }; + return undefined; + }, + jobRecord, + ctx: createMockContext(), + }); + + expect(result).toBeDefined(); + const rotatedNodes = result!.rotatedNodes; + + // Every entry is a non-zero 32-byte array. + for (const ipnsName of [NODE_IPNS, P7402_FOLDER_B_IPNS, P7402_FILE_C_IPNS]) { + const entry = rotatedNodes.get(ipnsName)!; + expect(entry.readKey).toBeInstanceOf(Uint8Array); + expect(entry.readKey.length).toBe(32); + expect( + entry.readKey.some((b) => b !== 0), + `${ipnsName} readKey is all zeros` + ).toBe(true); + } + + // `result.readKey` is the SAME reference the root branch retains as + // `ParentTrackingState.parentNewReadKey` (top-level convenience field aliases + // rootResult.childReadKey). The rotatedNodes root entry must be a distinct copy. + const rootEntry = rotatedNodes.get(NODE_IPNS)!; + const rootEntrySnapshot = new Uint8Array(rootEntry.readKey); + expect(rootEntry.readKey).not.toBe(result!.readKey); // distinct object, not aliased + expect(rootEntry.readKey).toEqual(result!.readKey); // but equal in value (correct key) + + // Simulate the future zero-on-drop of parentNewReadKey: zeroing the retained + // reference must NOT corrupt the returned map entry. + result!.readKey.fill(0); + expect(rootEntry.readKey).toEqual(rootEntrySnapshot); // entry survives unchanged + expect(rootEntry.readKey.some((b) => b !== 0)).toBe(true); // still non-zero + }); }); diff --git a/packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts b/packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts index bbd33e37f7..8eb55bb1ed 100644 --- a/packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts +++ b/packages/sdk-core/src/__tests__/rotation/grant-remint.test.ts @@ -46,8 +46,13 @@ const NEW_GENERATION = 3; /** Simulated 4-byte wrapped key returned by the mock wrapKey. */ const MOCK_WRAPPED_BYTES = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); -/** Expected base64 encoding of MOCK_WRAPPED_BYTES (what updateGrantFn receives). */ -const EXPECTED_ENCRYPTED_KEY = btoa(String.fromCharCode(0xde, 0xad, 0xbe, 0xef)); +/** + * Expected HEX encoding of MOCK_WRAPPED_BYTES (what updateGrantFn receives). + * The re-mint `encryptedReadKey` MUST be hex: `PATCH /shares/:id/grant` validates + * even-length hex and decodes via `Buffer.from(.., 'hex')`, matching the + * share-CREATE path and the Rust re-mint twin (`hex::encode`). base64 400s. + */ +const EXPECTED_ENCRYPTED_KEY = 'deadbeef'; const SHARE_ID_A = 'share-aaaa-1111'; const SHARE_ID_B = 'share-bbbb-2222'; @@ -85,6 +90,7 @@ describe('reMintGrantsRootedAt', () => { ]); const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([RECIPIENT_PUB_KEY_A]); const ctx = createMockContext(); const job = makeJobRecord(); @@ -92,6 +98,7 @@ describe('reMintGrantsRootedAt', () => { queryGrantsFn: mockQueryGrants, updateGrantFn: mockUpdateGrant, deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, }); // queryGrantsFn must be called with the rotated nodeId @@ -100,7 +107,7 @@ describe('reMintGrantsRootedAt', () => { // wrapKey must be called with (newReadKey, recipientPublicKey) — ECIES wrap expect(mockFns.wrapKey).toHaveBeenCalledWith(NEW_READ_KEY, RECIPIENT_PUB_KEY_A); - // updateGrantFn must be called with (shareId, base64EncryptedKey, newGeneration) + // updateGrantFn must be called with (shareId, hexEncryptedKey, newGeneration) expect(mockUpdateGrant).toHaveBeenCalledWith( SHARE_ID_A, EXPECTED_ENCRYPTED_KEY, @@ -145,6 +152,7 @@ describe('reMintGrantsRootedAt', () => { ]); const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([RECIPIENT_PUB_KEY_A]); const ctx = createMockContext(); const job = makeJobRecord(); @@ -152,6 +160,7 @@ describe('reMintGrantsRootedAt', () => { queryGrantsFn: mockQueryGrants, updateGrantFn: mockUpdateGrant, deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, }); // Exactly one update (non-revoked) and one delete (revoked) @@ -184,4 +193,193 @@ describe('reMintGrantsRootedAt', () => { // No crypto operations should have run expect(mockFns.wrapKey).not.toHaveBeenCalled(); }); + + // ------------------------------------------------------------------------- + // D-03d consumer 2 (TS re-mint) — fail-closed recipient-pin enforcement. + // + // reMintGrantsRootedAt must verify grant.recipientPublicKey (which round-trips + // through the untrusted relay via listSentGrants) against the node's + // owner-sealed recipientPins BEFORE wrapKey. A relay-substituted recipient + // (mismatch) or an absent/empty pin list (D-03e no-legacy) is a HARD fail — + // it throws and aborts the node's re-mint, unlike the per-grant isRevoked skip. + // ------------------------------------------------------------------------- + + it('Test A (D-03d mismatch): throws and does NOT wrap when getPinsFn omits the grant recipient', async () => { + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + // Pins list contains a DIFFERENT recipient (relay substituted the pubkey). + const mockGetPins = vi.fn().mockResolvedValue([RECIPIENT_PUB_KEY_B]); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await expect( + reMintGrantsRootedAt(NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, + }) + ).rejects.toThrow(/pinned/i); + + // Fail-closed: the read key was never wrapped to the substituted recipient. + expect(mockFns.wrapKey).not.toHaveBeenCalled(); + expect(mockUpdateGrant).not.toHaveBeenCalled(); + }); + + it('Test B (D-03e absent): throws when getPinsFn returns an empty pin list', async () => { + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([]); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await expect( + reMintGrantsRootedAt(NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, + }) + ).rejects.toThrow(); + + expect(mockFns.wrapKey).not.toHaveBeenCalled(); + expect(mockUpdateGrant).not.toHaveBeenCalled(); + }); + + it('Test B2 (D-03e absent seam): throws when getPinsFn is missing for a surviving grant', async () => { + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await expect( + reMintGrantsRootedAt(NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + }) + ).rejects.toThrow(); + + expect(mockFns.wrapKey).not.toHaveBeenCalled(); + expect(mockUpdateGrant).not.toHaveBeenCalled(); + }); + + it('Test F (file carve-out): a file-rooted grant re-mints WITHOUT any pin check', async () => { + // A shared FILE has no NodeWriteBody, so it can never carry an owner-sealed + // pin. Passing nodeKind='file' must EXEMPT it from the D-03e 0-pins fail- + // closed check (which would otherwise abort every rotation of a folder that + // contains a separately-shared file). No getPinsFn is supplied, and an empty + // pin source must NOT throw for a file. + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([]); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await reMintGrantsRootedAt( + NODE_ID, + NEW_READ_KEY, + NEW_GENERATION, + job, + ctx, + { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, + }, + 'file' + ); + + // The pin seam is NOT consulted for a file, and the grant is re-minted. + expect(mockGetPins).not.toHaveBeenCalled(); + expect(mockFns.wrapKey).toHaveBeenCalledWith(NEW_READ_KEY, RECIPIENT_PUB_KEY_A); + expect(mockUpdateGrant).toHaveBeenCalledWith( + SHARE_ID_A, + EXPECTED_ENCRYPTED_KEY, + NEW_GENERATION + ); + }); + + it('Test F2 (folder still enforced): a folder-rooted grant with empty pins still throws', async () => { + // Contrast to Test F: the carve-out is file-only. A FOLDER (nodeKind + // defaulted/absent) with an empty pin list stays fully fail-closed. + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([]); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await expect( + reMintGrantsRootedAt( + NODE_ID, + NEW_READ_KEY, + NEW_GENERATION, + job, + ctx, + { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, + }, + 'folder' + ) + ).rejects.toThrow(); + expect(mockUpdateGrant).not.toHaveBeenCalled(); + }); + + it('Test C (match): proceeds and wraps when getPinsFn includes the grant recipient', async () => { + const mockQueryGrants = vi + .fn() + .mockResolvedValue([ + { shareId: SHARE_ID_A, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + const mockUpdateGrant = vi.fn().mockResolvedValue(undefined); + const mockDeleteGrant = vi.fn().mockResolvedValue(undefined); + const mockGetPins = vi.fn().mockResolvedValue([RECIPIENT_PUB_KEY_A]); + const ctx = createMockContext(); + const job = makeJobRecord(); + + await reMintGrantsRootedAt(NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, { + queryGrantsFn: mockQueryGrants, + updateGrantFn: mockUpdateGrant, + deleteGrantFn: mockDeleteGrant, + getPinsFn: mockGetPins, + }); + + // Pins fetched once for the node, recipient verified, then wrapped as before. + expect(mockGetPins).toHaveBeenCalledWith(NODE_ID); + expect(mockFns.wrapKey).toHaveBeenCalledWith(NEW_READ_KEY, RECIPIENT_PUB_KEY_A); + expect(mockUpdateGrant).toHaveBeenCalledWith( + SHARE_ID_A, + EXPECTED_ENCRYPTED_KEY, + NEW_GENERATION + ); + }); }); diff --git a/packages/sdk-core/src/__tests__/share/recipient-pins.test.ts b/packages/sdk-core/src/__tests__/share/recipient-pins.test.ts new file mode 100644 index 0000000000..11de406db9 --- /dev/null +++ b/packages/sdk-core/src/__tests__/share/recipient-pins.test.ts @@ -0,0 +1,373 @@ +/** + * TDD tests for the recipient-pubkey pin machinery (Phase 80 Plan 04, D-03a/c/e). + * + * RED phase: written before implementation. + * + * Covers: + * - Pure helpers `assertRecipientPinned` / `appendRecipientPin` / `extractRecipientPins` + * (share/recipient-pins.ts), including the D-03e empty/absent hard-fail and + * cross-encoding (raw-bytes / hex / base64) normalization. + * - `updateFolderMetadataAndPublish` threading `recipientPins` into the sealed + * write-body (preservation across a writeChildren-only update) and unioning + * local ∪ remote pins across a CAS-409 merge (T-80-11 durability). + * - A write→read round-trip at the sdk-core seal boundary: seal a node with a + * pin, unseal it, and read the pin back (the substantive round-trip the thin + * `client.addRecipientPubkeyPin` / `client.getRecipientPubkeyPins` wrappers + * delegate to — the wrappers themselves are covered by `@cipherbox/sdk` + * typecheck, since sdk-core cannot import sdk). + * + * Mock boundary: only network I/O (ipfs + ipns) is mocked; the @cipherbox/core + * codec (sealNode/unsealNode) runs for real so round-trip assertions reflect the + * actual sealed envelope, matching write-body.test.ts / registration.test.ts. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + assertRecipientPinned, + appendRecipientPin, + extractRecipientPins, +} from '../../share/recipient-pins'; +import { updateFolderMetadataAndPublish } from '../../folder/registration'; +import { sealNode, unsealNode } from '@cipherbox/core'; +import type { NodeWriteBody, PublishedNode, WriteChildRef } from '@cipherbox/core'; +import { bytesToBase64, base64ToBytes, bytesToHex } from '@cipherbox/crypto'; +import { createMockContext } from '../helpers'; + +// --------------------------------------------------------------------------- +// Module mocks — only I/O layers; @cipherbox/core runs real +// --------------------------------------------------------------------------- + +const mockFns = vi.hoisted(() => ({ + addToIpfs: vi.fn(), + fetchFromIpfs: vi.fn(), + createAndPublishIpnsRecord: vi.fn(), + resolveIpnsRecord: vi.fn(), +})); + +vi.mock('../../ipfs', () => ({ + addToIpfs: mockFns.addToIpfs, + fetchFromIpfs: mockFns.fetchFromIpfs, +})); + +vi.mock('../../ipns', () => ({ + createAndPublishIpnsRecord: mockFns.createAndPublishIpnsRecord, + resolveIpnsRecord: mockFns.resolveIpnsRecord, +})); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const NODE_ID = '550e8400-e29b-41d4-a716-446655440000'; +const READ_KEY = new Uint8Array(32).fill(0xab); +const WRITE_KEY = new Uint8Array(32).fill(0xcd); +const IPNS_PRIVATE_KEY = new Uint8Array(64).fill(0x01); + +// Two distinct 65-byte uncompressed-secp256k1-shaped recipient pubkeys. +const RECIPIENT_A = ((): Uint8Array => { + const k = new Uint8Array(65); + k[0] = 0x04; + for (let i = 1; i < 65; i++) k[i] = (i * 7) & 0xff; + return k; +})(); +const RECIPIENT_B = ((): Uint8Array => { + const k = new Uint8Array(65); + k[0] = 0x04; + for (let i = 1; i < 65; i++) k[i] = (i * 13 + 1) & 0xff; + return k; +})(); + +const RECIPIENT_A_B64 = bytesToBase64(RECIPIENT_A); +const RECIPIENT_B_B64 = bytesToBase64(RECIPIENT_B); + +/** + * Build a real sealed remote PublishedNode carrying the given write-body fields, + * used to simulate a racing writer's published state fetched via decodeRemote + * during a CAS-409 retry. + */ +async function buildSealedRemote(writeBody: { + writeChildren: WriteChildRef[]; + recipientPins?: string[]; +}): Promise { + const node = { + schema: 'node/v3' as const, + kind: 'folder' as const, + id: NODE_ID, + generation: 0, + createdAt: Date.now(), + modifiedAt: Date.now(), + children: [], + writeBody: { + ipnsPrivateKey: IPNS_PRIVATE_KEY, + writeChildren: writeBody.writeChildren, + ...(writeBody.recipientPins ? { recipientPins: writeBody.recipientPins } : {}), + }, + }; + const sealed = await sealNode(node, READ_KEY, WRITE_KEY); + return new TextEncoder().encode(JSON.stringify(sealed)); +} + +// --------------------------------------------------------------------------- +// Pure helpers +// --------------------------------------------------------------------------- + +describe('recipient-pins pure helpers', () => { + describe('extractRecipientPins', () => { + it('returns the recipientPins list from a decoded write-body', () => { + const wb: NodeWriteBody = { + ipnsPrivateKey: IPNS_PRIVATE_KEY, + writeChildren: [], + recipientPins: [RECIPIENT_A_B64], + }; + expect(extractRecipientPins(wb)).toEqual([RECIPIENT_A_B64]); + }); + + it('defaults to [] when recipientPins is absent', () => { + const wb: NodeWriteBody = { ipnsPrivateKey: IPNS_PRIVATE_KEY, writeChildren: [] }; + expect(extractRecipientPins(wb)).toEqual([]); + }); + + it('defaults to [] when the write-body itself is undefined', () => { + expect(extractRecipientPins(undefined)).toEqual([]); + }); + }); + + describe('appendRecipientPin', () => { + it('appends a recipient (as raw bytes) to an empty list', () => { + expect(appendRecipientPin([], RECIPIENT_A)).toEqual([RECIPIENT_A_B64]); + }); + + it('appends a recipient when pins is undefined', () => { + expect(appendRecipientPin(undefined, RECIPIENT_A)).toEqual([RECIPIENT_A_B64]); + }); + + it('is idempotent — appending the same recipient twice yields a single entry (dedup by raw bytes)', () => { + const once = appendRecipientPin([], RECIPIENT_A); + const twice = appendRecipientPin(once, RECIPIENT_A); + expect(twice).toEqual([RECIPIENT_A_B64]); + }); + + it('dedups across encodings — a hex-encoded recipient equal to an existing base64 pin is not duplicated', () => { + const hexA = '0x' + bytesToHex(RECIPIENT_A); + expect(appendRecipientPin([RECIPIENT_A_B64], hexA)).toEqual([RECIPIENT_A_B64]); + }); + + it('keeps existing distinct pins and appends the new one', () => { + expect(appendRecipientPin([RECIPIENT_A_B64], RECIPIENT_B)).toEqual([ + RECIPIENT_A_B64, + RECIPIENT_B_B64, + ]); + }); + }); + + describe('assertRecipientPinned', () => { + it('throws when the pin list is empty (D-03e no-legacy hard fail)', () => { + expect(() => assertRecipientPinned(RECIPIENT_A, [])).toThrow(); + }); + + it('throws when the pin list is absent/undefined (D-03e)', () => { + expect(() => assertRecipientPinned(RECIPIENT_A, undefined)).toThrow(); + }); + + it('throws when the recipient is not a member of a non-empty list', () => { + expect(() => assertRecipientPinned(RECIPIENT_B, [RECIPIENT_A_B64])).toThrow(); + }); + + it('returns normally (void) when the recipient is pinned (raw-byte match)', () => { + expect(assertRecipientPinned(RECIPIENT_A, [RECIPIENT_A_B64])).toBeUndefined(); + }); + + it('normalizes both sides — a hex-encoded recipient matches its base64 pin', () => { + const hexA = '0x' + bytesToHex(RECIPIENT_A); + expect(assertRecipientPinned(hexA, [RECIPIENT_B_B64, RECIPIENT_A_B64])).toBeUndefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// updateFolderMetadataAndPublish — recipientPins preservation + CAS-409 union +// --------------------------------------------------------------------------- + +describe('updateFolderMetadataAndPublish — recipientPins durability (T-80-11)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => ({ + cid: 'QmTestCid', + size: data.length, + recorded: true, + })); + mockFns.createAndPublishIpnsRecord.mockResolvedValue({ success: true, sequenceNumber: 2n }); + }); + + it('seals recipientPins into the write-body and preserves them across a writeChildren-only update (clean publish)', async () => { + const ctx = createMockContext(); + let capturedBytes: Uint8Array | null = null; + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => { + capturedBytes = data; + return { cid: 'QmWithPins', size: data.length, recorded: true }; + }); + + await updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [{ childId: 'child-a', writeKeySealed: 'seal-a' }], + recipientPins: [RECIPIENT_A_B64], + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pins-clean', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }); + + expect(capturedBytes).not.toBeNull(); + const publishedNode = JSON.parse(new TextDecoder().decode(capturedBytes!)) as PublishedNode; + const unsealed = await unsealNode(publishedNode, READ_KEY, WRITE_KEY); + // The writeChildren mutation must NOT drop the recipient pin. + expect(extractRecipientPins(unsealed.writeBody)).toEqual([RECIPIENT_A_B64]); + expect(unsealed.writeBody!.writeChildren).toEqual([ + { childId: 'child-a', writeKeySealed: 'seal-a' }, + ]); + }); + + it('unions local ∪ remote recipientPins across a CAS-409 merge (a concurrent writer’s pin is never dropped)', async () => { + const ctx = createMockContext(); + let lastBytes: Uint8Array | null = null; + let callCount = 0; + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => { + lastBytes = data; + callCount++; + return { cid: `QmAttempt${callCount}`, size: data.length, recorded: true }; + }); + + const axios409 = Object.assign(new Error('Conflict'), { response: { status: 409 } }); + mockFns.createAndPublishIpnsRecord + .mockRejectedValueOnce(axios409) + .mockResolvedValueOnce({ success: true, sequenceNumber: 3n }); + mockFns.resolveIpnsRecord.mockResolvedValue({ + sequenceNumber: 2n, + cid: 'QmRemoteFromConcurrentWrite', + }); + // Remote (racing writer) already pinned RECIPIENT_B; local is adding RECIPIENT_A. + mockFns.fetchFromIpfs.mockResolvedValue( + await buildSealedRemote({ writeChildren: [], recipientPins: [RECIPIENT_B_B64] }) + ); + + await updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + recipientPins: [RECIPIENT_A_B64], + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pins-cas', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }); + + expect(callCount).toBe(2); + const publishedNode = JSON.parse(new TextDecoder().decode(lastBytes!)) as PublishedNode; + const unsealed = await unsealNode(publishedNode, READ_KEY, WRITE_KEY); + const pins = extractRecipientPins(unsealed.writeBody); + // Union — both the local and the concurrently-added remote pin survive. + expect(pins).toHaveLength(2); + expect(pins).toContain(RECIPIENT_A_B64); + expect(pins).toContain(RECIPIENT_B_B64); + }); + + it('preserves the remote pin on a CAS-409 even when this update adds no pin of its own', async () => { + const ctx = createMockContext(); + let lastBytes: Uint8Array | null = null; + let callCount = 0; + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => { + lastBytes = data; + callCount++; + return { cid: `QmAttempt${callCount}`, size: data.length, recorded: true }; + }); + + const axios409 = Object.assign(new Error('Conflict'), { response: { status: 409 } }); + mockFns.createAndPublishIpnsRecord + .mockRejectedValueOnce(axios409) + .mockResolvedValueOnce({ success: true, sequenceNumber: 3n }); + mockFns.resolveIpnsRecord.mockResolvedValue({ + sequenceNumber: 2n, + cid: 'QmRemoteFromConcurrentWrite', + }); + mockFns.fetchFromIpfs.mockResolvedValue( + await buildSealedRemote({ writeChildren: [], recipientPins: [RECIPIENT_B_B64] }) + ); + + await updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + // This update adds no pin of its OWN (explicit empty snapshot) — the remote + // pin must still survive via the CAS-409 union. (An OMITTED snapshot now + // fail-closes; "no pin of my own" is expressed as an explicit [].) + recipientPins: [], + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-pins-cas-noninvasive', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }); + + const publishedNode = JSON.parse(new TextDecoder().decode(lastBytes!)) as PublishedNode; + const unsealed = await unsealNode(publishedNode, READ_KEY, WRITE_KEY); + expect(extractRecipientPins(unsealed.writeBody)).toEqual([RECIPIENT_B_B64]); + }); +}); + +// --------------------------------------------------------------------------- +// Write → read round-trip at the sdk-core seal boundary (client-wrapper proxy) +// --------------------------------------------------------------------------- + +describe('recipient-pin write→read round-trip (sdk-core seal boundary)', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFns.createAndPublishIpnsRecord.mockResolvedValue({ success: true, sequenceNumber: 2n }); + }); + + it('appendRecipientPin → seal via updateFolderMetadataAndPublish → unseal → extract includes the pin', async () => { + const ctx = createMockContext(); + let capturedBytes: Uint8Array | null = null; + mockFns.addToIpfs.mockImplementation(async (_ctx: unknown, data: Uint8Array) => { + capturedBytes = data; + return { cid: 'QmRoundTrip', size: data.length, recorded: true }; + }); + + // Emulate client.addRecipientPubkeyPin: start from the node's current pins + // ([] here), append the recipient, then publish the unioned list. + const nextPins = appendRecipientPin([], RECIPIENT_A); + + await updateFolderMetadataAndPublish({ + children: [], + readKey: READ_KEY, + writeKey: WRITE_KEY, + writeChildren: [], + recipientPins: nextPins, + ipnsPrivateKey: IPNS_PRIVATE_KEY, + ipnsName: 'k51-roundtrip', + sequenceNumber: 1n, + ctx, + nodeId: NODE_ID, + nodeGeneration: 0, + }); + + const publishedNode = JSON.parse(new TextDecoder().decode(capturedBytes!)) as PublishedNode; + const unsealed = await unsealNode(publishedNode, READ_KEY, WRITE_KEY); + // Emulate client.getRecipientPubkeyPins: read the pin list back as raw bytes. + const pinsB64 = extractRecipientPins(unsealed.writeBody); + const pinsBytes = pinsB64.map((p) => base64ToBytes(p)); + expect( + pinsBytes.some( + (b) => b.length === RECIPIENT_A.length && b.every((v, i) => v === RECIPIENT_A[i]) + ) + ).toBe(true); + }); +}); diff --git a/packages/sdk-core/src/folder/registration.ts b/packages/sdk-core/src/folder/registration.ts index cf41105bec..6df822eb4e 100644 --- a/packages/sdk-core/src/folder/registration.ts +++ b/packages/sdk-core/src/folder/registration.ts @@ -26,6 +26,7 @@ import { addToIpfs, fetchFromIpfs } from '../ipfs'; import { createAndPublishIpnsRecord } from '../ipns'; import { publishWithCas } from '../cas'; import { mergeChildren } from './merge'; +import { appendRecipientPin } from '../share/recipient-pins'; import { wrapIpnsKeyForTee } from '../tee/wrap'; /** @@ -214,6 +215,16 @@ export async function updateFolderMetadataAndPublish(params: { writeKey?: Uint8Array; /** Write-chain entries to persist in the write-body (preserved verbatim). Defaults to []. */ writeChildren?: WriteChildRef[]; + /** + * Recipient-pubkey pins (base64 raw-pubkey bytes) to set/append into the + * sealed write-body (D-03a/c). Only meaningful when a real `writeKey` is + * supplied. Threaded through the seal AND the CAS-409 merge: the sealed + * write-body carries these ∪ the current remote pins so a routine update or a + * concurrent write never silently drops an existing pin (T-80-11). Omitting it + * on a clean publish preserves whatever pins the caller threads in; on a + * CAS-409 the remote's pins are always unioned in regardless. + */ + recipientPins?: string[]; ipnsPrivateKey: Uint8Array; ipnsPublicKey?: Uint8Array; ipnsName: string; @@ -270,6 +281,28 @@ export async function updateFolderMetadataAndPublish(params: { let currentWriteChildren: WriteChildRef[] = params.writeChildren ?? []; let remoteWriteChildren: WriteChildRef[] = []; + // Recipient-pin CAS state (D-03a/c). `currentRecipientPins` is what + // encodeAndUpload seals into the write-body; `remoteRecipientPins` captures + // the remote write-body's pins on a 409 so the merge can UNION them in and a + // concurrent writer's pin is never dropped (T-80-11). Only sealed when a real + // writeKey is supplied (a write-body exists at all). The empty-list case is + // omitted from the wire by encodeWriteBody, so it never perturbs the frozen + // empty-pin KAT. + // D-03a fail-closed (thread-80-4): a write-body reseal MUST carry an explicit + // recipient-pin snapshot. On a conflict-free (no-409) publish the CAS-409 + // union below never runs, so sealing `[]` for an OMITTED snapshot would + // SILENTLY ERASE the node's existing owner-sealed pins — permanently breaking + // fail-closed re-mint for its shares. Reject an omitted snapshot rather than + // erasing; an explicit `[]` (a genuinely unpinned node) is fine. Only enforced + // when a real write-body is sealed at all (writeKey present). + if (params.writeKey && params.recipientPins === undefined) { + throw new Error( + 'updateFolderMetadataAndPublish: recipientPins snapshot is required when sealing a write-body (writeKey present) — omitting it would erase existing owner-sealed pins; pass the current pins (or [] for an unpinned node)' + ); + } + let currentRecipientPins: string[] = params.recipientPins ?? []; + let remoteRecipientPins: string[] = []; + const result = await publishWithCas({ ipnsName: params.ipnsName, ipnsPrivateKey: params.ipnsPrivateKey, @@ -325,6 +358,10 @@ export async function updateFolderMetadataAndPublish(params: { writeBody: { ipnsPrivateKey: params.ipnsPrivateKey, writeChildren: currentWriteChildren, + // D-03a/c: carry the recipient pins (∪-merged on a CAS-409). + // encodeWriteBody omits an empty list from the wire, so this is + // inert when there are no pins. + recipientPins: currentRecipientPins, }, } : {}), @@ -346,6 +383,10 @@ export async function updateFolderMetadataAndPublish(params: { // here (fail-closed, matching the read-body's own auth contract). const node = await unsealNode(publishedNode, key, params.writeKey); remoteWriteChildren = node.writeBody?.writeChildren ?? []; + // Capture the remote write-body's recipient pins so the merge can UNION + // them with the local pins — a routine CAS-409 must never drop a + // concurrently-added pin (T-80-11 / D-03a durability). + remoteRecipientPins = node.writeBody?.recipientPins ?? []; return node.children ?? []; }, @@ -399,6 +440,14 @@ export async function updateFolderMetadataAndPublish(params: { for (const wc of remoteWriteChildren) byChildId.set(wc.childId, wc); currentWriteChildren = Array.from(byChildId.values()); } + + // Recipient pins are a monotonically-growing UNION — unlike the + // write-chain, a pin is a permanent trust anchor and is never pruned by + // a delete, so a plain dedup-union of local ∪ remote is correct and + // avoids ever dropping a concurrently-added pin (T-80-11 / D-03a). + for (const pin of remoteRecipientPins) { + currentRecipientPins = appendRecipientPin(currentRecipientPins, pin); + } } // SC#1 site B / T-70-01: defaults to the generic remote-wins mergeChildren // for every non-rotation caller; the rotation engine opts in explicitly diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index b27eb561e3..fc6f7efe80 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -73,6 +73,10 @@ export { issueReadGrant, claimInviteReadKey, type ReadGrantPayload, + assertRecipientPinned, + appendRecipientPin, + extractRecipientPins, + type RecipientPubkey, } from './share'; // Rotation engine + scope-exit predicate diff --git a/packages/sdk-core/src/rotation/engine.ts b/packages/sdk-core/src/rotation/engine.ts index d18ae2dccd..d9f150486c 100644 --- a/packages/sdk-core/src/rotation/engine.ts +++ b/packages/sdk-core/src/rotation/engine.ts @@ -40,6 +40,7 @@ import { generateEd25519Keypair, deriveIpnsName, bytesToBase64, + bytesToHex, base64ToBytes, } from '@cipherbox/crypto'; import { publishWithCas } from '../cas'; @@ -48,6 +49,7 @@ import { fetchFromIpfs, addToIpfs } from '../ipfs'; import type { SdkContext } from '../types'; import { updateFolderMetadataAndPublish } from '../folder/registration'; import { mergeRotatedChildren } from './merge'; +import { assertRecipientPinned } from '../share/recipient-pins'; // --------------------------------------------------------------------------- // Types — string-literal unions, never TypeScript enums (project convention) @@ -64,6 +66,16 @@ import { mergeRotatedChildren } from './merge'; * - `updateGrantFn(shareId, encryptedReadKey, newGeneration)` — persists the * re-minted ECIES-wrapped encrypted key for a non-revoked recipient. * - `deleteGrantFn(shareId)` — removes a revoked recipient's grant row. + * - `getPinsFn(nodeId)` — resolves the node's owner-sealed `recipientPins` + * (raw pubkey bytes or base64 strings) used to fail-closed verify each + * surviving grant's recipient before wrapping (D-03d consumer 2 / T-80-18). + * + * @security + * `getPinsFn` is a REQUIRED seam on the enforced re-mint path: the + * `recipientPublicKey` on each grant round-trips through the untrusted relay + * (via `listSentGrants`), so it MUST NOT be trusted as the wrap target. The + * pin list is the owner-sealed authority. A missing `getPinsFn` OR an empty + * pin list is a HARD fail-closed error (D-03e no-legacy) — never a skip. */ export type GrantRemintCallbacks = { queryGrantsFn: ( @@ -77,6 +89,7 @@ export type GrantRemintCallbacks = { newGeneration: number ) => Promise; deleteGrantFn: (shareId: string) => Promise; + getPinsFn?: (nodeId: string) => Promise; }; /** @@ -556,6 +569,17 @@ export async function mintFileKeyOnRotate(node: Node, _job: RotationJobRecord): * Invoked ONLY when `innerGrants` is non-empty (conditional — D-01). * When `callbacks` is absent the function is a clean no-op (D-04 seam). * + * FILE-ROOTED GRANTS ARE EXEMPT from the D-03d/D-03e pin check (Plan 80 + * file-share carve-out, twin of the Rust `re_mint_grants_rooted_at`): a FILE + * node structurally cannot carry an owner-sealed recipient pin (the pin lives + * in `NodeWriteBody.recipientPins`, and only folder/root shares reseal a + * write-body at issuance). Enforcing the "empty pins is a hard fail" rule on a + * file would fail-close EVERY rotation of a folder that merely CONTAINS a + * separately-shared file — aborting the whole rotation. So `nodeKind === 'file'` + * re-mints WITHOUT the pin check (the accepted, pre-existing file-share + * recipient-substitution limitation), while folder/root grants stay fully + * fail-closed. `nodeKind` is optional and defaults to the enforced path. + * * @security * Uses ECIES `wrapKey` (from `@cipherbox/crypto`) — never hand-roll key * wrapping. Does NOT zero `newReadKey` — caller is terminal owner (D-09). @@ -566,7 +590,8 @@ export async function reMintGrantsRootedAt( newGeneration: number, _job: RotationJobRecord, _ctx: SdkContext, - callbacks?: GrantRemintCallbacks + callbacks?: GrantRemintCallbacks, + nodeKind?: NodeKind ): Promise { // D-04 transport seam: when no callbacks are supplied the function is a clean // no-op. This preserves the D-01 conditional-invocation contract — the clean @@ -575,17 +600,61 @@ export async function reMintGrantsRootedAt( const grants = await callbacks.queryGrantsFn(nodeId); + // D-03d consumer 2 (T-80-18/T-80-19): fetch the node's owner-sealed pin list + // ONCE for the whole node before wrapping any surviving grant. The pin list — + // not the relay-fed `grant.recipientPublicKey` — authorizes the wrap. Only the + // enforced (surviving-grant) path needs pins; an all-revoked node performs no + // wrap, so it does not require the seam. + // File-share carve-out (see the doc comment): a file node can never carry an + // owner-sealed pin, so its re-mint is exempt from the pin check entirely — do + // not fetch pins for it (the seam would resolve a folder write-body and error + // on a leaf) and do not enforce below. + const isFile = nodeKind === 'file'; + let recipientPins: string[] = []; + if (!isFile && grants.some((grant) => !grant.isRevoked)) { + if (!callbacks.getPinsFn) { + // Fail-closed (D-03e no-legacy): the enforced path requires a real pin + // source. A missing seam is a hard invariant violation, never a TOFU pass. + throw new Error( + 'reMintGrantsRootedAt: getPinsFn seam is required to verify recipient pins before re-mint — refusing (D-03d/D-03e)' + ); + } + const rawPins = await callbacks.getPinsFn(nodeId); + // Normalize to base64 for `assertRecipientPinned` (its stored-pin encoding). + recipientPins = rawPins.map((pin) => (pin instanceof Uint8Array ? bytesToBase64(pin) : pin)); + } + for (const grant of grants) { if (grant.isRevoked) { // Revoked recipient: delete the grant row. Do NOT re-mint an encrypted key. // T-64-04b: re-minting for a revoked recipient defeats revocation. await callbacks.deleteGrantFn(grant.shareId); } else { - // Non-revoked recipient: ECIES-wrap the new readKey under their public key. + // Fail-closed recipient verification (D-03d consumer 2 / T-80-18): the + // relay may have substituted `grant.recipientPublicKey`, so assert it is + // pinned in the owner-sealed list BEFORE wrapping. A mismatch or an + // absent/empty pin list throws — aborting the node's re-mint (D-03e). This + // is a HARD fail, deliberately NOT a per-grant skip like the isRevoked + // branch (Pitfall 5). Reuses the shared sdk-core helper (80-04); the web + // consumer (80-08) reuses the same compare. File-rooted grants are exempt + // (see the file-share carve-out in the doc comment) — a file has no pin. + if (!isFile) { + assertRecipientPinned(grant.recipientPublicKey, recipientPins); + } + + // Non-revoked + pinned recipient: ECIES-wrap the new readKey under their key. // T-64-04c: always use wrapKey — never hand-roll key wrapping. // Do NOT zero newReadKey here — caller is terminal owner (D-09). const wrappedBytes = await wrapKey(newReadKey, grant.recipientPublicKey); - const encryptedReadKey = bytesToBase64(wrappedBytes); + // Encode as HEX — `PATCH /shares/:id/grant` (UpdateGrantDto.encryptedReadKey) + // validates even-length hex (`/^(?:[0-9a-fA-F]{2})+$/`) and decodes via + // `Buffer.from(.., 'hex')`, exactly like the share-CREATE path + // (share/index.ts `bytesToHex`) and the Rust re-mint twin + // (crates/sdk/src/rotation/engine.rs `hex::encode`). base64 here 400s every + // re-mint PATCH (folder sweep AND file inline) — the recipient also decodes + // it as hex, so base64 would be double-wrong. (Distinct from the owner-only + // ECIES key-checkpoint at ~:1152, which stays base64 to match Rust.) + const encryptedReadKey = bytesToHex(wrappedBytes); await callbacks.updateGrantFn(grant.shareId, encryptedReadKey, newGeneration); } } @@ -1185,7 +1254,8 @@ export async function rotateOne( generationPrime, jobRecord, ctx, - grantCallbacks + grantCallbacks, + node.kind ); } @@ -2052,9 +2122,12 @@ export async function rotateReadFromNode( // Plan 74-02 (SC1): surface the root's own post-rotation key into the // per-node map, keyed by ipnsName — mirrors the Rust root commit branch // (crates/sdk/src/rotation/engine.rs, 74-01). + // `readKey` is a defensive COPY owned by this collection, safe from a + // future zero-on-drop of the aliased `parentNewReadKey: rootResult.childReadKey` + // below — mirrors Rust's `Zeroizing<[u8;32]>` clone (D-04, T-80-08). rotatedNodes.set(rootNodeIpnsName, { ipnsName: rootNodeIpnsName, - readKey: rootResult.childReadKey, + readKey: new Uint8Array(rootResult.childReadKey), generation: rootResult.newGeneration, sequenceNumber: rootResult.newSequenceNumber, }); @@ -2223,9 +2296,12 @@ export async function rotateReadFromNode( // Plan 74-02 (SC1): surface this child's post-rotation key into the // per-node map, keyed by its ipnsName — mirrors the Rust BFS child // commit branch (crates/sdk/src/rotation/engine.rs, 74-01). + // `readKey` is a defensive COPY owned by this collection, safe from a + // future zero-on-drop of the aliased `parentNewReadKey: result.childReadKey` + // below — mirrors Rust's `Zeroizing<[u8;32]>` clone (D-04, T-80-08). rotatedNodes.set(item.childRef.ipnsName, { ipnsName: item.childRef.ipnsName, - readKey: result.childReadKey, + readKey: new Uint8Array(result.childReadKey), generation: result.newGeneration, sequenceNumber: result.newSequenceNumber, }); diff --git a/packages/sdk-core/src/share/index.ts b/packages/sdk-core/src/share/index.ts index c2d119849f..706d794f38 100644 --- a/packages/sdk-core/src/share/index.ts +++ b/packages/sdk-core/src/share/index.ts @@ -3,3 +3,10 @@ export { navigateReadChain, type NavigateResult } from './navigate'; export { issueReadGrant, claimInviteReadKey, claimInvite, type ReadGrantPayload } from './grant'; + +export { + assertRecipientPinned, + appendRecipientPin, + extractRecipientPins, + type RecipientPubkey, +} from './recipient-pins'; diff --git a/packages/sdk-core/src/share/recipient-pins.ts b/packages/sdk-core/src/share/recipient-pins.ts new file mode 100644 index 0000000000..c90acd5dd1 --- /dev/null +++ b/packages/sdk-core/src/share/recipient-pins.ts @@ -0,0 +1,125 @@ +/** + * Recipient-pubkey pin helpers (Phase 80 Plan 04, D-03a/c/e). + * + * The owner-sealed `NodeWriteBody.recipientPins` list (base64 raw-pubkey bytes + * on the wire) is the server-opaque, cross-device trust anchor bound at share / + * re-mint time. These pure helpers are the single source of truth for the three + * D-03d enforcement consumers (80-06 Rust, 80-07 TS, 80-08 web) to verify a + * recipient against, plus the issuance-time append used by the client wrappers. + * + * Encoding contract: + * - A stored pin is ALWAYS a base64 string of the raw pubkey bytes (produced + * by `bytesToBase64`), matching the `encodeWriteBody` / `decodeWriteBody` + * wire format in `@cipherbox/core`. + * - A `recipient` input may be raw bytes, a hex string (`0x`-prefixed or not), + * or a base64 string — `assertRecipientPinned` / `appendRecipientPin` + * normalize both sides to raw bytes before comparing so there is never a + * hex/base64 mismatch (PATTERNS "0x-strip / hex-decode convention"). + * + * These helpers NEVER touch key material or IPNS — they are pure functions over + * the decoded write-body's pin list. + */ + +import { base64ToBytes, bytesToBase64, hexToBytes } from '@cipherbox/crypto'; +import type { NodeWriteBody } from '@cipherbox/core'; + +/** A recipient pubkey accepted as raw bytes, hex (`0x`-optional), or base64. */ +export type RecipientPubkey = Uint8Array | string; + +/** Matches a (possibly `0x`-prefixed) even-length hex string. */ +const HEX_RE = /^[0-9a-fA-F]+$/; + +/** + * Normalize a recipient pubkey to raw bytes. + * + * - `Uint8Array` → a copy of the bytes (never aliases the caller's buffer). + * - hex string (`0x`-prefixed or bare, even length, hex alphabet) → decoded bytes. + * - anything else → treated as base64. + * + * The hex heuristic is intentionally strict (even length + hex alphabet) so a + * base64 pubkey (which for a 33/65-byte key is 44/88 chars and contains + * non-hex base64 characters in practice) is never mis-detected as hex. + */ +function toRawPubkeyBytes(input: RecipientPubkey): Uint8Array { + if (input instanceof Uint8Array) return Uint8Array.from(input); + const trimmed = input.trim(); + const bare = trimmed.startsWith('0x') || trimmed.startsWith('0X') ? trimmed.slice(2) : trimmed; + if (bare.length > 0 && bare.length % 2 === 0 && HEX_RE.test(bare)) { + return hexToBytes(bare); + } + return base64ToBytes(trimmed); +} + +/** Decode a stored pin (always base64) to raw bytes. */ +function pinToBytes(pin: string): Uint8Array { + return base64ToBytes(pin); +} + +/** Constant-length-independent byte equality (public data — timing not sensitive). */ +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + let diff = 0; + for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; + return diff === 0; +} + +/** + * Return the recipient-pin list (base64 strings) from a decoded write-body, or + * `[]` when the write-body is absent or carries no pins. + */ +export function extractRecipientPins(writeBody: NodeWriteBody | null | undefined): string[] { + return writeBody?.recipientPins ? [...writeBody.recipientPins] : []; +} + +/** + * Return a deduped pin list (base64) that includes `recipient`. + * + * Dedup is by raw pubkey bytes, so an existing pin equal to `recipient` under a + * different encoding is never duplicated. Existing pins are re-encoded to + * canonical base64 (idempotent for already-canonical input). Does NOT mutate the + * input list. + */ +export function appendRecipientPin( + pins: string[] | null | undefined, + recipient: RecipientPubkey +): string[] { + const recipientBytes = toRawPubkeyBytes(recipient); + const seen: Uint8Array[] = []; + const out: string[] = []; + const add = (bytes: Uint8Array): void => { + if (!seen.some((s) => bytesEqual(s, bytes))) { + seen.push(bytes); + out.push(bytesToBase64(bytes)); + } + }; + for (const p of pins ?? []) add(pinToBytes(p)); + add(recipientBytes); + return out; +} + +/** + * Assert `recipient` is pinned in `pins`, throwing otherwise. + * + * Fails CLOSED on an empty or absent pin list (D-03e no-legacy: an absent pin is + * a hard failure, never a TOFU pass) AND on a non-member recipient. Returns + * `void` on a raw-byte match. Both sides are normalized to raw bytes before the + * compare. + */ +export function assertRecipientPinned( + recipient: RecipientPubkey, + pins: string[] | null | undefined +): void { + const list = pins ?? []; + if (list.length === 0) { + throw new Error( + 'assertRecipientPinned: recipient pin list is empty or absent — refusing (D-03e no-legacy hard fail)' + ); + } + const recipientBytes = toRawPubkeyBytes(recipient); + for (const p of list) { + if (bytesEqual(pinToBytes(p), recipientBytes)) return; + } + throw new Error( + 'assertRecipientPinned: recipient is not pinned in the node write-body — refusing (D-03d)' + ); +} diff --git a/packages/sdk/src/__tests__/client-rotation.test.ts b/packages/sdk/src/__tests__/client-rotation.test.ts index 4dc34d8693..aab7cba97f 100644 --- a/packages/sdk/src/__tests__/client-rotation.test.ts +++ b/packages/sdk/src/__tests__/client-rotation.test.ts @@ -305,6 +305,114 @@ describe('CipherBoxClient — scope-exit rotation wiring (SC#2 / SC#4, Task 2)', expect(sdkCore.rotateReadFromNode).toHaveBeenCalledTimes(1); }); + // ── recipient-pin-lifecycle §5: inline grant-remint seam threading ────── + // The web file-grant re-mint gap is closed by supplying rotateReadFromNode's + // per-node re-mint inputs (innerGrants + grantCallbacks) from the host's + // rotationCallbacks.resolveInlineGrantRemint. These tests pin the wiring at + // the SDK chokepoint (the concrete web transport is thin untested glue). + + it('threads resolveInlineGrantRemint innerGrants/grantCallbacks into rotateReadFromNode when covered', async () => { + const innerGrants = [{ shareId: 's1' }]; // non-empty ENABLE gate sentinel + const grantCallbacks = { + queryGrantsFn: vi.fn(), + updateGrantFn: vi.fn(), + deleteGrantFn: vi.fn(), + getPinsFn: vi.fn(), + }; + const resolveInlineGrantRemint = vi.fn().mockResolvedValue({ innerGrants, grantCallbacks }); + const client = new CipherBoxClient( + createTestConfig({ + rotationCallbacks: { + getActiveGrantRootIpnsNames: async () => new Set([FOLDER_IPNS]), + getLocalGrantRecord: () => null, + persistJob: vi.fn(), + resolveInlineGrantRemint, + }, + }) + ); + setupFolder(client, FOLDER_IPNS); // folder nodeId: 'test-node-id' + vi.mocked(sdkCore.renameInFolder).mockReturnValue({ + updatedChildren: [], + renamedChild: {} as never, + }); + vi.mocked(sdkCore.updateFolderMetadataAndPublish).mockResolvedValue({ + cid: 'bafynew', + newSequenceNumber: 2n, + publishedChildren: [], + }); + vi.mocked(sdkCore.rotateReadFromNode).mockResolvedValue(undefined); + + await client.renameItem(FOLDER_IPNS, 'file1', 'new.txt'); + + // Resolved once for the rotation root (its IPNS name + own node id), and the + // resolved bundle handed verbatim to rotateReadFromNode's per-node seam. + expect(resolveInlineGrantRemint).toHaveBeenCalledWith(FOLDER_IPNS, 'test-node-id'); + const rotateArgs = vi.mocked(sdkCore.rotateReadFromNode).mock.calls[0][0]; + expect(rotateArgs.innerGrants).toBe(innerGrants); + expect(rotateArgs.grantCallbacks).toBe(grantCallbacks); + }); + + it('leaves innerGrants/grantCallbacks undefined when resolveInlineGrantRemint is omitted (unchanged sweep-only default)', async () => { + const client = new CipherBoxClient( + createTestConfig({ + rotationCallbacks: { + getActiveGrantRootIpnsNames: async () => new Set([FOLDER_IPNS]), + getLocalGrantRecord: () => null, + persistJob: vi.fn(), + }, + }) + ); + setupFolder(client, FOLDER_IPNS); + vi.mocked(sdkCore.renameInFolder).mockReturnValue({ + updatedChildren: [], + renamedChild: {} as never, + }); + vi.mocked(sdkCore.updateFolderMetadataAndPublish).mockResolvedValue({ + cid: 'bafynew', + newSequenceNumber: 2n, + publishedChildren: [], + }); + vi.mocked(sdkCore.rotateReadFromNode).mockResolvedValue(undefined); + + await client.renameItem(FOLDER_IPNS, 'file1', 'new.txt'); + + const rotateArgs = vi.mocked(sdkCore.rotateReadFromNode).mock.calls[0][0]; + expect(rotateArgs.innerGrants).toBeUndefined(); + expect(rotateArgs.grantCallbacks).toBeUndefined(); + }); + + it('leaves the seam disabled when resolveInlineGrantRemint resolves undefined (no active grants)', async () => { + const resolveInlineGrantRemint = vi.fn().mockResolvedValue(undefined); + const client = new CipherBoxClient( + createTestConfig({ + rotationCallbacks: { + getActiveGrantRootIpnsNames: async () => new Set([FOLDER_IPNS]), + getLocalGrantRecord: () => null, + persistJob: vi.fn(), + resolveInlineGrantRemint, + }, + }) + ); + setupFolder(client, FOLDER_IPNS); + vi.mocked(sdkCore.renameInFolder).mockReturnValue({ + updatedChildren: [], + renamedChild: {} as never, + }); + vi.mocked(sdkCore.updateFolderMetadataAndPublish).mockResolvedValue({ + cid: 'bafynew', + newSequenceNumber: 2n, + publishedChildren: [], + }); + vi.mocked(sdkCore.rotateReadFromNode).mockResolvedValue(undefined); + + await client.renameItem(FOLDER_IPNS, 'file1', 'new.txt'); + + expect(resolveInlineGrantRemint).toHaveBeenCalledTimes(1); + const rotateArgs = vi.mocked(sdkCore.rotateReadFromNode).mock.calls[0][0]; + expect(rotateArgs.innerGrants).toBeUndefined(); + expect(rotateArgs.grantCallbacks).toBeUndefined(); + }); + it('renameItem performs zero rotation when uncovered (default no-op callbacks)', async () => { const client = new CipherBoxClient(createTestConfig()); setupFolder(client, FOLDER_IPNS); diff --git a/packages/sdk/src/__tests__/delete-item.test.ts b/packages/sdk/src/__tests__/delete-item.test.ts index 16de1d72de..3aaaa6f784 100644 --- a/packages/sdk/src/__tests__/delete-item.test.ts +++ b/packages/sdk/src/__tests__/delete-item.test.ts @@ -56,6 +56,7 @@ interface CapturedPublishArgs { writeKey?: Uint8Array; writeChildren?: WriteChildRef[]; baseWriteChildren?: WriteChildRef[]; + recipientPins?: string[]; children: SealedChildRef[]; } @@ -69,7 +70,7 @@ interface CapturedPublishArgs { */ async function seedDelete( client: CipherBoxClient, - opts: { childResolveFails?: boolean } = {} + opts: { childResolveFails?: boolean; recipientPins?: string[] } = {} ): Promise<{ writeKey: Uint8Array; captured: () => CapturedPublishArgs | null }> { const folderKey = new Uint8Array(32).fill(0x11); const writeKey = new Uint8Array(32).fill(0x33); @@ -131,6 +132,7 @@ async function seedDelete( writeBody: { ipnsPrivateKey: new Uint8Array(64).fill(3), writeChildren: [nodeWriteChildRef, otherWriteChildRef], + ...(opts.recipientPins ? { recipientPins: opts.recipientPins } : {}), }, }, lastLoadedAt: Date.now(), @@ -209,6 +211,22 @@ describe('CipherBoxClient.deleteItem write-chain trim (SC#1)', () => { expect(published?.baseWriteChildren?.some((wc) => wc.childId === NODE_UUID)).toBe(true); }); + // D-03 (Plan 80) regression: a routine delete republish must PRESERVE the + // folder's owner-sealed recipient pins. Before the fix, deleteItem passed + // `writeChildren` explicitly and dropped `recipientPins`, so a shared folder + // republished pin-less on every ordinary write — hard-failing a later re-mint + // after re-materialize (D-03e) and silently defeating revocation. Pins are + // PUBLIC ECIES keys, threaded verbatim (never rotated). + it('preserves the folder recipient pins on a routine delete republish (D-03e)', async () => { + const pins = ['YWFhYWFhYWFh', 'YmJiYmJiYmJi']; + const { captured } = await seedDelete(client, { recipientPins: pins }); + + await client.deleteItem(FOLDER_IPNS, CHILD_IPNS); + + const published = captured(); + expect(published?.recipientPins).toEqual(pins); + }); + it('fails OPEN on a UUID resolve failure: delete still succeeds and write-body is left unchanged', async () => { const { captured } = await seedDelete(client, { childResolveFails: true }); diff --git a/packages/sdk/src/__tests__/owner-reconcile.test.ts b/packages/sdk/src/__tests__/owner-reconcile.test.ts index 0c6676f6bc..13895cab09 100644 --- a/packages/sdk/src/__tests__/owner-reconcile.test.ts +++ b/packages/sdk/src/__tests__/owner-reconcile.test.ts @@ -30,13 +30,22 @@ const mockFns = vi.hoisted(() => ({ wrapKey: vi.fn(), })); -vi.mock('@cipherbox/crypto', () => ({ - wrapKey: mockFns.wrapKey, - generateRandomBytes: vi.fn(), - unwrapKey: vi.fn(), - reWrapKey: vi.fn(), - bytesToBase64: vi.fn((bytes: Uint8Array) => btoa(String.fromCharCode(...bytes))), -})); +// Keep the real base64ToBytes/hexToBytes (via importOriginal) — the rebuilt +// sdk-core's assertRecipientPinned (80-04) decodes the pin list with them when +// verifying each surviving grant's recipient before wrapKey (D-03d consumer 2). +// Only the ECIES/randomness surface is stubbed; bytesToBase64 keeps its +// deterministic btoa form for the recipient pin list. +vi.mock('@cipherbox/crypto', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + wrapKey: mockFns.wrapKey, + generateRandomBytes: vi.fn(), + unwrapKey: vi.fn(), + reWrapKey: vi.fn(), + bytesToBase64: vi.fn((bytes: Uint8Array) => btoa(String.fromCharCode(...bytes))), + }; +}); // --------------------------------------------------------------------------- // Fixtures @@ -48,7 +57,9 @@ const NEW_READ_KEY = new Uint8Array(32).fill(0xab); const NEW_GENERATION = 3; const MOCK_WRAPPED_BYTES = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); -const EXPECTED_ENCRYPTED_KEY = btoa(String.fromCharCode(0xde, 0xad, 0xbe, 0xef)); +// Hex, not base64 — reMintGrantsRootedAt encodes the wrapped read key with +// bytesToHex to match the grant API (Gap C fix). +const EXPECTED_ENCRYPTED_KEY = 'deadbeef'; const SHARE_ID_SURVIVING = 'share-survive-1111'; const SHARE_ID_REVOKED = 'share-revoked-2222'; @@ -76,15 +87,24 @@ function makeCtx(): SdkContext { }; } -function makeTransport(grants: GrantRow[]): OwnerReconcileTransport & { +function makeTransport( + grants: GrantRow[], + pins?: Uint8Array[] +): OwnerReconcileTransport & { listSentGrants: ReturnType; updateGrant: ReturnType; deleteGrant: ReturnType; + getRecipientPubkeyPins: ReturnType; } { + // Default the owner-sealed pin list to the recipients of the supplied grants, + // so a surviving grant is pinned unless a test overrides `pins` to force a + // relay-substitution mismatch (D-03d) or an absent pin list (D-03e). + const defaultPins = pins ?? grants.map((grant) => grant.recipientPublicKey); return { listSentGrants: vi.fn().mockResolvedValue(grants), updateGrant: vi.fn().mockResolvedValue(undefined), deleteGrant: vi.fn().mockResolvedValue(undefined), + getRecipientPubkeyPins: vi.fn().mockResolvedValue(defaultPins), }; } @@ -118,6 +138,69 @@ describe('buildGrantRemintCallbacks', () => { { shareId: SHARE_ID_SURVIVING, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, ]); }); + + // D-02 (TS mirror, Plan 80-03 / SC2-perf / T-80-09): queryGrantsFn is invoked + // once per rotated node during a reconcile pass. Without a per-pass memo, each + // call re-fetches transport.listSentGrants() → O(nodes × shares) relay fetches. + // The closure-scoped cache in buildGrantRemintCallbacks bounds it to <=1 fetch + // while each call still returns the rootNodeId-filtered subset. + it('Test 1b: listSentGrants is fetched at most once across multiple queryGrantsFn calls, filtering stays correct per node', async () => { + const transport = makeTransport([ + { + shareId: SHARE_ID_SURVIVING, + recipientPublicKey: RECIPIENT_PUB_KEY_A, + isRevoked: false, + rootNodeId: ROOT_NODE_ID, + }, + { + shareId: SHARE_ID_OTHER_ROOT, + recipientPublicKey: RECIPIENT_PUB_KEY_B, + isRevoked: false, + rootNodeId: OTHER_NODE_ID, + }, + ]); + const callbacks = buildGrantRemintCallbacks(transport); + + // Invoke across multiple nodes within one pass. + const rootGrants = await callbacks.queryGrantsFn(ROOT_NODE_ID); + const otherGrants = await callbacks.queryGrantsFn(OTHER_NODE_ID); + const rootGrantsAgain = await callbacks.queryGrantsFn(ROOT_NODE_ID); + + // Single fetch across all three calls (the memo). + expect(transport.listSentGrants).toHaveBeenCalledTimes(1); + + // Per-node rootNodeId filtering is unchanged. + expect(rootGrants).toEqual([ + { shareId: SHARE_ID_SURVIVING, recipientPublicKey: RECIPIENT_PUB_KEY_A, isRevoked: false }, + ]); + expect(otherGrants).toEqual([ + { shareId: SHARE_ID_OTHER_ROOT, recipientPublicKey: RECIPIENT_PUB_KEY_B, isRevoked: false }, + ]); + expect(rootGrantsAgain).toEqual(rootGrants); + }); + + it('Test 1c: the cache is scoped per buildGrantRemintCallbacks call — a fresh callbacks bundle re-fetches (no global/static cache)', async () => { + const grants: GrantRow[] = [ + { + shareId: SHARE_ID_SURVIVING, + recipientPublicKey: RECIPIENT_PUB_KEY_A, + isRevoked: false, + rootNodeId: ROOT_NODE_ID, + }, + ]; + const transport = makeTransport(grants); + + const callbacksA = buildGrantRemintCallbacks(transport); + await callbacksA.queryGrantsFn(ROOT_NODE_ID); + await callbacksA.queryGrantsFn(ROOT_NODE_ID); + expect(transport.listSentGrants).toHaveBeenCalledTimes(1); + + // A new reconcile pass (new callbacks bundle) must fetch again — proving the + // memo is closure-scoped, not module-global. + const callbacksB = buildGrantRemintCallbacks(transport); + await callbacksB.queryGrantsFn(ROOT_NODE_ID); + expect(transport.listSentGrants).toHaveBeenCalledTimes(2); + }); }); describe('runOwnerReconcile', () => { @@ -185,4 +268,81 @@ describe('runOwnerReconcile', () => { expect(transport.updateGrant).not.toHaveBeenCalled(); expect(transport.deleteGrant).not.toHaveBeenCalled(); }); + + // D-03d consumer 2 (T-80-18): the recipientPublicKey round-trips through the + // untrusted relay via listSentGrants. When the node's owner-sealed pin list + // (getRecipientPubkeyPins read path) does NOT include that recipient, the + // reconcile pass MUST fail closed — throw and never wrap/persist the read key. + it('Test 5 (D-03d mismatch): reconcile fails closed when the pin list omits the surviving grant recipient', async () => { + const transport = makeTransport( + [ + { + shareId: SHARE_ID_SURVIVING, + recipientPublicKey: RECIPIENT_PUB_KEY_A, + isRevoked: false, + rootNodeId: ROOT_NODE_ID, + }, + ], + // Pin list contains a DIFFERENT recipient — relay substituted the pubkey. + [RECIPIENT_PUB_KEY_B] + ); + const ctx = makeCtx(); + const job = makeJobRecord(); + + await expect( + runOwnerReconcile(ROOT_NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, transport) + ).rejects.toThrow(/pinned/i); + + // Fail-closed: no wrap, no persistence of the re-minted key. + expect(mockFns.wrapKey).not.toHaveBeenCalled(); + expect(transport.updateGrant).not.toHaveBeenCalled(); + }); + + // D-03e no-legacy: an empty/absent owner-sealed pin list is a HARD failure, + // never a TOFU pass — the reconcile pass throws for a surviving grant. + it('Test 6 (D-03e absent): reconcile fails closed when the pin list is empty', async () => { + const transport = makeTransport( + [ + { + shareId: SHARE_ID_SURVIVING, + recipientPublicKey: RECIPIENT_PUB_KEY_A, + isRevoked: false, + rootNodeId: ROOT_NODE_ID, + }, + ], + [] // absent/empty pin list + ); + const ctx = makeCtx(); + const job = makeJobRecord(); + + await expect( + runOwnerReconcile(ROOT_NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, transport) + ).rejects.toThrow(); + + expect(mockFns.wrapKey).not.toHaveBeenCalled(); + expect(transport.updateGrant).not.toHaveBeenCalled(); + }); + + it('Test 7 (pin source): getPinsFn resolves via getRecipientPubkeyPins, matching pin wraps as before', async () => { + const transport = makeTransport([ + { + shareId: SHARE_ID_SURVIVING, + recipientPublicKey: RECIPIENT_PUB_KEY_A, + isRevoked: false, + rootNodeId: ROOT_NODE_ID, + }, + ]); + const ctx = makeCtx(); + const job = makeJobRecord(); + + await runOwnerReconcile(ROOT_NODE_ID, NEW_READ_KEY, NEW_GENERATION, job, ctx, transport); + + // The pin source is the owner-sealed read path, resolved for the node. + expect(transport.getRecipientPubkeyPins).toHaveBeenCalledWith(ROOT_NODE_ID); + expect(transport.updateGrant).toHaveBeenCalledWith( + SHARE_ID_SURVIVING, + EXPECTED_ENCRYPTED_KEY, + NEW_GENERATION + ); + }); }); diff --git a/packages/sdk/src/__tests__/shared-write.test.ts b/packages/sdk/src/__tests__/shared-write.test.ts index d1a1f1531b..4b7911ed0f 100644 --- a/packages/sdk/src/__tests__/shared-write.test.ts +++ b/packages/sdk/src/__tests__/shared-write.test.ts @@ -65,6 +65,7 @@ async function buildSealedParent(opts?: { extraChildren?: SealedChildRef[]; writeChildren?: WriteChildRef[]; ipnsPrivKey?: Uint8Array; + recipientPins?: string[]; }): Promise<{ publishedNode: PublishedNode; readKey: Uint8Array; @@ -85,6 +86,7 @@ async function buildSealedParent(opts?: { writeBody: { ipnsPrivateKey: opts?.ipnsPrivKey ?? ipnsPrivateKey, writeChildren: opts?.writeChildren ?? [], + ...(opts?.recipientPins ? { recipientPins: opts.recipientPins } : {}), }, }; const publishedNode = await sealNode(parent, readKey, writeKey); @@ -458,6 +460,62 @@ describe('deleteFromSharedFolder', () => { expect(unsealed.writeBody).toBeDefined(); expect(unsealed.writeBody!.writeChildren).toHaveLength(0); }); + + // D-03 (Plan 80) regression: a shared-write op MUST preserve the parent's + // owner-sealed recipientPins across the resealAndPublishParent chokepoint. + // Before the fix, resealAndPublishParent rebuilt writeBody as + // { ipnsPrivateKey, writeChildren } — dropping recipientPins — so a recipient + // (Bob) writing into a shared folder republished the folder root PIN-LESS, + // erasing the owner's pin on the wire. The owner then re-resolves empty pins, + // and a later upgrade/re-mint hard fails closed (D-03e), stranding the share + // (writable-shares 6.1 "Alice upgrades Bob to write" never reaches [write]). + it('preserves the parent recipientPins across a shared-write republish (D-03)', async () => { + const CHILD_UUID = 'cccccccc-dddd-eeee-ffff-000000000000'; + const child: SealedChildRef = { + name: 'doomed.txt', + ipnsName: 'k51child', + generation: 0, + versionFloor: 1n, + readKeySealed: 'fakebase64sealed', + }; + const writeChild: WriteChildRef = { + childId: CHILD_UUID, + writeKeySealed: 'fakebase64writesealed', + }; + // Two owner-sealed pins already present on the parent write-body. + const recipientPins = ['AgICAgICAgICAgICAgICAgICAgICAgICAgICAgICAgIC', 'AwMDAwMDAwMDAw==']; + + const { + publishedNode: pn, + readKey, + writeKey, + } = await buildSealedParent({ + extraChildren: [child], + writeChildren: [writeChild], + recipientPins, + }); + + let capturedPublished: PublishedNode | undefined; + const publishFn = vi.fn().mockImplementation(async (p: { published: PublishedNode }) => { + capturedPublished = p.published; + return { tombstoned: false, newSequenceNumber: 2n }; + }); + + const swCtx = await makeSWCtx({ + publishedNode: pn, + readKey, + writeKey, + children: [child], + publishNodeFn: publishFn, + }); + + await deleteFromSharedFolder(swCtx, { itemId: 'k51child', childNodeId: CHILD_UUID }); + + expect(capturedPublished).toBeDefined(); + const unsealed = await unsealNode(capturedPublished!, readKey, writeKey); + expect(unsealed.writeBody).toBeDefined(); + expect(unsealed.writeBody!.recipientPins).toEqual(recipientPins); + }); }); describe('updateSharedFile', () => { diff --git a/packages/sdk/src/bin/index.ts b/packages/sdk/src/bin/index.ts index f9def0d8fd..b64e4b29a8 100644 --- a/packages/sdk/src/bin/index.ts +++ b/packages/sdk/src/bin/index.ts @@ -533,7 +533,11 @@ export async function restoreFromBin(params: { // ipnsName-keyed). Pitfall 4: `generation` here is `restoredItem.generation` // (== nodeRef.generation) — the SAME value already used for the read-plane // reseal above — never a second, independently-derived generation. - let sourceWriteBodyParams: { writeKey?: Uint8Array; writeChildren?: WriteChildRef[] } = {}; + let sourceWriteBodyParams: { + writeKey?: Uint8Array; + writeChildren?: WriteChildRef[]; + recipientPins?: string[]; + } = {}; let baseSourceWriteChildren: WriteChildRef[] | undefined; let rehomedSourceWriteChildren: WriteChildRef[] | undefined; let didRehome = false; @@ -629,6 +633,10 @@ export async function restoreFromBin(params: { writeKey: targetWriteBodyParams.writeKey, writeChildren: rehomedTargetWriteChildren, baseWriteChildren: baseTargetWriteChildren, + // D-03: preserve the target folder's owner-sealed recipient pins on the + // restore republish (an omitted snapshot fail-closes; sealing pin-less + // would erase pins for a shared folder). Empty [] for an unpinned folder. + recipientPins: targetWriteBodyParams.recipientPins ?? [], ipnsPrivateKey: targetFolder.ipnsKeypair.privateKey, ipnsPublicKey: targetFolder.ipnsKeypair.publicKey, ipnsName: targetFolderIpnsName, @@ -667,6 +675,9 @@ export async function restoreFromBin(params: { writeKey: sourceWriteBodyParams.writeKey, writeChildren: rehomedSourceWriteChildren, baseWriteChildren: baseSourceWriteChildren, + // D-03: preserve the source folder's owner-sealed recipient pins on the + // lingering-ref drop republish (omitted snapshot fail-closes). + recipientPins: sourceWriteBodyParams.recipientPins ?? [], ipnsPrivateKey: sourceFolder.ipnsKeypair.privateKey, ipnsPublicKey: sourceFolder.ipnsKeypair.publicKey, ipnsName: sourceFolder.ipnsName, @@ -780,6 +791,9 @@ async function dropLingeringWriteChildRef(params: { writeKey: writeBodyParams.writeKey, writeChildren: trimmedWriteChildren, baseWriteChildren, + // D-03: preserve the parent's owner-sealed recipient pins on the + // lingering-ref drop republish (omitted snapshot fail-closes). + recipientPins: writeBodyParams.recipientPins ?? [], ipnsPrivateKey: originalParent.ipnsKeypair.privateKey, ipnsPublicKey: originalParent.ipnsKeypair.publicKey, ipnsName: originalParent.ipnsName, diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index b7ca5664e1..4b7d29a957 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -30,6 +30,7 @@ import { wrapKey, hexToBytes, bytesToHex, + base64ToBytes, deriveEd25519PublicKey, generateEd25519Keypair, generateRandomBytes, @@ -1345,7 +1346,7 @@ export class CipherBoxClient { */ private async getWriteBodyParams( folder: FolderState - ): Promise<{ writeKey?: Uint8Array; writeChildren?: WriteChildRef[] }> { + ): Promise<{ writeKey?: Uint8Array; writeChildren?: WriteChildRef[]; recipientPins?: string[] }> { return getWriteBodyParamsShared(folder, this.ctx); } @@ -1363,14 +1364,16 @@ export class CipherBoxClient { folder: FolderState, publishedChildren: SealedChildRef[], newSequenceNumber: bigint, - publishedWriteChildren?: WriteChildRef[] + publishedWriteChildren?: WriteChildRef[], + publishedRecipientPins?: string[] ): void { adoptPublishedFolderStateShared( this.folderTree, folder, publishedChildren, newSequenceNumber, - publishedWriteChildren + publishedWriteChildren, + publishedRecipientPins ); } @@ -2092,6 +2095,18 @@ export class CipherBoxClient { frontier: [], persistCallback: callbacks.persistJob, }; + // recipient-pin-lifecycle §5: resolve the INLINE grant-remint seam for + // this rotation root (only now that rotation is actually covered, so an + // uncovered mutation never pays the sent-grants fetch). When the host + // supplies it and the owner has active sent grants, the returned + // innerGrants/grantCallbacks enable rotateReadFromNode's per-node + // re-mint — re-minting FILE grants the reconcile sweep can't reach + // (no FolderTree entry) under each file's rotated read key. Undefined + // (no seam, or no active grants) → unchanged sweep-only behavior. + const inlineGrantRemint = await callbacks.resolveInlineGrantRemint?.( + params.rootNodeIpnsName, + params.rootNodeId + ); try { rotationResult = await sdkCore.rotateReadFromNode({ rootNodeId: params.rootNodeId, @@ -2118,14 +2133,15 @@ export class CipherBoxClient { writeKey: folder.writeKey, }; }, - // SC#4 (Plan 70-06) seam plumbing: no CipherBoxClientConfig seam - // supplies grant-remint callbacks/inner-grants today (Phase 66 - // is the host-wiring follow-up per RESEARCH) -- threading the - // fields here (currently always undefined) makes them - // structurally reachable from the real walk without requiring - // a new config surface in this plan. - innerGrants: undefined, - grantCallbacks: undefined, + // SC#4 (Plan 70-06) seam plumbing, now LIVE-WIRED for web + // (recipient-pin-lifecycle §5): the host's rotationCallbacks + // `resolveInlineGrantRemint` supplies the per-node re-mint inputs + // when the owner has active sent grants, so the walk re-mints + // surviving grants inline — closing the file-share re-mint gap the + // reconcile sweep cannot reach. Both undefined (no seam / no active + // grants) → sdk-core no-ops the seam, unchanged prior behavior. + innerGrants: inlineGrantRemint?.innerGrants, + grantCallbacks: inlineGrantRemint?.grantCallbacks, // SC#3 (Plan 70.1-05 / D-01..D-05): the owner's OWN vault // keypair wraps/unwraps the ECIES key-checkpoint. keyCheckpoint // is the seam Plan 70.1-05 added to RotationClientCallbacks -- @@ -2544,6 +2560,11 @@ export class CipherBoxClient { folderKey: parent.folderKey, writeKey: parentWriteKey, writeChildren: updatedWriteChildren, + // D-03 (Plan 80): thread the parent's existing recipient pins so a + // shared parent republished on child-create stays pinned (a pin-less + // republish hard-fails a later re-mint, D-03e). Pins are copied, never + // rotated. + recipientPins: parentWriteBodyParams.recipientPins, ipnsPrivateKey: parent.ipnsKeypair.privateKey, ipnsName: parentIpnsName, sequenceNumber: parent.sequenceNumber, @@ -2556,7 +2577,8 @@ export class CipherBoxClient { parent, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? updatedWriteChildren + publishedWriteChildren ?? updatedWriteChildren, + parentWriteBodyParams.recipientPins ); // Register the new child so it is immediately usable (upload/rename/etc.) @@ -2672,7 +2694,8 @@ export class CipherBoxClient { folder, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? writeBodyParams.writeChildren + publishedWriteChildren ?? writeBodyParams.writeChildren, + writeBodyParams.recipientPins ); // 4. Emit update event @@ -2904,6 +2927,9 @@ export class CipherBoxClient { readKey: destFolder.folderKey, writeKey: destWriteBodyParams.writeKey, writeChildren: rehomedDestWriteChildren, + // D-03 (Plan 80): preserve the destination folder's recipient pins on the + // move republish (a pin-less republish hard-fails a later re-mint, D-03e). + recipientPins: destWriteBodyParams.recipientPins, ipnsPrivateKey: destFolder.ipnsKeypair.privateKey, ipnsName: destIpnsName, sequenceNumber: destFolder.sequenceNumber, @@ -2916,7 +2942,8 @@ export class CipherBoxClient { destFolder, dstChildren, dstSeq, - dstPublishedWriteChildren ?? rehomedDestWriteChildren + dstPublishedWriteChildren ?? rehomedDestWriteChildren, + destWriteBodyParams.recipientPins ); this.emitter.emit({ type: 'folder:updated', @@ -2943,6 +2970,9 @@ export class CipherBoxClient { writeKey: sourceWriteBodyParams.writeKey, writeChildren: rehomedSourceWriteChildren, baseWriteChildren: sourceBaseWriteChildren, + // D-03 (Plan 80): preserve the source folder's recipient pins on the move + // republish (a pin-less republish hard-fails a later re-mint, D-03e). + recipientPins: sourceWriteBodyParams.recipientPins, ipnsPrivateKey: sourceFolder.ipnsKeypair.privateKey, ipnsName: sourceIpnsName, sequenceNumber: sourceFolder.sequenceNumber, @@ -2955,7 +2985,8 @@ export class CipherBoxClient { sourceFolder, srcChildren, srcSeq, - srcPublishedWriteChildren ?? rehomedSourceWriteChildren + srcPublishedWriteChildren ?? rehomedSourceWriteChildren, + sourceWriteBodyParams.recipientPins ); this.emitter.emit({ type: 'folder:updated', @@ -3060,6 +3091,9 @@ export class CipherBoxClient { writeKey: writeBodyParams.writeKey, writeChildren: trimmedWriteChildren, baseWriteChildren, + // D-03 (Plan 80): preserve the folder's recipient pins on the delete + // republish (a pin-less republish hard-fails a later re-mint, D-03e). + recipientPins: writeBodyParams.recipientPins, ipnsPrivateKey: folder.ipnsKeypair.privateKey, ipnsName: folderIpnsName, sequenceNumber: folder.sequenceNumber, @@ -3073,7 +3107,8 @@ export class CipherBoxClient { folder, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? trimmedWriteChildren + publishedWriteChildren ?? trimmedWriteChildren, + writeBodyParams.recipientPins ); // 4. Emit update event @@ -3223,6 +3258,9 @@ export class CipherBoxClient { folderKey: folder.folderKey, writeKey: writeBodyParams.writeKey, writeChildren: updatedWriteChildren, + // D-03 (Plan 80): preserve the folder's recipient pins on the upload + // republish (a pin-less republish hard-fails a later re-mint, D-03e). + recipientPins: writeBodyParams.recipientPins, ipnsPrivateKey: folder.ipnsKeypair.privateKey, ipnsName: folderIpnsName, sequenceNumber: folder.sequenceNumber, @@ -3278,7 +3316,8 @@ export class CipherBoxClient { folder, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? updatedWriteChildren + publishedWriteChildren ?? updatedWriteChildren, + writeBodyParams.recipientPins ); // 5. Emit events @@ -3542,6 +3581,9 @@ export class CipherBoxClient { folderKey: folder.folderKey, writeKey: writeBodyParams.writeKey, writeChildren: updatedWriteChildren, + // D-03 (Plan 80): preserve the folder's recipient pins on the batch + // add republish (a pin-less republish hard-fails a later re-mint, D-03e). + recipientPins: writeBodyParams.recipientPins, ipnsPrivateKey: folder.ipnsKeypair.privateKey, ipnsName: folderIpnsName, sequenceNumber: freshSeq, @@ -3593,7 +3635,8 @@ export class CipherBoxClient { folder, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? updatedWriteChildren + publishedWriteChildren ?? updatedWriteChildren, + writeBodyParams.recipientPins ); // Emit events @@ -3898,6 +3941,105 @@ export class CipherBoxClient { }); } + /** + * Append a recipient's issuance-time pubkey to a shared item's owner-sealed + * write-body pin list and republish (D-03a/c). + * + * The `NodeWriteBody.recipientPins` list is the server-opaque, cross-device + * trust anchor that all re-mint enforcement (80-06/07/08) verifies against. + * This is the issuance WRITE path — distinct from + * {@link resolveShareEncryptedWriteKey}, which only DERIVES a writeKey and + * never writes back a write-body (Pitfall 4). + * + * Reads the item's CURRENT pins (via {@link getWriteBodyParams}), appends the + * recipient (dedup by raw bytes), and CAS-republishes via + * `updateFolderMetadataAndPublish` — the node's generation is UNCHANGED (the + * pin rides inside the existing role-0x01 write-body seal at the current + * generation); only the IPNS `sequenceNumber` advances. Read-body content is + * untouched. + * + * `itemIpnsName` must be a folder the client tracks in its folder tree (its + * own `FolderState` carries the writeKey + IPNS signing key needed to seal its + * write-body). Fails closed if the item has no write-capable writeKey. + * + * @param itemIpnsName - IPNS name of the shared root item (owned by this client) + * @param recipientPublicKey - recipient's raw secp256k1 public key bytes + * @security Does NOT zero `recipientPublicKey` — the caller is its terminal owner (D-09). + */ + async addRecipientPubkeyPin(itemIpnsName: string, recipientPublicKey: Uint8Array): Promise { + return this.withOperation('addRecipientPubkeyPin', async () => { + const folder = await this.requireFolder(itemIpnsName, 'Shared item'); + + const writeBodyParams = await this.getWriteBodyParams(folder); + if (!writeBodyParams.writeKey) { + throw new Error( + `addRecipientPubkeyPin: item ${itemIpnsName} has no writeKey — cannot pin a recipient on a non-write-capable node` + ); + } + + // Reconcile-before-publish (ROT-07 durable anti-rollback): defer on any + // sequence mismatch and gate through the durable floor when configured — + // mirrors every other publish path in this file (createFolder/renameItem/ + // moveItem/deleteItem). The pin-issuance publish must not skip it. + await this.reconcileFolderSequence(itemIpnsName, folder.sequenceNumber, folder.folderKey); + + const nextPins = sdkCore.appendRecipientPin( + writeBodyParams.recipientPins ?? [], + recipientPublicKey + ); + + const baseChildren = [...folder.children]; + const { newSequenceNumber, publishedChildren, publishedWriteChildren } = + await sdkCore.updateFolderMetadataAndPublish({ + children: folder.children, + baseChildren, + folderKey: folder.folderKey, + ...writeBodyParams, + // Override the spread `recipientPins` with the appended union — the + // seal + CAS-409 merge preserves/unions these (T-80-11). + recipientPins: nextPins, + ipnsPrivateKey: folder.ipnsKeypair.privateKey, + ipnsName: itemIpnsName, + sequenceNumber: folder.sequenceNumber, + ctx: this.ctx, + nodeId: folder.nodeId, + nodeGeneration: folder.nodeGeneration, + }); + + this.adoptPublishedFolderState( + folder, + publishedChildren, + newSequenceNumber, + publishedWriteChildren ?? writeBodyParams.writeChildren, + nextPins + ); + // Keep the in-memory write-body mirror's pin list in sync so a follow-up + // getRecipientPubkeyPins in the same session reflects the new pin. + if (folder.metadata?.writeBody) { + folder.metadata.writeBody.recipientPins = nextPins; + } + }); + } + + /** + * Read a shared item's owner-sealed recipient-pubkey pin list (D-03a) for + * re-mint enforcement. + * + * Resolves + unseals the item's write-body and returns its `recipientPins` as + * raw pubkey bytes. Returns `[]` when the node carries no pins. + * + * @param itemIpnsName - IPNS name of the shared root item (owned by this client) + * @returns the pinned recipient pubkeys as raw byte arrays + */ + async getRecipientPubkeyPins(itemIpnsName: string): Promise { + return this.withOperation('getRecipientPubkeyPins', async () => { + const folder = await this.requireFolder(itemIpnsName, 'Shared item'); + const writeBodyParams = await this.getWriteBodyParams(folder); + const pinsB64 = writeBodyParams.recipientPins ?? []; + return pinsB64.map((p) => base64ToBytes(p)); + }); + } + /** * Conditional folder re-publish for lazy IPNS-key migration (TEE key-epoch * rotation). @@ -3947,7 +4089,8 @@ export class CipherBoxClient { folder, publishedChildren, newSequenceNumber, - publishedWriteChildren ?? writeBodyParams.writeChildren + publishedWriteChildren ?? writeBodyParams.writeChildren, + writeBodyParams.recipientPins ); } diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ea003eae8e..b7b4c24753 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -44,6 +44,7 @@ export type { PinningConfig, RotationClientCallbacks, LocalGrantRecord, + InlineGrantRemint, } from './types'; // SDK-owned resolved folder listings (SDK-READ-02, D-02) -- the single @@ -126,6 +127,11 @@ export { type TreeNode, } from '@cipherbox/sdk-core'; export { selectEncryptionMode } from '@cipherbox/sdk-core'; +// Pure recipient-pin compare (80-04, D-03d) -- no crypto/IO beyond byte +// normalization; re-exported so ShareDialog's upgrade path (80-08 consumer 3) +// verifies the server-fed recipient against the node's owner-sealed pin list +// via the facade instead of importing @cipherbox/sdk-core directly (D-07). +export { assertRecipientPinned } from '@cipherbox/sdk-core'; // D-07 full-boundary facade types (68.2-04) -- the web consumes these to call // client.bootstrapVaultKeys/serializeVault/deserializeVault (vault-bootstrap) diff --git a/packages/sdk/src/share/owner-reconcile.ts b/packages/sdk/src/share/owner-reconcile.ts index c016f3bc4b..0e5e8b4752 100644 --- a/packages/sdk/src/share/owner-reconcile.ts +++ b/packages/sdk/src/share/owner-reconcile.ts @@ -53,6 +53,16 @@ export type OwnerReconcileTransport = { updateGrant: (shareId: string, encryptedReadKey: string, generation: number) => Promise; /** Removes a revoked recipient's grant row. */ deleteGrant: (shareId: string) => Promise; + /** + * Resolves the node's owner-sealed `recipientPins` (raw pubkey bytes) via the + * client read path (`client.getRecipientPubkeyPins`) so the re-mint can + * fail-closed verify each grant's recipient before wrapping (D-03d consumer 2). + * + * Optional on the transport type so the concrete web wrapper (80-08) can wire + * it separately; when absent the re-mint fails CLOSED (the enforced path + * throws rather than trusting the relay-fed recipient). + */ + getRecipientPubkeyPins?: (nodeId: string) => Promise; }; /** @@ -66,9 +76,17 @@ export type OwnerReconcileTransport = { export function buildGrantRemintCallbacks( transport: OwnerReconcileTransport ): GrantRemintCallbacks { + // D-02 (TS mirror): memoize the sent-grants fetch for the lifetime of this + // callbacks bundle (one runOwnerReconcile pass). `queryGrantsFn(nodeId)` is + // invoked once per rotated node, so an un-cached `listSentGrants()` fans out + // to O(nodes × shares) relay fetches. The memo is closure-scoped (NOT + // global/static) so it never leaks state across reconcile passes; the + // per-node `rootNodeId` filter stays applied on each call. + let cachedGrants: Promise | undefined; return { queryGrantsFn: async (nodeId: string) => { - const grants = await transport.listSentGrants(); + cachedGrants ??= transport.listSentGrants(); + const grants = await cachedGrants; return grants .filter((grant) => grant.rootNodeId === nodeId) .map((grant) => ({ @@ -80,6 +98,20 @@ export function buildGrantRemintCallbacks( updateGrantFn: (shareId, encryptedReadKey, newGeneration) => transport.updateGrant(shareId, encryptedReadKey, newGeneration), deleteGrantFn: (shareId) => transport.deleteGrant(shareId), + // D-03d consumer 2 seam: resolve the node's owner-sealed recipientPins via + // the client read path so sdk-core's reMintGrantsRootedAt can fail-closed + // verify each surviving grant's recipient against the pin list BEFORE + // wrapping. Sources the pins from `getRecipientPubkeyPins` (80-04), NOT the + // relay-fed `/shares/sent` recipientPublicKey. Absent transport method → + // throw (fail-closed): the enforced path never trusts the relay recipient. + getPinsFn: (nodeId: string) => { + if (!transport.getRecipientPubkeyPins) { + throw new Error( + 'buildGrantRemintCallbacks: transport.getRecipientPubkeyPins is required to verify recipient pins before re-mint — refusing (D-03d/D-03e)' + ); + } + return transport.getRecipientPubkeyPins(nodeId); + }, }; } diff --git a/packages/sdk/src/share/shared-write.ts b/packages/sdk/src/share/shared-write.ts index 1cefff9119..fc6434bcdb 100644 --- a/packages/sdk/src/share/shared-write.ts +++ b/packages/sdk/src/share/shared-write.ts @@ -250,6 +250,18 @@ async function resealAndPublishParent( writeBody: { ipnsPrivateKey, writeChildren, + // D-03 (Plan 80): preserve the parent's owner-sealed recipient pins + // VERBATIM across every shared-write reseal. This is the single + // chokepoint all shared-write ops route through; rebuilding the + // write-body WITHOUT recipientPins republishes the shared folder root + // pin-less, erasing the owner's pin on the wire — so the owner's next + // re-resolve reads empty pins and a later upgrade/re-mint hard fails + // closed (D-03e). Pins are public ECIES keys, copied never rotated + // (mirrors the six owned client.ts sites + Rust build_folder_metadata, + // which commit ddb7082e6 fixed but missed this shared-write module). + ...(parentNode.writeBody?.recipientPins + ? { recipientPins: parentNode.writeBody.recipientPins } + : {}), }, }; const newParentPublished = await sealNode(updatedParent, swCtx.readKey, swCtx.writeKey); diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts index c689de17f2..4c009e52c7 100644 --- a/packages/sdk/src/types.ts +++ b/packages/sdk/src/types.ts @@ -11,6 +11,7 @@ import type { ExternalProviderConfig, RotationJobRecord, KeyCheckpointCallbacks, + reMintGrantsRootedAt, } from '@cipherbox/sdk-core'; import type { AxiosInstance } from '@cipherbox/api-client'; import type { SealedChildRef, Node, PublishedNode } from '@cipherbox/core'; @@ -35,6 +36,29 @@ export type LocalGrantRecord = { shareRootIpnsName: string; }; +/** + * The `reMintGrantsRootedAt` callbacks shape, derived structurally from + * sdk-core's exported function signature (the `GrantRemintCallbacks` type is + * not on the sdk-core public barrel — only the `.` export path is published — + * so we derive it rather than adding a new sdk-core export surface, mirroring + * `share/owner-reconcile.ts`). + */ +type GrantRemintCallbacks = NonNullable[5]>; + +/** + * Resolved inputs for `rotateReadFromNode`'s INLINE per-node grant re-mint seam + * (`innerGrants` + `grantCallbacks`), supplied by the host for the folder about + * to be scope-exit rotated. + * + * `innerGrants` is only a non-empty ENABLE gate for the seam (sdk-core + * `rotateOne` fires the re-mint iff `innerGrants.length > 0`); the actual + * per-node grant set is resolved by `grantCallbacks.queryGrantsFn(nodeId)`. + */ +export type InlineGrantRemint = { + innerGrants: ReadonlyArray; + grantCallbacks: GrantRemintCallbacks; +}; + /** * Injection seam for scope-exit read-key rotation (SC#2 / SC#3 / SC#4, Phase 68). * @@ -64,6 +88,26 @@ export type RotationClientCallbacks = { * identical to pre-Plan-70.1-05 behavior. */ keyCheckpoint?: KeyCheckpointCallbacks; + /** + * Optional injection seam for INLINE grant re-mint during scope-exit rotation + * (recipient-pin-lifecycle §5 — web file-grant re-mint gap). When supplied, + * `performScopeExitRotation` resolves it for the rotation root and threads the + * returned `innerGrants`/`grantCallbacks` into `rotateReadFromNode` so the + * per-node seam re-mints surviving grants under each node's rotated read key + * as the walk descends — INCLUDING separately-shared FILE leaves the + * login/opportunistic reconcile sweep can never reach (a file has no + * `FolderTree` entry, so the sweep skips it and its recipient would otherwise + * keep a stale key after the owner rotates the containing folder). Desktop + * (Rust) already wires this inline path; this closes the web parity gap. + * + * Returns `undefined` when the root has no active sent grants → the seam + * stays disabled and behavior is identical to the sweep-only default. Omitted + * entirely → unchanged pre-fix behavior (matches the NOOP default). + */ + resolveInlineGrantRemint?: ( + rootNodeIpnsName: string, + rootNodeId: string + ) => Promise; }; /** diff --git a/packages/sdk/src/write-body-params.ts b/packages/sdk/src/write-body-params.ts index 06f84ff0ce..5306f285d8 100644 --- a/packages/sdk/src/write-body-params.ts +++ b/packages/sdk/src/write-body-params.ts @@ -65,13 +65,23 @@ export function hasRealWriteKey(wk: Uint8Array | null | undefined): boolean { export async function getWriteBodyParams( folder: FolderState, ctx: SdkContext -): Promise<{ writeKey?: Uint8Array; writeChildren?: WriteChildRef[] }> { +): Promise<{ writeKey?: Uint8Array; writeChildren?: WriteChildRef[]; recipientPins?: string[] }> { const wk = folder.writeKey; if (!hasRealWriteKey(wk)) { return {}; } if (folder.metadata?.writeBody) { - return { writeKey: wk, writeChildren: folder.metadata.writeBody.writeChildren }; + // Surface the recipient pins alongside the write chain (D-03a) so a folder + // republish preserves them and pin issuance can read the current list + // without a second resolve. + return { + writeKey: wk, + writeChildren: folder.metadata.writeBody.writeChildren, + // Always a concrete array when a writeKey is present: updateFolderMetadataAndPublish + // fail-closes on an OMITTED pin snapshot (it would erase existing pins), so + // an unpinned folder must thread `[]`, never `undefined`. + recipientPins: folder.metadata.writeBody.recipientPins ?? [], + }; } const resolved = await sdkCore.resolveIpnsRecord(folder.ipnsName, ctx); if (!resolved) { @@ -81,10 +91,16 @@ export async function getWriteBodyParams( } const raw = await sdkCore.fetchFromIpfs(ctx, resolved.cid); const published = JSON.parse(new TextDecoder().decode(raw)) as PublishedNode; - if (!published.writeSealed) return { writeKey: wk, writeChildren: [] }; + // writeKey present but no on-wire write-body yet (pre-D-03 record): seal a fresh + // write-body going forward with an explicit empty pin list (never `undefined`). + if (!published.writeSealed) return { writeKey: wk, writeChildren: [], recipientPins: [] }; const node = await unsealNode(published, folder.folderKey, wk); try { - return { writeKey: wk, writeChildren: node.writeBody?.writeChildren ?? [] }; + return { + writeKey: wk, + writeChildren: node.writeBody?.writeChildren ?? [], + recipientPins: node.writeBody?.recipientPins ?? [], + }; } finally { // D-09: unsealNode just materialized a transient IPNS private key // (node.writeBody.ipnsPrivateKey) purely to let us read writeChildren. @@ -113,7 +129,8 @@ export function adoptPublishedFolderState( folder: FolderState, publishedChildren: SealedChildRef[], newSequenceNumber: bigint, - publishedWriteChildren?: WriteChildRef[] + publishedWriteChildren?: WriteChildRef[], + publishedRecipientPins?: string[] ): void { folder.children = publishedChildren; folder.sequenceNumber = newSequenceNumber; @@ -123,6 +140,14 @@ export function adoptPublishedFolderState( if (publishedWriteChildren) { if (folder.metadata.writeBody) { folder.metadata.writeBody.writeChildren = publishedWriteChildren; + // D-03 (Plan 80): keep the mirror's recipient pins in sync with what was + // just published so the NEXT getWriteBodyParams (which prefers this + // mirror) threads the pins forward instead of re-sealing pin-less. Pins + // are PUBLIC ECIES keys, copied verbatim. Only overwrite when the caller + // resolved a pin list to publish; leave the existing pins otherwise. + if (publishedRecipientPins !== undefined) { + folder.metadata.writeBody.recipientPins = publishedRecipientPins; + } } else { // 68.1-29: the folder carried a read-only metadata mirror (no // write-body -- e.g. loaded via loadFolder, or getWriteBodyParams @@ -131,9 +156,13 @@ export function adoptPublishedFolderState( // next getWriteBodyParams (prefers metadata.writeBody) and DFS descent // (walks metadata.writeBody.writeChildren) see the new WriteChildRefs // instead of the absent-mirror fallback that dropped them. + // D-03 (Plan 80): seed the recipient pins too, or the next mutation reads + // `metadata.writeBody.recipientPins === undefined` and republishes the + // shared folder pin-less (hard-failing a later re-mint, D-03e). folder.metadata.writeBody = { ipnsPrivateKey: new Uint8Array(folder.ipnsKeypair.privateKey), writeChildren: publishedWriteChildren, + recipientPins: publishedRecipientPins, }; } } diff --git a/tests/desktop-e2e/scripts/bump-ipns-sequence.ts b/tests/desktop-e2e/scripts/bump-ipns-sequence.ts index 62188a0905..a0acc9767d 100644 --- a/tests/desktop-e2e/scripts/bump-ipns-sequence.ts +++ b/tests/desktop-e2e/scripts/bump-ipns-sequence.ts @@ -125,6 +125,13 @@ async function main(): Promise { readKey: rootReadKey, writeKey: rootWriteKey, writeChildren: rootNode.writeBody.writeChildren, + // D-03 (Plan 80): a write-body reseal (writeKey present) now REQUIRES a + // recipientPins snapshot — omitting it would erase any owner-sealed pins. + // This bump republishes the root UNCHANGED, so preserve its current pins + // verbatim (the vault root is unpinned in practice, so this is [] — but + // read it from the write-body rather than hardcoding, so a pinned root + // round-trips its pins too). + recipientPins: rootNode.writeBody.recipientPins ?? [], ipnsPrivateKey: rootNode.writeBody.ipnsPrivateKey, ipnsName: rootIpnsName, sequenceNumber: rootSequenceNumber, diff --git a/tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts b/tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts index 663b1aeb52..828ec4769c 100644 --- a/tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts +++ b/tests/desktop-e2e/scripts/shared-scope-exit-rotation.mts @@ -44,9 +44,17 @@ import { resolveFileMetadata, resolveIpnsRecord, fetchFromIpfs, + updateFolderMetadataAndPublish, + appendRecipientPin, type SdkContext, } from '@cipherbox/sdk-core'; -import { unsealChildReadKey, type PublishedNode, type SealedChildRef } from '@cipherbox/core'; +import { + unsealChildReadKey, + unsealChildWriteKey, + unsealNode, + type PublishedNode, + type SealedChildRef, +} from '@cipherbox/core'; import { wrapKey, unwrapKey, bytesToHex, hexToBytes, clearBytes } from '@cipherbox/crypto'; import { authenticate, buildSdkContext, parseCliArgs } from '../../e2e-helpers/auth'; @@ -343,7 +351,88 @@ async function main(): Promise { if (!vaultKeyBlob) { throw new Error('Vault key blob not found'); } - const { rootReadKey } = vaultKeyBlob; + const { rootReadKey, rootWriteKey } = vaultKeyBlob; + + /** + * Pin a recipient's pubkey onto a shared grant-root folder's owner-sealed + * write-body (D-03c issuance write), mirroring the real web client's + * addRecipientPubkeyPin pin-first share flow (ShareDialog.tsx). + * + * Every grant root in this script is a DIRECT child of the vault root, so its + * write key is derived from the vault root's write key via the grant root's + * WriteChildRef (D-07 write plane). The pin is what the desktop scope-exit + * rotation's re-mint (FuseRotationDeps::get_recipient_pubkey_pins -> + * re_mint_grants_rooted_at, D-03d/D-03e) verifies the retained recipient + * against BEFORE re-wrapping the rotated read key. A raw `POST /shares` + * alone (no pin) leaves the grant root pin-less, so the re-mint fails closed + * ("0 pinned"), aborting the rotation mid-flight and cascading into + * AES-GCM refresh failures — exactly the D-16 regression this restores. + * + * updateFolderMetadataAndPublish runs a CAS-409 loop (maxAttempts 3) that + * UNIONs recipient pins, so this is race-safe against the mount's concurrent + * publishing of the same grant root. Pins are public ECIES keys — copied, + * never rotated. + */ + const pinRecipientOnGrantRoot = async ( + grantRootRef: SealedChildRef, + grantRootReadKey: Uint8Array, + grantRootNodeId: string, + recipientPublicKey: Uint8Array + ): Promise => { + // Vault-root write-body -> the grant root's WriteChildRef (keyed by the + // child node id, D-07), then derive the grant root's own write key. + const rootPublished = await fetchPublishedNode(rootIpnsName, ownerCtx); + const rootNode = await unsealNode(rootPublished, rootReadKey, rootWriteKey); + const wcr = rootNode.writeBody?.writeChildren.find((w) => w.childId === grantRootNodeId); + if (!wcr) { + throw new Error( + `pinRecipientOnGrantRoot: no WriteChildRef for grant root ${grantRootNodeId} under the vault root` + ); + } + const grantRootWriteKey = await unsealChildWriteKey( + wcr.writeKeySealed, + rootWriteKey, + grantRootNodeId, + 'folder', + grantRootRef.generation + ); + + // Grant root's own write-body -> current pins + writeChildren + signing seed. + const grantRootPublished = await fetchPublishedNode(grantRootRef.ipnsName, ownerCtx); + const grantRootNode = await unsealNode(grantRootPublished, grantRootReadKey, grantRootWriteKey); + if (!grantRootNode.writeBody) { + throw new Error( + `pinRecipientOnGrantRoot: grant root ${grantRootRef.ipnsName} has no write-body` + ); + } + const nextPins = appendRecipientPin( + grantRootNode.writeBody.recipientPins ?? [], + recipientPublicKey + ); + const resolved = await resolveIpnsRecord(grantRootRef.ipnsName, ownerCtx); + if (!resolved) { + throw new Error( + `pinRecipientOnGrantRoot: grant root ${grantRootRef.ipnsName} did not resolve` + ); + } + await updateFolderMetadataAndPublish({ + children: grantRootNode.children ?? [], + baseChildren: grantRootNode.children ?? [], + readKey: grantRootReadKey, + writeKey: grantRootWriteKey, + writeChildren: grantRootNode.writeBody.writeChildren, + recipientPins: nextPins, + ipnsPrivateKey: grantRootNode.writeBody.ipnsPrivateKey, + ipnsName: grantRootRef.ipnsName, + sequenceNumber: resolved.sequenceNumber, + nodeId: grantRootNodeId, + nodeGeneration: grantRootNode.generation, + ctx: ownerCtx, + }); + console.log( + ` pinned recipient on grant root ${grantRootRef.ipnsName} (${nextPins.length} pin(s) now sealed)` + ); + }; try { // ----------------------------------------------------------------- @@ -393,6 +482,14 @@ async function main(): Promise { const shareId: string = shareRes.data.shareId; console.log(`Created share ${shareId} for ${bobEmail} rooted at ${grantRootIpnsName}`); + // D-03c (Plan 80): pin Bob's pubkey onto the grant root's owner-sealed + // write-body — the real web share flow does this (pin-first), and the + // desktop scope-exit re-mint fails closed without it ("0 pinned", D-03e). + // Done BEFORE the sent_shares wait below so the mount's periodic metadata + // refresh materializes the pin onto the grant-root inode before the delete. + await pinRecipientOnGrantRoot(sharedRef, sharedFolderReadKey, grantRootNodeId, bobPublicKey); + nudge(join(args.mount, sharedFolderName)); + // Bob unwraps his copy of the key WHILE the share is active -- a positive // control proving the grant genuinely worked (so "cut off after rotation" // isn't just "never worked"). @@ -743,6 +840,16 @@ async function main(): Promise { `Created deep-grant shares ${eveShareId} (Eve, will be revoked) and ${carolShareId} (Carol, retained)` ); + // D-03c (Plan 80): pin BOTH recipients onto the deep grant root's write-body + // (mirrors the web pin-first share flow). Carol is the RETAINED recipient the + // re-mint must verify against and re-wrap (D-03d); Eve is revoked before the + // delete so her grant is hard-deleted and never re-minted, but pinning her too + // matches the web (pin-every-share) and is harmless (a stale pin grants + // nothing). Without Carol's pin the deep re-mint fails closed ("0 pinned"). + await pinRecipientOnGrantRoot(deepGrantRef, deepGrantReadKey, deepGrantNodeId, eve.publicKey); + await pinRecipientOnGrantRoot(deepGrantRef, deepGrantReadKey, deepGrantNodeId, carol.publicKey); + nudge(join(args.mount, deepGrantFolderName)); + // Positive control: BOTH recipients independently derive the grant-root // key, then walk it down to folderB, fileC, and fileSibling THEMSELVES -- // SealedChildRef.readKeySealed is sealed only under the PARENT read key, @@ -1076,6 +1183,25 @@ async function main(): Promise { `Created rename-overwrite shares ${frankShareId} (Frank, will be revoked) and ${graceShareId} (Grace, retained)` ); + // D-03c (Plan 80): pin both recipients onto the rename-overwrite grant root's + // write-body. Grace is the RETAINED recipient the re-mint re-wraps (D-03d); + // Frank is revoked before the rename so he is never re-minted. Without + // Grace's pin the re-mint fails closed ("0 pinned"), aborting the covered + // overwrite-rename's scope-exit rotation. + await pinRecipientOnGrantRoot( + renameFolderRef, + renameFolderReadKey, + renameFolderNodeId, + frank.publicKey + ); + await pinRecipientOnGrantRoot( + renameFolderRef, + renameFolderReadKey, + renameFolderNodeId, + grace.publicKey + ); + nudge(join(args.mount, renameFolderName)); + const frankReceivedRes = await frank.ctx.axiosInstance!.get('/shares/received'); const frankReceivedShare = ( frankReceivedRes.data.shares as Array<{ shareId: string; encryptedReadKey: string }> @@ -1224,6 +1350,7 @@ async function main(): Promise { clearBytes(frankPreRotationKey); } finally { clearBytes(rootReadKey); + clearBytes(rootWriteKey); clearBytes(ownerPrivateKey); clearBytes(bobPrivateKey); clearBytes(eve.privateKey); diff --git a/tests/sdk-e2e/src/fixtures/multi-account.ts b/tests/sdk-e2e/src/fixtures/multi-account.ts index c095c57814..ce9e884398 100644 --- a/tests/sdk-e2e/src/fixtures/multi-account.ts +++ b/tests/sdk-e2e/src/fixtures/multi-account.ts @@ -18,17 +18,30 @@ export interface MultiAccountFixture { /** * Create N test accounts with the given labels. * + * @param labels - account labels to create (created sequentially) + * @param optsByLabel - optional per-label options (e.g. `withInlineGrantRemint` + * to wire the web-mirroring rotation seam on a specific owner account). Labels + * absent from the map are created with defaults (no rotationCallbacks). + * * @example * const fixture = await createMultiAccountFixture(['alice', 'bob']); * const alice = fixture.accounts.get('alice')!; * const bob = fixture.accounts.get('bob')!; + * + * @example + * const fixture = await createMultiAccountFixture(['alice', 'bob'], { + * alice: { withInlineGrantRemint: true }, + * }); */ -export async function createMultiAccountFixture(labels: string[]): Promise { +export async function createMultiAccountFixture( + labels: string[], + optsByLabel?: Record +): Promise { const accounts = new Map(); // Create accounts sequentially to avoid race conditions on vault init for (const label of labels) { - const ctx = await createTestContext(label); + const ctx = await createTestContext(label, optsByLabel?.[label]); accounts.set(label, ctx); } diff --git a/tests/sdk-e2e/src/fixtures/test-harness.ts b/tests/sdk-e2e/src/fixtures/test-harness.ts index f6d86df134..47611e5aab 100644 --- a/tests/sdk-e2e/src/fixtures/test-harness.ts +++ b/tests/sdk-e2e/src/fixtures/test-harness.ts @@ -8,7 +8,13 @@ * initializes their vault, and returns a ready-to-use CipherBoxClient. */ -import { CipherBoxClient } from '@cipherbox/sdk'; +import { + CipherBoxClient, + buildGrantRemintCallbacks, + type RotationClientCallbacks, + type OwnerReconcileTransport, + type GrantRow, +} from '@cipherbox/sdk'; import { initializeVault } from '@cipherbox/core'; import { hexToBytes, bytesToHex } from '@cipherbox/crypto'; import { publishVaultKeyBlob, publishEmptyRootNode } from '@cipherbox/sdk-core'; @@ -36,6 +42,135 @@ function axiosDefaultHeaders(): Record | undefined { return THROTTLE_BYPASS ? { 'X-Throttle-Bypass': THROTTLE_BYPASS } : undefined; } +/** Shape of a sent-share row (SentShareResponseDto) as returned by GET /shares/sent. */ +interface SentShareRow { + shareId: string; + recipientPublicKey: string; + encryptedReadKey: string; + rootNodeId: string; + shareRootIpnsName: string; + rootGeneration: string; +} + +/** + * Build the opt-in web-mirroring `rotationCallbacks` seam for a single account. + * + * Mirrors apps/web (`rotation-driver.service.ts` + `owner-reconcile.service.ts`) + * but sources every grant op from THIS account's own API via `testFetch` + + * `accessToken` (the api-client global singleton carries no per-account auth). + * + * `clientHolder` is late-bound: the callbacks only fire during later mutations, + * so the CipherBoxClient is assigned into the holder AFTER construction. + * + * `resolveInlineGrantRemint` can be disabled at runtime via the + * `E2E_DISABLE_INLINE_REMINT=1` env hook (defaults to enabled) — this lets the + * suite prove the seam is a REAL gate by re-running with the seam removed and + * confirming the re-mint assertion fails. + */ +function buildInlineGrantRemintCallbacks( + apiUrl: string, + accessToken: string, + clientHolder: { client: CipherBoxClient | null } +): RotationClientCallbacks { + const authHeaders = (extra: Record = {}) => + fetchHeaders({ Authorization: `Bearer ${accessToken}`, ...extra }); + + async function fetchSentShares(): Promise { + const res = await testFetch(`${apiUrl}/shares/sent`, { headers: authHeaders() }); + if (!res.ok) { + throw new Error(`GET /shares/sent failed (${res.status}): ${await res.text()}`); + } + const data = (await res.json()) as { shares: SentShareRow[] }; + return data.shares; + } + + const toGrantRow = (s: SentShareRow): GrantRow => ({ + shareId: s.shareId, + recipientPublicKey: hexToBytes( + s.recipientPublicKey.startsWith('0x') ? s.recipientPublicKey.slice(2) : s.recipientPublicKey + ), + isRevoked: false, + rootNodeId: s.rootNodeId, + }); + + const listSentGrants = async (): Promise => (await fetchSentShares()).map(toGrantRow); + + const updateGrant = async ( + shareId: string, + encryptedReadKey: string, + generation: number + ): Promise => { + // sdk-core `reMintGrantsRootedAt` hands `encryptedReadKey` as even-length + // HEX (Gap C fix), matching PATCH /shares/:id/grant — forward it verbatim. + const res = await testFetch(`${apiUrl}/shares/${shareId}/grant`, { + method: 'PATCH', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ encryptedReadKey, rootGeneration: String(generation) }), + }); + if (!res.ok) { + throw new Error(`PATCH /shares/${shareId}/grant failed (${res.status}): ${await res.text()}`); + } + }; + + const deleteGrant = async (shareId: string): Promise => { + const res = await testFetch(`${apiUrl}/shares/${shareId}`, { + method: 'DELETE', + headers: authHeaders(), + }); + if (!res.ok && res.status !== 204) { + throw new Error(`DELETE /shares/${shareId} failed (${res.status}): ${await res.text()}`); + } + }; + + return { + getActiveGrantRootIpnsNames: async () => { + const shares = await fetchSentShares(); + return new Set(shares.map((s) => s.shareRootIpnsName)); + }, + getLocalGrantRecord: () => null, + persistJob: () => {}, + resolveInlineGrantRemint: async (_rootNodeIpnsName: string, _rootNodeId: string) => { + const shares = await fetchSentShares(); + if (shares.length === 0) return undefined; + + // nodeId -> shareRootIpnsName, so the per-node getRecipientPubkeyPins seam + // resolves the right node's owner-sealed pins during the rotation walk. + const nodeIdToIpnsName = new Map(); + for (const s of shares) { + if (!nodeIdToIpnsName.has(s.rootNodeId)) { + nodeIdToIpnsName.set(s.rootNodeId, s.shareRootIpnsName); + } + } + + // Derive grants from the SAME snapshot as nodeIdToIpnsName — a second + // /shares/sent fetch could observe different rows and desync the two. + const grants = shares.map(toGrantRow); + + const transport: OwnerReconcileTransport = { + listSentGrants, + updateGrant, + deleteGrant, + getRecipientPubkeyPins: (nodeId: string) => { + const ipnsName = nodeIdToIpnsName.get(nodeId); + if (!ipnsName) { + throw new Error( + `resolveInlineGrantRemint: no share-root IPNS name for node ${nodeId} — refusing to re-mint (fail-closed)` + ); + } + const client = clientHolder.client; + if (!client) throw new Error('resolveInlineGrantRemint: client not initialized'); + return client.getRecipientPubkeyPins(ipnsName); + }, + }; + + return { + innerGrants: grants, + grantCallbacks: buildGrantRemintCallbacks(transport), + }; + }, + }; +} + /** Core account data returned by createTestAccount (shared between sdk-e2e and load tests). */ export interface TestAccount { client: CipherBoxClient; @@ -65,6 +200,16 @@ export interface CreateAccountOptions { secret?: string; label: string; emailPrefix?: string; + /** + * Opt-in: wire this account's CipherBoxClient with a `rotationCallbacks` seam + * that mirrors apps/web (rotation-driver + owner-reconcile) but sourced from + * THIS account's own API via `testFetch` + its `accessToken` (never the + * api-client global singleton — that carries no per-account auth). When false + * (default) no `rotationCallbacks` is passed and the client behaves exactly as + * every other suite's client (zero rotation). Used to prove the web + * file-share grant re-mint on scope-exit rotation (recipient-pin-lifecycle §5). + */ + withInlineGrantRemint?: boolean; } /** @@ -147,6 +292,22 @@ export async function createTestAccount(opts: CreateAccountOptions): Promise { - const account = await createTestAccount({ label }); +export async function createTestContext( + label: string, + opts?: { withInlineGrantRemint?: boolean } +): Promise { + const account = await createTestAccount({ label, ...opts }); return { ...account, cleanup: () => account.client.destroy() }; } diff --git a/tests/sdk-e2e/src/suites/file-share-rotation-remint.test.ts b/tests/sdk-e2e/src/suites/file-share-rotation-remint.test.ts new file mode 100644 index 0000000000..9fedb65527 --- /dev/null +++ b/tests/sdk-e2e/src/suites/file-share-rotation-remint.test.ts @@ -0,0 +1,269 @@ +/** + * File-Share Grant Re-Mint on Scope-Exit Rotation (recipient-pin-lifecycle §5) + * + * Proves the web INLINE grant-remint seam: when an owner scope-exit-rotates a + * folder that CONTAINS a separately-shared FILE leaf, the file recipient's grant + * must be re-minted under the file's freshly-rotated read key. The login/ + * opportunistic reconcile sweep can NEVER reach a file leaf (a file has no + * FolderTree entry, so the sweep skips it) — only the inline seam + * (`RotationClientCallbacks.resolveInlineGrantRemint` → `rotateReadFromNode`'s + * per-node `reMintGrantsRootedAt`) re-wraps a file grant. + * + * Alice (owner) is wired WITH the inline-grant-remint seam; bob (folder + * recipient, pinned) and carol (file recipient, pin-exempt) receive shares. + * Rotating alice's folder D via a covered mutation (rename) must re-mint BOTH + * grants under the rotated keys. + * + * ───────────────────────────────────────────────────────────────────────────── + * STATUS: describe.skip — this suite is a live-stack REPRODUCTION, not a passing + * gate. Run against a local stack (see repo docs) to reproduce. It surfaced + * three genuine gaps that stopped the original fix (04198f81e) from re-minting + * a file grant end-to-end: Gap A is a harness artifact (worked around below), + * Gap C has since been FIXED (4b1b00d4f — engine.ts emits hex), and Gap B + * remains a real product gap. Un-skip once B is fixed. + * + * Gap A (harness artifact, worked around): + * `client.getRecipientPubkeyPins(D)` slow-paths through + * getWriteBodyParams → re-resolve → unsealNode when the folder's in-memory + * `metadata.writeBody` mirror is null (a folder created THIS session via + * createFolder registers with metadata: null — registerFolder, client.ts). + * During rotation the published node is already re-sealed under the NEW read + * key but the in-memory folderKey is still OLD → "CryptoError: Decryption + * failed" (write-body-params.ts:97). Real-web navigation loads the folder via + * DFS with metadata populated (fast in-memory pin path), so this is a harness + * seeding artifact — worked around below by evicting root+D and reloading via + * ensureFolderLoaded (mirrors a cold navigation). It COULD still bite a + * same-session create+share+rotate web flow. + * + * Gap C (product bug — FIXED in 4b1b00d4f): + * sdk-core `reMintGrantsRootedAt` used to encode the re-wrapped key as + * BASE64 while `PATCH /shares/:id/grant` (UpdateGrantDto.encryptedReadKey) + * requires even-length HEX, so every re-mint PATCH 400'd. Fixed: the + * read-plane re-mint path (engine.ts `bytesToHex`) now matches the API, and + * the harness forwards `encryptedReadKey` verbatim (the old + * `E2E_REMINT_HEX=1` diagnostic is removed). + * + * Gap B (REAL product gap — the pitfall this e2e was built to find): + * Re-minting carol's FILE grant requires the file node F to be rotated (its + * readKey changes). But `rotateReadFromNode`'s per-node key source + * (`nodeKeySource`, client.ts) reads ONLY the in-memory `folderTree`, which + * holds FOLDERS only — a file leaf is never registered there. So rotateOne + * fail-closes at the D-01 guard (engine.ts:1095): + * "rotateOne: no valid IPNS private key for k51… — provide via + * nodeKeySource (Phase 64) or write-body wiring (Phase 65)". + * The web fix wires the inline re-mint SEAM but does NOT thread a file leaf's + * IPNS/write key material into the rotation walk, so a file grant can never be + * re-minted. The web fix needs to thread file write-material (recover the + * file's ipnsPrivateKey/writeKey from the parent's write-body) into + * nodeKeySource, mirroring the desktop/Rust path. + * ───────────────────────────────────────────────────────────────────────────── + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { wrapKey, unwrapKey, bytesToHex, hexToBytes } from '@cipherbox/crypto'; +import { createMultiAccountFixture, type MultiAccountFixture } from '../fixtures/multi-account'; +import { API_URL, testFetch } from '../fixtures/test-harness'; +import { generateTextContent } from '../helpers/data-generators'; + +/** A sent-share row (SentShareResponseDto) from GET /shares/sent. */ +interface SentShareRow { + shareId: string; + recipientPublicKey: string; + encryptedReadKey: string; + rootNodeId: string; + shareRootIpnsName: string; + rootGeneration: string; +} + +/** Fetch a specific sent-share row by shareId for the given account. */ +async function getSentShare(accessToken: string, shareId: string): Promise { + const res = await testFetch(`${API_URL}/shares/sent`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + expect(res.ok).toBe(true); + const data = (await res.json()) as { shares: SentShareRow[] }; + const share = data.shares.find((s) => s.shareId === shareId); + if (!share) throw new Error(`sent share ${shareId} not found`); + return share; +} + +// SKIPPED: live-stack reproduction of a blocked fix — see the file header +// (Gap A worked-around, Gap C fixed, Gap B still blocks the file re-mint). +describe.skip('File-Share Grant Re-Mint on Scope-Exit Rotation', () => { + let fixture: MultiAccountFixture; + + beforeAll(async () => { + // Only alice (the owner performing the rotation) needs the inline seam. + fixture = await createMultiAccountFixture(['alice', 'bob', 'carol'], { + alice: { withInlineGrantRemint: true }, + }); + }); + + afterAll(async () => { + if (fixture) await fixture.cleanupAll(); + }); + + // Folder D and its two files, resolved in the first `it` and reused serially. + let D: { id: string; ipnsName: string; folderKey: Uint8Array }; + let F: { readKey: Uint8Array; nodeId: string; ipnsName: string }; + let gIpnsName: string; + + let carolShareId: string; + let carolEncBefore: string; + let bobShareId: string; + let bobEncBefore: string; + + it('sets up folder D with two files and resolves file F identity', async () => { + const alice = fixture.accounts.get('alice')!; + + const folder = await alice.client.createFolder(alice.rootIpnsName, 'D'); + expect(folder.id).toBeTruthy(); + D = { id: folder.id, ipnsName: folder.ipnsName, folderKey: folder.folderKey }; + + await alice.client.uploadFile( + D.ipnsName, + generateTextContent('secret-F'), + 'secret.txt', + 'text/plain' + ); + await alice.client.uploadFile( + D.ipnsName, + generateTextContent('data-G'), + 'other.txt', + 'text/plain' + ); + + // Files are NOT in the folderTree — only their SealedChildRef pointers live in + // the parent folder's (in-memory) children list. + const children = alice.client.getFolderTree().get(D.ipnsName)!.children; + const fChildRef = children.find((c) => c.name === 'secret.txt'); + const gChildRef = children.find((c) => c.name === 'other.txt'); + expect(fChildRef).toBeTruthy(); + expect(gChildRef).toBeTruthy(); + gIpnsName = gChildRef!.ipnsName; + + const fId = await alice.client.resolveChildIdentity(fChildRef!, D.folderKey); + expect(fId.kind).toBe('file'); + F = { readKey: fId.readKey, nodeId: fId.nodeId, ipnsName: fChildRef!.ipnsName }; + expect(F.readKey.length).toBe(32); + }); + + it("creates carol's FILE share rooted at F", async () => { + const alice = fixture.accounts.get('alice')!; + const carol = fixture.accounts.get('carol')!; + + const res = await testFetch(`${API_URL}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${alice.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipientPublicKey: '0x' + bytesToHex(carol.publicKey), + encryptedReadKey: bytesToHex(await wrapKey(F.readKey, carol.publicKey)), + rootNodeId: F.nodeId, + shareRootIpnsName: F.ipnsName, + }), + }); + expect(res.status).toBe(201); + const data = await res.json(); + carolShareId = data.shareId; + carolEncBefore = data.encryptedReadKey; + expect(carolShareId).toBeTruthy(); + expect(carolEncBefore).toBeTruthy(); + }); + + it("creates bob's FOLDER share rooted at D (with recipient pin)", async () => { + const alice = fixture.accounts.get('alice')!; + const bob = fixture.accounts.get('bob')!; + + // A folder grant root must carry the recipient's owner-sealed pin; the + // inline re-mint fail-closes (getPinsFn) for a folder without one. + await alice.client.addRecipientPubkeyPin(D.ipnsName, bob.publicKey); + + const res = await testFetch(`${API_URL}/shares`, { + method: 'POST', + headers: { + Authorization: `Bearer ${alice.accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + recipientPublicKey: '0x' + bytesToHex(bob.publicKey), + encryptedReadKey: bytesToHex(await wrapKey(D.folderKey, bob.publicKey)), + rootNodeId: D.id, + shareRootIpnsName: D.ipnsName, + }), + }); + expect(res.status).toBe(201); + const data = await res.json(); + bobShareId = data.shareId; + bobEncBefore = data.encryptedReadKey; + expect(bobShareId).toBeTruthy(); + }); + + it('triggers scope-exit rotation on D via a covered mutation (rename)', async () => { + const alice = fixture.accounts.get('alice')!; + + // Gap A work-around: evict root + D so ensureRootFolderState fully re-resolves + // root (populating root.metadata) and the DFS descent then loads D with a + // populated metadata.writeBody mirror — mirroring a real-web cold navigation. + // Without this, D (created this session) has metadata: null and + // getRecipientPubkeyPins re-resolves + unseals the just-rotated node with a + // stale key mid-rotation ("Decryption failed"). See the file header, Gap A. + const ft = alice.client.getFolderTree(); + ft.delete(D.ipnsName); + ft.delete(alice.rootIpnsName); + await alice.client.ensureFolderLoaded(D.ipnsName); + + // D is an active grant root → this mutation triggers scope-exit rotation, + // which walks D's subtree and rotates D + F + G, firing the inline re-mint. + // renameItem's `childId` param is the child's ipnsName (renameInFolder keys + // on ipnsName, not display name). + // + // NOTE: with the fix wired this currently THROWS — Gap B (no file IPNS key) + // when the rotation walk reaches file F. See the file header. + await alice.client.renameItem(D.ipnsName, gIpnsName, 'other2.txt'); + }); + + it("re-mints carol's FILE grant under F's rotated read key", async () => { + const alice = fixture.accounts.get('alice')!; + const carol = fixture.accounts.get('carol')!; + + const carolShare = await getSentShare(alice.accessToken, carolShareId); + // The grant ciphertext must have changed (re-minted, not stale). + expect(carolShare.encryptedReadKey).not.toBe(carolEncBefore); + + // Carol unwraps the re-minted grant with her own private key. + const newFReadKey = await unwrapKey(hexToBytes(carolShare.encryptedReadKey), carol.privateKey); + expect(newFReadKey.length).toBe(32); + // It must be F's NEW (rotated) key, distinct from the pre-rotation readKey. + expect(bytesToHex(newFReadKey)).not.toBe(bytesToHex(F.readKey)); + + // Prove carol's grant now wraps F's ACTUAL post-rotation readKey: re-resolve + // F's identity via alice's refreshed folderTree (D's folderKey + F's + // SealedChildRef are both rotated in-place after the successful rotation) and + // deep-equal the two keys. + const rotatedFolder = alice.client.getFolderTree().get(D.ipnsName)!; + const fChildRefAfter = rotatedFolder.children.find((c) => c.ipnsName === F.ipnsName); + expect(fChildRefAfter).toBeTruthy(); + const fIdAfter = await alice.client.resolveChildIdentity( + fChildRefAfter!, + rotatedFolder.folderKey + ); + expect(bytesToHex(newFReadKey)).toBe(bytesToHex(fIdAfter.readKey)); + }); + + it("re-mints bob's FOLDER grant under D's rotated read key", async () => { + const alice = fixture.accounts.get('alice')!; + const bob = fixture.accounts.get('bob')!; + + const bobShare = await getSentShare(alice.accessToken, bobShareId); + expect(bobShare.encryptedReadKey).not.toBe(bobEncBefore); + + const newFolderKey = await unwrapKey(hexToBytes(bobShare.encryptedReadKey), bob.privateKey); + expect(newFolderKey.length).toBe(32); + // Equals D's rotated folderKey (refreshed in the in-memory folderTree). + const rotatedFolderKey = alice.client.getFolderTree().get(D.ipnsName)!.folderKey; + expect(bytesToHex(newFolderKey)).toBe(bytesToHex(rotatedFolderKey)); + }); +}); diff --git a/tests/vectors/node-codec.json b/tests/vectors/node-codec.json index 171b8da0c7..0405632b1e 100644 --- a/tests/vectors/node-codec.json +++ b/tests/vectors/node-codec.json @@ -110,6 +110,29 @@ "readSealed": "AAECAwQFBgcICQoLwITmRUibaxjcUMWqTw2tjNj1SSbX+ixuFegCosfoUdY3ofiIU6p2ijB1MaDBZbi3Zsd9QnSaFajwzr407Dc6k20iy/2brlsHQrBPHgE1QtUoHhSKkdac1i73uslcNMd0MNul+TgrttrE7fDzW4rWY4/XGhbDo4yn/0FxCUZ5u3mnTvloLy1Pu0C0ffZNS0m/IeTSRZ1/ZTr27nVCmAM5KC7eZKEId/nEPZCIMv0bNpuEKw==", "writeSealed": "AAECAwQFBgcICQoLMiqLir863DZC/iFi+/UAgnEE4p5WSNGLPyIq3dVDV8eiQyx0AcgspiL1XzS5TmKOR0UmOc0ejtOssjD1L7culRzR0PXsJpMYW6fjv7gF4QDD8vk3jr6tMUkL9Rz0slANjNGiCA==" } + }, + { + "description": "folder node FULL-SEAL LOCK with non-empty recipientPins (fixed key + fixed IV, D-03b) — locks the write-body pin path byte-for-byte across Rust/TS", + "node_id": "550e8400-e29b-41d4-a716-446655440000", + "kind": 1, + "generation": 0, + "read_key": "0101010101010101010101010101010101010101010101010101010101010101", + "write_key": "0202020202020202020202020202020202020202020202020202020202020202", + "ipns_private_key_hex": "4444444444444444444444444444444444444444444444444444444444444444", + "fixed_iv": "000102030405060708090a0b", + "recipient_pins": [ + "AhERERERERERERERERERERERERERERERERERERERERER", + "AyIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIiIi" + ], + "expected_published_node": { + "schema": "node/v3", + "kind": "folder", + "id": "550e8400-e29b-41d4-a716-446655440000", + "generation": 0, + "aeadVersion": 1, + "readSealed": "AAECAwQFBgcICQoLwITmRUibaxjcUMWqTw2tjNj1SSbX+ixuFegCosfoUdY3ofiIU6p2ijB1MaDBZbi3Zsd9QnSaFajwzr407Dc6k20iy/2brlsHQrBPHgE1QtUoHhSKkdac1i73uslcNMd0MNul+TgrttrE7fDzW4rWY4/XGhbDo4yn/0FxCUZ5u3mnTvloLy1Pu0C0ffZNS0m/IeTSRZ1/ZTr27nVCmAM5KC7eZKEId/nEPZCIMv0bNpuEKw==", + "writeSealed": "AAECAwQFBgcICQoLMiqLir863DZC/iFi+/UAgnEE4p5WSNGLPyIq3dVDV8eiQyx0AcgspiL1XzS5TmKOR0UmOc0ejtOssjD1L7culRzR0PXsJpMYW6fjv7gF4QDD8vlmQfrucBZNcxNp8J97YOz0aURHkM/jrVVq3sESeTlglv4UQSMtfL/zeer6pc9G9nOmPhfa8f6D77JN3I3J6f9FTzOz8svArbrFdTm4+4OTnziF5TDmvA75kJriJB5JrZrFgA573h3xhlKofu6wJT2jMW9rIcQbqIBJqoaGv8wDVsQ=" + } } ] }