Skip to content

feat: switch file IPNS keys from deterministic HKDF to random#181

Merged
FSM1 merged 14 commits into
mainfrom
feat/random-file-ipns-keys
Feb 21, 2026
Merged

feat: switch file IPNS keys from deterministic HKDF to random#181
FSM1 merged 14 commits into
mainfrom
feat/random-file-ipns-keys

Conversation

@FSM1

@FSM1 FSM1 commented Feb 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • File IPNS keys are now randomly generated Ed25519 keypairs instead of deterministically derived via HKDF
  • The private key is ECIES-wrapped and stored as ipnsPrivateKeyEncrypted in the FilePointer (inside AES-GCM encrypted folder metadata)
  • Legacy files without the field fall back to HKDF derivation for backward compatibility
  • Implemented across TypeScript crypto package, web app services/hooks, and Rust desktop FUSE
  • Security review conducted and all Critical/High/Medium findings addressed

Motivation

The deterministic HKDF design was justified by "self-sovereign recovery" but recovery already traverses the folder tree via fileMetaIpnsName — it never derives IPNS names independently. The asymmetry with folder keys (which are already random) also blocks future per-file write-sharing. This aligns file IPNS key management with the existing folder pattern.

Changes

Crypto Package

  • Added generateFileIpnsKeypair() — random Ed25519 keypair + IPNS name
  • Added optional ipnsPrivateKeyEncrypted to FilePointer type
  • Added validation (type check + min length 64 chars) in validateFolderMetadata()

Web App

  • createFileMetadata() uses random keygen + ECIES wrap with userPublicKey
  • Added getFileIpnsPrivateKey() helper — decrypts from FilePointer or HKDF fallback
  • updateFileMetadata(), restoreVersion(), deleteVersion() accept decrypted key
  • addFileToFolder()/addFilesToFolder() store wrapped key in FilePointer

Desktop (Rust)

  • create() uses ed25519_dalek::SigningKey::generate(OsRng) instead of HKDF
  • populate_folder() decrypts ECIES-wrapped key, falls back to HKDF for legacy files
  • build_folder_metadata() uses cached wrapped hex to avoid redundant ECIES re-wrapping
  • Added file_ipns_key_encrypted_hex cache field to InodeKind::File

Security Review

  • Full review in .planning/security/REVIEW-2026-02-21-random-ipns-keys.md
  • CRITICAL: HKDF fallback in Rust for legacy files — fixed
  • HIGH: Key zeroing after use, ECIES caching — fixed
  • MEDIUM: Min-length validation, stale docs — fixed
  • LOW: TEE enrollment, lazy migration — todos logged

Test plan

  • pnpm --filter @cipherbox/crypto test — 230/230 pass
  • pnpm --filter web build — clean
  • cargo build (desktop) — clean
  • Manual: upload file → verify FilePointer has ipnsPrivateKeyEncrypted
  • Manual: verify old files still resolve via HKDF fallback

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Per-file IPNS keys are now randomly generated for new files.
    • Per-file IPNS private keys are encrypted and carried in file metadata; desktop and web flows propagate and cache these encrypted keys.
    • Desktop flows include enrollment paths for TEE-wrapped keys and lazy migration for legacy files.
  • Documentation

    • Metadata schemas and migration guidance updated for encrypted IPNS key storage.
  • Tests

    • Added tests for key generation and metadata validation.

FSM1 and others added 10 commits February 21, 2026 04:47
Moved to done/, beginning implementation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: ddd3dce423f5
… to FilePointer

Add random Ed25519 keypair generation for file IPNS records, replacing
deterministic HKDF derivation for new files. HKDF derivation is kept
for backward compatibility with legacy FilePointers.

- Add optional ipnsPrivateKeyEncrypted field to FilePointer type
- Add generateFileIpnsKeypair() using random Ed25519 keygen
- Export new function from file and root index
- Update folder metadata validation to accept optional field
- Add tests for random keypair generation and validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… updates

- createFileMetadata() now generates random Ed25519 keypair instead of
  HKDF derivation, returns ECIES-wrapped ipnsPrivateKeyEncrypted
- Add getFileIpnsPrivateKey() helper: decrypts from FilePointer or
  falls back to HKDF for legacy files
- updateFileMetadata/restoreVersion/deleteVersion now accept the IPNS
  private key directly instead of re-deriving via HKDF
- addFileToFolder/addFilesToFolder store ipnsPrivateKeyEncrypted in
  FilePointer for new files

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- handleAddFile/handleAddFiles pass userPublicKey to createFileMetadata
  and forward ipnsPrivateKeyEncrypted to addFileToFolder/addFilesToFolder
- handleUpdateFile decrypts IPNS key from FilePointer via
  getFileIpnsPrivateKey before calling updateFileMetadata
- handleRestoreVersion/handleDeleteVersion use the same pattern

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add optional ipns_private_key_encrypted to Rust FilePointer struct
  (skip_serializing_if None, serde default for backward compat)
- create() generates random Ed25519 keypair via OsRng instead of HKDF
- build_folder_metadata() ECIES-wraps file IPNS private key into
  FilePointer when available
- populate_folder() decrypts ipns_private_key_encrypted from remote
  metadata when present, falls back to None (HKDF on demand) for
  legacy FilePointers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…IPNS keys

- Add ipnsPrivateKeyEncrypted field to FilePointer table (Section 7)
- Document migration strategy and HKDF fallback behavior
- Reorganize Section 14 (IPNS Key Derivation Summary) into HKDF-derived
  and random keypair categories
- Update FileMetadata storage note to reflect dual-source IPNS keys

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- CRITICAL: Add HKDF fallback in Rust populate_folder for legacy files
  without ipnsPrivateKeyEncrypted (prevents silent data divergence)
- HIGH: Zero IPNS private key after use in createFileMetadata
- HIGH: Cache ECIES-wrapped key hex in inode to avoid re-wrapping on
  every metadata publish
- MEDIUM: Add minimum length validation (64 chars) on
  ipnsPrivateKeyEncrypted in validateFolderMetadata
- MEDIUM: Update stale doc comments referencing HKDF derivation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 0b61d0d623d1
Desktop-created files are not enrolled with TEE for IPNS republishing.
Logged as future work from security review LOW-01.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 0edd75511b27
Mark CRITICAL-01, HIGH-01, HIGH-02, MEDIUM-01, MEDIUM-02 as FIXED
with commit references. Remove suggested code (now implemented).
Update summary table and remaining open items.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 46966f48dfa5
Legacy files without ipnsPrivateKeyEncrypted fall back to HKDF
derivation indefinitely. Should wrap and write back on next publish.
Logged from security review LOW-02.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: fc71f7dc4708
@coderabbitai

coderabbitai Bot commented Feb 21, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@FSM1 has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 16 minutes and 18 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

Walkthrough

This PR replaces deterministic HKDF-derived per-file IPNS keys with random Ed25519 keypairs, adds ECIES-wrapped storage via a new ipnsPrivateKeyEncrypted field, implements lazy migration for legacy pointers, and threads TEE/public-key enrollment for desktop-created files.

Changes

Cohort / File(s) Summary
Planning & Reviews
\.planning/STATE.md, .planning/security/REVIEW-2026-02-21-random-ipns-keys.md, .planning/todos/done/2026-02-21-random-file-ipns-keys.md, .planning/todos/done/2026-02-21-lazy-migration-legacy-file-pointers.md, .planning/todos/pending/2026-02-21-desktop-tee-enrollment-for-new-files.md
Adds security review, decision and TODO docs describing the design change to random Ed25519 per-file keys, ECIES-wrapped storage, dual-read migration strategy, lazy migration steps, and desktop TEE enrollment guidance.
Crypto core & types
packages/crypto/src/file/derive-ipns.ts, packages/crypto/src/file/types.ts, packages/crypto/src/file/index.ts, packages/crypto/src/index.ts
Introduces generateFileIpnsKeypair() for non-deterministic Ed25519 keys and exports it; adds optional ipnsPrivateKeyEncrypted?: string to FilePointer type.
Crypto validation & tests
packages/crypto/src/folder/metadata.ts, packages/crypto/src/__tests__/file-ipns.test.ts
Validates ipnsPrivateKeyEncrypted format/length in folder metadata; adds tests for random key generation and v2 FilePointer validation including ipnsPrivateKeyEncrypted.
Web services & hooks
apps/web/src/services/file-metadata.service.ts, apps/web/src/services/folder.service.ts, apps/web/src/hooks/useFolder.ts
Switches file metadata creation to random keypair generation and ECIES wrapping; adds getFileIpnsPrivateKey() for decrypt/HKDF-fallback and migration; propagates ipnsPrivateKeyEncrypted through FilePointer creation, update, restore, delete, and lazy-migration flows.
Desktop crypto & tests
apps/desktop/src-tauri/src/crypto/folder.rs, apps/desktop/src-tauri/src/crypto/tests.rs
Adds ipns_private_key_encrypted: Option<String> to desktop FilePointer/FolderEntry structs and tests; validates camelCase serialization and round-trip behavior.
Desktop FUSE inode & module
apps/desktop/src-tauri/src/fuse/inode.rs, apps/desktop/src-tauri/src/fuse/mod.rs, apps/desktop/src-tauri/src/fuse/operations.rs
Adds file_ipns_key_encrypted_hex: Option<String> to InodeKind::File; threads public key into populate_folder; implements ECIES unwrap with HKDF fallback during populate/resolve; generates random per-file Ed25519 keypairs on create, eagerly ECIES-wraps/caches them, and propagates ipns_private_key_encrypted into FilePointer metadata.
Documentation
docs/METADATA_SCHEMAS.md
Documents new ipnsPrivateKeyEncrypted field, describes dual strategy (random Ed25519 for new files, HKDF fallback for legacy), and details lazy-migration and recovery flows.

Sequence Diagram

sequenceDiagram
    participant Client as Web Client
    participant FM as FileMetadataService
    participant Crypto as Crypto Module
    participant FS as FolderService
    participant IPFS as Folder Metadata (IPFS)

    Client->>FM: createFileMetadata(userPublicKey, ...)
    FM->>Crypto: generateFileIpnsKeypair()
    Crypto-->>FM: {privateKey, publicKey, ipnsName}
    FM->>Crypto: wrapKey(privateKey, userPublicKey) (ECIES)
    Crypto-->>FM: ipnsPrivateKeyEncrypted (hex)
    FM->>FM: buildIpnsRecord(ipnsName, privateKey, teeWrapped?)
    FM-->>Client: {ipnsRecord, ipnsPrivateKeyEncrypted}
    Client->>FS: addFileToFolder({..., ipnsPrivateKeyEncrypted})
    FS->>FS: createFilePointer(include ipnsPrivateKeyEncrypted)
    FS->>IPFS: publishFolderMetadata(filePointers)
    IPFS-->>FS: confirmed
    FS-->>Client: {filePointer, newSequenceNumber}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: switching file IPNS keys from deterministic HKDF-based derivation to random Ed25519 keypairs, which is the central objective across all changes in the PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/random-file-ipns-keys

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
.planning/STATE.md (1)

164-174: ⚠️ Potential issue | 🟡 Minor

Two new pending todos are missing from the Pending Todos list.

The PR adds .planning/todos/pending/2026-02-21-desktop-tee-enrollment-for-new-files.md and .planning/todos/pending/2026-02-21-lazy-migration-legacy-file-pointers.md, but neither appears in the Pending Todos section, and the count still reads 7 instead of 9.

📝 Proposed fix
-7 pending todo(s):
+9 pending todo(s):
 
 - `2026-02-07-web-worker-large-file-encryption.md` -- ...
 ...
 - `2026-02-21-ipns-resolution-alternatives.md` -- ...
+- `2026-02-21-desktop-tee-enrollment-for-new-files.md` -- Enroll desktop-created files into TEE for IPNS republishing (area: desktop)
+- `2026-02-21-lazy-migration-legacy-file-pointers.md` -- Lazy migration of legacy FilePointers to random IPNS keys (area: web)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.planning/STATE.md around lines 164 - 174, Update the "Pending Todos"
section in .planning/STATE.md to include the two new pending todo files
introduced by the PR: `2026-02-21-desktop-tee-enrollment-for-new-files.md` and
`2026-02-21-lazy-migration-legacy-file-pointers.md`, add appropriate brief
descriptions/area tags consistent with the existing entries, and bump the
pending count from 7 to 9 so the list and count reflect the actual files
present.
🧹 Nitpick comments (4)
packages/crypto/src/folder/metadata.ts (1)

74-85: Consider tightening the minimum length to ECIES_MIN_CIPHERTEXT_SIZE * 2 and fix the inline comment.

Two small issues:

  1. The threshold 64 is well below the actual minimum hex length of a valid ECIES-wrapped 32-byte key (~162 hex chars). Strings in the [64, 161] range will pass validation but then fail at unwrapKey() with a less useful error. The ECIES_MIN_CIPHERTEXT_SIZE constant is already exported from the same package and is the right source of truth.
  2. The inline comment at Line 74 says "non-empty hex string" but the actual check is a 64-char minimum — slightly misleading.
♻️ Proposed refinement

Add import at the top of the file:

+import { ECIES_MIN_CIPHERTEXT_SIZE } from '../constants';

Then update the validation block:

-      // Optional: ipnsPrivateKeyEncrypted must be a non-empty hex string if present
+      // Optional: ipnsPrivateKeyEncrypted must be a hex string long enough to hold ECIES output
       if (entry.ipnsPrivateKeyEncrypted !== undefined) {
         if (
           typeof entry.ipnsPrivateKeyEncrypted !== 'string' ||
-          entry.ipnsPrivateKeyEncrypted.length < 64
+          entry.ipnsPrivateKeyEncrypted.length < ECIES_MIN_CIPHERTEXT_SIZE * 2
         ) {
           throw new CryptoError(
-            'Invalid metadata format: ipnsPrivateKeyEncrypted must be a hex string (min 64 chars)',
+            'Invalid metadata format: ipnsPrivateKeyEncrypted must be a hex string of sufficient length',
             'DECRYPTION_FAILED'
           );
         }
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/crypto/src/folder/metadata.ts` around lines 74 - 85, Update the
ipnsPrivateKeyEncrypted validation to use the canonical ECIES minimum length and
fix the comment: import ECIES_MIN_CIPHERTEXT_SIZE from the package and replace
the hardcoded 64 check with a hex-length check of ECIES_MIN_CIPHERTEXT_SIZE * 2
(since hex chars are 2 per byte); also change the inline comment to accurately
state "must be a hex string at least ECIES_MIN_CIPHERTEXT_SIZE bytes (hex length
ECIES_MIN_CIPHERTEXT_SIZE*2)". Keep the CryptoError thrown (with same code
'DECRYPTION_FAILED') and ensure this prevents passing values that would later
fail in unwrapKey().
packages/crypto/src/file/derive-ipns.ts (1)

1-17: File header JSDoc describes only the deterministic HKDF path — update to reflect the new random generation function.

The module comment accurately describes deriveFileIpnsKeypair but doesn't mention the new random generateFileIpnsKeypair. As both functions now live in this file, the header should be updated to avoid misleading future readers.

📝 Proposed update to file header
 /**
  * `@cipherbox/crypto` - File IPNS Key Derivation
  *
- * Derives a deterministic Ed25519 IPNS keypair for a specific file
- * from the user's secp256k1 privateKey + fileId using HKDF-SHA256.
+ * Two IPNS keypair strategies for per-file IPNS records:
+ *
+ * 1. Deterministic (legacy): derives an Ed25519 keypair from the user's
+ *    secp256k1 privateKey + fileId via HKDF-SHA256 (`deriveFileIpnsKeypair`).
+ * 2. Random (new files): generates a fresh Ed25519 keypair via CSPRNG
+ *    (`generateFileIpnsKeypair`). The private key is ECIES-wrapped and stored
+ *    in the parent folder's FilePointer so it can be recovered from any device.
  *
  * Derivation path:
  ...
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/crypto/src/file/derive-ipns.ts` around lines 1 - 17, Update the file
JSDoc to describe both available key derivation methods: the existing
deterministic HKDF-based flow used by deriveFileIpnsKeypair (HKDF-SHA256 with
salt "CipherBox-v1" and info "cipherbox-file-ipns-v1:{fileId}" → 32-byte Ed25519
seed → keypair → IPNS name) and the new random generation provided by
generateFileIpnsKeypair (random Ed25519 keypair → IPNS name). Mention that both
functions coexist in this module, note their different use-cases (deterministic
per-file vs random), and keep the domain-separation note distinguishing this
domain from vault and registry derivations.
apps/desktop/src-tauri/src/fuse/inode.rs (1)

804-843: Test should verify file_ipns_key_encrypted_hex propagation for populated file pointers.

The test_populate_folder_with_file_pointers test doesn't assert on the new file_ipns_key_encrypted_hex or file_ipns_private_key fields after population. Since the test FilePointer has no ipns_private_key_encrypted, both should be None (HKDF will also fail with a zero private key). Consider asserting these to lock in the expected behavior for legacy file pointers.

🧪 Suggested assertion additions
         match &child.kind {
-            InodeKind::File { file_meta_ipns_name, file_meta_resolved, .. } => {
+            InodeKind::File { file_meta_ipns_name, file_meta_resolved, file_ipns_key_encrypted_hex, .. } => {
                 assert_eq!(
                     file_meta_ipns_name.as_deref(),
                     Some("k51qzi5uqu5dljtg5upm7x7ugan9lql3ewyknv4r4mhhkwzn8n7cnbd1unfwgx")
                 );
                 assert!(!file_meta_resolved, "FilePointer should not be resolved yet");
+                assert!(file_ipns_key_encrypted_hex.is_none(), "Legacy FilePointer should have no cached encrypted hex");
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/inode.rs` around lines 804 - 843, The test
test_populate_folder_with_file_pointers should also assert that new fields
related to IPNS keys remain None for legacy FilePointer entries: after calling
InodeTable::populate_folder and fetching the child inode (created from the
FolderChild::File FilePointer), add assertions that the inode's
file_ipns_key_encrypted_hex and file_ipns_private_key are None (since the
provided FilePointer has no ipns_private_key_encrypted and the provided
private_key is a zeroed vector and HKDF would not produce a valid value). Update
the assertions near the InodeKind::File match to explicitly check these two
fields to lock in expected behavior.
apps/desktop/src-tauri/src/fuse/mod.rs (1)

462-521: ECIES wrapping result for legacy files is not cached back to the inode.

When file_ipns_key_encrypted_hex is None but file_ipns_private_key is Some (HKDF-fallback / legacy files), the ECIES-wrapped hex is computed on every build_folder_metadata call but never stored back into the inode's file_ipns_key_encrypted_hex. This means every metadata publish for a legacy file triggers a redundant ECIES wrap_key operation.

Consider caching the result after the first wrap to avoid repeated ECIES operations. Since build_folder_metadata takes &self, you'd need interior mutability or a post-publish cache update.

♻️ Example: cache after build

One approach is to collect the (ino, hex) pairs produced during build_folder_metadata and apply them afterward:

// After build_folder_metadata returns successfully, update inode cache:
// (requires &mut self, so call from update_folder_metadata / flush_publish_queue)
if let Some(inode) = self.inodes.get_mut(child_ino) {
    if let InodeKind::File { ref mut file_ipns_key_encrypted_hex, .. } = inode.kind {
        if file_ipns_key_encrypted_hex.is_none() {
            *file_ipns_key_encrypted_hex = Some(computed_hex);
        }
    }
}

Based on learnings: the PR summary mentions "ECIES caching" as an addressed security fix, and build_folder_metadata is called on every publish (debounced or immediate).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src-tauri/src/fuse/mod.rs` around lines 462 - 521,
build_folder_metadata computes ECIES-wrapped hex for legacy files (when
file_ipns_key_encrypted_hex is None but file_ipns_private_key is Some) but never
writes that result back to the inode, causing repeated wrap_key calls; collect
the newly-computed (ino, hex) pairs inside build_folder_metadata (or return
them) and after a successful publish/update call mutate the inode to set
file_ipns_key_encrypted_hex (locate InodeKind::File handling,
file_ipns_key_encrypted_hex, file_ipns_private_key and
crate::crypto::ecies::wrap_key) — update self.inodes (or via the caller like
update_folder_metadata / flush_publish_queue) to store the hex so future
build_folder_metadata calls skip wrap_key. Ensure synchronization (interior
mutability or caller-held &mut self) when applying the cache updates.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src-tauri/src/fuse/operations.rs`:
- Around line 969-990: The current create() flow generates a per-file Ed25519
keypair and attempts to ECIES-wrap the IPNS private key using
crate::crypto::ecies::wrap_key, but on wrap failure it only logs a warning and
continues with ipns_key_encrypted_hex = None, risking irreversible loss of the
random IPNS key if later re-wrap also fails; change this so that a wrap_key()
Err is treated as fatal in the create() function: in the Err(e) branch for
wrap_key replace the log::warn+None path with a log::error that includes the
error, then call reply.error(libc::EIO) (or otherwise propagate the error) and
return, ensuring the code does not proceed to create metadata without an
encrypted IPNS key (also note build_folder_metadata will then not be invoked
with a missing ipnsPrivateKeyEncrypted).

---

Outside diff comments:
In @.planning/STATE.md:
- Around line 164-174: Update the "Pending Todos" section in .planning/STATE.md
to include the two new pending todo files introduced by the PR:
`2026-02-21-desktop-tee-enrollment-for-new-files.md` and
`2026-02-21-lazy-migration-legacy-file-pointers.md`, add appropriate brief
descriptions/area tags consistent with the existing entries, and bump the
pending count from 7 to 9 so the list and count reflect the actual files
present.

---

Nitpick comments:
In `@apps/desktop/src-tauri/src/fuse/inode.rs`:
- Around line 804-843: The test test_populate_folder_with_file_pointers should
also assert that new fields related to IPNS keys remain None for legacy
FilePointer entries: after calling InodeTable::populate_folder and fetching the
child inode (created from the FolderChild::File FilePointer), add assertions
that the inode's file_ipns_key_encrypted_hex and file_ipns_private_key are None
(since the provided FilePointer has no ipns_private_key_encrypted and the
provided private_key is a zeroed vector and HKDF would not produce a valid
value). Update the assertions near the InodeKind::File match to explicitly check
these two fields to lock in expected behavior.

In `@apps/desktop/src-tauri/src/fuse/mod.rs`:
- Around line 462-521: build_folder_metadata computes ECIES-wrapped hex for
legacy files (when file_ipns_key_encrypted_hex is None but file_ipns_private_key
is Some) but never writes that result back to the inode, causing repeated
wrap_key calls; collect the newly-computed (ino, hex) pairs inside
build_folder_metadata (or return them) and after a successful publish/update
call mutate the inode to set file_ipns_key_encrypted_hex (locate InodeKind::File
handling, file_ipns_key_encrypted_hex, file_ipns_private_key and
crate::crypto::ecies::wrap_key) — update self.inodes (or via the caller like
update_folder_metadata / flush_publish_queue) to store the hex so future
build_folder_metadata calls skip wrap_key. Ensure synchronization (interior
mutability or caller-held &mut self) when applying the cache updates.

In `@packages/crypto/src/file/derive-ipns.ts`:
- Around line 1-17: Update the file JSDoc to describe both available key
derivation methods: the existing deterministic HKDF-based flow used by
deriveFileIpnsKeypair (HKDF-SHA256 with salt "CipherBox-v1" and info
"cipherbox-file-ipns-v1:{fileId}" → 32-byte Ed25519 seed → keypair → IPNS name)
and the new random generation provided by generateFileIpnsKeypair (random
Ed25519 keypair → IPNS name). Mention that both functions coexist in this
module, note their different use-cases (deterministic per-file vs random), and
keep the domain-separation note distinguishing this domain from vault and
registry derivations.

In `@packages/crypto/src/folder/metadata.ts`:
- Around line 74-85: Update the ipnsPrivateKeyEncrypted validation to use the
canonical ECIES minimum length and fix the comment: import
ECIES_MIN_CIPHERTEXT_SIZE from the package and replace the hardcoded 64 check
with a hex-length check of ECIES_MIN_CIPHERTEXT_SIZE * 2 (since hex chars are 2
per byte); also change the inline comment to accurately state "must be a hex
string at least ECIES_MIN_CIPHERTEXT_SIZE bytes (hex length
ECIES_MIN_CIPHERTEXT_SIZE*2)". Keep the CryptoError thrown (with same code
'DECRYPTION_FAILED') and ensure this prevents passing values that would later
fail in unwrapKey().

Comment thread apps/desktop/src-tauri/src/fuse/operations.rs
FSM1 and others added 2 commits February 21, 2026 05:53
When getFileIpnsPrivateKey() falls back to HKDF derivation for legacy
FilePointers lacking ipnsPrivateKeyEncrypted, the derived key is now
ECIES-wrapped with the user's public key and returned to the caller.
The caller (useFolder hooks) persists the wrapped key by updating the
FilePointer in folder metadata via a fire-and-forget folder re-publish.

This gradually migrates all actively-used files to random IPNS keys,
allowing eventual removal of the HKDF derivation code path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: eb87c793767c
Add public_key parameter to populate_folder so that when HKDF fallback
derives an IPNS key for a legacy FilePointer, the key is ECIES-wrapped
and cached in the inode's file_ipns_key_encrypted_hex. The next folder
metadata publish persists the wrapped key in the FilePointer, migrating
the file to the random-key format.

Also fix 3 test compilation errors (missing ipns_private_key_encrypted
field) and add backward/forward compatibility tests per the Metadata
Evolution Protocol (Section 6.1).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 26e64a4fa8be

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
apps/web/src/hooks/useFolder.ts (2)

832-847: ⚠️ Potential issue | 🟡 Minor

Duplicate // 5. step label

Line 832 (// 5. Update file metadata...) and line 847 (// 5. Publish only the file IPNS record...) both use the same step number. The second occurrence should start at // 6., shifting // 6.// 7. and // 6b.// 7b..

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useFolder.ts` around lines 832 - 847, The comments
numbering is duplicated at the update/publish steps; update the inline step
comments around the updateFileMetadata call and the subsequent publish block so
they increment correctly: change the second "// 5. Publish only the file IPNS
record..." to "// 6. Publish only the file IPNS record..." and then bump the
subsequent "// 6." → "// 7." and "// 6b." → "// 7b." (search around the
updateFileMetadata call and the publish logic that references
fileIpnsPrivateKey, filePointer.fileMetaIpnsName, parentFolder.folderKey, and
createVersion to find the exact comment locations).

821-844: ⚠️ Potential issue | 🟠 Major

fileIpnsPrivateKey is never zeroed after use — same issue exists in handleRestoreVersion and handleDeleteVersion

createFileMetadata establishes the zeroing pattern (ipnsKeypair.privateKey.fill(0)) within the service. Here, the decrypted file IPNS private key remains in memory until the call stack unwinds. Wrapping the signing operations in try/finally matches the established pattern and minimises the exposure window.

🔐 Proposed fix (apply the same pattern in `handleRestoreVersion` and `handleDeleteVersion`)
-        const { privateKey: fileIpnsPrivateKey, migratedIpnsPrivateKeyEncrypted } =
-          await getFileIpnsPrivateKey(
-            filePointer,
-            auth.vaultKeypair.privateKey,
-            auth.vaultKeypair.publicKey
-          );
-
-        // 4. Determine whether to create a version entry
+        const { privateKey: fileIpnsPrivateKey, migratedIpnsPrivateKeyEncrypted } =
+          await getFileIpnsPrivateKey(
+            filePointer,
+            auth.vaultKeypair.privateKey,
+            auth.vaultKeypair.publicKey
+          );
+
+        // 4. Determine whether to create a version entry
         const createVersion = shouldCreateVersion(currentMetadata, fileData.forceVersion ?? false);
 
-        // 5. Update file metadata and publish new IPNS record
-        const { ipnsRecord, prunedCids } = await updateFileMetadata({
-          fileIpnsPrivateKey,
-          ...
-        });
+        let ipnsRecord: FileIpnsRecordPayload;
+        let prunedCids: string[];
+        try {
+          ({ ipnsRecord, prunedCids } = await updateFileMetadata({
+            fileIpnsPrivateKey,
+            fileMetaIpnsName: filePointer.fileMetaIpnsName,
+            folderKey: parentFolder.folderKey,
+            currentMetadata,
+            updates: { ... },
+            createVersion,
+          }));
+        } finally {
+          fileIpnsPrivateKey.fill(0);
+        }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useFolder.ts` around lines 821 - 844, The decrypted file
IPNS private key returned from getFileIpnsPrivateKey (assigned to
fileIpnsPrivateKey) is never zeroed after use — wrap the code that uses
fileIpnsPrivateKey (the call to updateFileMetadata and any signing operations)
in a try/finally and in the finally call fileIpnsPrivateKey.fill(0) (matching
the pattern used in createFileMetadata where ipnsKeypair.privateKey.fill(0));
apply the same try/finally zeroing pattern in the sibling handlers
handleRestoreVersion and handleDeleteVersion so the private key is wiped as soon
as operations complete.
🧹 Nitpick comments (3)
apps/web/src/hooks/useFolder.ts (2)

872-888: Lazy migration fire-and-forget block is duplicated three times — extract to a helper

The same ~15-line pattern (null-guard → folderService.updateFolderMetadata.then(updateSequence).catch(warn)) appears verbatim in handleUpdateFile, handleRestoreVersion, and handleDeleteVersion.

♻️ Suggested helper
function triggerLazyMigrationPublish(
  parentId: string,
  parentFolder: FolderNode,
  updatedChildren: FolderChild[]
): void {
  folderService
    .updateFolderMetadata({
      folderId: parentFolder.id,
      children: updatedChildren,
      folderKey: parentFolder.folderKey,
      ipnsPrivateKey: parentFolder.ipnsPrivateKey,
      ipnsName: parentFolder.ipnsName,
      sequenceNumber: parentFolder.sequenceNumber,
    })
    .then(({ newSequenceNumber }) => {
      useFolderStore.getState().updateFolderSequence(parentId, newSequenceNumber);
    })
    .catch((err) => {
      console.warn('Lazy IPNS key migration: folder re-publish failed, will retry:', err);
    });
}

Each caller reduces to:

if (migratedIpnsPrivateKeyEncrypted) {
  triggerLazyMigrationPublish(parentId, parentFolder, updatedChildren);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useFolder.ts` around lines 872 - 888, The repeated
lazy-migration pattern in handleUpdateFile, handleRestoreVersion, and
handleDeleteVersion (null-guard → folderService.updateFolderMetadata →
.then(updateFolderSequence) → .catch(console.warn)) should be extracted into a
single helper function (e.g., triggerLazyMigrationPublish) that accepts
parentId, parentFolder, and updatedChildren; replace each duplicated block with
a guard if (migratedIpnsPrivateKeyEncrypted) {
triggerLazyMigrationPublish(parentId, parentFolder, updatedChildren); } and have
the helper call folderService.updateFolderMetadata(...) and on success call
useFolderStore.getState().updateFolderSequence(parentId, newSequenceNumber) and
on failure console.warn the error.

724-732: Capture publicKey before Promise.all to avoid the non-null assertion

auth.vaultKeypair is checked for null before this block, but TypeScript can't narrow it inside the async Promise.all callback, hence the !. Extracting it once before the async boundary is safer and consistent with handleAddFile (line 649).

♻️ Proposed fix
+        const userPublicKey = auth.vaultKeypair.publicKey;
+
         const filesWithRecords = await Promise.all(
           filesData.map(async (f) => {
             const fileId = crypto.randomUUID();
             const { ipnsRecord, ipnsPrivateKeyEncrypted } = await createFileMetadata({
               ...
-              userPublicKey: auth.vaultKeypair!.publicKey,
+              userPublicKey,
               ...
             });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/hooks/useFolder.ts` around lines 724 - 732, Capture
auth.vaultKeypair.publicKey into a local const before entering the
async/Promise.all boundary (e.g., const userPublicKey =
auth.vaultKeypair.publicKey) and replace the non-null assertion usage
auth.vaultKeypair!.publicKey inside the createFileMetadata call with that local
userPublicKey; update the code in useFolder (the block that calls
createFileMetadata inside Promise.all) to use userPublicKey to avoid the
null-assertion and ensure type-safety consistent with handleAddFile.
apps/web/src/services/file-metadata.service.ts (1)

56-92: Document caller responsibility to zero the returned privateKey

createFileMetadata explicitly zeros its keypair (line 181), establishing a clear in-module convention. getFileIpnsPrivateKey transfers ownership of a live private key buffer to the caller but doesn't document that the caller must zero it after use. Adding a @returns note (or a @remarks) makes the lifetime contract explicit and prevents the gap seen in the callers.

📝 Suggested JSDoc addition
  * `@returns` Decrypted Ed25519 IPNS private key, plus migration data if HKDF fallback was used
+ * `@remarks` The caller is responsible for zeroing `privateKey` with `.fill(0)` after use.
  */
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/web/src/services/file-metadata.service.ts` around lines 56 - 92, Update
the JSDoc for getFileIpnsPrivateKey to document the ownership and lifecycle of
the returned privateKey buffer: state that the function returns a live
Uint8Array containing the Ed25519 IPNS private key (and optional
migratedIpnsPrivateKeyEncrypted string) and that the caller is responsible for
zeroing/clearing the privateKey buffer after use (mirroring the convention in
createFileMetadata where the keypair is explicitly zeroed); add this as an
explicit `@returns` note or `@remarks` so callers know to securely wipe the buffer
once finished.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@apps/web/src/hooks/useFolder.ts`:
- Around line 832-847: The comments numbering is duplicated at the
update/publish steps; update the inline step comments around the
updateFileMetadata call and the subsequent publish block so they increment
correctly: change the second "// 5. Publish only the file IPNS record..." to "//
6. Publish only the file IPNS record..." and then bump the subsequent "// 6." →
"// 7." and "// 6b." → "// 7b." (search around the updateFileMetadata call and
the publish logic that references fileIpnsPrivateKey,
filePointer.fileMetaIpnsName, parentFolder.folderKey, and createVersion to find
the exact comment locations).
- Around line 821-844: The decrypted file IPNS private key returned from
getFileIpnsPrivateKey (assigned to fileIpnsPrivateKey) is never zeroed after use
— wrap the code that uses fileIpnsPrivateKey (the call to updateFileMetadata and
any signing operations) in a try/finally and in the finally call
fileIpnsPrivateKey.fill(0) (matching the pattern used in createFileMetadata
where ipnsKeypair.privateKey.fill(0)); apply the same try/finally zeroing
pattern in the sibling handlers handleRestoreVersion and handleDeleteVersion so
the private key is wiped as soon as operations complete.

---

Nitpick comments:
In `@apps/web/src/hooks/useFolder.ts`:
- Around line 872-888: The repeated lazy-migration pattern in handleUpdateFile,
handleRestoreVersion, and handleDeleteVersion (null-guard →
folderService.updateFolderMetadata → .then(updateFolderSequence) →
.catch(console.warn)) should be extracted into a single helper function (e.g.,
triggerLazyMigrationPublish) that accepts parentId, parentFolder, and
updatedChildren; replace each duplicated block with a guard if
(migratedIpnsPrivateKeyEncrypted) { triggerLazyMigrationPublish(parentId,
parentFolder, updatedChildren); } and have the helper call
folderService.updateFolderMetadata(...) and on success call
useFolderStore.getState().updateFolderSequence(parentId, newSequenceNumber) and
on failure console.warn the error.
- Around line 724-732: Capture auth.vaultKeypair.publicKey into a local const
before entering the async/Promise.all boundary (e.g., const userPublicKey =
auth.vaultKeypair.publicKey) and replace the non-null assertion usage
auth.vaultKeypair!.publicKey inside the createFileMetadata call with that local
userPublicKey; update the code in useFolder (the block that calls
createFileMetadata inside Promise.all) to use userPublicKey to avoid the
null-assertion and ensure type-safety consistent with handleAddFile.

In `@apps/web/src/services/file-metadata.service.ts`:
- Around line 56-92: Update the JSDoc for getFileIpnsPrivateKey to document the
ownership and lifecycle of the returned privateKey buffer: state that the
function returns a live Uint8Array containing the Ed25519 IPNS private key (and
optional migratedIpnsPrivateKeyEncrypted string) and that the caller is
responsible for zeroing/clearing the privateKey buffer after use (mirroring the
convention in createFileMetadata where the keypair is explicitly zeroed); add
this as an explicit `@returns` note or `@remarks` so callers know to securely wipe
the buffer once finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/desktop/src-tauri/src/fuse/inode.rs`:
- Around line 393-474: The current logic falls back to HKDF whenever decryption
fails, even if file_pointer.ipns_private_key_encrypted is present; modify the
flow so HKDF is used only for legacy pointers (when ipns_private_key_encrypted
is None). Concretely: when attempting to decode/decrypt the encrypted hex (in
the block that assigns file_ipns_key from
file_pointer.ipns_private_key_encrypted), treat any decode/decrypt failure as
unrecoverable and keep file_ipns_key as None but also record that an encrypted
value was present (e.g., a flag or check
file_pointer.ipns_private_key_encrypted.is_some()); then change the HKDF branch
to run only when file_pointer.ipns_private_key_encrypted.is_none() and
private_key is 32 bytes (call sites: file_ipns_key, the HKDF call
crypto::hkdf::derive_file_ipns_keypair, and references to
file_pointer.ipns_private_key_encrypted); finally ensure cached_encrypted_hex
does not wrap/encode an HKDF-derived key when an encrypted value was present but
invalid (only wrap when the original field was None).

Comment thread apps/desktop/src-tauri/src/fuse/inode.rs
FSM1 and others added 2 commits February 21, 2026 06:13
- Zero fileIpnsPrivateKey via try/finally in handleUpdateFile,
  handleRestoreVersion, and handleDeleteVersion (security)
- Make ECIES wrap_key failure fatal in desktop create() to prevent
  unrecoverable loss of random IPNS keys
- Use ECIES_MIN_CIPHERTEXT_SIZE constant for validation instead of
  hardcoded 64
- Add @remarks to getFileIpnsPrivateKey documenting caller zeroing
  responsibility
- Fix duplicate step comment numbers in handleUpdateFile
- Capture publicKey before Promise.all to avoid non-null assertion
- Update derive-ipns.ts file header to document both key strategies
- Add test assertions for new inode fields in populate test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 0da54a18fcd7
…encrypted key

When ipnsPrivateKeyEncrypted is present but decryption fails, HKDF
derivation would produce a key that doesn't match the file's random
IPNS name, causing invalid per-file IPNS publishes. Now HKDF fallback
is gated on the field being absent (legacy files only).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Entire-Checkpoint: 5cfa8adc7a26
@FSM1 FSM1 enabled auto-merge (squash) February 21, 2026 05:18
@FSM1 FSM1 merged commit 7f01f98 into main Feb 21, 2026
12 checks passed
@FSM1 FSM1 deleted the feat/random-file-ipns-keys branch February 21, 2026 06:16
This was referenced Mar 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant