Skip to content

feat(sync): envelope-encrypt the namespace-map event log (P4-SEC-02 + P4-SEC-07 foundation)#25

Merged
Reederey87 merged 2 commits into
mainfrom
fix/p4-sec-02-envelope-encryption
Jul 1, 2026
Merged

feat(sync): envelope-encrypt the namespace-map event log (P4-SEC-02 + P4-SEC-07 foundation)#25
Reederey87 merged 2 commits into
mainfrom
fix/p4-sec-02-envelope-encryption

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

Encrypts the namespace-map event log at the hub boundary (P4-SEC-02) using envelope encryption with a per-epoch Workspace Content Key (P4-SEC-07 foundation).

What changed

  • Envelope crypto (internal/sync/eventcrypt.go): XChaCha20-Poly1305 (chacha20poly1305.NewX, 24-byte random nonce) under a 32-byte per-epoch WCK; AAD = event.ID || uint64(epoch). The carrier (ID/DeviceID/Seq/HLC/DeviceSig) stays plaintext so hub ordering, dedup, and Ed25519 verification are unchanged. enc.v1 sentinel + typed errors.
  • Migration 00013 (workspace_keys + workspace_key_grants): epoch metadata + grant audit in SQLite (the wrapped WCK rides the event payload, never SQLite). Schema 12→13.
  • Keyring (internal/workspacekeys/keyring.go): EnsureBootstrap / GrantAllEpochs / Rotate / IngestGrant / Prime. WCK stored in OS keychain / 0600 file fallback (devicekeys.HybridStore); age-wrapped (X25519) to approved recipients.
  • Decorator (internal/sync/encryptedhub.go): EncryptedHub wraps FileHub/R2Hub. Push encrypts non-grants; Pull ingests grants in HLC order then decrypts; anti-downgrade on plaintext; blob ops passthrough.
  • Wiring: hubFromOptions wraps both backends in EncryptedHub; init bootstraps epoch 1; devices approve/enroll --approve grants all epochs; devices revoke/lost rotates; new devices recipient helper.
  • Grant event (device.key.granted): plaintext on the hub (payload is age-wrapped); recorded in applyEventTx but not in mustVerifyEvent (bootstrap chicken-and-egg).

What the hub sees

Only enc.v1 ciphertext carriers + signed carrier fields + device.key.granted events (whose payloads are age-wrapped WCKs). No paths, remotes, types, or payload content in plaintext.

Validation

  • gofmt -w cmd internal clean
  • golangci-lint run — 0 issues
  • go run ./cmd/spec-drift --base origin/main --head HEAD — passed
  • go test -race ./... — all green
  • E2E (sync_encrypted.txtar): hub stores only enc.v1 (grep enc.v1 + ! grep plaintext path/remote); two-device decrypt after enroll+approve; revoke rotates to epoch 2.

Follow-ups

  • P4-SEC-07 full: workspace-ID pairing across devices (spec/07 §211 anticipates provisioning the same logical ws_ id).
  • P4-SEC-08 (hub-side grant verification / anti-replay) remains open.
  • Hub-based WCK recovery for a solo device that loses its keychain (self-grant removed to avoid epoch collision; re-grant from another device is the recovery path in a multi-device workspace).

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>

Summary by CodeRabbit

  • New Features

    • Added encrypted hub syncing so event data is stored as ciphertext and decrypted on authorized devices.
    • Added support for device key grants, approval, and rotation during enroll/revoke flows.
    • Added a new device command to display the local device’s recipient key.
  • Bug Fixes

    • Improved multi-device sync so newly approved devices can access earlier workspace history.
    • Revoking a device now rotates shared workspace keys to keep remaining devices working.
  • Documentation

    • Updated architecture, security, and audit docs to reflect encrypted sync behavior.

Reederey and others added 2 commits June 30, 2026 20:20
… P4-SEC-07 foundation)

Encrypt event-log payloads at the hub boundary with XChaCha20-Poly1305 under
a per-epoch Workspace Content Key (WCK) wrapped with age to approved device
recipients. The hub stores only enc.v1 ciphertext carriers; the signed carrier
(ID/DeviceID/Seq/HLC/DeviceSig) stays plaintext so ordering, dedup, and
Ed25519 verification are unchanged. init bootstraps epoch 1; devices approve
grants all held epochs; devices revoke/lost rotates for go-forward forward
secrecy; Pull ingests grants in HLC order then decrypts.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
Address PR #25 review findings. Pull previously returned an error on the
first enc.v1 event it could not decrypt or whose epoch it did not hold;
the sync caller does `return err`, so a single un-decryptable object
(wrong-key cross-device epoch collision, corruption/forgery, an unknown
envelope version, or an unexpected plaintext/downgrade event) aborted the
whole batch and never advanced the pull cursor — permanently wedging that
device's sync and never reaching ApplyEvents' quarantine + safe-cursor
machinery. That is the exact self-DoS the untrusted-hub model must resist.

Pull now degrades instead of aborting:
- missing epoch key (grant not yet propagated) truncates the batch — the
  decryptable prefix is returned and applies, the cursor advances up to but
  not past the event, and the next sync retries once the grant arrives;
- a held-epoch decrypt failure, a malformed/unknown envelope, and a
  non-grant plaintext event (anti-downgrade) are each skipped with a loud
  warning and Pull continues.

Bad events are still never applied (no unauthenticated data enters the log),
but one bad object can no longer brick a device. This also de-fangs the
acknowledged P4-SEC-07 epoch-collision case (log + skip, not wedge) and
removes the anti-downgrade brick for a stale/pre-envelope hub.

Rewrote the anti-downgrade/missing-epoch/unknown-version tests to assert the
skip/truncate contract and added TestEncryptedHubPoisonEventDoesNotWedge.
Updated spec/07, spec/16, spec/18.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 1, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements zero-knowledge envelope encryption for the workspace event log. It adds enc.v1 event encryption primitives, a workspace keyring for epoch-based Workspace Content Keys (WCK), an EncryptedHub decorator wrapping hub Push/Pull, storage migrations, CLI grant/rotate/bootstrap wiring, e2e test scripts, and supporting documentation.

Changes

Workspace content key envelope encryption

Layer / File(s) Summary
enc.v1 event encryption primitives
internal/sync/eventcrypt.go, internal/sync/eventcrypt_test.go
Adds EncryptEvent/DecryptEvent/ParseEncryptedEnvelope, envelope structs, AEAD helpers, error sentinels, and unit tests.
device.key.granted event type
internal/sync/events.go
Adds EventDeviceKeyGranted constant, DeviceKeyGrant payload struct, event constructor, and apply-time grant audit recording.
WCK schema and store persistence
internal/state/migrations/00013_workspace_keys.sql, internal/state/store.go, internal/state/store_test.go, internal/cli/root_test.go
Adds workspace_keys/workspace_key_grants tables, epoch/grant CRUD methods, and updates schema-version test expectations.
Device key store WCK custody
internal/devicekeys/devicekeys.go, internal/devicekeys/devicekeys_test.go
Adds keychain-first, file-fallback WCK storage with workspace-ID validation and tests.
Workspace Keyring lifecycle
internal/workspacekeys/keyring.go, internal/workspacekeys/keyring_test.go
Adds bootstrap, grant, rotate, and ingest logic with age-based key wrapping and tests.
EncryptedHub decorator
internal/sync/encryptedhub.go, internal/sync/encryptedhub_test.go, internal/sync/hub.go, internal/sync/doc.go
Adds Push/Pull encryption/decryption, grant ingestion, truncation/skip semantics, blob passthrough, and tests.
CLI wiring
internal/cli/hub.go, internal/cli/init.go, internal/cli/devices.go
Wires EncryptedHub into hub resolution, bootstraps WCK on init, and adds grant/rotate/recipient device subcommands.
E2E scripts and dependency
cmd/devstrap/testdata/script/sync_encrypted.txtar, cmd/devstrap/testdata/script/sync_materialize.txtar, go.mod
Adds/updates multi-device enroll/approve/revoke sync scripts and promotes golang.org/x/crypto to direct.
Documentation updates
docs/audits/README.md, spec/*.md
Updates architecture, security, data model, roadmap, audit, and work-log docs to reflect the shipped feature.

Sequence Diagram(s)

sequenceDiagram
  participant DeviceA
  participant Keyring
  participant EncryptedHub
  participant Hub
  participant DeviceB

  DeviceA->>Keyring: EnsureBootstrap (mint epoch 1 WCK)
  DeviceA->>EncryptedHub: Push(events)
  EncryptedHub->>Keyring: WCK(epoch)
  EncryptedHub->>Hub: store enc.v1 carriers
  DeviceA->>Keyring: GrantAllEpochs(DeviceB recipient)
  DeviceA->>EncryptedHub: Push(grant events)
  EncryptedHub->>Hub: store grant events
  DeviceB->>EncryptedHub: Pull(afterHLC)
  EncryptedHub->>Hub: fetch events
  EncryptedHub->>Keyring: IngestGrant(device.key.granted)
  EncryptedHub->>DeviceB: decrypted plaintext events
  DeviceA->>Keyring: Rotate (on revoke DeviceB)
  Keyring-->>DeviceA: epoch+1 grants to remaining devices
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

A carrot key, an epoch spun,
Wrapped in age beneath the sun,
The hub sees naught but locked-up crumbs,
While Devices A and B share sums.
🥕 Rotate, ingest, decrypt, repeat —
This bunny's burrow keeps it neat!

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description is detailed, but it omits the required Tests section and Safety Checklist items from the template. Add the Tests checklist with gofmt/go test entries and the Safety Checklist covering plaintext secrets, dirty worktrees, fetched refs, mac isolation, and spec/README updates.
Docstring Coverage ⚠️ Warning Docstring coverage is 40.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: envelope-encrypting the namespace-map event log.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/p4-sec-02-envelope-encryption

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

@Reederey87 Reederey87 merged commit 8c739b8 into main Jul 1, 2026
10 of 11 checks passed
@Reederey87 Reederey87 deleted the fix/p4-sec-02-envelope-encryption branch July 1, 2026 01:26

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

Caution

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

⚠️ Outside diff range comments (2)
internal/cli/hub.go (1)

114-116: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Make the remote hub ID include the backend identity.

This value is described as the per-hub cursor key, but both r2://bucket-a and s3://bucket-b for the same workspace collapse to r2:<workspace_id>. Repointing a workspace at a different bucket or endpoint would therefore reuse the old cursor against a different event log and can skip unseen history. Include at least scheme + bucket (and endpoint override if it changes the namespace) in the hub ID.

🤖 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 `@internal/cli/hub.go` around lines 114 - 116, The remote hub ID returned from
the hub creation path in hub.R2Hub should not be keyed only by WorkspaceID,
because that collapses different backends into the same cursor namespace. Update
the hub ID construction in the logic that returns hub.R2Hub to include backend
identity information such as the scheme and bucket, and any endpoint override
when it changes the namespace, so rerouting a workspace to another remote does
not reuse an old sync cursor.
internal/cli/devices.go (1)

133-145: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Validate the device keys before marking it approved.

enroll --approve refuses approval without a signing key, but devices approve sets trust_state='approved' first and only then tries the best-effort WCK grant. That lets an operator create an approved device that cannot be signature-verified and may not even have an age recipient to receive grants. Mirror the approval preconditions here and fail before SetDeviceTrustState.

🤖 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 `@internal/cli/devices.go` around lines 133 - 145, The devices approval flow in
`SetDeviceTrustState` is missing the same preconditions enforced by `enroll
--approve`, so it can mark a device approved before confirming it has the
required signing key and age recipient. Update the approval path in the device
command handler to validate the device keys first, and return an error before
calling `store.SetDeviceTrustState` when those prerequisites are not met. Keep
the WCK grant in `grantWorkspaceKeyToApprovedDevice` as a follow-up only after
validation succeeds and approval is actually applied.
🧹 Nitpick comments (1)
cmd/devstrap/testdata/script/sync_encrypted.txtar (1)

46-55: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Forward-secrecy claim isn't actually verified.

The script rotates the WCK on revoke and re-checks that the hub still stores ciphertext, but never proves the actual security property: that dev_b can no longer decrypt content written under the new epoch. Consider adding a post-rotation step where device A adds new content and syncs, then asserting device B (using its now-superseded epoch key) cannot decrypt/materialize it.

🤖 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 `@cmd/devstrap/testdata/script/sync_encrypted.txtar` around lines 46 - 55, The
sync_encrypted.txtar test only checks that revoke rotates the WCK and keeps
ciphertext in the hub, but it does not verify that the revoked device loses
access to post-rotation data. Extend the scenario after devices revoke dev_b and
the subsequent sync by having dev_a create new content under the rotated epoch,
sync it, then attempt to decrypt/materialize that content with dev_b and assert
failure. Use the existing devstrap sync/devices revoke flow and the dev_a/dev_b
handles in this script to locate the right place to add the post-rotation access
check.
🤖 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 `@internal/cli/devices.go`:
- Around line 149-156: The revoke/lost flow in devices.go is treating
rotateWorkspaceKeyOnRevoke as best-effort, which can leave future payloads
encrypted under the old WCK epoch. Update the revoke path in the device
trust-state handling to check and propagate the error from
rotateWorkspaceKeyOnRevoke, or persist a rotation-required state before
returning success. Make the change in the branch that handles trustState ==
"revoked" || trustState == "lost" so the command does not report a successful
revoke if key rotation fails.

In `@internal/cli/hub.go`:
- Around line 40-45: The hubFromOptions flow now always returns an EncryptedHub,
but it does not ensure a bootstrap path for upgraded single-device workspaces
with CurrentKeyEpoch()==0, so first normal Push can still fail. Update
hubFromOptions to detect the unbootstrapped encrypted case and call the same
bootstrap/repair logic used by init and the approval helper (EnsureBootstrap or
an equivalent explicit upgrade path) before returning dssync.EncryptedHub, so
the wrapped hub is ready for first sync.

In `@internal/cli/init.go`:
- Around line 95-97: The bootstrap path is using a key directory recomputed from
the raw options instead of the normalized one. Update the init flow in `init` so
`buildKeyring` receives and uses the cleaned `paths.KeyDir()` produced after
`paths.Home` normalization, matching `ensureLocalDeviceIdentity`. This keeps
`EnsureBootstrap` and the local identity/epoch-1 WCK in the same directory even
when the home path is relative or rewritten.

In `@internal/devicekeys/devicekeys.go`:
- Around line 260-267: Add input validation in FileStore.WriteWCK and the
matching StoreWCK path so secret material is rejected before any
filesystem/keychain write happens. In the WriteWCK flow, after
validateWorkspaceID, also reject epoch <= 0 and require len(wck) == 32 before
os.MkdirAll/os.WriteFile; mirror the same checks in the StoreWCK-related code
path to keep behavior consistent. Use the existing WriteWCK and StoreWCK symbols
to locate both implementations and ensure invalid grants never create orphan
entries or persist unusable keys.
- Around line 260-268: The FileStore.WriteWCK path only sets 0600 on initial
creation, so existing fallback WCK files may retain broader permissions after
rotation. Update WriteWCK (and the wckPath write flow) to explicitly enforce
0600 after the write, either by calling chmod on the target file or by writing
to a temp file and atomically replacing it. Keep the fix localized to
FileStore.WriteWCK so rotated WCK files always end up with the expected
restrictive mode.

In `@internal/state/migrations/00013_workspace_keys.sql`:
- Around line 10-28: Add schema-level validation to prevent invalid WCK epochs
from being stored in the workspace key tables. Update the CREATE TABLE
definitions for workspace_keys and workspace_key_grants in the
00013_workspace_keys migration to enforce a positive epoch value with a CHECK
constraint on the epoch column. Keep the rest of the schema unchanged, and make
sure both table definitions use the same constraint so malformed key/grant
metadata cannot persist.

In `@internal/state/store.go`:
- Around line 1158-1164: Reject non-positive epochs in RecordKeyGrantTx and the
other key-grant writer methods so malformed payloads never reach the database.
Add an epoch > 0 validation at the start of RecordKeyGrantTx, and apply the same
guard to the related writers referenced in the review so they fail fast before
any INSERT OR IGNORE executes. Use the existing Tx method names and keep the
error returned directly from these entry points.

In `@internal/sync/encryptedhub.go`:
- Around line 59-75: Authenticate device.key.granted before it can advance the
active epoch: in EncryptedHub.Pull, verify the sender signature/trust before
calling Keyring.IngestGrant, and reject any grant whose key material differs
from an epoch already held locally. Update the Keyring/IngestGrant path so it
cannot overwrite an existing epoch with different WCK data, and keep the Push
flow (CurrentEpoch/Prime/WCK) relying only on trusted, immutable epoch state.

In `@internal/sync/eventcrypt.go`:
- Around line 191-193: ParseEncryptedEnvelope should reject enc.v1 envelopes
whose epoch is zero or negative, instead of treating them as valid and letting
EncryptedHub.Pull hit the missing-key path. Add a non-positive epoch guard in
ParseEncryptedEnvelope alongside the existing envelopeVersion check, returning
an ErrUnknownEnvelopeVersion-style error with the event ID and epoch details.
Mirror the same validation in EncryptEvent so malformed or impossible hub data
is never emitted in the first place.

In `@internal/sync/events.go`:
- Around line 492-500: The EventDeviceKeyGranted handling currently bypasses
authenticity checks, so forged grants can be ingested by EncryptedHub.Pull and
stored by Keyring.IngestGrant before any verification. Add an authentication
gate for device.key.granted in the event validation path used by
internal/sync/events.go, and make sure EventDeviceKeyGranted is only accepted
after it has been verified or otherwise proven trustworthy. Keep the bootstrap
exception intact, but ensure the grant cannot be applied or recorded unless it
passes the new authenticity check.

In `@internal/workspacekeys/keyring.go`:
- Around line 105-128: Ensure the key epoch transition is serialized and atomic
across Keyring.EnsureBootstrap and Keyring.Rotate, since both mint/store WCKs
without a lifecycle lock and RecordKeyEpoch can ignore concurrent inserts. Guard
the full bootstrap/rotation flow with a single operation mutex or transaction,
and only advance/publish the new epoch after KeyStore.StoreWCK, all grant/audit
writes, and k.Store.RecordKeyEpoch succeed. Also make sure Push only observes
the finalized epoch after the transition is durably committed.

In `@spec/15_SECURITY_THREAT_MODEL.md`:
- Line 132: The threat model text incorrectly says the file-backed hub exists
only for tests, but the shipped CLI still supports the local backend through
`--hub-file` and `hub: file:<path>`. Update the wording in this section to
describe it as a local/non-production backend instead of test-only, keeping the
rest of the `Hub-backend trust model` description aligned with the actual
`internal/cli/hub.go` surface and the production `Cloudflare R2` backend
distinction.

In `@spec/18_WORK_LOG.md`:
- Around line 51-61: Remove the stale `golangci-lint`/`gofmt` follow-up from the
Follow-ups list in `spec/18_WORK_LOG.md` because the same entry already records
those checks as completed validation. Update the work log entry so it stays
internally consistent, keeping the remaining follow-ups (like P4-SEC-07 and
P4-SEC-08) intact and ensuring the `Follow-ups` section reflects only unresolved
items.

---

Outside diff comments:
In `@internal/cli/devices.go`:
- Around line 133-145: The devices approval flow in `SetDeviceTrustState` is
missing the same preconditions enforced by `enroll --approve`, so it can mark a
device approved before confirming it has the required signing key and age
recipient. Update the approval path in the device command handler to validate
the device keys first, and return an error before calling
`store.SetDeviceTrustState` when those prerequisites are not met. Keep the WCK
grant in `grantWorkspaceKeyToApprovedDevice` as a follow-up only after
validation succeeds and approval is actually applied.

In `@internal/cli/hub.go`:
- Around line 114-116: The remote hub ID returned from the hub creation path in
hub.R2Hub should not be keyed only by WorkspaceID, because that collapses
different backends into the same cursor namespace. Update the hub ID
construction in the logic that returns hub.R2Hub to include backend identity
information such as the scheme and bucket, and any endpoint override when it
changes the namespace, so rerouting a workspace to another remote does not reuse
an old sync cursor.

---

Nitpick comments:
In `@cmd/devstrap/testdata/script/sync_encrypted.txtar`:
- Around line 46-55: The sync_encrypted.txtar test only checks that revoke
rotates the WCK and keeps ciphertext in the hub, but it does not verify that the
revoked device loses access to post-rotation data. Extend the scenario after
devices revoke dev_b and the subsequent sync by having dev_a create new content
under the rotated epoch, sync it, then attempt to decrypt/materialize that
content with dev_b and assert failure. Use the existing devstrap sync/devices
revoke flow and the dev_a/dev_b handles in this script to locate the right place
to add the post-rotation access check.
🪄 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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 376e7386-d0ad-4e84-a5e9-357ef739c3b0

📥 Commits

Reviewing files that changed from the base of the PR and between d4daa21 and 3824eaa.

📒 Files selected for processing (33)
  • cmd/devstrap/testdata/script/sync_encrypted.txtar
  • cmd/devstrap/testdata/script/sync_materialize.txtar
  • docs/audits/README.md
  • go.mod
  • internal/cli/devices.go
  • internal/cli/hub.go
  • internal/cli/init.go
  • internal/cli/root_test.go
  • internal/devicekeys/devicekeys.go
  • internal/devicekeys/devicekeys_test.go
  • internal/hub/r2.go
  • internal/state/migrations/00013_workspace_keys.sql
  • internal/state/store.go
  • internal/state/store_test.go
  • internal/sync/doc.go
  • internal/sync/encryptedhub.go
  • internal/sync/encryptedhub_test.go
  • internal/sync/eventcrypt.go
  • internal/sync/eventcrypt_test.go
  • internal/sync/events.go
  • internal/sync/hub.go
  • internal/workspacekeys/keyring.go
  • internal/workspacekeys/keyring_test.go
  • spec/00_START_HERE.md
  • spec/03_SYSTEM_ARCHITECTURE.md
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/12_DATA_MODEL_SQLITE.md
  • spec/13_CLI_DAEMON_API.md
  • spec/14_MVP_ROADMAP_AND_BACKLOG.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/16_TEST_PLAN.md
  • spec/18_WORK_LOG.md

Comment thread internal/cli/devices.go
Comment on lines 149 to +156
if trustState == "revoked" || trustState == "lost" {
// P4-SEC-07: rotate the WCK epoch so go-forward events encrypt
// under a key the revoked device does not hold. The revoked
// device is already excluded from ApprovedRecipients (its
// trust_state was just changed), so Rotate grants the new epoch
// only to remaining approved devices. Skip silently if no epoch
// was ever bootstrapped (pre-envelope workspace).
rotateWorkspaceKeyOnRevoke(cmd.Context(), stderr, opts, store)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

Do not make WCK rotation best-effort on revoke/lost.

If rotateWorkspaceKeyOnRevoke fails, the command still reports the device as revoked and future event-log payloads continue under the old epoch. That leaves the revoked device able to decrypt new namespace-map traffic with the WCK it already holds, which breaks the forward-secrecy guarantee this flow is meant to enforce. Treat rotation failure as a command error or persist an explicit “rotation required before next sync” state.

🤖 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 `@internal/cli/devices.go` around lines 149 - 156, The revoke/lost flow in
devices.go is treating rotateWorkspaceKeyOnRevoke as best-effort, which can
leave future payloads encrypted under the old WCK epoch. Update the revoke path
in the device trust-state handling to check and propagate the error from
rotateWorkspaceKeyOnRevoke, or persist a rotation-required state before
returning success. Make the change in the branch that handles trustState ==
"revoked" || trustState == "lost" so the command does not report a successful
revoke if key rotation fails.

Comment thread internal/cli/hub.go
Comment on lines 40 to +45
func hubFromOptions(ctx context.Context, opts *options, store *state.Store, hubFile string) (dssync.Hub, string, error) {
backend, hubID, err := selectBackendHub(ctx, opts, store, hubFile)
if err != nil {
return nil, "", err
}
return dssync.EncryptedHub{Hub: backend, Keyring: buildKeyring(opts, store)}, hubID, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Add an upgrade/bootstrap path before returning an encrypted hub.

This now wraps every backend in EncryptedHub, but only init and the approval helper call EnsureBootstrap. An upgraded single-device workspace with CurrentKeyEpoch()==0 still has no WCK for its first normal event sync, so the first Push fails with no CLI path here to repair it. Please bootstrap on first encrypted sync or add an explicit upgrade path before returning the wrapped hub.

🤖 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 `@internal/cli/hub.go` around lines 40 - 45, The hubFromOptions flow now always
returns an EncryptedHub, but it does not ensure a bootstrap path for upgraded
single-device workspaces with CurrentKeyEpoch()==0, so first normal Push can
still fail. Update hubFromOptions to detect the unbootstrapped encrypted case
and call the same bootstrap/repair logic used by init and the approval helper
(EnsureBootstrap or an equivalent explicit upgrade path) before returning
dssync.EncryptedHub, so the wrapped hub is ready for first sync.

Comment thread internal/cli/init.go
Comment on lines +95 to +97
kr := buildKeyring(opts, store)
if _, err := kr.EnsureBootstrap(cmd.Context()); err != nil {
return fmt.Errorf("bootstrap workspace key: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Bootstrap is using the pre-normalized key directory.

paths.Home is normalized on Lines 43-47 and ensureLocalDeviceIdentity uses that cleaned path, but buildKeyring(opts, store) recomputes opts.paths().KeyDir() from the original options. With a relative or rewritten home, init can store the local identity and epoch-1 WCK in different directories, and later syncs will not be able to reload the WCK. Thread the normalized paths.KeyDir() into keyring construction here.

🤖 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 `@internal/cli/init.go` around lines 95 - 97, The bootstrap path is using a key
directory recomputed from the raw options instead of the normalized one. Update
the init flow in `init` so `buildKeyring` receives and uses the cleaned
`paths.KeyDir()` produced after `paths.Home` normalization, matching
`ensureLocalDeviceIdentity`. This keeps `EnsureBootstrap` and the local
identity/epoch-1 WCK in the same directory even when the home path is relative
or rewritten.

Comment on lines +260 to +267
func (s FileStore) WriteWCK(workspaceID string, epoch int64, wck []byte) error {
if err := validateWorkspaceID(workspaceID); err != nil {
return err
}
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
return fmt.Errorf("create device key directory: %w", err)
}
if err := os.WriteFile(s.wckPath(workspaceID, epoch), []byte(base64.StdEncoding.EncodeToString(wck)+"\n"), 0o600); err != nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Validate epoch and WCK length before persisting secret material.

StoreWCK can be called with a grant-derived epoch before state records the epoch. Reject epoch <= 0 and len(wck) != 32 here as well, so invalid grants cannot leave orphan keychain/file entries or persist unusable keys.

Also applies to: 291-302

🤖 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 `@internal/devicekeys/devicekeys.go` around lines 260 - 267, Add input
validation in FileStore.WriteWCK and the matching StoreWCK path so secret
material is rejected before any filesystem/keychain write happens. In the
WriteWCK flow, after validateWorkspaceID, also reject epoch <= 0 and require
len(wck) == 32 before os.MkdirAll/os.WriteFile; mirror the same checks in the
StoreWCK-related code path to keep behavior consistent. Use the existing
WriteWCK and StoreWCK symbols to locate both implementations and ensure invalid
grants never create orphan entries or persist unusable keys.

Comment on lines +260 to +268
func (s FileStore) WriteWCK(workspaceID string, epoch int64, wck []byte) error {
if err := validateWorkspaceID(workspaceID); err != nil {
return err
}
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
return fmt.Errorf("create device key directory: %w", err)
}
if err := os.WriteFile(s.wckPath(workspaceID, epoch), []byte(base64.StdEncoding.EncodeToString(wck)+"\n"), 0o600); err != nil {
return fmt.Errorf("write workspace key: %w", err)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify the fallback writer now repairs permissions and has an overwrite regression test.
rg -nP 'func \(s FileStore\) WriteWCK|os\.WriteFile|os\.Chmod|os\.Rename' internal/devicekeys/devicekeys.go
rg -nP 'WriteWCK|wck file permissions|0o600' internal/devicekeys/devicekeys_test.go

Repository: Reederey87/DevStrap

Length of output: 1126


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== internal/devicekeys/devicekeys.go =="
sed -n '240,285p' internal/devicekeys/devicekeys.go | cat -n

echo
echo "== internal/devicekeys/devicekeys_test.go =="
sed -n '200,255p' internal/devicekeys/devicekeys_test.go | cat -n

Repository: Reederey87/DevStrap

Length of output: 4465


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== devicekeys.go relevant section =="
sed -n '46,220p' internal/devicekeys/devicekeys.go | cat -n

Repository: Reederey87/DevStrap

Length of output: 7162


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "WriteWCK\(" internal . -g '*.go'

Repository: Reederey87/DevStrap

Length of output: 779


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '285,320p' internal/devicekeys/devicekeys.go | cat -n

Repository: Reederey87/DevStrap

Length of output: 2102


Force 0600 after writing fallback WCK files. os.WriteFile(..., 0o600) only sets mode on creation; rewriting an existing file can leave it readable if it was created with broader permissions. Add a chmod or atomic replace path so rotated WCK files always end up 0600.

🤖 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 `@internal/devicekeys/devicekeys.go` around lines 260 - 268, The
FileStore.WriteWCK path only sets 0600 on initial creation, so existing fallback
WCK files may retain broader permissions after rotation. Update WriteWCK (and
the wckPath write flow) to explicitly enforce 0600 after the write, either by
calling chmod on the target file or by writing to a temp file and atomically
replacing it. Keep the fix localized to FileStore.WriteWCK so rotated WCK files
always end up with the expected restrictive mode.

Comment on lines +191 to +193
if env.Version != envelopeVersion {
return env, fmt.Errorf("%w: event %s has envelope version %d, want %d", ErrUnknownEnvelopeVersion, event.ID, env.Version, envelopeVersion)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Reject non-positive epochs in the envelope.

ParseEncryptedEnvelope currently accepts epoch <= 0 as a valid enc.v1 payload. In EncryptedHub.Pull, that falls into the “missing key” path and truncates the batch forever, because no legitimate bootstrap/rotate flow can ever mint epoch 0 or a negative epoch. Please fail these envelopes closed here, and mirror the same guard in EncryptEvent, so malformed hub data is skipped instead of wedging sync.

Suggested guard
 	if env.Version != envelopeVersion {
 		return env, fmt.Errorf("%w: event %s has envelope version %d, want %d", ErrUnknownEnvelopeVersion, event.ID, env.Version, envelopeVersion)
 	}
+	if env.Epoch < 1 {
+		return env, fmt.Errorf("parse envelope event %s: invalid epoch %d", event.ID, env.Epoch)
+	}
 	return env, nil
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if env.Version != envelopeVersion {
return env, fmt.Errorf("%w: event %s has envelope version %d, want %d", ErrUnknownEnvelopeVersion, event.ID, env.Version, envelopeVersion)
}
if env.Version != envelopeVersion {
return env, fmt.Errorf("%w: event %s has envelope version %d, want %d", ErrUnknownEnvelopeVersion, event.ID, env.Version, envelopeVersion)
}
if env.Epoch < 1 {
return env, fmt.Errorf("parse envelope event %s: invalid epoch %d", event.ID, env.Epoch)
}
🤖 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 `@internal/sync/eventcrypt.go` around lines 191 - 193, ParseEncryptedEnvelope
should reject enc.v1 envelopes whose epoch is zero or negative, instead of
treating them as valid and letting EncryptedHub.Pull hit the missing-key path.
Add a non-positive epoch guard in ParseEncryptedEnvelope alongside the existing
envelopeVersion check, returning an ErrUnknownEnvelopeVersion-style error with
the event ID and epoch details. Mirror the same validation in EncryptEvent so
malformed or impossible hub data is never emitted in the first place.

Comment thread internal/sync/events.go
Comment on lines +492 to +500
case EventDeviceKeyGranted:
// P4-SEC-07: record the grant audit row transactionally with the event
// insert. The secret WCK is ingested into the keychain by the
// EncryptedHub decorator during Pull (on the recipient device only);
// this case only records the non-secret membership audit on every
// device that applies the grant. Grant events are intentionally NOT in
// mustVerifyEvent so a newly-approved device can ingest its first WCK
// during the pre-enrollment bootstrap window (SEC-04); they inherit
// SEC-04 fail-closed trust once that lands.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Authenticate device.key.granted before accepting it.

EncryptedHub.Pull ingests every grant before application, and Keyring.IngestGrant stores any wrapped WCK addressed to the local recipient. Since this event type is explicitly excluded from mustVerifyEvent, a malicious hub can forge a grant to the device’s public age recipient and overwrite an epoch key locally, which turns all enc.v1 events for that epoch into undecryptable data. The bootstrap path still needs an authenticity gate before these events are ingested or recorded.

🤖 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 `@internal/sync/events.go` around lines 492 - 500, The EventDeviceKeyGranted
handling currently bypasses authenticity checks, so forged grants can be
ingested by EncryptedHub.Pull and stored by Keyring.IngestGrant before any
verification. Add an authentication gate for device.key.granted in the event
validation path used by internal/sync/events.go, and make sure
EventDeviceKeyGranted is only accepted after it has been verified or otherwise
proven trustworthy. Keep the bootstrap exception intact, but ensure the grant
cannot be applied or recorded unless it passes the new authenticity check.

Comment on lines +105 to +128
func (k *Keyring) EnsureBootstrap(ctx context.Context) (int64, error) {
epoch, err := k.Store.CurrentKeyEpoch(ctx)
if err != nil {
return 0, err
}
if epoch > 0 {
return epoch, nil
}
if err := k.resolve(ctx); err != nil {
return 0, err
}
const firstEpoch = int64(1)
wck, err := dssync.NewWCK()
if err != nil {
return 0, err
}
if err := k.KeyStore.StoreWCK(ctx, k.workspaceID, firstEpoch, wck); err != nil {
return 0, err
}
if err := k.Store.RecordKeyEpoch(ctx, firstEpoch); err != nil {
return 0, err
}
k.cacheWCK(firstEpoch, wck)
return firstEpoch, nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make epoch transitions single-writer and atomic.

EnsureBootstrap and Rotate both mint/store new WCKs outside a lifecycle lock, and Rotate records next as the current epoch before all grant events/audit rows are durable. Because RecordKeyEpoch is INSERT OR IGNORE, concurrent callers can generate different key bytes for the same epoch; because Push uses the max held epoch, any failure after Line 194 leaves this device encrypting under a key other approved devices never received. Please guard the whole transition with one operation mutex/transaction and only publish the new epoch after the grants are recorded successfully.

Also applies to: 178-221

🤖 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 `@internal/workspacekeys/keyring.go` around lines 105 - 128, Ensure the key
epoch transition is serialized and atomic across Keyring.EnsureBootstrap and
Keyring.Rotate, since both mint/store WCKs without a lifecycle lock and
RecordKeyEpoch can ignore concurrent inserts. Guard the full bootstrap/rotation
flow with a single operation mutex or transaction, and only advance/publish the
new epoch after KeyStore.StoreWCK, all grant/audit writes, and
k.Store.RecordKeyEpoch succeed. Also make sure Push only observes the finalized
epoch after the transition is durably committed.

- no raw Git mirror by default.

Hub-backend trust model (`HUB-*`): the hub is a **two-plane zero-knowledge store** — (1) a signed, HLC-ordered append-only event log (the namespace map) and (2) a content-addressed encrypted blob store (`age_blob:<sha256>`) for env values and non-git/draft content. Repo content never transits the hub; it rides git's own transport via blobless clone/fetch from each project's existing remote. The backend is pluggable behind one Hub interface: the chosen production backend is **Cloudflare R2** (S3 API, client-side age encryption, namespaced by `workspace_id`) — any S3-compatible store reuses the same interface — and a file-backed local backend exists **only for tests**. Either backend sees only ciphertext plus the signed map — it cannot read code, secrets, or drafts.
Hub-backend trust model (`HUB-*`): the hub is a **two-plane zero-knowledge store** — (1) a signed, HLC-ordered append-only event log (the namespace map) whose payloads are **envelope-encrypted** (`enc.v1`, XChaCha20-Poly1305 under a per-epoch Workspace Content Key, `P4-SEC-02`/`SEC-07`, shipped) and (2) a content-addressed encrypted blob store (`age_blob:<sha256>`) for env values and non-git/draft content. Repo content never transits the hub; it rides git's own transport via blobless clone/fetch from each project's existing remote. The backend is pluggable behind one Hub interface: the chosen production backend is **Cloudflare R2** (S3 API, client-side encryption, namespaced by `workspace_id`) — any S3-compatible store reuses the same interface — and a file-backed local backend exists **only for tests**. Either backend sees only ciphertext plus the signed carrier map — it cannot read code, secrets, drafts, or event payloads.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The file-backed hub is not test-only.

This says the local file backend exists “only for tests”, but internal/cli/hub.go still exposes --hub-file and hub: file:<path> for real CLI use. Please rephrase this as a local/non-production backend so the threat model matches the shipped surface. As per coding guidelines, "After the last codebase modification in a session, review every file in spec/ and update each file as needed so the specs remain accurate, complete, and current before handoff."

🤖 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 `@spec/15_SECURITY_THREAT_MODEL.md` at line 132, The threat model text
incorrectly says the file-backed hub exists only for tests, but the shipped CLI
still supports the local backend through `--hub-file` and `hub: file:<path>`.
Update the wording in this section to describe it as a local/non-production
backend instead of test-only, keeping the rest of the `Hub-backend trust model`
description aligned with the actual `internal/cli/hub.go` surface and the
production `Cloudflare R2` backend distinction.

Source: Coding guidelines

Comment thread spec/18_WORK_LOG.md
Comment on lines +51 to +61
Follow-ups:
- P4-SEC-07 full: workspace-ID pairing across devices (spec/07 §211 anticipates provisioning the same logical `ws_...` id; currently each `init` mints a separate workspace id, and the joining device's bootstrap WCK is overwritten by the origin device's grant on first pull — functional but not the intended shared-workspace model).
- P4-SEC-08 (hub-side grant verification / anti-replay) remains open.
- Hub-based WCK recovery for a solo device that loses its keychain (self-grant removed to avoid epoch collision; a re-grant from another device is the recovery path in a multi-device workspace).
- `golangci-lint run` + `gofmt -w cmd internal` to be run before PR.

Review fix (subagent review of PR #25) — make `EncryptedHub.Pull` non-wedging:
- The original `Pull` returned an error on the *first* enc.v1 event it could not decrypt or whose epoch it did not hold, and the caller (`internal/cli/sync.go`) does `return err`, so the whole batch aborted and the pull cursor never advanced. Since `Pull(afterHLC)` only returns events with HLC past the cursor, a single un-decryptable object (wrong-key cross-device epoch collision, corruption, forgery, or an unexpected plaintext/downgrade event) permanently wedged that device's sync and never reached `ApplyEvents`' quarantine + safe-cursor machinery. This is the exact self-DoS the zero-knowledge/untrusted-hub model must resist.
- `Pull` now degrades instead of aborting: a **missing epoch key** (grant not yet propagated) **truncates** the batch — the decryptable prefix is returned so it applies and the cursor advances up to but not past that event, and the next sync retries once the grant arrives; a **held-epoch decrypt failure**, a **malformed/unknown envelope**, and a **non-grant plaintext event** (anti-downgrade) are each **skipped with a loud `logging.Logger(ctx).Warn`** and Pull continues. Bad events are still never applied (the security property holds — no unauthenticated data enters the log), but one bad object can no longer brick a device. This also de-fangs the acknowledged P4-SEC-07 epoch-collision case (it now logs + skips on the affected device rather than wedging) and removes the anti-downgrade brick for a stale/pre-envelope hub.
- Tests: rewrote `TestEncryptedHubAntiDowngrade`/`TestEncryptedHubMissingEpoch`/`TestEncryptedHubUnknownVersion` to assert the skip/truncate contract, and added `TestEncryptedHubPoisonEventDoesNotWedge` (good events on either side of a wrong-key epoch-1 poison event still deliver). The typed sentinels remain in use (`ErrMissingWorkspaceKey` still guards `Push`; `ErrUnknownEnvelopeVersion`/`ErrPlaintextEventFromHub` from `ParseEncryptedEnvelope`).
- Validated: `gofmt -w cmd internal`; `go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.0 run` — 0 issues; `go run ./cmd/spec-drift --base origin/main --head HEAD` — passed; `go test -race ./...` — green (incl. `cmd/devstrap` e2e and `internal/cli`).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale lint/gofmt follow-up.

This entry still lists golangci-lint run / gofmt -w cmd internal under Follow-ups, but the same entry already records them as completed validation a few lines later. Keeping both makes the work log internally inconsistent. As per coding guidelines, "After the last codebase modification in a session, review every file in spec/ and update each file as needed so the specs remain accurate, complete, and current before handoff."

🤖 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 `@spec/18_WORK_LOG.md` around lines 51 - 61, Remove the stale
`golangci-lint`/`gofmt` follow-up from the Follow-ups list in
`spec/18_WORK_LOG.md` because the same entry already records those checks as
completed validation. Update the work log entry so it stays internally
consistent, keeping the remaining follow-ups (like P4-SEC-07 and P4-SEC-08)
intact and ensuring the `Follow-ups` section reflects only unresolved items.

Source: Coding guidelines

Reederey87 added a commit that referenced this pull request Jul 1, 2026
… spec recommendations (#28)

* docs(audit): sixth-pass design & implementation audit (post-PR-#25) + spec recommendations

Sixth-pass audit of trunk 8c739b8 (PR #25 — live R2 hub + envelope-encryption
foundation) via a verification-driven nine-dimension multi-agent workflow
(review -> adversarial per-finding verification -> synthesis), anchored by six
exa-backed best-practice research topics. 43 findings (P1=5, P2=25, P3=13);
~22 candidates were refuted or dropped as duplicates during verification.

Headlines: the envelope layer still trusts the hub (P6-SEC-01 unverified grant
ingestion; P6-SYNC-01 whole-batch abort wedges the cursor); the now-live hub GC
deletes live draft blobs (P6-HUB-01, P6-DATA-01); a universal 2-minute git
timeout silently breaks eager materialization of large repos (P6-GIT-01).

- Add docs/audits/AUDIT_RECOMMENDATIONS_2026-07-01_PASS6.md (43 findings with
  file:line evidence, actionable steps, concrete examples, research notes,
  and a P4/P5 backlog appendix).
- Reconcile docs/audits/README.md per convention #3: add the pass-6 index row +
  open backlog; move shipped P4-SEC-02 to a Recently-shipped section; correct
  P4-SEC-05 to partial; scope P4-SEC-07 to its open remainder (applies P6-DOC-02).
- Weave recommendations into 16 spec files (a "Pass 6 audit recommendations"
  section each). Apply the pure-doc fixes directly: spec/00 stale sync comment +
  missing command (P6-DOC-03), spec/13 status block + env-rotate/hub-gc docs
  (P6-DOC-01), and internal/workspacekeys tracks_code on spec/07/09/15 (P6-DOC-04).

Docs/spec-only cycle; no Go changed. Validated: gofmt clean, spec-drift passed,
go test -race ./... green (content-staleness tests confirm no command/migration
reference dropped). golangci-lint's only hit is a pre-existing, already-nolint'd
gosec G115 in the untouched internal/sync/eventcrypt.go (CI's pinned version
honors the suppression).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(audit): address review nits — annotate applied P6-DOC-* fixes; refresh pass-4 index count

Internal review (APPROVE-WITH-NITS) flagged two ledger-tracking inconsistencies:
- The Pass-6 table header said 'all 43 open' while the P6-DOC-* documentation
  fixes were applied in this PR; annotate P6-DOC-01/03/04 accordingly and reword
  the header to '40 open; 3 pure-doc fixes applied'.
- Refresh the stale Pass-4 index count (was '~19 shipped (PR #20), ~25 open').

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(audit): address CodeRabbit review — sharpen example correctness

- spec/07 (P6-SEC-02): widen kid from 32-bit to full sha256 digest (collision resistance)
- spec/06 (P6-XP-04): keep Linux D-Bus substring cases; layer sentinels on top (don't swap out)
- spec/03 (P6-HUB-04): re-validate retention marker per pull + reject HLC regression (no indefinite cache)
- spec/15 (P6-SEC-01): require grant verification before any keyring mutation, even transiently
- spec/12 (P6-DATA-06): SUM(trust_state='local')=1, not COUNT (non-null expr counts all rows)
- README (ledger): MD028 separator between adjacent dated blockquotes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Reederey87 added a commit that referenced this pull request Jul 2, 2026
…WCK (P6-SEC-01a) (#31)

EncryptedHub.Pull called IngestGrant on the raw, unverified hub batch before
any signature/trust check, so a malicious/zero-knowledge hub could forge a
device.key.granted event wrapping an attacker-chosen Workspace Content Key to
the victim's own recipient and read all future namespace events — a full break
of the PR #25 envelope layer.

- Add an EncryptedHub.Verify seam; Pull verifies each grant carrier BEFORE
  IngestGrant and skips (never ingests) on failure. The refused carrier still
  flows to ApplyEvents and lands in the P6-SYNC-01 quarantine.
- Export (*state.Store).VerifyRemoteEvent (content-hash self-check + trust/
  signature verification, mirroring insertEvent's permanent-failure set);
  hubFromOptions wires it in, so the trust regime is identical to the apply
  path: fail-closed once any device is approved, P4-SEC-04 bootstrap window
  before enrollment. Verify == nil preserves prior behavior for unit tests.
- Closes P6-SEC-01 step (a). Steps (b) overwrite-refusal and (c) verified-epoch
  gating land with PR 3/3's (epoch,kid) + founder/join split.
- Tests: Pull refuses/ingests/nil-verifier back-compat; VerifyRemoteEvent
  regime table; content-hash-mismatch rejection; malicious-hub acceptance
  TestSyncRejectsForgedGrantBeforeWCKIngest (forged grant at epoch 2^40 wrapped
  to the victim recipient -> CurrentKeyEpoch unchanged, no WCK file written, one
  quarantine conflict).
- Specs 00/07/12/14/15/16 + work log updated.

Part 2/3 of the PASS6 hub-trust workstream.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Jul 2, 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