Skip to content

feat: rewrite TEE republish as a verify-in-enclave lease renewer#585

Merged
FSM1 merged 47 commits into
mainfrom
feat/tee-lease-renewer-contract-rewrite
Jul 1, 2026
Merged

feat: rewrite TEE republish as a verify-in-enclave lease renewer#585
FSM1 merged 47 commits into
mainfrom
feat/tee-lease-renewer-contract-rewrite

Conversation

@FSM1

@FSM1 FSM1 commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Phase 67 — TEE Lease-Renewer Contract Rewrite

Reshapes the TEE worker from a record originator into a pure record lease-renewer. The TEE receives a marshaled signedRecord, verifies its signature in-enclave, and re-emits the same CID and same sequence with only a later EOL — it can no longer originate or repoint a CID, nor increment the sequence.

Requirements: TEE-01, TEE-02, TEE-03, TEE-06

What changed

  • Verify-in-enclave lease renewer (apps/tee-worker/src/routes/republish.ts): parse → verify Ed25519 signature before decryption → decrypt via internal-epoch fallback → assert name↔key binding → re-sign same value+sequence with later EOL → zero key on every path. The +1n increment is gone; newSequenceNumber == parsed.sequence.
  • renewIpnsRecord (ipns-signer.ts): takes only (ed25519PrivateKey, marshaledExistingRecord) — no cid/sequence args, so the relay cannot inject either. Value and sequence come exclusively from the parsed existing record.
  • Internal epoch self-derivation (tee-keys.ts getInternalCurrentEpoch): epoch derived from the TEE's own clock, never a relay-supplied scalar. decryptWithFallback reshaped to 2 args; stale epoch-N-2 keys throw ReEnrollRequiredError before any unwrap.
  • Name↔key binding: deriveEd25519PublicKey(decryptedKey) byte-compared to publicKeyFromIpnsName(ipnsName); parsed.pubKey is never trusted (undefined for Ed25519 identity records).
  • Schedule collapse (ScheduleCollapse migration + entity): drops the 4 duplicated signing-input columns (encrypted_ipns_key, key_epoch, latest_cid, sequence_number). The canonical ipns_records row is now the sole source of signing inputs.
  • Equality CAS + tombstone guard (republish.service.ts renewIpnsRecordEol): WHERE sequence_number = :loaded AND tombstoned_at IS NULL — cannot regress the sequence or resurrect a tombstoned name. Two-layer tombstone filter (pre-batch + write).
  • createSubfolder TEE wiring (sdk-core/registration.ts): ECIES-wraps the IPNS key under the TEE public key and enrolls new subfolders; fail-closed on incomplete teeKeys.
  • Local dev stack (docker-compose.yml): tee-worker service on :3002; sdk-e2e bullmq/pg devDeps.

Ship-pass changes (this PR, on top of the executed phase)

  • 9fce9c8f — removed dead signIpnsRecord (the old CID-originating primitive the rewrite superseded; a footgun that could mint an arbitrary CID/sequence) and embedded the information_schema column-drop assertion in the tee-republish suite beforeAll (T-67-08-T).
  • 526c900dgetInternalCurrentEpoch treats a malformed/non-positive EPOCH_ZERO_TIMESTAMP_MS as MIN_EPOCH (a non-numeric anchor previously produced a NaN epoch); createSubfolder requires a positive-integer currentEpoch.
  • da9f298/1624f17 — gate the tee-republish sdk-e2e suite to the local live stack (skipIf(CI)); CI provisions no TEE worker / cipherbox DB, so the round-trip is the documented local publish gate (verified 2/2 locally).
  • 53dd036greptile P1s: epoch-upgrade write now scoped to { ipnsName, userId, tombstonedAt: IsNull(), keyEpoch } (tombstone immutability + owner scope + epoch CAS); getDueEntries filters signedRecord/keyEpoch non-null to prevent a null deref aborting the whole batch.
  • 76b1517fCodeRabbit PR review: reject publish when the TEE returns a different sequence (TEE-02 defense-in-depth); reactivate an existing schedule row on re-enroll; move TEE validation before the IPFS upload (fail-fast, no orphaned blob); + test hardening.
  • eaa37faf, b4c04c3, 3f80a8e — security + validation + learnings docs; ROADMAP/STATE drift fixes; deferred-findings todos.

Gates

Gate Result
Verification (goal-backward) ✅ PASS — 8/8 must-haves
Security audit ✅ SECURED — 24/24 threats closed, threats_open: 0; all 3 CRITICAL mitigations enforced in code
Nyquist validation ✅ compliant — TEE-01/02/03/06 all covered, 0 gaps
SDK E2E round-trip (live relay→TEE→DB) ✅ 2/2 local — same CID + same seq + later EOL; tombstoned name never re-signed
Unit tests ✅ tee-worker 76, apps/api 71, sdk-core 10
CodeRabbit CLI (pre-ship) 16 → 5 fixed, 9 deferred (todos), 2 skipped
Greptile PR review 2 P1 → both fixed + resolved
CodeRabbit PR review 7 threads → 5 fixed, 2 deferred (todos); all resolved

Deferred (todos filed under .planning/todos/pending/)

  • TEE republish write-path hardening — distinguish real DB/config errors from CAS-miss/decrypt-mismatch, per-entry null guard in the route. (The epoch-upgrade CAS + getDueEntries null-safety were fixed in this PR.)
  • Canonical encryptedIpnsPrivateKey rename — cross-layer wire-contract rename of the pre-existing encryptedIpnsKey/upgradedEncryptedKey fields (CodeRabbit-labeled Heavy lift).
  • renewIpnsRecord later-EOL invariant + test hardeningParsedIpnsRecord doesn't expose validity, so a direct EOL assertion needs a crypto-package change; the invariant holds by construction today.
  • CI TEE-worker for tee-republish — optionally provision a TEE worker in the CI sdk-e2e job so the round-trip is CI-gated (currently local-only).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a local TEE worker simulator setup for development and CI.
    • Introduced end-to-end republish coverage to verify renewed records keep the same content identity while updating expiry details.
  • Bug Fixes

    • Improved republish handling so renewal uses the latest canonical record data and skips tombstoned entries.
    • Strengthened key-renewal behavior to avoid invalid updates and require re-enrollment when keys are too old.

FSM1 added 30 commits July 1, 2026 00:15
Entire-Checkpoint: fc036595e056
Entire-Checkpoint: 38d6a126d501
Entire-Checkpoint: 3c00825431b3
Entire-Checkpoint: 0f7367351e38
…ule entity

- Remove encryptedIpnsKey, keyEpoch, latestCid, sequenceNumber @column blocks
- Signing inputs now sourced from ipns_records via JOIN on ipnsName (D-02/TEE-03)
- Retained 7 scheduling-metadata @column fields unchanged
…umns

- DROP COLUMN IF EXISTS for encrypted_ipns_key, key_epoch, latest_cid, sequence_number
- CREATE INDEX IDX_ipns_republish_schedule_ipns_name for getDueEntries JOIN
- down() throws per D-01 greenfield waiver (matches Phase-66 pattern)
- Timestamp 1751000000000 orders strictly after 1750000000000-ApiSchemaCutover
Entire-Checkpoint: 81315731a1b4
…vation

- 3 RED tests: unset anchor returns MIN_EPOCH, 5-week anchor returns 2,
  future anchor clamps to MIN_EPOCH (1)
- Import getInternalCurrentEpoch which does not exist yet (RED gate)
- Also import afterEach and add EPOCH_ZERO_TIMESTAMP_MS cleanup to beforeEach
…vation

- Export EPOCH_DURATION_MS constant (4-week interval)
- Export getInternalCurrentEpoch() reading EPOCH_ZERO_TIMESTAMP_MS at call
  time (not module load) for testability
- Returns MIN_EPOCH (1) as safe fallback when anchor unset or in the future
- Clamps via Math.max(MIN_EPOCH, ...) — never returns below 1
- Satisfies §6.7-1: never reads a relay-supplied epoch scalar
…eEnrollRequiredError

- Replace 3-arg decryptWithFallback tests with 2-arg API (keyEpoch hint)
- Add stale guard tests: ReEnrollRequiredError thrown before any decryptIpnsKey
  call when keyEpoch < internalCurrentEpoch - 1
- Assert requiresReEnroll, keyEpoch, currentEpoch on error; message security check
- Add vi.spyOn on getKeypair to assert no-unwrap on stale path
- Add mid-rotation fallback test: key decrypts via internalCurrentEpoch
- Update epoch migration round-trip to use 2-arg signature + setInternalEpoch helper
- Import ReEnrollRequiredError which does not exist yet (RED gate)
…uiredError stale guard

- Export ReEnrollRequiredError: requiresReEnroll=true, keyEpoch, currentEpoch;
  message names epoch integers only (no key material, satisfies T-67-02-I)
- decryptWithFallback(encryptedIpnsKey, keyEpoch): derive internalCurrentEpoch
  from getInternalCurrentEpoch() — never reads relay-supplied epoch scalar (D-03)
- Hard stale floor: keyEpoch < internalCurrentEpoch-1 throws ReEnrollRequiredError
  BEFORE any unwrap attempt, satisfying T-67-02-E2 and §6.7-3
- Fallback trial order: keyEpoch first, then internalCurrentEpoch (mid-rotation)
- Remove 3-arg signature (currentEpoch/previousEpoch relay params gone, T-67-02-E)
- Also imports getInternalCurrentEpoch from tee-keys (added in previous commit)
RED gate: 5 tests assert equal-CID, equal-sequence, different-bytes,
valid-signature, and explicit-lifetime behaviors. All fail because
renewIpnsRecord is not yet exported from ipns-signer.ts.

Security invariants under test: TEE-01 / TEE-02
Adds renewIpnsRecord(ed25519PrivateKey, marshaledExistingRecord, lifetimeMs?)
alongside the existing signIpnsRecord (unchanged for back-compat).

Implementation: parse the marshaled record with parseIpnsRecord from
@cipherbox/crypto, then re-sign using createIpnsRecord with the parsed
value and sequence only — structurally preventing CID repoint or sequence
increment (TEE-01 / TEE-02). createIpnsRecord emits ValidityType 0 (EOL)
automatically via the ipns package.

All 5 ipns-signer tests pass: equal-CID, equal-sequence, different-bytes,
valid-signature, explicit-lifetime.
- tests forwarding of encryptedIpnsPrivateKey + keyEpoch to createAndPublishIpnsRecord
- test return value includes TEE fields when teeKeys provided
- test omits TEE fields when teeKeys absent (unchanged behavior)
- test fail-closed throw when teeKeys.currentPublicKey is empty
- import wrapKey, bytesToHex, hexToBytes from @cipherbox/crypto
- validate teeKeys fail-closed: throws when currentPublicKey is empty or
  currentEpoch is not finite (does not publish un-enrolled subfolder)
- ECIES-wrap the generated ipnsPrivateKey under teeKeys.currentPublicKey
  via wrapKey before publish (zero-knowledge: server only receives the
  hex-encoded wrapped ciphertext)
- forward encryptedIpnsPrivateKey + keyEpoch to createAndPublishIpnsRecord
  so the subfolder's first ipns_records row enrolls in TEE renewal
- return encryptedIpnsPrivateKey + keyEpoch from createSubfolder
- do not zero ipnsPrivateKey/readKey/writeKey (D-09 terminal-owner)
- build from repo root context (Dockerfile COPYs monorepo pnpm-lock.yaml/packages/*)
- host port 3002 to avoid conflict with mock-ipns-routing on 3001
- TEE_MODE=simulator, CIPHERBOX_ENVIRONMENT=development
- document TEE_WORKER_URL=http://localhost:3002 in .env.example
- bullmq@^5.67.3 and pg@^8.14.1 match apps/api versions (no new lockfile entry)
- @types/pg for type-safe make-due DB poke in round-trip suite (67-08)
- update pnpm-lock.yaml for tests/sdk-e2e importer
- Add makeEntry helper creating real IPNS records + real ECIES encryption
- Assert newSequenceNumber == parsed seq (no increment, §7.3 test 12)
- Assert verify-fail path never calls decryptWithFallback (spy uncalled)
- Assert binding-mismatch rejects without emitting signedRecord (§7.3 test 18)
- Assert ReEnrollRequiredError surfaces as requiresReEnroll: true (§7.3 test 19)
- Assert epoch upgrade fields present when usedEpoch != internalCurrentEpoch
- Remove old relay-scalar fields (latestCid/sequenceNumber/currentEpoch/previousEpoch)
- Replace signIpnsRecord mock with renewIpnsRecord mock
- Reshape RepublishEntry: add signedRecord, remove latestCid/sequenceNumber/currentEpoch/previousEpoch
- Add requiresReEnroll to RepublishResult for structured re-enroll signal
- Parse signedRecord bytes via parseIpnsRecord before any decryption
- Verify signature via verifyIpnsRecordSignature BEFORE decryptWithFallback
- Assert name-key binding using deriveEd25519PublicKey vs publicKeyFromIpnsName
- Re-sign same CID + same sequence via renewIpnsRecord (no +1, TEE-02)
- Epoch upgrade uses getInternalCurrentEpoch() not relay scalars (D-03)
- Re-encrypt key before zeroing; zero key on all paths including binding-fail
- ReEnrollRequiredError surfaces as requiresReEnroll: true with safe error string
- Fix binding-mismatch test assertion to match exact safe error message
…uild

- rewrite createMockEntry to remove dropped schedule fields (encryptedIpnsKey,
  keyEpoch, latestCid, sequenceNumber — removed in 67-01)
- add createMockRecord helper for IpnsRecord pairs
- set up QB mocks: scheduleQBMock (innerJoin chain) + recordSelectQBMock +
  recordUpdateQBMock (renewIpnsRecordEol)
- add getDueEntries tests: innerJoin condition, tombstone-filter exclusion,
  paired {schedule, record} result shape
- add teeEntries tests: signedRecord from record, no latestCid/sequenceNumber/
  currentEpoch/previousEpoch in the relay body
- add renewIpnsRecordEol CAS hit/miss/tombstone tests (equality CAS, no seq bump)
- add requiresReEnroll routing test (non-fatal failure)
- add epoch upgrade → ipnsRecordRepository test (not schedule)
- rewrite enrollFolder tests for 2-arg scheduling-only signature
…urce + equality CAS

Task 1: getDueEntries JOIN + teeEntries rebuild (TEE-03)
- RepublishEntry: remove latestCid/sequenceNumber/currentEpoch/previousEpoch;
  add signedRecord (base64 of ipns_records.signed_record)
- RepublishResult: add requiresReEnroll?: true
- getDueEntries returns Array<{schedule, record}> via two-step QB:
  scheduleQB inner-joins ipns_records (tombstoned_at IS NULL + key IS NOT NULL),
  then records fetched with same filter to guard against race window
- processRepublishBatch builds teeEntries entirely from paired record:
  encryptedIpnsKey, keyEpoch, signedRecord — no schedule snapshot, no epoch scalars

Task 2: renewIpnsRecordEol equality CAS + epoch upgrade → ipns_records (TEE-04)
- replace syncIpnsRecordSequence (LessThanOrEqual write-back) with
  renewIpnsRecordEol: QB UPDATE signed_record WHERE sequence_number = :expected
  AND tombstoned_at IS NULL (equality CAS, EOL-only, does NOT change seq)
- affected === 0 → log debug + return (harmless discard, no throw)
- epoch upgrade writes encryptedIpnsPrivateKey + keyEpoch to ipns_records,
  not the schedule
- requiresReEnroll → log + handleEntryFailure (non-fatal, Phase 68/69 deferred)
- schedule save contains ONLY scheduling fields (no crypto columns)
- remove teeKeyStateService from batch path (epoch now self-derived by TEE)
FSM1 and others added 2 commits July 1, 2026 04:47
…ch validation

getInternalCurrentEpoch: a non-numeric or non-positive EPOCH_ZERO_TIMESTAMP_MS
made parseInt return NaN, which flowed through Math.max into a NaN epoch and the
stale-key guard. Treat unset, NaN, and non-positive anchors identically as
MIN_EPOCH. Adds malformed/non-positive anchor tests.

createSubfolder: currentEpoch was only checked for finiteness, so a negative or
fractional epoch could still be published. Require a positive integer (>= 1),
consistent with the fail-closed enrollment intent.

Addresses CodeRabbit findings on tee-keys.ts and registration.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 13ea2052c2cf
…dings

ROADMAP: Phase 67 plan count 7/8 -> 8/8 (all plans complete).
STATE: align Current focus and progress bar with the frontmatter (Phase 68, 7/9, 78%).

Capture 9 deferred CodeRabbit findings as todos: republish write-path error
handling + epoch-upgrade CAS, canonical encryptedIpnsPrivateKey rename, and the
renewIpnsRecord later-EOL invariant + test-quality cluster.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 81dd50501911
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@FSM1, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 39 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: fc81e90d-ab18-4b7f-b5a7-179311588136

📥 Commits

Reviewing files that changed from the base of the PR and between 113bb5d and a73c4d6.

📒 Files selected for processing (3)
  • .planning/todos/pending/2026-07-01-tee-republish-writepath-error-handling-hardening.md
  • apps/api/src/republish/republish.service.spec.ts
  • apps/api/src/republish/republish.service.ts

Walkthrough

Phase 67 marks the TEE lease-renewer flow complete: the API now sources signing inputs from ipns_records, the worker verifies and renews existing IPNS records with internally derived epoch handling, SDK folder creation can enroll TEE keys, and local/e2e CI wiring plus tracking docs were updated.

Changes

TEE Lease-Renewer Contract Rewrite

Layer / File(s) Summary
Planning, tracking, and verification docs
.planning/REQUIREMENTS.md, .planning/ROADMAP.md, .planning/STATE.md, .planning/phases/*, .planning/todos/*
Phase 67 status, validation, security, research, and follow-up notes were updated to reflect the completed contract rewrite and remaining hardening items.
TEE worker epoch, renewal, and republish flow
apps/tee-worker/src/services/*, apps/tee-worker/src/routes/republish.ts, apps/tee-worker/src/__tests__/*
The worker now derives epoch internally, refuses stale keys, renews existing IPNS records without changing CID/sequence, and returns structured re-enrollment signals.
SDK enrollment and local worker wiring
packages/sdk-core/src/folder/registration.ts, docker/docker-compose.yml, apps/api/.env.example, tests/sdk-e2e/package.json, release-please-config.json, .github/workflows/ci.yml
TEE enrollment metadata is threaded through folder creation, and local/CI simulator wiring is added for the tee-worker and e2e stack.
API republish orchestration
apps/api/src/republish/republish.service.ts, apps/api/src/republish/republish.service.spec.ts, apps/api/src/tee/tee.service.ts, apps/api/src/tee/tee.service.spec.ts, apps/api/src/ipns/ipns.service.ts, apps/api/src/migrations/1751000000000-ScheduleCollapse.ts, apps/api/src/republish/republish-schedule.entity.ts
Republish scheduling now joins against ipns_records, renews EOL with equality-CAS, collapses enrollFolder inputs, and removes duplicated schedule signing columns.
End-to-end lease-renewal validation
tests/sdk-e2e/src/suites/tee-republish.test.ts
A live-stack e2e suite drives schedule dueing, BullMQ republish jobs, and tombstone/non-tombstone renewal assertions.

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

Possibly related PRs

  • FSM1/cipher-box#79: Both PRs touch the TEE republish contract and the epoch-related fields on RepublishEntry.
  • FSM1/cipher-box#77: This PR’s TEE todo/documentation changes align with the same worker/env contract area and renewal wiring.
  • FSM1/cipher-box#395: Both PRs modify the TEE republish route and its request/response shape.

Suggested labels: release:api:feat, release:sdk-core:feat, release:sdk:fix, release:cipherbox-sdk:fix

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: rewriting TEE republish into a verify-in-enclave lease renewer.
Docstring Coverage ✅ Passed Docstring coverage is 90.48% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/tee-lease-renewer-contract-rewrite

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

❤️ Share

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

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

github-actions Bot commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Release Preview

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

Cascade Details

  • sdk-core minor -> sdk patch (direct dependency)
  • sdk-core minor -> web patch (direct dependency)
  • api minor -> cipherbox-fuse patch (direct dependency)
  • api minor -> cipherbox-sdk patch (direct dependency)
  • api minor -> desktop patch (direct dependency)

@greptile-apps

greptile-apps Bot commented Jul 1, 2026

Copy link
Copy Markdown

Greptile Summary

This PR rewrites the TEE republish worker from a record originator into a pure lease-renewer: the TEE receives the marshaled signedRecord from the relay, verifies the Ed25519 signature before any decryption, decrypts the IPNS key via an internally-derived epoch, asserts name↔key binding, and re-signs the same CID and same sequence number with only a later EOL. The relay can no longer inject a CID, sequence number, or epoch scalar.

  • TEE route (republish.ts): six-step pipeline — parse → verify-before-decrypt → decrypt → binding check → renewIpnsRecord → epoch upgrade; stale keys throw ReEnrollRequiredError before any unwrap; all key-zeroing paths are covered.
  • Relay service (republish.service.ts): getDueEntries two-step query with tombstone/null guard; renewIpnsRecordEol is an equality CAS (sequence_number = :loaded AND tombstoned_at IS NULL) replacing the old LessThanOrEqual seq-sync; defense-in-depth check rejects results where the TEE returns a different sequence.
  • ScheduleCollapse migration drops four signing-input columns from ipns_republish_schedule; createSubfolder in sdk-core now ECIES-wraps the IPNS key under the TEE public key on enrollment.

Confidence Score: 5/5

The rewrite is structurally sound: verify-before-decrypt order is enforced, the relay cannot inject a CID or sequence, tombstone immutability is preserved at two layers, and all key-zeroing paths are covered. The only finding is a missing array-length equality check in the name↔key binding that is vacuously safe in practice (Ed25519 public keys are always 32 bytes) but is worth tightening.

The core invariants — same CID, same sequence, later EOL, relay cannot inject signing inputs, stale keys trigger re-enrollment — are all correctly implemented and backed by 76 TEE-worker unit tests, 71 API tests, and a live round-trip e2e suite. The flagged hardening gap in the binding comparison cannot be exploited given normal Ed25519 key sizes and does not represent a current defect.

apps/tee-worker/src/routes/republish.ts — binding check length guard is missing; all other changed files look correct.

Important Files Changed

Filename Overview
apps/tee-worker/src/routes/republish.ts Core verify-in-enclave lease-renewer rewrite; sound pipeline order (verify → decrypt → bind → renew), but the name↔key binding comparison uses .every() without a length equality guard.
apps/api/src/republish/republish.service.ts getDueEntries two-step query with tombstone/null-guard, equality CAS renewIpnsRecordEol, defense-in-depth sequence check before publish — all look correct.
apps/tee-worker/src/services/ipns-signer.ts Replaced signIpnsRecord (CID-originating) with renewIpnsRecord (value+seq sourced exclusively from parsed existing record); relay cannot inject CID or sequence.
apps/tee-worker/src/services/key-manager.ts decryptWithFallback now takes 2 args and derives currentEpoch internally; stale-floor check before any unwrap is correct; ReEnrollRequiredError is structured and key-material-free.
apps/tee-worker/src/services/tee-keys.ts getInternalCurrentEpoch reads EPOCH_ZERO_TIMESTAMP_MS at call time, guards against NaN/non-positive anchors, falls back to MIN_EPOCH=1; correct and test-friendly.
apps/api/src/migrations/1751000000000-ScheduleCollapse.ts Drops four signing-input columns from ipns_republish_schedule and adds the JOIN index; down() deliberately throws (greenfield waiver); looks correct.
packages/sdk-core/src/folder/registration.ts TEE wiring added: ECIES-wraps IPNS key before any IPFS upload (fail-closed on missing/invalid teeKeys); encryptedIpnsPrivateKey forwarded to createAndPublishIpnsRecord.
apps/api/src/republish/republish-schedule.entity.ts Four signing-input columns removed, matching the ScheduleCollapse migration; scheduling-only columns remain.
apps/api/src/tee/tee.service.ts RepublishEntry now carries signedRecord instead of latestCid/sequenceNumber/currentEpoch/previousEpoch; RepublishResult gains requiresReEnroll flag; type contracts match the TEE route.
tests/sdk-e2e/src/suites/tee-republish.test.ts End-to-end round-trip: Test A verifies same-seq/same-CID/later-EOL; Test B verifies tombstoned name is never re-signed; both tests assert TEE-01/TEE-02 invariants against the live DB.
.github/workflows/ci.yml Adds simulator TEE worker step (started before the API); exports DB/Redis env vars for tee-republish.test.ts to hit the CI stack; startup health-poll is correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Relay as Relay (republish.service.ts)
    participant TEE as TEE Worker (republish.ts)
    participant DB as ipns_records (PostgreSQL)

    Relay->>DB: getDueEntries() — schedules JOIN ipns_records (tombstonedAt IS NULL, signedRecord/keyEpoch NOT NULL)
    DB-->>Relay: "DueEntryPair[] {schedule, record}"
    Relay->>TEE: "POST /republish [{encryptedIpnsKey, keyEpoch, ipnsName, signedRecord}]"
    Note over TEE: 1. parseIpnsRecord(signedRecordBytes)
    Note over TEE: 2. verifyIpnsRecordSignature — BEFORE decrypt
    Note over TEE: 3. decryptWithFallback(encryptedKey, keyEpoch) — throws ReEnrollRequiredError if stale
    Note over TEE: 4. binding: deriveEd25519PublicKey(key) == publicKeyFromIpnsName(name)
    Note over TEE: 5. renewIpnsRecord — same value+seq, later EOL
    Note over TEE: 6. reEncryptForEpoch if usedEpoch != internalCurrentEpoch
    Note over TEE: 7. key.fill(0)
    TEE-->>Relay: "RepublishResult {signedRecord, newSequenceNumber, upgradedEncryptedKey?}"
    Relay->>Relay: "assert newSequenceNumber === record.sequenceNumber (TEE-02)"
    Relay->>Relay: publishSignedRecord(ipnsName, signedRecord)
    Relay->>DB: scheduleRepository.save(schedule) — scheduling fields only
    Relay->>DB: "renewIpnsRecordEol WHERE sequence_number=:loaded AND tombstoned_at IS NULL"
    alt epoch upgrade
        Relay->>DB: "ipnsRecordRepository.update {encryptedIpnsPrivateKey, keyEpoch}"
    end
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Relay as Relay (republish.service.ts)
    participant TEE as TEE Worker (republish.ts)
    participant DB as ipns_records (PostgreSQL)

    Relay->>DB: getDueEntries() — schedules JOIN ipns_records (tombstonedAt IS NULL, signedRecord/keyEpoch NOT NULL)
    DB-->>Relay: "DueEntryPair[] {schedule, record}"
    Relay->>TEE: "POST /republish [{encryptedIpnsKey, keyEpoch, ipnsName, signedRecord}]"
    Note over TEE: 1. parseIpnsRecord(signedRecordBytes)
    Note over TEE: 2. verifyIpnsRecordSignature — BEFORE decrypt
    Note over TEE: 3. decryptWithFallback(encryptedKey, keyEpoch) — throws ReEnrollRequiredError if stale
    Note over TEE: 4. binding: deriveEd25519PublicKey(key) == publicKeyFromIpnsName(name)
    Note over TEE: 5. renewIpnsRecord — same value+seq, later EOL
    Note over TEE: 6. reEncryptForEpoch if usedEpoch != internalCurrentEpoch
    Note over TEE: 7. key.fill(0)
    TEE-->>Relay: "RepublishResult {signedRecord, newSequenceNumber, upgradedEncryptedKey?}"
    Relay->>Relay: "assert newSequenceNumber === record.sequenceNumber (TEE-02)"
    Relay->>Relay: publishSignedRecord(ipnsName, signedRecord)
    Relay->>DB: scheduleRepository.save(schedule) — scheduling fields only
    Relay->>DB: "renewIpnsRecordEol WHERE sequence_number=:loaded AND tombstoned_at IS NULL"
    alt epoch upgrade
        Relay->>DB: "ipnsRecordRepository.update {encryptedIpnsPrivateKey, keyEpoch}"
    end
Loading

Reviews (6): Last reviewed commit: "fix(api): scope TEE republish pairing an..." | Re-trigger Greptile

Comment thread apps/api/src/republish/republish.service.ts
Comment thread apps/api/src/republish/republish.service.ts
@codecov

codecov Bot commented Jul 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.50649% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 70.83%. Comparing base (a036a84) to head (a73c4d6).

Files with missing lines Patch % Lines
apps/api/src/republish/republish.service.ts 90.56% 5 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #585      +/-   ##
==========================================
+ Coverage   65.57%   70.83%   +5.25%     
==========================================
  Files         151      180      +29     
  Lines       12318    22521   +10203     
  Branches     1380     1390      +10     
==========================================
+ Hits         8078    15952    +7874     
- Misses       4005     6335    +2330     
+ Partials      235      234       -1     
Flag Coverage Δ
api 83.38% <93.50%> (+0.03%) ⬆️
api-client 83.38% <93.50%> (+0.03%) ⬆️
core 83.38% <93.50%> (+0.03%) ⬆️
crypto 83.38% <93.50%> (+0.03%) ⬆️
desktop 16.44% <ø> (-15.00%) ⬇️
rust 70.75% <ø> (?)
sdk 83.38% <93.50%> (+0.03%) ⬆️
sdk-core 83.38% <93.50%> (+0.03%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
apps/api/src/ipns/ipns.service.ts 86.63% <ø> (ø)
...pps/api/src/republish/republish-schedule.entity.ts 66.66% <ø> (-1.76%) ⬇️
apps/api/src/tee/tee.service.ts 98.41% <ø> (ø)
packages/sdk-core/src/folder/registration.ts 82.80% <100.00%> (+2.95%) ⬆️
apps/api/src/republish/republish.service.ts 86.89% <90.56%> (-1.66%) ⬇️

... and 86 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The tee-republish round-trip needs the TEE worker (:3002), the cipherbox DB, redis
and BullMQ. CI's sdk-e2e job provisions none of these (it uses cipherbox_test and
runs no TEE worker), so the module-level beforeAll DB query failed with
'database cipherbox does not exist'. Gate the suite and its beforeAll to local runs
via describe.skipIf(!!process.env.CI); it remains the documented local publish gate
(verified 2/2 locally). Todo filed to optionally provision a CI TEE worker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: dbcd0ba66601

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

🧹 Nitpick comments (2)
apps/api/src/tee/tee.service.ts (1)

13-21: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Use the canonical encrypted-key field name in this contract.

RepublishEntry is where this payload shape gets cemented, and encryptedIpnsKey diverges from the repo standard encryptedIpnsPrivateKey. Keeping the shorter alias here will keep spreading the non-canonical name across the API/TEE boundary and tests. As per coding guidelines, "Use encryptedIpnsPrivateKey for encrypted IPNS private keys".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/tee/tee.service.ts` around lines 13 - 21, Rename the
RepublishEntry field in tee.service.ts from the non-canonical encryptedIpnsKey
to encryptedIpnsPrivateKey, and update any related references in the TEE/API
contract so this payload consistently uses the repo-standard name. Keep the rest
of the RepublishEntry shape unchanged, and make sure any callers, tests, or
serializers/deserializers that use this interface are aligned with the new field
name.

Source: Coding guidelines

apps/tee-worker/src/__tests__/republish.test.ts (1)

215-229: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert that renewIpnsRecord receives the original marshaled record.

Because the mock always returns fixed bytes, this test still passes if the route starts rebuilding or mutating the record before renewal. A call-argument assertion here would make the suite actually guard the “renew the exact existing record” contract. As per path instructions, "**/*.test.ts: Focus on test coverage, edge cases, and test quality. Ensure tests are meaningful and not just for coverage metrics."

♻️ Suggested assertion
   it('processes a single entry successfully and returns valid base64 signedRecord', async () => {
     const { encryptedIpnsKey, keyEpoch, ipnsName, signedRecord } = await makeEntry({});
+    const { renewIpnsRecord } = await import('../services/ipns-signer.js');

     const app = await createTestApp();
     const res = await postJson(app, '/republish', {
       entries: [{ encryptedIpnsKey, keyEpoch, ipnsName, signedRecord }],
     });

     expect(res.status).toBe(200);
     const results = res.body.results as Array<Record<string, unknown>>;
     expect(results[0].success).toBe(true);
+    expect(vi.mocked(renewIpnsRecord)).toHaveBeenCalledWith(
+      expect.any(Uint8Array),
+      Buffer.from(signedRecord, 'base64')
+    );
     // signedRecord is the base64 of the mock's [5, 6, 7, 8]
     const decoded = Buffer.from(results[0].signedRecord as string, 'base64');
     expect(decoded.length).toBeGreaterThan(0);
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/tee-worker/src/__tests__/republish.test.ts` around lines 215 - 229, The
single-entry republish test only checks the returned base64 output, so it can
miss regressions where the route mutates the record before renewal. Update the
test around the republish flow to assert that renewIpnsRecord is called with the
original marshaled signedRecord produced by makeEntry, using the existing
createTestApp and postJson setup to verify the exact argument passed into the
renewal path.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/republish/republish.service.ts`:
- Around line 197-211: The epoch-upgrade path in republish.service.ts is
overwriting ipns_records without the same concurrency guard used elsewhere, so
an older batch can clobber newer key material. Update the write in the
epoch-upgrade branch near the logger.log and ipnsRecordRepository.update call to
include the current row version from record (sequence_number) and only update
when tombstoned_at is null, matching the protection pattern used by
renewIpnsRecordEol(). If the guarded update affects no rows, treat it as a stale
write and skip the upgrade.
- Around line 268-284: Refreshing an existing enrollment in republish.service.ts
should reactivate the schedule row, not just update nextRepublishAt. In the
existing branch of the enrollment logic, also reset the status from stale to
active and clear failure state fields like consecutiveFailures and lastError
before saving the existing schedule so getDueEntries() can pick it up again. Use
the existing enrollment flow around the existing check and
scheduleRepository.save(existing) to apply the reset consistently.
- Around line 173-195: The republish flow in RepublishService currently only
checks that result.newSequenceNumber exists before calling publishSignedRecord
and renewIpnsRecordEol, so a forward/regressed TEE response can be published
even when it does not match the loaded record sequence. Add an explicit “same
sequence” guard in the success path of the republish handler to compare the
loaded record.sequenceNumber against the TEE-returned sequence before
publishing, and abort the publish/update path if they differ. Use the existing
symbols publishSignedRecord, renewIpnsRecordEol, and result.newSequenceNumber in
RepublishService to locate and enforce this invariant.

In `@apps/tee-worker/src/__tests__/ipns-signer.test.ts`:
- Around line 61-69: The renewal test only checks that the serialized IPNS
record bytes differ, which doesn’t verify the lease-renew behavior. Update the
`renewIpnsRecord` test in `ipns-signer.test.ts` to decode both the `original`
and `renewed` records and assert that the expiry/EOL (validity) value is
actually moved forward. Use the existing `makeOriginalRecord` and
`renewIpnsRecord` helpers to locate the assertion, and keep the byte-equality
check only as a secondary guard if needed.

In `@apps/tee-worker/src/routes/republish.ts`:
- Around line 57-61: Rename the RepublishEntry contract field from
encryptedIpnsKey to encryptedIpnsPrivateKey to match the canonical IPNS private
key naming. Update the interface in republish.ts and any related relay↔TEE
payload handling or serialization/deserialization that references RepublishEntry
so the new field name is used consistently across the boundary.

In `@packages/sdk-core/src/folder/registration.test.ts`:
- Around line 156-168: Add a fail-closed test in registration.test.ts for
invalid teeKeys.currentEpoch alongside the existing createSubfolder empty
currentPublicKey case, using the createSubfolder and
mockFns.createAndPublishIpnsRecord symbols to verify it rejects when
currentEpoch is 0, NaN, or non-integer. Keep the assertion that publishing is
not reached, and mirror the current test style so the new case clearly covers
the currentEpoch validation branch without changing production code.

In `@packages/sdk-core/src/folder/registration.ts`:
- Around line 92-114: The TEE enrollment checks in createSubfolder are happening
too late, after the node has already been sealed and uploaded, which allows side
effects before a fail-closed error. Move the teeKeys validation and wrapKey flow
earlier in registration.ts so createSubfolder verifies currentPublicKey and
currentEpoch before any IPFS upload work begins, then only proceed with the
upload path once the TEE fields are confirmed valid.

In `@tests/sdk-e2e/src/suites/tee-republish.test.ts`:
- Around line 137-141: The `enqueueRepublishBatch` helper leaks the Redis
connection if `queue.add` fails because `queue.close()` is only reached on
success. Update `enqueueRepublishBatch` to ensure the `Queue` instance is always
closed by wrapping the `queue.add` call in a `try/finally` (or equivalent
cleanup path) so the `republish` queue is closed even when `queue.add` throws.

---

Nitpick comments:
In `@apps/api/src/tee/tee.service.ts`:
- Around line 13-21: Rename the RepublishEntry field in tee.service.ts from the
non-canonical encryptedIpnsKey to encryptedIpnsPrivateKey, and update any
related references in the TEE/API contract so this payload consistently uses the
repo-standard name. Keep the rest of the RepublishEntry shape unchanged, and
make sure any callers, tests, or serializers/deserializers that use this
interface are aligned with the new field name.

In `@apps/tee-worker/src/__tests__/republish.test.ts`:
- Around line 215-229: The single-entry republish test only checks the returned
base64 output, so it can miss regressions where the route mutates the record
before renewal. Update the test around the republish flow to assert that
renewIpnsRecord is called with the original marshaled signedRecord produced by
makeEntry, using the existing createTestApp and postJson setup to verify the
exact argument passed into the renewal path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: a5e9df46-3381-44a8-b84e-d1181a7ed58c

📥 Commits

Reviewing files that changed from the base of the PR and between a036a84 and ec78815.

⛔ Files ignored due to path filters (2)
  • .claude/commands/ship-phase.md is excluded by !.claude/commands/**
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml, !**/pnpm-lock.yaml
📒 Files selected for processing (53)
  • .planning/REQUIREMENTS.md
  • .planning/ROADMAP.md
  • .planning/STATE.md
  • .planning/phases/66-api-schema-cutover-publish-gate-and-tombstone/66-LEARNINGS.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-01-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-01-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-02-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-02-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-03-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-03-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-04-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-04-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-05-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-05-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-06-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-06-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-07-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-07-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-08-PLAN.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-08-SUMMARY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-CONTEXT.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-DISCUSSION-LOG.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-PATTERNS.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-RESEARCH.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-SECURITY.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-VALIDATION.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-VERIFICATION.md
  • .planning/todos/completed/2026-06-29-createsubfolder-tee-republish-wiring.md
  • .planning/todos/pending/2026-07-01-rename-encrypted-ipns-key-canonical-field.md
  • .planning/todos/pending/2026-07-01-renew-ipns-record-eol-invariant-and-tests.md
  • .planning/todos/pending/2026-07-01-tee-republish-writepath-error-handling-hardening.md
  • apps/api/.env.example
  • apps/api/src/ipns/ipns.service.ts
  • apps/api/src/migrations/1751000000000-ScheduleCollapse.ts
  • apps/api/src/republish/republish-schedule.entity.ts
  • apps/api/src/republish/republish.service.spec.ts
  • apps/api/src/republish/republish.service.ts
  • apps/api/src/tee/tee.service.spec.ts
  • apps/api/src/tee/tee.service.ts
  • apps/tee-worker/src/__tests__/ipns-signer.test.ts
  • apps/tee-worker/src/__tests__/key-manager.test.ts
  • apps/tee-worker/src/__tests__/republish.test.ts
  • apps/tee-worker/src/__tests__/tee-keys.test.ts
  • apps/tee-worker/src/routes/republish.ts
  • apps/tee-worker/src/services/ipns-signer.ts
  • apps/tee-worker/src/services/key-manager.ts
  • apps/tee-worker/src/services/tee-keys.ts
  • docker/docker-compose.yml
  • packages/sdk-core/src/folder/registration.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • release-please-config.json
  • tests/sdk-e2e/package.json
  • tests/sdk-e2e/src/suites/tee-republish.test.ts

Comment thread apps/api/src/republish/republish.service.ts
Comment thread apps/api/src/republish/republish.service.ts
Comment thread apps/api/src/republish/republish.service.ts
Comment thread apps/tee-worker/src/__tests__/ipns-signer.test.ts
Comment thread apps/tee-worker/src/routes/republish.ts
Comment thread packages/sdk-core/src/folder/registration.test.ts
Comment thread packages/sdk-core/src/folder/registration.ts Outdated
Comment thread tests/sdk-e2e/src/suites/tee-republish.test.ts
FSM1 and others added 4 commits July 1, 2026 05:27
Addresses two greptile P1 findings on republish.service.ts:

- Epoch-upgrade ipnsRecordRepository.update was scoped only by ipnsName, so it
  could re-encrypt a tombstoned row (violating tombstone immutability) or touch
  another user's row sharing the name. Scope it to
  { ipnsName, userId, tombstonedAt: IsNull(), keyEpoch: <loaded> } — tombstone
  guard + owner scope + epoch CAS (also closes the concurrent-rotation clobber).

- getDueEntries filtered only encryptedIpnsPrivateKey non-null, so a record with
  a null signedRecord/keyEpoch made the teeEntries map deref null with '!' OUTSIDE
  the inner try, aborting the whole batch. Filter signedRecord + keyEpoch non-null
  so an incomplete row drops out instead of crashing the batch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 8d0200ea5de5
…ivation, fail-fast

- republish.service: reject before publishing when the TEE returns a sequence !=
  the loaded one (defense-in-depth for TEE-02; prevents delegated routing drifting
  ahead of the CAS-guarded ipns_records row).
- enrollFolder: reactivate an existing schedule row on re-enroll (reset status,
  consecutiveFailures, lastError) so a previously-failed folder rejoins the batch.
- createSubfolder: move TEE enrollment validation + wrap BEFORE the seal/IPFS
  upload so a malformed teeKeys fails closed without leaving an orphaned blob.
- registration.test: add invalid-currentEpoch fail-closed case (0/NaN/1.5/-1).
- tee-republish e2e: try/finally around queue.close to avoid a Redis leak.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: de6a6732976e
31 learnings mined from the phase artifacts: 10 decisions, 8 lessons,
8 patterns, 5 surprises (incl. the grep-AC-forced query-builder TypeORM
metadata bug that only the sdk-e2e round-trip caught).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 01adc5658e8b
Provision the full TEE stack in the sdk-e2e job so the tee-republish
lease-renewer round-trip is exercised in CI, not just locally.

- Build the tee-worker and start it headless in simulator mode on :3002
  before the API, so TeeModule.onModuleInit can populate tee_key_state
  (the bootstrap is a one-shot that swallows errors, so ordering matters).
- Wire TEE_WORKER_URL + a shared TEE_WORKER_SECRET onto the API.
- Export DB_DATABASE=cipherbox_test and REDIS_PORT=6379 onto the test step
  so the suite's DB/redis config hits the CI stack instead of local defaults.
- Drop the SKIP_TEE_LIVE=!!CI gate in tee-republish.test.ts; the
  schedule-collapse migration is applied by the API's migrationsRun on boot.

Resolves the deferred CI todo from the Phase 67 ship.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: 30b7a3c115a3

@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 (1)
apps/api/src/republish/republish.service.ts (1)

74-88: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Scope republish record pairing and renewal by userId.

getDueEntries() maps records by ipnsName only, and renewIpnsRecordEol() updates by ipns_name only. Since Line 226 says ipnsName uniqueness is app-level rather than database-enforced, duplicate names can pair or renew another user’s row. Carry userId through the lookup and CAS update. As per path instructions, apps/api/** should focus on security vulnerabilities and API contract consistency.

Suggested fix
-    const ipnsNames = schedules.map((s) => s.ipnsName);
+    const pairKey = (userId: string, ipnsName: string) => `${userId}:${ipnsName}`;
+    const ipnsNames = [...new Set(schedules.map((s) => s.ipnsName))];
+    const userIds = [...new Set(schedules.map((s) => s.userId))];
     const records = await this.ipnsRecordRepository.find({
       where: {
+        userId: In(userIds),
         ipnsName: In(ipnsNames),
         tombstonedAt: IsNull(),
         encryptedIpnsPrivateKey: Not(IsNull()),
@@
-    const recordMap = new Map(records.map((r) => [r.ipnsName, r]));
+    const recordMap = new Map(records.map((r) => [pairKey(r.userId, r.ipnsName), r]));
@@
-        const record = recordMap.get(schedule.ipnsName);
+        const record = recordMap.get(pairKey(schedule.userId, schedule.ipnsName));
             await this.renewIpnsRecordEol(
               schedule.ipnsName,
+              schedule.userId,
               record.sequenceNumber, // loaded seq from the batch
               Buffer.from(result.signedRecord, 'base64')
             );
@@
   private async renewIpnsRecordEol(
     ipnsName: string,
+    userId: string,
     loadedSequenceNumber: string,
     renewedSignedRecord: Buffer
   ): Promise<void> {
@@
-        .where('ipns_name = :ipnsName AND sequence_number = :expected AND tombstoned_at IS NULL', {
+        .where('ipns_name = :ipnsName AND user_id = :userId AND sequence_number = :expected AND tombstoned_at IS NULL', {
           ipnsName,
+          userId,
           expected: loadedSequenceNumber,
         })

Also applies to: 209-213, 449-462

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/republish/republish.service.ts` around lines 74 - 88,
getDueEntries() and renewIpnsRecordEol() currently key only on ipnsName, which
can pair or update another user’s record when names collide. Carry userId
through the record lookup and renewal flow in RepublishService, and include it
in the Map key and the optimistic update/CAS criteria. Update the repository
query and any downstream accessors so the selected record is always matched by
both ipnsName and userId, preserving API/security isolation.

Source: Path instructions

🧹 Nitpick comments (1)
apps/api/src/republish/republish.service.ts (1)

134-138: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Use the canonical encryptedIpnsPrivateKey field name.

Line 135 still sends an encrypted IPNS private key as encryptedIpnsKey; the repo convention requires encryptedIpnsPrivateKey. Rename the TEE republish DTO/worker consumer together to keep the contract consistent. As per coding guidelines, “Use encryptedIpnsPrivateKey for encrypted IPNS private keys.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/republish/republish.service.ts` around lines 134 - 138, The
republish DTO is using the non-canonical encrypted IPNS private key field name,
so update the mapping in republish.service.ts where teeEntries is built to use
encryptedIpnsPrivateKey instead of encryptedIpnsKey. Make sure the TEE republish
contract is kept consistent by renaming the corresponding DTO/worker consumer
field to match, and verify any references to RepublishEntry align with the
canonical encryptedIpnsPrivateKey name.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@apps/api/src/republish/republish.service.ts`:
- Around line 74-88: getDueEntries() and renewIpnsRecordEol() currently key only
on ipnsName, which can pair or update another user’s record when names collide.
Carry userId through the record lookup and renewal flow in RepublishService, and
include it in the Map key and the optimistic update/CAS criteria. Update the
repository query and any downstream accessors so the selected record is always
matched by both ipnsName and userId, preserving API/security isolation.

---

Nitpick comments:
In `@apps/api/src/republish/republish.service.ts`:
- Around line 134-138: The republish DTO is using the non-canonical encrypted
IPNS private key field name, so update the mapping in republish.service.ts where
teeEntries is built to use encryptedIpnsPrivateKey instead of encryptedIpnsKey.
Make sure the TEE republish contract is kept consistent by renaming the
corresponding DTO/worker consumer field to match, and verify any references to
RepublishEntry align with the canonical encryptedIpnsPrivateKey name.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 08bd2462-c565-4327-821a-0d59f1988c5f

📥 Commits

Reviewing files that changed from the base of the PR and between ec78815 and 113bb5d.

📒 Files selected for processing (9)
  • .github/workflows/ci.yml
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-LEARNINGS.md
  • .planning/todos/completed/2026-07-01-ci-tee-worker-for-tee-republish-e2e.md
  • .planning/todos/pending/2026-07-01-tee-republish-writepath-error-handling-hardening.md
  • apps/api/src/republish/republish.service.spec.ts
  • apps/api/src/republish/republish.service.ts
  • packages/sdk-core/src/folder/registration.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • tests/sdk-e2e/src/suites/tee-republish.test.ts
✅ Files skipped from review due to trivial changes (2)
  • .planning/todos/pending/2026-07-01-tee-republish-writepath-error-handling-hardening.md
  • .planning/phases/67-tee-lease-renewer-contract-rewrite/67-LEARNINGS.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • packages/sdk-core/src/folder/registration.test.ts
  • packages/sdk-core/src/folder/registration.ts
  • tests/sdk-e2e/src/suites/tee-republish.test.ts
  • apps/api/src/republish/republish.service.spec.ts

Address CodeRabbit review on PR #585. getDueEntries paired schedules to
ipns_records by ipnsName only, and renewIpnsRecordEol CASed on
ipns_name + sequence_number only. Since ipnsName uniqueness is app-level
(not a DB constraint), a name shared across owners could pair or renew
another user's row. Carry userId through both, matching the epoch-upgrade
write that was already userId-scoped during the ship.

- getDueEntries: key the record map by userId+ipnsName and filter the
  ipns_records query by userId.
- renewIpnsRecordEol: add user_id to the equality CAS.
- Tests: cross-user records no longer pair; the renewal CAS asserts userId.

The CodeRabbit nitpick to rename the encryptedIpnsKey wire field is deferred
to the write-path hardening todo (cross-package rename, own scoped change).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Entire-Checkpoint: de91c92602bf
@FSM1

FSM1 commented Jul 1, 2026

Copy link
Copy Markdown
Owner Author

CodeRabbit review addressed (latest review, commit 113bb5db8)

Fixed — republish.service.ts userId scoping (Major) in a73c4d660:
getDueEntries now pairs schedules↔records by (userId, ipnsName) instead of ipnsName alone, and filters the ipns_records query by userId; renewIpnsRecordEol adds user_id to the equality CAS. This closes the same cross-owner gap the epoch-upgrade write was already scoped against — ipnsName uniqueness is app-level, not DB-enforced. Added unit tests: a cross-user record no longer pairs, and the renewal CAS asserts userId (40/40 republish specs green).

Deferred — encryptedIpnsKeyencryptedIpnsPrivateKey rename (nitpick): tracked in .planning/todos/pending/2026-07-01-tee-republish-writepath-error-handling-hardening.md. It's a cross-package wire-contract rename (API sender + tee-worker consumer at republish.ts:58,121 + ~15 worker test sites) better done as its own atomic, lockstep change than folded into review resolution.

The 10 inline review threads were already resolved; the two items above were CodeRabbit "outside diff range" body comments with no thread to resolve.

@FSM1 FSM1 merged commit ab209a9 into main Jul 1, 2026
30 checks passed
@FSM1 FSM1 deleted the feat/tee-lease-renewer-contract-rewrite branch July 1, 2026 12:33
FSM1 added a commit that referenced this pull request Jul 1, 2026
chore: remove duplicate encryptedIpnsKey rename todo entry

The encryptedIpnsKey -> encryptedIpnsPrivateKey wire-contract rename is
already tracked by the dedicated todo
2026-07-01-rename-encrypted-ipns-key-canonical-field.md (which also covers
upgradedEncryptedKey). Finding #4 was added to the write-path hardening todo
during PR #585 review resolution before that dedicated todo was noticed,
duplicating the item. Remove the redundant entry.


Entire-Checkpoint: ea2585e9cec3

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:api:feat Minor version bump (new feature) for api release:cipherbox-fuse:fix Patch version bump (bug fix) for cipherbox-fuse release:cipherbox-sdk:fix Patch version bump (bug fix) for cipherbox-sdk release:desktop:fix Patch version bump (bug fix) for desktop release:sdk:fix Patch version bump (bug fix) for sdk release:sdk-core:feat Minor version bump (new feature) for sdk-core release:tee-worker:feat Minor version bump (new feature) for tee-worker release:web:fix Patch version bump (bug fix) for web

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant