Skip to content

fix(keys): typed keychain custody + never mint over a published identity (P6-XP-04)#62

Merged
Reederey87 merged 3 commits into
mainfrom
fix/p6-xp-04-keychain-custody
Jul 3, 2026
Merged

fix(keys): typed keychain custody + never mint over a published identity (P6-XP-04)#62
Reederey87 merged 3 commits into
mainfrom
fix/p6-xp-04-keychain-custody

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 3, 2026

Copy link
Copy Markdown
Owner

Summary

Closes P6-XP-04: the substring keychain-error heuristic minted divergent device identities on headless Linux (dead D-Bus session → "not found" → fresh key into the file store), wedging sync for exactly the future service install target; StoreWCK/LoadWCK shared the heuristic.

  • Typed classification at the right layer: Secret-Service-unreachable recognition lives in internal/platform.mapKeyringError (→ ErrUnsupported; godbus errors are untyped, so the string inspection is unavoidable there and only there). devicekeys uses errors.Is on typed sentinels exclusively.
  • Never mint over a published identity: mint paths receive the published key and refuse with a remedy (ErrKeychainUnreachable) instead of minting a divergent one — the audit's exact wedge, pinned by a regression test through InsertLocalEvent.
  • Recorded custody: a per-store keychain|file decision (new local_meta table, local-only) recorded once at init from safe evidence only — explicit DEVSTRAP_NO_KEYCHAIN, a positive probe, or a negative probe on a genuine first init with no published keys. An upgraded keychain-backed store first run headless records NOTHING (stays legacy-hybrid + mint guard) — never stranded. Keychain custody fails closed rather than silently downgrading. Honored at every HybridStore site (signing, WCK, run path, hub-credential slot); doctor reports the backend.

Review

Opus-4.8 implementation; Codex review found 2 P2s (transient-probe persistence stranding upgraded stores; run path ignoring recorded custody) — both fixed, plus one more unstamped site (hub-credential slot) found in the sweep. Line-level main-session review of the classification core and the fix round.

Note for the merge train

This branch is based on pre-wave main; its local_meta migration currently numbered 00016 collides with #61's 00016 by design of parallel development — it gets renumbered to the next free slot at its rebase (it merges last of the wave's code PRs). Do not merge before that rebase.

Ledger rows move in the wave's docs close-out PR.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added clearer custody-aware handling for local device keys, including a one-time choice between file storage and system keychain.
    • doctor now reports key custody status alongside other device key checks.
    • Headless setups can now consistently use file-based key storage when the keychain isn’t available.
  • Bug Fixes

    • Prevented accidental key changes when a key is already published.
    • Improved handling of unreachable keychain services so failures are reported more accurately and safely.

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR introduces a typed, recorded key-custody model (keychain vs. file) governing device and signing identity storage. It adds custody classification in the platform keyring error mapper, a Custody type and mint-guard logic in devicekeys, a local_meta migration and store APIs, init-time custody recording, doctor diagnostics, and updates numerous CLI code paths to resolve keys via custody-aware helpers.

Changes

Key custody model implementation

Layer / File(s) Summary
Typed keychain error classification
internal/platform/platform.go, internal/platform/platform_test.go
Rewrites mapKeyringError as a switch and adds secretServiceUnreachable to classify D-Bus/Secret Service errors into typed sentinels, with new tests.
Custody types, mint guards, and secret resolution
internal/devicekeys/devicekeys.go, internal/devicekeys/devicekeys_test.go, internal/devicekeys/custody_test.go
Adds Custody, ErrKeychainUnreachable, WithCustody, Probe, mintGuard, resolveSecret/storeSecretCustody/loadSecret; updates Ensure/EnsureSigning/Read/ReadSigning, WCK, and hub S3 credential paths.
Custody persistence and migration
internal/state/store.go, internal/state/store_test.go, internal/state/custody_wedge_test.go, internal/state/migrations/00019_local_meta.sql
Adds local_meta table migration, KeyCustody/RecordKeyCustody/EffectiveKeyCustody, threads custody into ensureLocalEventSignature, updates schema version expectations, adds wedge regression tests.
Init-time custody recording
internal/cli/init.go
Adds resolveKeyStore and recordKeyCustodyAtInit, recording custody once and updating identity Ensure/read calls.
CLI consumers wired to custody-aware key resolution
internal/cli/blob_gc.go, internal/cli/env.go, internal/cli/materialize.go, internal/cli/run.go, internal/cli/devices.go, internal/cli/sync.go, internal/cli/keys.go, internal/cli/hub.go, associated *_test.go
Replaces direct hybrid store construction with resolveKeyStore/buildKeyring(ctx, ...), adds hubCredStore and store-aware resolveHubS3Credentials.
Doctor diagnostics for key custody
internal/cli/doctor.go, internal/cli/custody_cli_test.go, internal/cli/root_test.go
Adds keyCustodyStatus check with warning/ok grading and new regression tests.
Spec and work-log documentation
spec/05..., spec/06..., spec/09..., spec/12..., spec/13..., spec/15..., spec/18_WORK_LOG.md
Updates spec documentation describing custody model, threat model, migration inventory, and doctor CLI docs; adds work log entry.

Estimated code review effort: 4 (Complex) | ~75 minutes

Sequence Diagram(s)

sequenceDiagram
    participant CLI as CLI Command
    participant ResolveKeyStore as resolveKeyStore
    participant Store as state.Store
    participant HybridStore as devicekeys.HybridStore
    participant Keychain as Platform Keychain

    CLI->>ResolveKeyStore: resolve(ctx, paths, store)
    ResolveKeyStore->>Store: KeyCustody(ctx)
    Store-->>ResolveKeyStore: recorded custody
    ResolveKeyStore->>HybridStore: WithCustody(EffectiveKeyCustody(recorded))
    ResolveKeyStore-->>CLI: keyStore
    CLI->>HybridStore: Ensure/Read(ctx, deviceID, published)
    HybridStore->>HybridStore: mintGuard / resolveSecret
    HybridStore->>Keychain: Load/Store (if custody=keychain)
    Keychain-->>HybridStore: secret or ErrKeychainUnreachable
    HybridStore-->>CLI: identity or refusal error
Loading
sequenceDiagram
    participant Init as devstrap init
    participant Record as recordKeyCustodyAtInit
    participant Probe as HybridStore.Probe
    participant Store as state.Store

    Init->>Record: recordKeyCustodyAtInit(ctx, store)
    Record->>Store: KeyCustody(ctx)
    alt custody already recorded
        Record-->>Init: no-op
    else custody unset
        Record->>Probe: check DEVSTRAP_NO_KEYCHAIN / reachability
        Probe-->>Record: keychain reachable / unreachable
        Record->>Store: RecordKeyCustody(ctx, decision)
        Store-->>Record: persisted (write-once)
    end
    Init-->>Init: proceed with resolveKeyStore
Loading

Estimated code review effort: 4 (Complex) | ~75 minutes

Possibly related PRs

  • Reederey87/DevStrap#25: Both PRs modify hub/keyring wiring for EncryptedHub, with the main PR passing ctx/*state.Store into buildKeyring in internal/cli/hub.go, directly intersecting the retrieved PR's buildKeyring-based keyring wrapping.
  • Reederey87/DevStrap#32: Both PRs modify pushLocalEventsGated in internal/cli/sync.go, with the main PR threading ctx into buildKeyring at the same code path reworked by the retrieved PR.
  • Reederey87/DevStrap#45: The main PR's custody-aware hubCredStore/store.KeyCustody(ctx) routing for hub S3 credentials overlaps directly with the retrieved PR's hub S3 credential resolution/storage codepaths.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description covers the change summary, but it omits the required Tests and Safety Checklist sections from the template. Add the Tests section with the required commands and a Safety Checklist section with all checklist items completed or explained.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: typed keychain custody and preventing minting over published identities.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
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/p6-xp-04-keychain-custody

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

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

🧹 Nitpick comments (3)
internal/state/store.go (2)

2491-2518: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate local_meta read logic between tx.keyCustody and Store.KeyCustody.

Both methods run the identical query/scan/error-handling logic, differing only in the receiver (tx.tx vs s.db). Since *sql.Tx and *sql.DB share the same QueryRowContext signature, this can be deduplicated behind a small interface.

♻️ Proposed refactor to share the read logic
+type queryRower interface {
+	QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
+}
+
+func readKeyCustody(ctx context.Context, q queryRower) (devicekeys.Custody, error) {
+	var v string
+	err := q.QueryRowContext(ctx, `SELECT value FROM local_meta WHERE key = ?;`, keyCustodyMetaKey).Scan(&v)
+	if errors.Is(err, sql.ErrNoRows) {
+		return devicekeys.CustodyUnset, nil
+	}
+	if err != nil {
+		return devicekeys.CustodyUnset, fmt.Errorf("read key custody: %w", err)
+	}
+	return devicekeys.Custody(v), nil
+}
+
 func (tx *Tx) keyCustody(ctx context.Context) (devicekeys.Custody, error) {
-	var v string
-	err := tx.tx.QueryRowContext(ctx, `SELECT value FROM local_meta WHERE key = ?;`, keyCustodyMetaKey).Scan(&v)
-	if errors.Is(err, sql.ErrNoRows) {
-		return devicekeys.CustodyUnset, nil
-	}
-	if err != nil {
-		return devicekeys.CustodyUnset, fmt.Errorf("read key custody: %w", err)
-	}
-	return devicekeys.Custody(v), nil
+	return readKeyCustody(ctx, tx.tx)
 }

 func (s *Store) KeyCustody(ctx context.Context) (devicekeys.Custody, error) {
-	var v string
-	err := s.db.QueryRowContext(ctx, `SELECT value FROM local_meta WHERE key = ?;`, keyCustodyMetaKey).Scan(&v)
-	if errors.Is(err, sql.ErrNoRows) {
-		return devicekeys.CustodyUnset, nil
-	}
-	if err != nil {
-		return devicekeys.CustodyUnset, fmt.Errorf("read key custody: %w", err)
-	}
-	return devicekeys.Custody(v), nil
+	return readKeyCustody(ctx, s.db)
 }
🤖 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/state/store.go` around lines 2491 - 2518, The key-custody read logic
is duplicated between tx.keyCustody and Store.KeyCustody, so factor the shared
local_meta query/scan/error handling into a small helper that accepts something
with QueryRowContext and reuse it from both methods. Keep the existing behavior
for sql.ErrNoRows and wrapped read errors, and leave the receiver-specific entry
points (tx.tx and s.db) as thin wrappers around the shared helper.

2432-2434: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Custody-stamped store construction is duplicated with resolveKeyStore in internal/cli/init.go.

devicekeys.NewHybridStore(keyDir, platform.Detect().Keychain).WithCustody(EffectiveKeyCustody(custody)) is the same composition as resolveKeyStore (internal/cli/init.go:375-382). Since internal/cli already imports internal/state, exposing an equivalent constructor here (e.g. a state package helper taking keyDir + custody) would let both call sites share one implementation instead of evolving in parallel.

🤖 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/state/store.go` around lines 2432 - 2434, The custody-stamped key
store construction is duplicated between the current
`devicekeys.NewHybridStore(...).WithCustody(EffectiveKeyCustody(custody))` path
and `resolveKeyStore`, so introduce a shared helper in `state` that builds the
hybrid store from `keyDir` and `custody`. Update `EnsureSigning`’s call site to
use that helper, and have `resolveKeyStore` reuse the same shared construction
so both paths stay in sync.
internal/cli/root_test.go (1)

249-253: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Tighten the doctor custody assertion to reject unreachable keychain warnings.

This check still passes if doctor reports keychain (currently unreachable), because it only searches for keychain and later checks 0 error(s), not warnings.

Proposed test tightening
 	if !strings.Contains(stdout, "key custody") || !strings.Contains(stdout, "keychain") {
 		t.Fatalf("doctor stdout = %q, want a key custody row reporting keychain", stdout)
 	}
+	if strings.Contains(stdout, "keychain (currently unreachable)") {
+		t.Fatalf("doctor stdout = %q, want reachable keychain custody", stdout)
+	}
🤖 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/root_test.go` around lines 249 - 253, The doctor custody test in
root_test.go is too loose because it still passes when stdout contains a
keychain warning like “currently unreachable.” Tighten the assertion around the
doctor output check in the relevant test by matching the key-custody row more
specifically and explicitly rejecting any unreachable keychain wording, using
the existing stdout inspection and the doctor command expectations in the test
helper.
🤖 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/hub.go`:
- Line 414: The `hub login` help text is now misleading because `hubCredStore`
records credentials in the active custody backend, which can be the file store
even when a keychain is available. Update the help/usage wording associated with
`hub login` in `hub.go` so it describes custody-aware storage instead of
implying “OS keychain” is always used with file only as fallback. Make sure the
revised text matches the behavior of `hubCredStore` and the login flow around
`keys := hubCredStore(cmd.Context(), opts, store)`.

In `@internal/platform/platform.go`:
- Around line 238-247: Tighten the D-Bus error substring matching in the
classifier loop in platform.go so only missing-session-bus and missing-service
cases map to ErrUnsupported; remove or narrow the broad "dbus" and
"org.freedesktop.secrets" needles and keep the specific fallbacks like "session
bus", "was not provided", and "not provided by any". Update the logic around the
error classification function that uses this needle list, and add a negative
test covering a real/hard D-Bus failure to ensure it is not treated as
unsupported.

---

Nitpick comments:
In `@internal/cli/root_test.go`:
- Around line 249-253: The doctor custody test in root_test.go is too loose
because it still passes when stdout contains a keychain warning like “currently
unreachable.” Tighten the assertion around the doctor output check in the
relevant test by matching the key-custody row more specifically and explicitly
rejecting any unreachable keychain wording, using the existing stdout inspection
and the doctor command expectations in the test helper.

In `@internal/state/store.go`:
- Around line 2491-2518: The key-custody read logic is duplicated between
tx.keyCustody and Store.KeyCustody, so factor the shared local_meta
query/scan/error handling into a small helper that accepts something with
QueryRowContext and reuse it from both methods. Keep the existing behavior for
sql.ErrNoRows and wrapped read errors, and leave the receiver-specific entry
points (tx.tx and s.db) as thin wrappers around the shared helper.
- Around line 2432-2434: The custody-stamped key store construction is
duplicated between the current
`devicekeys.NewHybridStore(...).WithCustody(EffectiveKeyCustody(custody))` path
and `resolveKeyStore`, so introduce a shared helper in `state` that builds the
hybrid store from `keyDir` and `custody`. Update `EnsureSigning`’s call site to
use that helper, and have `resolveKeyStore` reuse the same shared construction
so both paths stay in sync.
🪄 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: 6c395966-497b-4eda-ac76-acd981c4152a

📥 Commits

Reviewing files that changed from the base of the PR and between c8f745f and fc82449.

📒 Files selected for processing (32)
  • internal/cli/blob_gc.go
  • internal/cli/blob_gc_test.go
  • internal/cli/custody_cli_test.go
  • internal/cli/devices.go
  • internal/cli/devices_grant_replay_test.go
  • internal/cli/doctor.go
  • internal/cli/env.go
  • internal/cli/hub.go
  • internal/cli/hub_test.go
  • internal/cli/init.go
  • internal/cli/keys.go
  • internal/cli/materialize.go
  • internal/cli/root_test.go
  • internal/cli/run.go
  • internal/cli/sync.go
  • internal/devicekeys/custody_test.go
  • internal/devicekeys/devicekeys.go
  • internal/devicekeys/devicekeys_test.go
  • internal/platform/platform.go
  • internal/platform/platform_test.go
  • internal/state/custody_wedge_test.go
  • internal/state/migrations/00016_local_meta.sql
  • internal/state/store.go
  • internal/state/store_test.go
  • internal/workspacekeys/keyring_test.go
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/06_LINUX_COMPATIBILITY.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/12_DATA_MODEL_SQLITE.md
  • spec/13_CLI_DAEMON_API.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/18_WORK_LOG.md

Comment thread internal/cli/hub.go
Comment thread internal/platform/platform.go
…ity (P6-XP-04)

keychainUnavailable classified keyring errors by substring-matching
err.Error(); on headless Linux a dead D-Bus session read as 'not found',
so EnsureSigning minted a divergent signing identity into the file store
— a split-custody wedge the SQL mismatch guard caught only after the
divergent key file was on disk. The same heuristic guarded StoreWCK/
LoadWCK, extending the blast radius to workspace-key custody.

Secret-Service-unreachable recognition moves DOWN into the platform seam
(mapKeyringError -> ErrUnsupported, where godbus's untyped errors are
unavoidable); everything above uses errors.Is on typed sentinels. Mint
paths take the device's published key and refuse to mint a divergent
identity when custody is unreachable or a published key's private half
is absent (ErrKeychainUnreachable). A per-store custody decision
(migration: local_meta, local-only KV) is recorded once at init — only
from safe evidence: explicit DEVSTRAP_NO_KEYCHAIN, a positive keychain
probe, or a negative probe on a genuine first init with no published
keys (never stranding an upgraded keychain-backed store) — and honored
everywhere (signing, WCK, hub-credential slot, run path); keychain
custody fails closed instead of silently downgrading to files. doctor
reports the custody backend.

Post-review (Codex P2 x2): custody resolution is side-effect-free and
recording is init-only with the never-strand gating; the run path and
hub-credential slot thread recorded custody.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 force-pushed the fix/p6-xp-04-keychain-custody branch from fc82449 to 496bb98 Compare July 3, 2026 19:12
@Reederey87 Reederey87 enabled auto-merge (squash) July 3, 2026 19:12
Reederey87 and others added 2 commits July 3, 2026 15:14
…re hub login help (CodeRabbit)

Bare 'dbus' / 'org.freedesktop.secrets' needles also match errors a LIVE
Secret Service produces (timeouts, dismissed prompts); classifying those
as backend-unavailable could record file custody from a transient
failure at first init. Only missing-bus/missing-service signatures
qualify now, with a negative test for live-service errors.

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

The custody tests inherited the ambient environment: CI sets
DEVSTRAP_NO_KEYCHAIN=1 job-wide and runners have no usable keychain
(dead session bus on Linux, interaction-not-allowed on macOS), so five
tests validated locally against a live Mac keychain went red on CI.
Every custody test now pins the env in both directions with t.Setenv
and injects a fake backend through a new keychainBackend seam
(cli + state) instead of touching the host keychain; the doctor
custody-row assertion is pinned to file custody under an explicit
DEVSTRAP_NO_KEYCHAIN=1. Verified green under both the CI job env and a
cleared environment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 merged commit ce1c618 into main Jul 3, 2026
12 of 13 checks passed
@Reederey87 Reederey87 deleted the fix/p6-xp-04-keychain-custody branch July 3, 2026 19:34

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
internal/state/store_test.go (1)

209-227: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Table-list check doesn't cover the new local_meta table.

The IN (...) list used for tableCount still enumerates the pre-existing 19 tables and doesn't include local_meta, so this test doesn't assert the new migration's table actually exists (that coverage instead relies on custody_wedge_test.go exercising KeyCustody/RecordKeyCustody). Consider adding local_meta to the list (and bumping the expected count) for direct migration-completeness coverage 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/state/store_test.go` around lines 209 - 227, The schema table-count
assertion in the store migration test is still checking only the original 19
tables and misses the new local_meta table. Update the sqlite_master query in
the test that verifies st.db so it includes local_meta in the IN list, and
adjust the expected tableCount accordingly. Use the existing schema-version and
table-count check in the test to keep direct migration coverage for the new
table.
🤖 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 `@spec/09_SECRETS_AND_ENVIRONMENT.md`:
- Line 206: Update the `Recorded custody decision` paragraph in the
secrets/environment spec to use the correct `local_meta` migration identifier:
replace both references to migration `00016` with `00019`, matching the
`local_meta` table introduced by `00019_local_meta.sql` and the current schema
version. Keep the rest of the custody behavior description unchanged and verify
any nearby mentions of `local_meta`, `HybridStore`, or custody recording stay
consistent with the updated migration number.

---

Nitpick comments:
In `@internal/state/store_test.go`:
- Around line 209-227: The schema table-count assertion in the store migration
test is still checking only the original 19 tables and misses the new local_meta
table. Update the sqlite_master query in the test that verifies st.db so it
includes local_meta in the IN list, and adjust the expected tableCount
accordingly. Use the existing schema-version and table-count check in the test
to keep direct migration coverage for the new table.
🪄 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: 19827b0d-5134-4b0a-bd28-2922198b6ba9

📥 Commits

Reviewing files that changed from the base of the PR and between fc82449 and 0fd68dc.

📒 Files selected for processing (32)
  • internal/cli/blob_gc.go
  • internal/cli/blob_gc_test.go
  • internal/cli/custody_cli_test.go
  • internal/cli/devices.go
  • internal/cli/devices_grant_replay_test.go
  • internal/cli/doctor.go
  • internal/cli/env.go
  • internal/cli/hub.go
  • internal/cli/hub_test.go
  • internal/cli/init.go
  • internal/cli/keys.go
  • internal/cli/materialize.go
  • internal/cli/root_test.go
  • internal/cli/run.go
  • internal/cli/sync.go
  • internal/devicekeys/custody_test.go
  • internal/devicekeys/devicekeys.go
  • internal/devicekeys/devicekeys_test.go
  • internal/platform/platform.go
  • internal/platform/platform_test.go
  • internal/state/custody_wedge_test.go
  • internal/state/migrations/00019_local_meta.sql
  • internal/state/store.go
  • internal/state/store_test.go
  • internal/workspacekeys/keyring_test.go
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/06_LINUX_COMPATIBILITY.md
  • spec/09_SECRETS_AND_ENVIRONMENT.md
  • spec/12_DATA_MODEL_SQLITE.md
  • spec/13_CLI_DAEMON_API.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/18_WORK_LOG.md
✅ Files skipped from review due to trivial changes (5)
  • internal/cli/devices_grant_replay_test.go
  • internal/workspacekeys/keyring_test.go
  • spec/05_MAC_FIRST_IMPLEMENTATION.md
  • spec/13_CLI_DAEMON_API.md
  • spec/18_WORK_LOG.md
🚧 Files skipped from review as they are similar to previous changes (18)
  • internal/cli/keys.go
  • internal/cli/blob_gc_test.go
  • internal/cli/sync.go
  • internal/cli/materialize.go
  • internal/cli/init.go
  • internal/cli/blob_gc.go
  • internal/platform/platform.go
  • internal/cli/run.go
  • spec/15_SECURITY_THREAT_MODEL.md
  • internal/cli/hub_test.go
  • internal/cli/doctor.go
  • internal/cli/custody_cli_test.go
  • internal/devicekeys/devicekeys_test.go
  • internal/cli/hub.go
  • internal/cli/env.go
  • spec/06_LINUX_COMPATIBILITY.md
  • internal/cli/devices.go
  • internal/devicekeys/custody_test.go


- **Typed classification, not string matching.** The platform seam (`internal/platform`, `mapKeyringError`) is the single place that turns the keyring library's error vocabulary into typed sentinels: `ErrSecretNotFound` (the backend is reachable but holds no such secret — a mint may proceed) versus `ErrUnsupported` (the backend is unreachable/unsupported — including a missing Linux Secret Service / D-Bus session, which go-keyring surfaces as an untyped godbus error that the seam classifies here). `internal/devicekeys` consumes those sentinels with `errors.Is`; it never inspects error strings. A live-backend hard failure stays untyped and fails closed.
- **Never mint over a published identity.** `EnsureSigning`/`Ensure` receive the device's already-published public key and refuse to mint a replacement when the keychain is unreachable (the split-custody wedge) or when a key is published but its private half is absent from a reachable backend — either would diverge from `devices.signing_public_key`. The refusal carries the remedy (run from a desktop session, or set `DEVSTRAP_NO_KEYCHAIN=1` and migrate the key file). The same never-mint-over-held principle guards the WCK custody path.
- **Recorded custody decision.** At init the decision is recorded once in the local, never-synced `local_meta` table (migration `00016`) and never rewritten, but only from *safe evidence* so an existing store is never stranded: `file` when `DEVSTRAP_NO_KEYCHAIN=1` (explicit operator choice); `keychain` when the probe positively finds the keychain reachable (safe regardless of pre-existing secrets); and `file` from an unreachable probe *only* for a genuine first init (a brand-new device with no already-published keys). Crucially, an unreachable probe on an *already-initialized* store — e.g. a pre-`00016` store whose secrets live only in the keychain, first run headless after upgrade — records nothing and stays `CustodyUnset`, so a later desktop run still reads the keychain where the real secrets are. Later runs honor a recorded decision: a `file`-custody store keeps using files even if a keychain appears; a `keychain`-custody store refuses to silently degrade to file custody (it fails closed with a remedy) unless `DEVSTRAP_NO_KEYCHAIN=1` forces file custody. `doctor` reports the recorded backend and warns when it is currently unreachable, overridden, or unrecorded. Under legacy (unrecorded) custody the historical hybrid behavior is preserved — prefer the keychain, fall back to the file store when unavailable and nothing is published — so the mint guard above still prevents a divergent mint. All device/workspace/hub-credential key stores are stamped with the recorded custody, so a stale keychain entry can never shadow the authoritative file key on a file-custody machine. The same `HybridStore` also custodies Workspace Content Keys (WCK) for event-log envelope encryption (`P4-SEC-07`/`P6-SEC-02`, keyed `wck.<workspace_id>.<epoch>.<kid>` where `kid = hex(sha256(wck))`; the pre-kid `wck.<workspace_id>.<epoch>` form is the legacy slot, lazily upgraded by `Keyring.Prime`). The kid is validated (64 lowercase hex chars or empty) before it reaches any keychain account name or file path. Manual `devices enroll --approve` records an approved device age recipient so future captures include that recipient, and grants every held WCK epoch's fleet key to the newly-approved device. Production synced env blobs still require automatic remote enrollment; fingerprint confirmation is shipped (`P4-SEC-04`) and gates every approval that adds a recipient.

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

Correct the migration number for local_meta (0001600019).

The custody decision is stored in the local_meta table introduced by 00019_local_meta.sql, and the schema version this PR targets is 19 (see TestMigrateEnsureSummaryAndVersion / db status: schema version: 19). This paragraph references migration 00016 twice, which is inaccurate.

As per coding guidelines: "review every file in spec/ and update each file as needed so the specs remain accurate, complete, and current."

📝 Proposed doc fix
-- **Recorded custody decision.** At init the decision is recorded once in the local, never-synced `local_meta` table (migration `00016`) and never rewritten, but only from *safe evidence* so an existing store is never stranded: `file` when `DEVSTRAP_NO_KEYCHAIN=1` (explicit operator choice); `keychain` when the probe positively finds the keychain reachable (safe regardless of pre-existing secrets); and `file` from an unreachable probe *only* for a genuine first init (a brand-new device with no already-published keys). Crucially, an unreachable probe on an *already-initialized* store — e.g. a pre-`00016` store whose secrets live only in the keychain, first run headless after upgrade — records nothing and stays `CustodyUnset`, so a later desktop run still reads the keychain where the real secrets are.
+- **Recorded custody decision.** At init the decision is recorded once in the local, never-synced `local_meta` table (migration `00019`) and never rewritten, but only from *safe evidence* so an existing store is never stranded: `file` when `DEVSTRAP_NO_KEYCHAIN=1` (explicit operator choice); `keychain` when the probe positively finds the keychain reachable (safe regardless of pre-existing secrets); and `file` from an unreachable probe *only* for a genuine first init (a brand-new device with no already-published keys). Crucially, an unreachable probe on an *already-initialized* store — e.g. a pre-`00019` store whose secrets live only in the keychain, first run headless after upgrade — records nothing and stays `CustodyUnset`, so a later desktop run still reads the keychain where the real secrets are.
📝 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
- **Recorded custody decision.** At init the decision is recorded once in the local, never-synced `local_meta` table (migration `00016`) and never rewritten, but only from *safe evidence* so an existing store is never stranded: `file` when `DEVSTRAP_NO_KEYCHAIN=1` (explicit operator choice); `keychain` when the probe positively finds the keychain reachable (safe regardless of pre-existing secrets); and `file` from an unreachable probe *only* for a genuine first init (a brand-new device with no already-published keys). Crucially, an unreachable probe on an *already-initialized* store — e.g. a pre-`00016` store whose secrets live only in the keychain, first run headless after upgrade — records nothing and stays `CustodyUnset`, so a later desktop run still reads the keychain where the real secrets are. Later runs honor a recorded decision: a `file`-custody store keeps using files even if a keychain appears; a `keychain`-custody store refuses to silently degrade to file custody (it fails closed with a remedy) unless `DEVSTRAP_NO_KEYCHAIN=1` forces file custody. `doctor` reports the recorded backend and warns when it is currently unreachable, overridden, or unrecorded. Under legacy (unrecorded) custody the historical hybrid behavior is preserved — prefer the keychain, fall back to the file store when unavailable and nothing is published — so the mint guard above still prevents a divergent mint. All device/workspace/hub-credential key stores are stamped with the recorded custody, so a stale keychain entry can never shadow the authoritative file key on a file-custody machine. The same `HybridStore` also custodies Workspace Content Keys (WCK) for event-log envelope encryption (`P4-SEC-07`/`P6-SEC-02`, keyed `wck.<workspace_id>.<epoch>.<kid>` where `kid = hex(sha256(wck))`; the pre-kid `wck.<workspace_id>.<epoch>` form is the legacy slot, lazily upgraded by `Keyring.Prime`). The kid is validated (64 lowercase hex chars or empty) before it reaches any keychain account name or file path. Manual `devices enroll --approve` records an approved device age recipient so future captures include that recipient, and grants every held WCK epoch's fleet key to the newly-approved device. Production synced env blobs still require automatic remote enrollment; fingerprint confirmation is shipped (`P4-SEC-04`) and gates every approval that adds a recipient.
- **Recorded custody decision.** At init the decision is recorded once in the local, never-synced `local_meta` table (migration `00019`) and never rewritten, but only from *safe evidence* so an existing store is never stranded: `file` when `DEVSTRAP_NO_KEYCHAIN=1` (explicit operator choice); `keychain` when the probe positively finds the keychain reachable (safe regardless of pre-existing secrets); and `file` from an unreachable probe *only* for a genuine first init (a brand-new device with no already-published keys). Crucially, an unreachable probe on an *already-initialized* store — e.g. a pre-`00019` store whose secrets live only in the keychain, first run headless after upgrade — records nothing and stays `CustodyUnset`, so a later desktop run still reads the keychain where the real secrets are. Later runs honor a recorded decision: a `file`-custody store keeps using files even if a keychain appears; a `keychain`-custody store refuses to silently degrade to file custody (it fails closed with a remedy) unless `DEVSTRAP_NO_KEYCHAIN=1` forces file custody. `doctor` reports the recorded backend and warns when it is currently unreachable, overridden, or unrecorded. Under legacy (unrecorded) custody the historical hybrid behavior is preserved — prefer the keychain, fall back to the file store when unavailable and nothing is published — so the mint guard above still prevents a divergent mint. All device/workspace/hub-credential key stores are stamped with the recorded custody, so a stale keychain entry can never shadow the authoritative file key on a file-custody machine. The same `HybridStore` also custodies Workspace Content Keys (WCK) for event-log envelope encryption (`P4-SEC-07`/`P6-SEC-02`, keyed `wck.<workspace_id>.<epoch>.<kid>` where `kid = hex(sha256(wck))`; the pre-kid `wck.<workspace_id>.<epoch>` form is the legacy slot, lazily upgraded by `Keyring.Prime`). The kid is validated (64 lowercase hex chars or empty) before it reaches any keychain account name or file path. Manual `devices enroll --approve` records an approved device age recipient so future captures include that recipient, and grants every held WCK epoch's fleet key to the newly-approved device. Production synced env blobs still require automatic remote enrollment; fingerprint confirmation is shipped (`P4-SEC-04`) and gates every approval that adds a recipient.
🧰 Tools
🪛 LanguageTool

[grammar] ~206-~206: Use a hyphen to join words.
Context: ...to the newly-approved device. Production synced env blobs still require automatic...

(QB_NEW_EN_HYPHEN)

🤖 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/09_SECRETS_AND_ENVIRONMENT.md` at line 206, Update the `Recorded custody
decision` paragraph in the secrets/environment spec to use the correct
`local_meta` migration identifier: replace both references to migration `00016`
with `00019`, matching the `local_meta` table introduced by
`00019_local_meta.sql` and the current schema version. Keep the rest of the
custody behavior description unchanged and verify any nearby mentions of
`local_meta`, `HybridStore`, or custody recording stay consistent with the
updated migration number.

Source: Coding guidelines

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