From aa7210cd90261658c79096942beb9e64dbf7d7fc Mon Sep 17 00:00:00 2001 From: Artem Matskevych <52060443+Reederey87@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:03:34 -0400 Subject: [PATCH 1/4] feat(devices): synced device-trust propagation (TRUST-01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devices revoke/lost now emit synced device.revoked/device.lost events (mustVerify, same transaction as the local trust flip, before WCK rotation), so one device's revoke reaches the whole fleet: receiving devices flip the target sticky/monotonically (only pending/approved transition; the local device never flips from a remote event; only the local fingerprint ceremony resurrects) and flag needs_rotation exactly once (gated on an actual change — replays never re-flag cleared rotations). device.approved is deliberately NOT propagated: a synced approval would let one compromised device enroll attackers fleet-wide; approval stays the local P4-SEC-04 ceremony. Mutual revocation converges deterministically within one batch (HLC-earlier revoke wins; the counter-revoke quarantines once its signer flips); cross-window bystander divergence is a documented accepted residual — fail-closed either way, loud, operator re-approves the survivor. Closes the P6-SYNC-01/P6-SYNC-03 trust-propagation residual (spec/07, spec/15). e2e: sync_trust_propagation.txtar (three devices). sync_never_granted_epoch_wedge.txtar expectations updated: the revoke event also rides the post-rotation epoch, so a never-granted device quarantines two carriers. Co-Authored-By: Claude Fable 5 --- .../sync_never_granted_epoch_wedge.txtar | 6 +- .../script/sync_trust_propagation.txtar | 62 ++++ internal/cli/devices.go | 40 ++- internal/cli/devices_test.go | 51 +++ internal/state/store.go | 111 +++++- internal/state/store_test.go | 132 ++++++- internal/sync/events.go | 60 ++++ internal/sync/trust_apply_test.go | 322 ++++++++++++++++++ spec/00_START_HERE.md | 2 +- spec/07_NAMESPACE_AND_SYNC_MODEL.md | 11 +- spec/09_SECRETS_AND_ENVIRONMENT.md | 4 +- spec/13_CLI_DAEMON_API.md | 2 +- spec/14_MVP_ROADMAP_AND_BACKLOG.md | 2 +- spec/15_SECURITY_THREAT_MODEL.md | 8 +- spec/16_TEST_PLAN.md | 2 + spec/18_WORK_LOG.md | 22 ++ 16 files changed, 808 insertions(+), 29 deletions(-) create mode 100644 cmd/devstrap/testdata/script/sync_trust_propagation.txtar create mode 100644 internal/sync/trust_apply_test.go diff --git a/cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar b/cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar index 578bc1a..2229917 100644 --- a/cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar +++ b/cmd/devstrap/testdata/script/sync_never_granted_epoch_wedge.txtar @@ -69,8 +69,12 @@ exec sh -c 'test -d $WORK/CodeB/work/a2-proj/.git' # cursor advances (the P6-SEC-03 un-wedge). env DEVSTRAP_SYNC_KEY_GRANT_GRACE=0s exec devstrap --home=$WORK/homeC --root=$WORK/CodeC sync --hub-file=$WORK/hub.json +# TRUST-01: A's revoke of D now ALSO emits a synced device.revoked event, +# sealed (like all post-rotation traffic) under epoch 2 — so never-granted C +# quarantines TWO epoch-2 carriers (the trust event + the project event), each +# with its undecryptable row and the successor's hash-chain hold. exec devstrap --home=$WORK/homeC --root=$WORK/CodeC conflicts list -stdout '1 open conflict' +stdout '4 open conflict' exec devstrap --home=$WORK/homeC --root=$WORK/CodeC doctor stdout 'awaiting key grants' stdout 'epoch 2' diff --git a/cmd/devstrap/testdata/script/sync_trust_propagation.txtar b/cmd/devstrap/testdata/script/sync_trust_propagation.txtar new file mode 100644 index 0000000..f01f66f --- /dev/null +++ b/cmd/devstrap/testdata/script/sync_trust_propagation.txtar @@ -0,0 +1,62 @@ +# TRUST-01: `devices revoke` on device A emits a synced device.revoked event, +# so bystander C learns the revocation on its next sync WITHOUT a local +# operator action: C's devices list shows B revoked, C's encrypted bindings +# flag for rotation, and B's subsequent events quarantine on C. + +env DEVSTRAP_NO_KEYCHAIN=1 +env GIT_AUTHOR_NAME=devstrap-test +env GIT_AUTHOR_EMAIL=test@example.com +env GIT_COMMITTER_NAME=devstrap-test +env GIT_COMMITTER_EMAIL=test@example.com + +exec git init --bare -b main $WORK/remote-a.git +exec git clone $WORK/remote-a.git $WORK/src-a +exec sh -c 'echo a > $WORK/src-a/README.md' +exec sh -c 'cd $WORK/src-a && git add README.md && git commit -m init && git branch -M main && git push origin main' + +exec git init --bare -b main $WORK/remote-b.git +exec git clone $WORK/remote-b.git $WORK/src-b +exec sh -c 'echo b > $WORK/src-b/README.md' +exec sh -c 'cd $WORK/src-b && git add README.md && git commit -m init && git branch -M main && git push origin main' + +# Device A founds the workspace; B and C join it. +exec devstrap --home=$WORK/homeA --root=$WORK/CodeA init +exec sh -c 'WS=$(devstrap --home=$WORK/homeA --root=$WORK/CodeA devices recipient --workspace-id); devstrap --home=$WORK/homeB --root=$WORK/CodeB init --join --workspace-id "$WS"; devstrap --home=$WORK/homeC --root=$WORK/CodeC init --join --workspace-id "$WS"' + +# Full mutual enrollment: every device pins every other device (fingerprint +# ceremony), so all three verify each other's events fail-closed. +exec sh -c 'for SRC in A B C; do for DST in A B C; do [ "$SRC" = "$DST" ] && continue; ID=$(devstrap --home=$WORK/home$SRC --root=$WORK/Code$SRC devices list | awk "NR==1{print \$1}"); REC=$(devstrap --home=$WORK/home$SRC --root=$WORK/Code$SRC devices recipient); SIG=$(devstrap --home=$WORK/home$SRC --root=$WORK/Code$SRC devices recipient --signing); FP=$(devstrap --home=$WORK/home$SRC --root=$WORK/Code$SRC devices recipient --fingerprint); devstrap --home=$WORK/home$DST --root=$WORK/Code$DST devices enroll "$ID" --name "dev$SRC" --os linux --arch arm64 --age-recipient "$REC" --signing-public-key "$SIG" --approve --fingerprint "$FP" || exit 1; done; done' + +# A adds a project and captures an env profile (so C has bindings to flag), +# everyone converges. +exec devstrap --home=$WORK/homeA --root=$WORK/CodeA add --path work/proj-a $WORK/remote-a.git +exec mkdir -p $WORK/CodeA/work/proj-a +exec sh -c 'printf "API_TOKEN=trust-marker\n" > $WORK/CodeA/work/proj-a/.env' +exec devstrap --home=$WORK/homeA --root=$WORK/CodeA env capture work/proj-a .env +exec devstrap --home=$WORK/homeA --root=$WORK/CodeA sync --hub-file=$WORK/hub.json +exec devstrap --home=$WORK/homeB --root=$WORK/CodeB sync --hub-file=$WORK/hub.json +exec devstrap --home=$WORK/homeC --root=$WORK/CodeC sync --hub-file=$WORK/hub.json + +# A revokes B and pushes the trust event. +exec sh -c 'B_ID=$(devstrap --home=$WORK/homeB --root=$WORK/CodeB devices list | awk "NR==1{print \$1}"); devstrap --home=$WORK/homeA --root=$WORK/CodeA devices revoke "$B_ID"' +stderr 'propagates to other devices on their next sync' +exec devstrap --home=$WORK/homeA --root=$WORK/CodeA sync --hub-file=$WORK/hub.json + +# C learns the revocation on its next sync — no local operator action. +exec devstrap --home=$WORK/homeC --root=$WORK/CodeC sync --hub-file=$WORK/hub.json +exec sh -c 'devstrap --home=$WORK/homeC --root=$WORK/CodeC devices list | grep devB | grep revoked' +# The revoke flagged C's bindings AND the superseding rewrap event must not +# wipe the flag (dogfood-found bug: the replace-all upsert reset +# needs_rotation) — assert a NON-ZERO count, not just the label. +exec sh -c 'devstrap --home=$WORK/homeC --root=$WORK/CodeC doctor; true' +stdout 'secrets needing rotation [1-9]' + +# B keeps pushing after the revocation; C quarantines B's event instead of +# applying it. +exec devstrap --home=$WORK/homeB --root=$WORK/CodeB add --path work/proj-b $WORK/remote-b.git +exec devstrap --home=$WORK/homeB --root=$WORK/CodeB sync --hub-file=$WORK/hub.json +exec devstrap --home=$WORK/homeC --root=$WORK/CodeC sync --hub-file=$WORK/hub.json +exec devstrap --home=$WORK/homeC --root=$WORK/CodeC status +! stdout 'work/proj-b' +exec devstrap --home=$WORK/homeC --root=$WORK/CodeC conflicts list +stdout 'event_verification_failure' diff --git a/internal/cli/devices.go b/internal/cli/devices.go index e53009b..84b657a 100644 --- a/internal/cli/devices.go +++ b/internal/cli/devices.go @@ -301,8 +301,34 @@ func newDeviceTrustCommand(stdout io.Writer, opts *options, use, trustState stri return err } } - if err := store.SetDeviceTrustState(cmd.Context(), args[0], trustState); err != nil { - return err + if trustState == "revoked" || trustState == "lost" { + // TRUST-01: the trust flip and the synced device.revoked/lost + // event land in ONE transaction (P6-DATA-03), BEFORE the WCK + // rotation below — a rotation failure can never orphan the + // trust write, and the trust event's seq precedes the new + // epoch's grant events in this device's stream. + eventType := dssync.EventDeviceRevoked + if trustState == "lost" { + eventType = dssync.EventDeviceLost + } + payloadJSON, err := json.Marshal(dssync.DeviceTrustPayload{DeviceID: args[0]}) + if err != nil { + return err + } + if err := store.WithTx(cmd.Context(), func(tx *state.Tx) error { + if err := tx.SetDeviceTrustStateTx(cmd.Context(), args[0], trustState); err != nil { + return err + } + _, err := store.InsertLocalEventTx(cmd.Context(), tx, dssync.NewDeviceTrustEvent(eventType, string(payloadJSON))) + return err + }); err != nil { + return err + } + _, _ = fmt.Fprintf(stderr, "note: the %s state propagates to other devices on their next sync (they will quarantine %s's events and flag secrets for rotation)\n", trustState, args[0]) + } else { + if err := store.SetDeviceTrustState(cmd.Context(), args[0], trustState); err != nil { + return err + } } if _, err := fmt.Fprintf(stdout, "Device %s marked %s\n", args[0], trustState); err != nil { return err @@ -784,7 +810,15 @@ func rotateWorkspaceKeyOnRevoke(ctx context.Context, stderr io.Writer, opts *opt } newEpoch, grants, rerr := kr.Rotate(ctx) if rerr != nil { - _, _ = fmt.Fprintf(stderr, "warning: workspace key rotation failed: %v\n", rerr) + // Adversarial-review (TRUST-01): a failed rotation leaves the OLD + // epoch active, so every event pushed until a rotation succeeds — + // including the device.revoked event itself — stays decryptable by + // the revoked device (it still holds the old WCK). The trust flip is + // deliberately NOT rolled back: refusing the revoke because rotation + // failed would keep a compromised device APPROVED, which is worse. + // Be loud about the confidentiality gap and the remedy instead. + _, _ = fmt.Fprintf(stderr, "warning: workspace key rotation FAILED: %v\n", rerr) + _, _ = fmt.Fprintf(stderr, "warning: events pushed before a successful rotation remain readable by the revoked device (it holds the current epoch key). Run 'devstrap keys rotate' until it succeeds, then 'devstrap sync'.\n") return } if len(grants) > 0 { diff --git a/internal/cli/devices_test.go b/internal/cli/devices_test.go index c203d9b..9914cb8 100644 --- a/internal/cli/devices_test.go +++ b/internal/cli/devices_test.go @@ -371,3 +371,54 @@ func TestJoinerApprovingAnotherDeviceDoesNotSelfMint(t *testing.T) { t.Fatalf("CurrentKeyEpoch = %d, want 0 (joiner must not self-mint on approve)", epoch) } } + +// TestDeviceRevokeEmitsTrustEvent (TRUST-01): `devices revoke` writes the +// trust flip and the synced device.revoked event in one transaction, so the +// local event log carries the revocation for the next push. +func TestDeviceRevokeEmitsTrustEvent(t *testing.T) { + ctx := context.Background() + home := t.TempDir() + root := t.TempDir() + t.Setenv("DEVSTRAP_NO_KEYCHAIN", "1") + if _, _, err := executeForTest("--home", home, "--root", root, "init"); err != nil { + t.Fatal(err) + } + store, err := state.Open(ctx, filepath.Join(home, "state.db")) + if err != nil { + t.Fatal(err) + } + defer func() { _ = store.Close() }() + if err := store.UpsertDevice(ctx, state.Device{ + ID: "dev_remote", Name: "remote", OS: "linux", Arch: "arm64", TrustState: "approved", + }); err != nil { + t.Fatal(err) + } + + stdout, stderr, err := executeForTest("--home", home, "--root", root, "devices", "revoke", "dev_remote") + if err != nil { + t.Fatalf("revoke: stdout=%q stderr=%q err=%v", stdout, stderr, err) + } + if !strings.Contains(stderr, "propagates to other devices on their next sync") { + t.Fatalf("stderr = %q, want propagation note", stderr) + } + + events, err := store.LocalPendingEventsBySeq(ctx, 0) + if err != nil { + t.Fatal(err) + } + found := false + for _, ev := range events { + if ev.Type == dssync.EventDeviceRevoked { + var p dssync.DeviceTrustPayload + if err := json.Unmarshal([]byte(ev.PayloadJSON), &p); err != nil { + t.Fatal(err) + } + if p.DeviceID == "dev_remote" { + found = true + } + } + } + if !found { + t.Fatalf("no device.revoked event for dev_remote in the local log (%d events)", len(events)) + } +} diff --git a/internal/state/store.go b/internal/state/store.go index 1fb308a..9352084 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -742,6 +742,16 @@ UPDATE devices SET name = ?, updated_at = ? WHERE id = ?; } func (s *Store) SetDeviceTrustState(ctx context.Context, deviceID, trustState string) error { + return s.WithTx(ctx, func(tx *Tx) error { + return tx.SetDeviceTrustStateTx(ctx, deviceID, trustState) + }) +} + +// SetDeviceTrustStateTx is the transactional core of SetDeviceTrustState +// (TRUST-01): `devices revoke`/`lost` write the trust flip and insert the +// synced device.revoked/device.lost event in ONE transaction (P6-DATA-03), so +// a failure between the two can never orphan either side. +func (tx *Tx) SetDeviceTrustStateTx(ctx context.Context, deviceID, trustState string) error { if deviceID == "" { return fmt.Errorf("device id is required") } @@ -750,7 +760,7 @@ func (s *Store) SetDeviceTrustState(ctx context.Context, deviceID, trustState st default: return fmt.Errorf("unsupported trust state %q", trustState) } - current, err := currentDevice(ctx, s.db) + current, err := currentDevice(ctx, tx.tx) if err != nil { return err } @@ -758,7 +768,7 @@ func (s *Store) SetDeviceTrustState(ctx context.Context, deviceID, trustState st return fmt.Errorf("refusing to change local device trust state") } now := timestampNow() - result, err := s.db.ExecContext(ctx, ` + result, err := tx.tx.ExecContext(ctx, ` UPDATE devices SET trust_state = ?, updated_at = ? WHERE id = ?; `, trustState, now, deviceID) if err != nil { @@ -774,6 +784,46 @@ UPDATE devices SET trust_state = ?, updated_at = ? WHERE id = ?; return nil } +// ApplyRemoteDeviceTrustTx applies a synced device.revoked/device.lost event +// to the local trust table (TRUST-01). The transition is STICKY/monotonic: +// only pending or approved rows flip (pending -> revoked is the more +// fail-closed direction, consistent with hasEnrolledDevices counting revoked +// rows); revoked <-> lost churn and replays are no-ops; only the local +// fingerprint ceremony (`devices approve`) resurrects a device. The local +// device never flips from a remote event — a hub cannot talk this device into +// distrusting itself; the operator sees the fleet's decision via peers +// quarantining its events instead. Returns whether a row actually changed so +// the caller can gate side effects (needs_rotation flagging) off replays. +func (tx *Tx) ApplyRemoteDeviceTrustTx(ctx context.Context, deviceID, trustState string) (bool, error) { + if deviceID == "" { + return false, fmt.Errorf("device id is required") + } + switch trustState { + case "revoked", "lost": + default: + return false, fmt.Errorf("unsupported remote trust state %q", trustState) + } + current, err := currentDevice(ctx, tx.tx) + if err != nil { + return false, err + } + if deviceID == current.ID { + return false, nil + } + result, err := tx.tx.ExecContext(ctx, ` +UPDATE devices SET trust_state = ?, updated_at = ? +WHERE id = ? AND trust_state IN ('pending', 'approved'); +`, trustState, timestampNow(), deviceID) + if err != nil { + return false, fmt.Errorf("apply remote device trust: %w", err) + } + affected, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("read remote trust update count: %w", err) + } + return affected > 0, nil +} + func (s *Store) SetDevicePublicKey(ctx context.Context, deviceID, publicKey string) error { if deviceID == "" { return fmt.Errorf("device id must not be empty") @@ -1670,6 +1720,7 @@ func (tx *Tx) UpsertEnvProfileTx(ctx context.Context, namespaceID string, p EnvP eventDeviceID = event.DeviceID eventID = event.ID } + preservedRotation := map[string]int{} var existingID string err := tx.tx.QueryRowContext(ctx, ` SELECT COALESCE(env_profile_id, '') @@ -1708,9 +1759,37 @@ WHERE id = ?; `, p.Name, p.Provider, p.Mode, eventHLC, eventDeviceID, eventID, now, existingID); err != nil { return EnvProfile{}, fmt.Errorf("update env profile: %w", err) } + // TRUST-01 dogfood fix: the replace-all-bindings upsert was silently + // WIPING needs_rotation — a revoke flags the bindings, then the + // rewrap's superseding env.profile.updated re-inserted fresh rows with + // the flag cleared, on the revoker AND on every receiving device + // (breaking the P5-PROD-03 doctor surfacing). Carry the flag forward + // per var name; clearing stays the explicit, device-local operator + // action (`env rotate` -> ClearRotationForProject), per spec/09. + rotFlags := map[string]int{} + rotRows, rerr := tx.tx.QueryContext(ctx, `SELECT var_name, needs_rotation FROM secret_bindings WHERE env_profile_id = ?;`, existingID) + if rerr != nil { + return EnvProfile{}, fmt.Errorf("read env binding rotation flags: %w", rerr) + } + for rotRows.Next() { + var varName string + var flag int + if err := rotRows.Scan(&varName, &flag); err != nil { + _ = rotRows.Close() + return EnvProfile{}, fmt.Errorf("scan env binding rotation flag: %w", err) + } + rotFlags[varName] = flag + } + if err := rotRows.Err(); err != nil { + return EnvProfile{}, err + } + if err := rotRows.Close(); err != nil { + return EnvProfile{}, fmt.Errorf("close env binding rotation flags: %w", err) + } if _, err := tx.tx.ExecContext(ctx, `DELETE FROM secret_bindings WHERE env_profile_id = ?;`, existingID); err != nil { return EnvProfile{}, fmt.Errorf("replace env bindings: %w", err) } + preservedRotation = rotFlags } for _, varName := range varNames { bindingID, err := id.New("sec") @@ -1719,16 +1798,16 @@ WHERE id = ?; } if encrypted { if _, err := tx.tx.ExecContext(ctx, ` -INSERT INTO secret_bindings (id, env_profile_id, var_name, encrypted_value_ref, required, created_at, updated_at) -VALUES (?, ?, ?, ?, 1, ?, ?); -`, bindingID, existingID, varName, p.BlobRef, now, now); err != nil { +INSERT INTO secret_bindings (id, env_profile_id, var_name, encrypted_value_ref, required, needs_rotation, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?, ?); +`, bindingID, existingID, varName, p.BlobRef, preservedRotation[varName], now, now); err != nil { return EnvProfile{}, fmt.Errorf("insert secret binding %s: %w", varName, err) } } else { if _, err := tx.tx.ExecContext(ctx, ` -INSERT INTO secret_bindings (id, env_profile_id, var_name, provider_ref, required, created_at, updated_at) -VALUES (?, ?, ?, ?, 1, ?, ?); -`, bindingID, existingID, varName, p.Refs[varName], now, now); err != nil { +INSERT INTO secret_bindings (id, env_profile_id, var_name, provider_ref, required, needs_rotation, created_at, updated_at) +VALUES (?, ?, ?, ?, 1, ?, ?, ?); +`, bindingID, existingID, varName, p.Refs[varName], preservedRotation[varName], now, now); err != nil { return EnvProfile{}, fmt.Errorf("insert provider secret binding %s: %w", varName, err) } } @@ -1820,8 +1899,19 @@ WHERE n.id = ? AND n.workspace_id = ? AND n.status = 'active'; // must be rotated at their source — rewrapping recipients alone does not revoke // historical access. Returns the number of bindings flagged. func (s *Store) MarkEncryptedBindingsNeedingRotation(ctx context.Context) (int, error) { + return markEncryptedBindingsNeedingRotation(ctx, s.db) +} + +// MarkEncryptedBindingsNeedingRotationTx is the Tx variant, used by the +// device.revoked/device.lost apply path so the flip and the rotation flags +// land atomically (TRUST-01). +func (tx *Tx) MarkEncryptedBindingsNeedingRotationTx(ctx context.Context) (int, error) { + return markEncryptedBindingsNeedingRotation(ctx, tx.tx) +} + +func markEncryptedBindingsNeedingRotation(ctx context.Context, q sqlExecutor) (int, error) { now := timestampNow() - res, err := s.db.ExecContext(ctx, ` + res, err := q.ExecContext(ctx, ` UPDATE secret_bindings SET needs_rotation = 1, updated_at = ? WHERE encrypted_value_ref IS NOT NULL AND needs_rotation = 0; @@ -3259,7 +3349,8 @@ SELECT COUNT(*) FROM devices WHERE trust_state IN ('approved', 'revoked', 'lost' // lifecycle-script environments, so they are trust-affecting. func mustVerifyEvent(eventType string) bool { switch eventType { - case "project.deleted", "project.renamed", "env.profile.updated": + case "project.deleted", "project.renamed", "env.profile.updated", + "device.revoked", "device.lost": return true default: return false diff --git a/internal/state/store_test.go b/internal/state/store_test.go index f76dd6a..5bc555f 100644 --- a/internal/state/store_test.go +++ b/internal/state/store_test.go @@ -1493,7 +1493,7 @@ func TestEnvProfilesForBlobRef(t *testing.T) { } func TestMustVerifyEventIncludesTrustAffectingTypes(t *testing.T) { - for _, eventType := range []string{"project.deleted", "project.renamed", "env.profile.updated"} { + for _, eventType := range []string{"project.deleted", "project.renamed", "env.profile.updated", "device.revoked", "device.lost"} { if !mustVerifyEvent(eventType) { t.Fatalf("mustVerifyEvent(%q) = false, want true", eventType) } @@ -1667,3 +1667,133 @@ func TestClearRotationForProject(t *testing.T) { } } } + +// TestApplyRemoteDeviceTrustTxMatrix pins the sticky/monotonic transition +// rules for synced device.revoked/device.lost (TRUST-01). +func TestApplyRemoteDeviceTrustTxMatrix(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(); err != nil { + t.Fatal(err) + } + if err := st.EnsureWorkspace(ctx, "test", "/tmp/Code"); err != nil { + t.Fatal(err) + } + local, err := st.EnsureDevice(ctx, "local-device") + if err != nil { + t.Fatal(err) + } + seed := func(id, trust string) { + t.Helper() + if err := st.UpsertDevice(ctx, Device{ID: id, Name: id, OS: "linux", Arch: "arm64", TrustState: trust}); err != nil { + t.Fatal(err) + } + } + seed("dev-pending", "pending") + seed("dev-approved", "approved") + seed("dev-revoked", "revoked") + seed("dev-lost", "lost") + + cases := []struct { + target, to string + wantChanged bool + wantState string + }{ + {"dev-pending", "revoked", true, "revoked"}, // pending -> revoked is fail-closed + {"dev-approved", "lost", true, "lost"}, // approved -> lost flips + {"dev-revoked", "lost", false, "revoked"}, // revoked <-> lost churn no-ops + {"dev-lost", "revoked", false, "lost"}, // sticky both directions + {local.ID, "revoked", false, local.TrustState}, // local device never flips remotely + } + for _, tc := range cases { + var changed bool + if err := st.WithTx(ctx, func(tx *Tx) error { + var err error + changed, err = tx.ApplyRemoteDeviceTrustTx(ctx, tc.target, tc.to) + return err + }); err != nil { + t.Fatalf("%s -> %s: %v", tc.target, tc.to, err) + } + if changed != tc.wantChanged { + t.Fatalf("%s -> %s: changed=%v, want %v", tc.target, tc.to, changed, tc.wantChanged) + } + devices, err := st.ListDevices(ctx) + if err != nil { + t.Fatal(err) + } + for _, d := range devices { + if d.ID == tc.target && d.TrustState != tc.wantState { + t.Fatalf("%s -> %s: state=%q, want %q", tc.target, tc.to, d.TrustState, tc.wantState) + } + } + } + // approved is not a valid remote transition target. + if err := st.WithTx(ctx, func(tx *Tx) error { + _, err := tx.ApplyRemoteDeviceTrustTx(ctx, "dev-pending", "approved") + return err + }); err == nil { + t.Fatal("remote approve must be rejected — approval is the local ceremony only") + } +} + +// TestUpsertEnvProfileTxPreservesNeedsRotation (TRUST-01 dogfood finding): a +// superseding upsert (revoke rewrap repointing the blob ref) must carry each +// binding's needs_rotation flag forward — wiping it broke the P5-PROD-03 +// doctor surfacing on the revoker and on every receiving device. +func TestUpsertEnvProfileTxPreservesNeedsRotation(t *testing.T) { + ctx := context.Background() + st, err := Open(ctx, filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate(); err != nil { + t.Fatal(err) + } + if err := st.EnsureWorkspace(ctx, "test", "/tmp/Code"); err != nil { + t.Fatal(err) + } + if _, err := st.EnsureDevice(ctx, "local-device"); err != nil { + t.Fatal(err) + } + ns, err := st.UpsertProject(ctx, UpsertProjectParams{Path: "work/x", Type: "git_repo", RemoteURL: "https://example.com/x", RemoteKey: "example.com/x"}) + if err != nil { + t.Fatal(err) + } + params := EnvProfileParams{Name: "default", Provider: "devstrap_encrypted", Mode: "hydrate_or_runtime", BlobRef: "age_blob:old", VarNames: []string{"API_TOKEN", "DB_URL"}} + if err := st.WithTx(ctx, func(tx *Tx) error { + _, err := tx.UpsertEnvProfileTx(ctx, ns.ID, params, Event{ID: "e1", DeviceID: "d1", HLC: 10}) + return err + }); err != nil { + t.Fatal(err) + } + if _, err := st.MarkEncryptedBindingsNeedingRotation(ctx); err != nil { + t.Fatal(err) + } + // Superseding upsert (rewrap: same vars, new ref) must keep the flags. + params.BlobRef = "age_blob:new" + if err := st.WithTx(ctx, func(tx *Tx) error { + _, err := tx.UpsertEnvProfileTx(ctx, ns.ID, params, Event{ID: "e2", DeviceID: "d1", HLC: 20}) + return err + }); err != nil { + t.Fatal(err) + } + n, err := st.CountSecretBindingsNeedingRotation(ctx) + if err != nil { + t.Fatal(err) + } + if n != 2 { + t.Fatalf("needs_rotation after superseding upsert = %d, want 2 (flags preserved)", n) + } + // And the operator clear still works on the fresh rows. + if _, err := st.ClearRotationForProject(ctx, ns.ID); err != nil { + t.Fatal(err) + } + if n, err = st.CountSecretBindingsNeedingRotation(ctx); err != nil || n != 0 { + t.Fatalf("after clear: n=%d err=%v", n, err) + } +} diff --git a/internal/sync/events.go b/internal/sync/events.go index 92d3a71..9156a09 100644 --- a/internal/sync/events.go +++ b/internal/sync/events.go @@ -26,6 +26,13 @@ const ( // ENV-SYNC-01: captured/bound env profile metadata; supersedes the planned "env.profile.bound". EventEnvProfileUpdated = "env.profile.updated" EventDeviceKeyGranted = "device.key.granted" // P4-SEC-07: age-wrapped WCK epoch grant + // TRUST-01: fleet-wide device-trust propagation. Only the fail-safe + // direction syncs — device.approved is DELIBERATELY not an event, because + // propagating approvals would let one compromised approved device enroll + // attacker devices fleet-wide; approval stays the local P4-SEC-04 + // fingerprint ceremony. + EventDeviceRevoked = "device.revoked" + EventDeviceLost = "device.lost" ) // Conflict type identifiers, exported so the CLI resolver can branch on them @@ -114,6 +121,14 @@ type EnvProfilePayload struct { Refs map[string]string `json:"refs,omitempty"` // provider profiles only (var -> op:// ref) } +// DeviceTrustPayload names the target of a device.revoked/device.lost event +// (TRUST-01). The resulting trust state derives from the event TYPE, not the +// payload, so there is exactly one source of truth. Reason is audit-only. +type DeviceTrustPayload struct { + DeviceID string `json:"device_id"` + Reason string `json:"reason,omitempty"` +} + // DeviceKeyGrant carries a device.key.granted event (P4-SEC-07): a Workspace // Content Key for an epoch, age-wrapped to a single approved device's X25519 // recipient. Grant events ride the hub event log as PLAINTEXT (the decorator @@ -276,6 +291,17 @@ func NewEnvProfileEvent(payloadJSON string) state.Event { } } +// NewDeviceTrustEvent builds an unsigned device.revoked/device.lost event +// from a pre-marshaled DeviceTrustPayload (TRUST-01). The store stamps HLC, +// seq, device id, and the device signature on InsertLocalEvent. +func NewDeviceTrustEvent(typ, payloadJSON string) state.Event { + return state.Event{ + Type: typ, + PayloadJSON: payloadJSON, + ContentHash: state.ContentHash(payloadJSON), + } +} + // NewDeviceKeyGrantEvent builds an unsigned device.key.granted event from a // pre-marshaled DeviceKeyGrant payload (P4-SEC-07). The store stamps HLC, seq, // device id, and the device signature on InsertLocalEvent. Grant events are NOT @@ -851,6 +877,40 @@ func applyEventTx(ctx context.Context, tx *state.Tx, event state.Event) error { return fmt.Errorf("apply env profile for %q: %w", payload.Path, err) } return nil + case EventDeviceRevoked, EventDeviceLost: + // TRUST-01: a synced trust flip. Signature verification already ran + // (mustVerifyEvent), so the SIGNER is a locally-approved device; the + // TARGET may be unknown here (device records do not sync), so ensure a + // placeholder row exists for the sticky update to act on — + // pending -> revoked is the fail-closed direction. The update itself + // is monotonic (only pending/approved flip; the local device never + // flips from a remote event) and replay-idempotent; needs_rotation is + // flagged only when a row ACTUALLY changed, so replays can never + // re-flag values an operator already rotated and cleared. + var payload DeviceTrustPayload + if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { + return fmt.Errorf("decode event %s: %w", event.ID, err) + } + if payload.DeviceID == "" { + return fmt.Errorf("decode event %s: empty target device id", event.ID) + } + if err := tx.EnsureRemoteDeviceTx(ctx, payload.DeviceID); err != nil { + return err + } + trustState := "revoked" + if event.Type == EventDeviceLost { + trustState = "lost" + } + changed, err := tx.ApplyRemoteDeviceTrustTx(ctx, payload.DeviceID, trustState) + if err != nil { + return err + } + if changed { + if _, err := tx.MarkEncryptedBindingsNeedingRotationTx(ctx); err != nil { + return err + } + } + return nil 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 diff --git a/internal/sync/trust_apply_test.go b/internal/sync/trust_apply_test.go new file mode 100644 index 0000000..91f44ec --- /dev/null +++ b/internal/sync/trust_apply_test.go @@ -0,0 +1,322 @@ +package sync + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/Reederey87/DevStrap/internal/devicekeys" + "github.com/Reederey87/DevStrap/internal/state" +) + +func signedDeviceTrustEvent(t *testing.T, signing devicekeys.SigningIdentity, id, signer string, seq, hlc int64, typ, target string) state.Event { + t.Helper() + raw, err := json.Marshal(DeviceTrustPayload{DeviceID: target}) + if err != nil { + t.Fatal(err) + } + ev := state.Event{ + ID: id, + DeviceID: signer, + Seq: seq, + HLC: hlc << hlcLogicalBits, + Type: typ, + PayloadJSON: string(raw), + ContentHash: state.ContentHash(string(raw)), + } + sig, err := devicekeys.Sign(signing.Private, "devstrap:event:v2", state.EventSignaturePayloadV2(ev)) + if err != nil { + t.Fatal(err) + } + ev.DeviceSig = sig + return ev +} + +func deviceTrustState(t *testing.T, st *state.Store, id string) string { + t.Helper() + devices, err := st.ListDevices(context.Background()) + if err != nil { + t.Fatal(err) + } + for _, d := range devices { + if d.ID == id { + return d.TrustState + } + } + return "" +} + +// TestApplyDeviceRevokedFlipsTrustAndFlagsRotation: a revoke signed by an +// approved device flips the target and flags encrypted bindings for rotation. +func TestApplyDeviceRevokedFlipsTrustAndFlagsRotation(t *testing.T) { + ctx := context.Background() + st, device := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + now := time.Now().UnixMilli() + + // Seed a project + captured profile so there is a binding to flag. + add := projEvent(t, device.ID, EventProjectAdded, now, "work/acme/api", "github.com/acme/api") + env := signedEnvProfileEvent(t, signingA, "evt_env_seed", "device-a", 1, now+1, EnvProfilePayload{ + Path: "work/acme/api", Profile: "default", Provider: "devstrap_encrypted", + Mode: "hydrate_or_runtime", BlobRef: "age_blob:seed", VarNames: []string{"API_TOKEN"}, + }) + if _, err := ApplyEvents(ctx, st, []state.Event{add, env}); err != nil { + t.Fatal(err) + } + + revoke := signedDeviceTrustEvent(t, signingA, "evt_revoke_b", "device-a", 2, now+2, EventDeviceRevoked, "device-b") + _, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{revoke}, nil) + if err != nil { + t.Fatal(err) + } + if stats.Quarantined != 0 { + t.Fatalf("stats=%+v, want the revoke applied", stats) + } + if got := deviceTrustState(t, st, "device-b"); got != "revoked" { + t.Fatalf("device-b trust=%q, want revoked", got) + } + n, err := st.CountSecretBindingsNeedingRotation(ctx) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("needs_rotation count=%d, want 1", n) + } +} + +// TestApplyDeviceTrustReplayDoesNotReflagRotation: after the operator rotates +// and clears the flags, a second trust event for the SAME already-revoked +// target (changed=false) must not re-flag. +func TestApplyDeviceTrustReplayDoesNotReflagRotation(t *testing.T) { + ctx := context.Background() + st, device := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + now := time.Now().UnixMilli() + + add := projEvent(t, device.ID, EventProjectAdded, now, "work/acme/api", "github.com/acme/api") + env := signedEnvProfileEvent(t, signingA, "evt_env_seed", "device-a", 1, now+1, EnvProfilePayload{ + Path: "work/acme/api", Profile: "default", Provider: "devstrap_encrypted", + Mode: "hydrate_or_runtime", BlobRef: "age_blob:seed", VarNames: []string{"API_TOKEN"}, + }) + revoke := signedDeviceTrustEvent(t, signingA, "evt_revoke_b", "device-a", 2, now+2, EventDeviceRevoked, "device-b") + if _, err := ApplyEvents(ctx, st, []state.Event{add, env, revoke}); err != nil { + t.Fatal(err) + } + project, err := st.ProjectByPath(ctx, "work/acme/api") + if err != nil { + t.Fatal(err) + } + if _, err := st.ClearRotationForProject(ctx, project.ID); err != nil { + t.Fatal(err) + } + + // A later `lost` for the already-revoked target is a sticky no-op. + lost := signedDeviceTrustEvent(t, signingA, "evt_lost_b", "device-a", 3, now+3, EventDeviceLost, "device-b") + if _, err := ApplyEvents(ctx, st, []state.Event{lost}); err != nil { + t.Fatal(err) + } + if got := deviceTrustState(t, st, "device-b"); got != "revoked" { + t.Fatalf("device-b trust=%q, want revoked (sticky, lost no-op)", got) + } + n, err := st.CountSecretBindingsNeedingRotation(ctx) + if err != nil { + t.Fatal(err) + } + if n != 0 { + t.Fatalf("needs_rotation count=%d, want 0 (no re-flag on changed=false)", n) + } +} + +// TestApplyDeviceRevokedUnknownTargetCreatesRevokedPlaceholder: the target may +// be a device this store has never seen (device records do not sync); the +// apply creates the placeholder and revokes it, so its future events +// quarantine. +func TestApplyDeviceRevokedUnknownTargetCreatesRevokedPlaceholder(t *testing.T) { + ctx := context.Background() + st, _ := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + now := time.Now().UnixMilli() + revoke := signedDeviceTrustEvent(t, signingA, "evt_revoke_c", "device-a", 1, now, EventDeviceRevoked, "device-never-seen") + if _, err := ApplyEvents(ctx, st, []state.Event{revoke}); err != nil { + t.Fatal(err) + } + if got := deviceTrustState(t, st, "device-never-seen"); got != "revoked" { + t.Fatalf("unknown target trust=%q, want revoked placeholder", got) + } +} + +// TestApplyDeviceRevokedFromUntrustedSignerQuarantines: device.revoked is +// mustVerify — a pending (unpinned) signer's revoke quarantines instead of +// flipping trust. +func TestApplyDeviceRevokedFromUntrustedSignerQuarantines(t *testing.T) { + ctx := context.Background() + st, _ := newSyncStore(t) + signingP := addRemoteDeviceForApplyTest(t, st, "device-pending", "pending") + addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + now := time.Now().UnixMilli() + revoke := signedDeviceTrustEvent(t, signingP, "evt_revoke_bad", "device-pending", 1, now, EventDeviceRevoked, "device-b") + _, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{revoke}, nil) + if err != nil { + t.Fatal(err) + } + if stats.Quarantined != 1 { + t.Fatalf("stats=%+v, want the untrusted revoke quarantined", stats) + } + if got := deviceTrustState(t, st, "device-b"); got != "approved" { + t.Fatalf("device-b trust=%q, want approved (untrusted revoke rejected)", got) + } +} + +// TestApplyDeviceTrustLocalTargetIsNoOp: a remote event naming THIS device +// never flips local trust — the hub cannot talk a device into distrusting +// itself. +func TestApplyDeviceTrustLocalTargetIsNoOp(t *testing.T) { + ctx := context.Background() + st, device := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + now := time.Now().UnixMilli() + revoke := signedDeviceTrustEvent(t, signingA, "evt_revoke_local", "device-a", 1, now, EventDeviceRevoked, device.ID) + _, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{revoke}, nil) + if err != nil { + t.Fatal(err) + } + if stats.Quarantined != 0 { + t.Fatalf("stats=%+v, want event consumed as a no-op", stats) + } + if got := deviceTrustState(t, st, device.ID); got == "revoked" || got == "lost" { + t.Fatalf("local device trust=%q, must never flip from a remote event", got) + } +} + +// TestApplyMutualRevocationSingleBatchDeterministic: [A revokes B, B revokes A] +// in one batch converges to the HLC-earlier revoke regardless of input order — +// the winner flips its target first, so the loser's event then fails +// verification (its signer is revoked) and quarantines. +func TestApplyMutualRevocationSingleBatchDeterministic(t *testing.T) { + now := time.Now().UnixMilli() + for _, order := range []string{"a-first", "b-first"} { + t.Run(order, func(t *testing.T) { + ctx := context.Background() + st, _ := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + signingB := addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + revokeB := signedDeviceTrustEvent(t, signingA, "evt_a_revokes_b", "device-a", 1, now, EventDeviceRevoked, "device-b") + revokeA := signedDeviceTrustEvent(t, signingB, "evt_b_revokes_a", "device-b", 1, now+1, EventDeviceRevoked, "device-a") + batch := []state.Event{revokeB, revokeA} + if order == "b-first" { + batch = []state.Event{revokeA, revokeB} + } + _, stats, err := ApplyEventsWithStats(ctx, st, batch, nil) + if err != nil { + t.Fatal(err) + } + // A's revoke has the earlier HLC: it applies, B flips to revoked, + // then B's counter-revoke fails verification and quarantines. + if got := deviceTrustState(t, st, "device-b"); got != "revoked" { + t.Fatalf("device-b trust=%q, want revoked (HLC-earlier revoke wins)", got) + } + if got := deviceTrustState(t, st, "device-a"); got != "approved" { + t.Fatalf("device-a trust=%q, want approved (counter-revoke quarantined)", got) + } + if stats.Quarantined != 1 { + t.Fatalf("stats=%+v, want exactly the counter-revoke quarantined", stats) + } + }) + } +} + +// TestApplyDeviceRevokedThenTargetEventQuarantines: once the revoke applies, +// the revoked device's later events in the SAME batch quarantine while other +// devices' events keep applying. +func TestApplyDeviceRevokedThenTargetEventQuarantines(t *testing.T) { + ctx := context.Background() + st, device := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + signingB := addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + now := time.Now().UnixMilli() + revoke := signedDeviceTrustEvent(t, signingA, "evt_revoke_b", "device-a", 1, now, EventDeviceRevoked, "device-b") + fromB := signedProjEvent(t, signingB, "evt_b_after", "device-b", 1, now+1, EventProjectAdded, "work/acme/late", "github.com/acme/late") + fromLocal := projEvent(t, device.ID, EventProjectAdded, now+2, "work/acme/fine", "github.com/acme/fine") + fromLocal.Seq = 1 + _, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{revoke, fromB, fromLocal}, nil) + if err != nil { + t.Fatal(err) + } + if stats.Quarantined != 1 { + t.Fatalf("stats=%+v, want exactly B's post-revoke event quarantined", stats) + } + if _, err := st.ProjectByPath(ctx, "work/acme/late"); err == nil { + t.Fatal("revoked device's project applied, want quarantined") + } + if _, err := st.ProjectByPath(ctx, "work/acme/fine"); err != nil { + t.Fatalf("unrelated device's event must still apply: %v", err) + } +} + +// TestApplyMutualRevocationCrossWindowDivergesLoudly pins the ACCEPTED +// residual (adversarial review): two bystanders that pull a mutual revocation +// in OPPOSITE windows diverge (each trusts the device whose revoke it saw +// first), but the outcome is fail-closed and LOUD on both — the loser's +// counter-revoke is preserved in an open verification quarantine, never +// silently dropped. Recovery is the documented local ceremony; note that +// re-approving the loser REPLAYS its counter-revoke (flipping the other +// device), so full recovery is re-approving BOTH — two steps, no data loss. +func TestApplyMutualRevocationCrossWindowDivergesLoudly(t *testing.T) { + ctx := context.Background() + now := time.Now().UnixMilli() + + newBystander := func() (*state.Store, state.Event, state.Event) { + st, _ := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + signingB := addRemoteDeviceForApplyTest(t, st, "device-b", "approved") + revokeB := signedDeviceTrustEvent(t, signingA, "evt_a_revokes_b", "device-a", 1, now, EventDeviceRevoked, "device-b") + revokeA := signedDeviceTrustEvent(t, signingB, "evt_b_revokes_a", "device-b", 1, now+1, EventDeviceRevoked, "device-a") + return st, revokeB, revokeA + } + + // Bystander C pulls A's revoke first (window 1), then B's (window 2). + stC, revokeB, revokeA := newBystander() + if _, err := ApplyEvents(ctx, stC, []state.Event{revokeB}); err != nil { + t.Fatal(err) + } + if _, err := ApplyEvents(ctx, stC, []state.Event{revokeA}); err != nil { + t.Fatal(err) + } + // Bystander D pulls in the opposite window order. + stD, revokeB2, revokeA2 := newBystander() + if _, err := ApplyEvents(ctx, stD, []state.Event{revokeA2}); err != nil { + t.Fatal(err) + } + if _, err := ApplyEvents(ctx, stD, []state.Event{revokeB2}); err != nil { + t.Fatal(err) + } + + // Divergence: C revoked B and kept A; D revoked A and kept B. + if got := deviceTrustState(t, stC, "device-b"); got != "revoked" { + t.Fatalf("C: device-b=%q, want revoked", got) + } + if got := deviceTrustState(t, stC, "device-a"); got != "approved" { + t.Fatalf("C: device-a=%q, want approved", got) + } + if got := deviceTrustState(t, stD, "device-a"); got != "revoked" { + t.Fatalf("D: device-a=%q, want revoked", got) + } + if got := deviceTrustState(t, stD, "device-b"); got != "approved" { + t.Fatalf("D: device-b=%q, want approved", got) + } + + // Loudness: the losing counter-revoke is an OPEN verification conflict on + // both bystanders, preserved for operator-driven replay — never silent. + for name, st := range map[string]*state.Store{"C": stC, "D": stD} { + open, err := st.OpenConflictsByType(ctx, ConflictEventVerification) + if err != nil { + t.Fatal(err) + } + if len(open) != 1 { + t.Fatalf("%s: open verification conflicts = %d, want 1 (the quarantined counter-revoke)", name, len(open)) + } + } +} diff --git a/spec/00_START_HERE.md b/spec/00_START_HERE.md index 1dc7e58..03cdcc0 100644 --- a/spec/00_START_HERE.md +++ b/spec/00_START_HERE.md @@ -156,7 +156,7 @@ Implemented in this repository: Not implemented yet (genuinely unbuilt — features that are partly shipped are listed under "now built" below, never here): - the bespoke **HTTP/SSE relay** (full-state snapshot exchange is now SHIPPED — sealed snapshots, signed retention manifest, `hub compact`, fail-closed import; the live R2/S3 backend is shipped: the `aws-sdk-go-v2` S3 adapter is wired behind the `hubFromOptions` `r2://` seam, with the `Hub` interface, R2 keying/retry/conditional-put logic, blob GC, retention floor, and content-hash verification all shipped and unit-tested, and the same conformance contract proven against MinIO via an env-gated integration test); -- production **remote device registration** (out-of-band fingerprint confirmation, the local trust plane, synced encrypted env/draft bundle exchange, and device-revoke rewrap are shipped); +- production **remote device registration** (out-of-band fingerprint confirmation, the local trust plane, synced encrypted env/draft bundle exchange, device-revoke rewrap, and synced `device.revoked`/`device.lost` trust propagation — TRUST-01 — are shipped); - daemon, local socket API, FSEvents-specific Mac watcher, LaunchAgent/systemd installers; - OS-enforced agent sandboxing, project-env allowlists, and non-generic engine adapters; - cross-machine working-state sync — git-state validation plane (`repo.gitstate.observed`) and WIP refs (`refs/devstrap/wip/*`); the encrypted draft-bundle layer (Layer C) is shipped; diff --git a/spec/07_NAMESPACE_AND_SYNC_MODEL.md b/spec/07_NAMESPACE_AND_SYNC_MODEL.md index c9f4cc5..fc65e1e 100644 --- a/spec/07_NAMESPACE_AND_SYNC_MODEL.md +++ b/spec/07_NAMESPACE_AND_SYNC_MODEL.md @@ -233,7 +233,6 @@ conflict.resolved ```text workspace.created device.registered -device.revoked # revoke is local-only today; no applied event path (P6-SYNC-01/03) device.heartbeat project.restored # today restoration happens via a project.added event with HLC above the tombstone repo.remote.changed @@ -612,7 +611,9 @@ If two devices modify the same draft offline, create two snapshots and require m Devices are enrolled and approved per-device (`devstrap devices`, `15_SECURITY_THREAT_MODEL.md`). Encrypted env and draft blobs are age-encrypted to the **set** of approved device recipients, so a trust change carries a cryptographic cost — age has no native revocation, and a recipient that still holds the old key can read any ciphertext it already pulled. -`device.revoked` (revoke / lost) therefore drives two actions: +`devices revoke`/`lost` emit a synced `device.revoked`/`device.lost` event (TRUST-01, mustVerify, in the SAME transaction as the local trust flip) so the whole fleet learns the decision; on each receiving device the apply is sticky/monotonic (only `pending`/`approved` rows flip; the local device never flips from a remote event; only the local fingerprint ceremony resurrects a device) and flags `secret_bindings.needs_rotation` exactly once (gated on an actual state change, so replays never re-flag cleared rotations). `device.approved` is DELIBERATELY not propagated — approval stays the local `P4-SEC-04` ceremony, because a propagated approval would let one compromised device enroll attackers fleet-wide. Accepted residual: mutual revocation (A revokes B while B revokes A) converges deterministically within one pull batch (HLC order — the earlier revoke wins and the counter-revoke quarantines) but bystanders that see the two events across different pull windows can diverge (one vs both revoked); either outcome is fail-closed and loud (quarantine rows preserve the loser), and the operator resolves by the local ceremony — note re-approving one side REPLAYS its quarantined counter-revoke (flipping the other side), so full recovery on a divergent bystander is re-approving BOTH devices, two steps, no data loss (pinned by `TestApplyMutualRevocationCrossWindowDivergesLoudly`). + +Locally, revoke / lost also drives two actions: ```text 1. re-encrypt every affected age_blob: to the REDUCED recipient set, @@ -716,10 +717,10 @@ Pinned by `internal/sync` grace tests (within-grace truncates, expired quarantin ### P6-SYNC-01 — Signature/trust failures in `ApplyEvents` no longer abort the whole batch -**Status.** Steps 1-2 are shipped: verification failures wrap `state.ErrEventVerification`, and `ApplyEvents` records `event_verification_failure` conflicts for signature/trust/content-hash failures and `ErrDivergentEvent`, then continues applying the rest of the batch. Quarantined events are counted as *consumed* for the cursor (a batch ending in one must not be re-delivered forever by the inclusive pull boundary). `insertEvent` verifies signature/trust **before** the prev-hash chain check — otherwise a revoked device's second chained event would surface as a transient `ErrEventHashChain` (its quarantined predecessor is never inserted) and permanently hold the cursor, reintroducing the wedge. Conflict details carry a machine-readable `kind` (`verification` vs `divergent`) plus the full marshaled `state.Event`, and dedup by event ID (the error string is volatile across trust-state changes). `devices approve` and `devices enroll --approve` replay matching `verification`-kind quarantined events and resolve those that now apply; a replayed `device.key.granted` additionally **ingests its WCK into the keyring** (post-#33 review, gpt-5.5) — `EncryptedHub.Pull` is the only other ingest path and it already advanced past the quarantined carrier, so without replay-time ingestion the granted `(epoch, kid)` would be permanently lost and every fleet event sealed under it would defer forever. `divergent`-kind rows are data-integrity disputes and are never auto-resolved by approval. Remaining gap: `devices revoke` is still local-only, so there is no applied `device.revoked` trust event for other devices to learn automatically; a still-pushing revoked device also grows one open conflict row per distinct poisoned event (bounded aggregation is a follow-up). +**Status.** Steps 1-2 are shipped: verification failures wrap `state.ErrEventVerification`, and `ApplyEvents` records `event_verification_failure` conflicts for signature/trust/content-hash failures and `ErrDivergentEvent`, then continues applying the rest of the batch. Quarantined events are counted as *consumed* for the cursor (a batch ending in one must not be re-delivered forever by the inclusive pull boundary). `insertEvent` verifies signature/trust **before** the prev-hash chain check — otherwise a revoked device's second chained event would surface as a transient `ErrEventHashChain` (its quarantined predecessor is never inserted) and permanently hold the cursor, reintroducing the wedge. Conflict details carry a machine-readable `kind` (`verification` vs `divergent`) plus the full marshaled `state.Event`, and dedup by event ID (the error string is volatile across trust-state changes). `devices approve` and `devices enroll --approve` replay matching `verification`-kind quarantined events and resolve those that now apply; a replayed `device.key.granted` additionally **ingests its WCK into the keyring** (post-#33 review, gpt-5.5) — `EncryptedHub.Pull` is the only other ingest path and it already advanced past the quarantined carrier, so without replay-time ingestion the granted `(epoch, kid)` would be permanently lost and every fleet event sealed under it would defer forever. `divergent`-kind rows are data-integrity disputes and are never auto-resolved by approval. The `device.revoked`/`device.lost` apply path is now shipped (TRUST-01) — `devices revoke`/`lost` emit the synced trust event and other devices flip the target on their next pull, so revoked traffic is rejected by synced trust state fleet-wide. Remaining: a still-pushing revoked device grows one open conflict row per distinct poisoned event (bounded aggregation is a follow-up). **Remaining actionable step.** -1. Ship a real `device.revoked` apply path so revoked events are rejected by synced trust state, not only by the verifier that made the local revoke decision. +1. ~~Ship a real `device.revoked` apply path~~ — SHIPPED (TRUST-01, 2026-07-05): revoked events are rejected by synced trust state fleet-wide, not only by the verifier that made the local revoke decision. ```go if errors.Is(err, state.ErrEventVerification) { insertVerificationConflict(...); continue } @@ -745,7 +746,7 @@ Records surface as `status` "Skipped hub events: N" and a graded `doctor` "skipp SELECT COUNT(*) FROM devices WHERE trust_state IN ('approved', 'revoked', 'lost'); ``` -A revoked/lost row proves a deliberate local operator trust decision happened (revoking a never-approved `pending` device also closes the window — the safe, more-fail-closed direction, since no sync/remote path can inject a revoked/lost row), so revoking (or losing) the last approved device keeps verification fail-closed; auto-created `pending` placeholders from `EnsureRemoteDeviceTx` deliberately do not count, and the genuinely-never-enrolled bootstrap window (`P4-SEC-04`) is unchanged. Post-revoke events from the revoked device or any unknown device land in the `P6-SYNC-01` per-event quarantine (`event_verification_failure` conflicts) instead of applying or aborting the batch. Pinned by `TestHasEnrolledDevicesStickyAfterRevoke` (`internal/state`) and `TestApplyEventsRevokedLastDeviceStaysFailClosed` (`internal/sync`). The deeper fix — synced `device.revoked` trust propagation so *other* devices also learn of the revocation — remains open (tracked with the `P6-SYNC-01` residuals). +A revoked/lost row proves a deliberate local operator trust decision happened (revoking a never-approved `pending` device also closes the window — the safe, more-fail-closed direction, since no sync/remote path can inject a revoked/lost row), so revoking (or losing) the last approved device keeps verification fail-closed; auto-created `pending` placeholders from `EnsureRemoteDeviceTx` deliberately do not count, and the genuinely-never-enrolled bootstrap window (`P4-SEC-04`) is unchanged. Post-revoke events from the revoked device or any unknown device land in the `P6-SYNC-01` per-event quarantine (`event_verification_failure` conflicts) instead of applying or aborting the batch. Pinned by `TestHasEnrolledDevicesStickyAfterRevoke` (`internal/state`) and `TestApplyEventsRevokedLastDeviceStaysFailClosed` (`internal/sync`). The deeper fix — synced `device.revoked`/`device.lost` trust propagation so *other* devices also learn of the revocation — is SHIPPED (TRUST-01, 2026-07-05): the sticky apply path flips `pending`/`approved` rows only, never the local device, and `hasEnrolledDevices` still counts the flipped rows, so propagation can only ever narrow the window. ### P6-XP-03 — `run-loop` never runs its advertised scan stage, so new local projects never reach the hub diff --git a/spec/09_SECRETS_AND_ENVIRONMENT.md b/spec/09_SECRETS_AND_ENVIRONMENT.md index 9e72b13..70fd630 100644 --- a/spec/09_SECRETS_AND_ENVIRONMENT.md +++ b/spec/09_SECRETS_AND_ENVIRONMENT.md @@ -31,7 +31,7 @@ Behavior: - hydrates on another device only after device approval; - can write local `.env.local` or inject at runtime. -Current implementation covers local capture, hydrate, provider binding, runtime injection, local OS-backed device private-key storage, manual remote-device enrollment, local device trust-state commands, and hub-backed env-bundle exchange: `devstrap env capture` parses without mutating process env, rejects dangerous variable names and interpolation-looking values unless `--literal` is explicit, encrypts the parsed bundle to the local device plus approved device age recipients, writes a `0600` ciphertext blob under `~/.devstrap/blobs`, records only `age_blob:` references in SQLite, emits `env.profile.updated`, and adds the captured file to `.gitignore` when it is inside the project. `devstrap env hydrate --write ` decrypts encrypted blobs with the local device age identity or resolves 1Password `op://` refs through `op inject`, writes the requested env file atomically with mode `0600`, refuses overwrites unless `--force`, and gitignores the hydrated target when it is inside the project. `devstrap run` injects encrypted local profiles into subprocess env or delegates 1Password refs to `op run`. `devstrap devices enroll/list/approve/revoke/lost/rename/recipient` exposes local device registration and trust-state management and refuses revocation of the current local device; `devices recipient` prints the local device's public age recipient and signing key for manual out-of-band enrollment on another device. Automatic remote enrollment remains future work; out-of-band fingerprint confirmation and the one-paste pairing-code ceremony are shipped (`P4-SEC-04` parts 1+2, see `19_CLOUD_PROVISIONING_GUIDE.md` §E). +Current implementation covers local capture, hydrate, provider binding, runtime injection, local OS-backed device private-key storage, manual remote-device enrollment, local device trust-state commands, and hub-backed env-bundle exchange: `devstrap env capture` parses without mutating process env, rejects dangerous variable names and interpolation-looking values unless `--literal` is explicit, encrypts the parsed bundle to the local device plus approved device age recipients, writes a `0600` ciphertext blob under `~/.devstrap/blobs`, records only `age_blob:` references in SQLite, emits `env.profile.updated`, and adds the captured file to `.gitignore` when it is inside the project. `devstrap env hydrate --write ` decrypts encrypted blobs with the local device age identity or resolves 1Password `op://` refs through `op inject`, writes the requested env file atomically with mode `0600`, refuses overwrites unless `--force`, and gitignores the hydrated target when it is inside the project. `devstrap run` injects encrypted local profiles into subprocess env or delegates 1Password refs to `op run`. `devstrap devices enroll/list/approve/revoke/lost/rename/recipient` exposes local device registration and trust-state management and refuses revocation of the current local device; revoke/lost propagate fleet-wide via synced `device.revoked`/`device.lost` events (TRUST-01) while approval stays the local fingerprint ceremony; `devices recipient` prints the local device's public age recipient and signing key for manual out-of-band enrollment on another device. Automatic remote enrollment remains future work; out-of-band fingerprint confirmation and the one-paste pairing-code ceremony are shipped (`P4-SEC-04` parts 1+2, see `19_CLOUD_PROVISIONING_GUIDE.md` §E). ### Mode B — Secret-manager references @@ -62,7 +62,7 @@ Env profile replay is LWW by the profile row's source-event coordinate: the high Because blobs are encrypted client-side to the enrolled device recipient set before upload (see *Encryption*), the hub stays zero-knowledge: it sees only opaque event carriers and `age_blob:` ciphertext. Repo content never uses this path — it rides git's own blobless (`--filter=blob:none`) clone/fetch transport from each repo's existing remote — and `.git`, `node_modules`, and build artifacts are never placed in the blob store. -Device revoke/lost re-encrypts affected env and draft blobs to the reduced recipient set and flags exposed env values for rotation (see *Device trust* and *Encryption*). Env rewrap now emits superseding `env.profile.updated` events before hub cleanup, then uploads the new ciphertext and deletes or queues the superseded hub blob. `secret_bindings.needs_rotation` clearing remains device-local/operator-controlled. Snapshots carry an env-profile pointer per entry (`SnapshotEnv`), merged on import by its own source-event coordinate, so profiles survive event-log compaction and snapshot bootstrap; recovery pulls the referenced env blobs alongside draft blobs. +Device revoke/lost re-encrypts affected env and draft blobs to the reduced recipient set and flags exposed env values for rotation (see *Device trust* and *Encryption*). Env rewrap now emits superseding `env.profile.updated` events before hub cleanup, then uploads the new ciphertext and deletes or queues the superseded hub blob. `secret_bindings.needs_rotation` clearing remains device-local/operator-controlled, and the superseding upsert carries each var's flag forward (a rewrap must never silently clear a rotation warning — dogfood-found fix, `TestUpsertEnvProfileTxPreservesNeedsRotation`). Snapshots carry an env-profile pointer per entry (`SnapshotEnv`), merged on import by its own source-event coordinate, so profiles survive event-log compaction and snapshot bootstrap; recovery pulls the referenced env blobs alongside draft blobs. Backend is Cloudflare R2 from the start, pluggable behind one `Hub` interface, with a file-backed local backend kept only for tests. The R2/S3 `aws-sdk-go-v2` adapter is wired behind `hub: r2://` (`P5-HUB-01`); credentials come from `DEVSTRAP_HUB_S3_*` env/config, never the URI or `state.db`. diff --git a/spec/13_CLI_DAEMON_API.md b/spec/13_CLI_DAEMON_API.md index 4853b52..faf32c9 100644 --- a/spec/13_CLI_DAEMON_API.md +++ b/spec/13_CLI_DAEMON_API.md @@ -386,7 +386,7 @@ devstrap devices recipient --fingerprint # print local device's fingerprint (c Current implementation enrolls remote device records either manually with identity/key flags or via `--code `, lists and renames device records, prints a local pairing code, and updates non-local device trust state to `approved`, `revoked`, or `lost`. `devices pairing-code` reads the local device row and workspace id, refuses if the local device lacks either public key, prints exactly the one-paste blob plus newline on stdout (frozen script contract), and prints the local fingerprint plus operator instructions on stderr. The blob carries workspace id, device id, name, OS, arch, age recipient, and signing public key; it carries **no fingerprint** and is unauthenticated by design, so integrity still comes from confirming the derived fingerprint. `devices enroll --code "$CODE"` is mutually exclusive with the manual identity/key flags and carries the device id itself, so no positional id is accepted; it refuses a workspace-id mismatch before falling through to the existing approval, epoch-contiguity, upsert, grant, and replay flow. Composition target: `devstrap devices enroll --code "$CODE" --approve --fingerprint "$FP"` is the founder-side one-command enrollment. `devices recipient` is a read-only helper that prints the local device's age recipient (or Ed25519 signing public key with `--signing`, the workspace id with `--workspace-id` for `init --join --workspace-id` pairing, or the device fingerprint with `--fingerprint`; `--signing`, `--workspace-id`, and `--fingerprint` are mutually exclusive, and the bare default output stays frozen because scripts consume it unadorned) so it can be shared out-of-band for manual enrollment on another device. `devices list` appends each device's fingerprint as the **last** column (earlier columns unchanged; `-` when a row lacks either key); `--json` is unchanged and does not carry the fingerprint. Env capture encrypts local bundles to the local recipient plus approved remote recipients. -**Fingerprint confirmation (`P4-SEC-04`).** `devices approve` and `enroll --approve` gate the trust-state change on out-of-band fingerprint confirmation *before* any DB write. The fingerprint is a full 256-bit digest binding the device's Ed25519 signing key and age recipient (never a truncated short authentication string), computed from the row/flags/code being approved — never from the local keystore — and rendered as 13 dash-separated base32 groups. Confirmation resolves in one of three ways: `--fingerprint ` compares (constant-time, dash/case/space-insensitive) and refuses on mismatch; with no flag and a TTY the fingerprint is printed and the operator must type `yes`; with no flag and no TTY the command refuses with a copy-paste remedy embedding the computed `--fingerprint ` (except `init --join --code`, which keeps initialization scriptable by storing the founder as pending and printing the follow-up approve command). `SECU-05`: approving a stored row that lacks a signing key **or** age recipient (a bare pending placeholder auto-created by sync) is refused with a re-enroll remedy rather than pinning a keyless row. `devices revoke`/`lost` are unaffected. `devices approve` and `enroll --approve` grant every held WCK epoch to the newly-approved device (`P4-SEC-07`); on a keyless **joiner** the approve path grants nothing (it is founder-gated — a joiner never self-mints) but still pins the enrolled device's keys and flips verification fail-closed, which is the documented founder-pinning ceremony a joiner runs BEFORE its first sync (`P4-SEC-04` joiner half; in a multi-device fleet the joiner pins every existing device this way — an unpinned signer's events quarantine and replay once that device is approved); `devices approve` and `enroll --approve` also replay open `verification`-kind `event_verification_failure` conflicts from that device using the stored full event JSON and resolve conflicts whose events now apply (`divergent`-kind rows are never auto-resolved). `devices approve` and `enroll --approve` refuse (before any trust write) when this device's own keyring is incomplete — a gap in held epochs `1..max` or an open `key_grant_waits` row — because the grant set would inherit the gap and strand the approved device (`P6-SEC-03` contiguity guard); `--allow-epoch-gap` overrides, after which the approved device quarantines events at the missing epochs until re-approved from a complete device — note those open quarantine conflicts also keep `hub gc` refused on that device for as long as the gap lasts (run gc from a complete device) — and a keyless device always passes (the founder-pinning ceremony grants nothing). The contiguity guard runs before the fingerprint prompt, so an operator is never asked to confirm an approval that will be refused. `devices revoke`/`lost` rotate the WCK to a new epoch (go-forward forward secrecy) before the blob re-encryption pass; env blobs emit superseding `env.profile.updated` events and draft blobs emit superseding `draft.snapshot.created` events before hub cleanup, so peers never replay a deleted ciphertext ref. When a hub is configured, revoke also best-effort deletes the revoked device's signed sync ack from the hub (`P4-SYNC-06`; a compactor already ignores non-approved acks and reclaims the whole stream, so a failure here is non-fatal). It refuses to change the current local device trust state so a user cannot revoke the only active local root by accident. Automatic remote enrollment and a synced `device.revoked` event path remain future work. +**Fingerprint confirmation (`P4-SEC-04`).** `devices approve` and `enroll --approve` gate the trust-state change on out-of-band fingerprint confirmation *before* any DB write. The fingerprint is a full 256-bit digest binding the device's Ed25519 signing key and age recipient (never a truncated short authentication string), computed from the row/flags/code being approved — never from the local keystore — and rendered as 13 dash-separated base32 groups. Confirmation resolves in one of three ways: `--fingerprint ` compares (constant-time, dash/case/space-insensitive) and refuses on mismatch; with no flag and a TTY the fingerprint is printed and the operator must type `yes`; with no flag and no TTY the command refuses with a copy-paste remedy embedding the computed `--fingerprint ` (except `init --join --code`, which keeps initialization scriptable by storing the founder as pending and printing the follow-up approve command). `SECU-05`: approving a stored row that lacks a signing key **or** age recipient (a bare pending placeholder auto-created by sync) is refused with a re-enroll remedy rather than pinning a keyless row. `devices revoke`/`lost` are unaffected. `devices approve` and `enroll --approve` grant every held WCK epoch to the newly-approved device (`P4-SEC-07`); on a keyless **joiner** the approve path grants nothing (it is founder-gated — a joiner never self-mints) but still pins the enrolled device's keys and flips verification fail-closed, which is the documented founder-pinning ceremony a joiner runs BEFORE its first sync (`P4-SEC-04` joiner half; in a multi-device fleet the joiner pins every existing device this way — an unpinned signer's events quarantine and replay once that device is approved); `devices approve` and `enroll --approve` also replay open `verification`-kind `event_verification_failure` conflicts from that device using the stored full event JSON and resolve conflicts whose events now apply (`divergent`-kind rows are never auto-resolved). `devices approve` and `enroll --approve` refuse (before any trust write) when this device's own keyring is incomplete — a gap in held epochs `1..max` or an open `key_grant_waits` row — because the grant set would inherit the gap and strand the approved device (`P6-SEC-03` contiguity guard); `--allow-epoch-gap` overrides, after which the approved device quarantines events at the missing epochs until re-approved from a complete device — note those open quarantine conflicts also keep `hub gc` refused on that device for as long as the gap lasts (run gc from a complete device) — and a keyless device always passes (the founder-pinning ceremony grants nothing). The contiguity guard runs before the fingerprint prompt, so an operator is never asked to confirm an approval that will be refused. `devices revoke`/`lost` rotate the WCK to a new epoch (go-forward forward secrecy) before the blob re-encryption pass; env blobs emit superseding `env.profile.updated` events and draft blobs emit superseding `draft.snapshot.created` events before hub cleanup, so peers never replay a deleted ciphertext ref. When a hub is configured, revoke also best-effort deletes the revoked device's signed sync ack from the hub (`P4-SYNC-06`; a compactor already ignores non-approved acks and reclaims the whole stream, so a failure here is non-fatal). It refuses to change the current local device trust state so a user cannot revoke the only active local root by accident. Revoke/lost additionally emit a synced `device.revoked`/`device.lost` event in the same transaction as the trust flip (TRUST-01), so the fleet learns the decision on its next sync — receiving devices flip the target sticky/monotonically and flag `needs_rotation`; approval never propagates (local ceremony only). Automatic remote enrollment remains future work. ## Doctor command diff --git a/spec/14_MVP_ROADMAP_AND_BACKLOG.md b/spec/14_MVP_ROADMAP_AND_BACKLOG.md index 639d6d7..a7540da 100644 --- a/spec/14_MVP_ROADMAP_AND_BACKLOG.md +++ b/spec/14_MVP_ROADMAP_AND_BACKLOG.md @@ -558,4 +558,4 @@ These are validated forward-direction decisions from the sixth-pass viability re - **DIRECTION — hardening freeze before new planes (AD-2): COMPLETE 2026-07-03.** All four named sync/crypto criticals are shipped — the confidentiality/keychain gap (`P6-SEC-01`, PRs #31/#33/#34), the whole-batch pull wedge (`P6-SYNC-01`, PR #30), the live-blob-loss GC (`P6-HUB-01`, PR #36), and the dropped-cursor path (`P5-SYNC-01`, PR #59 — per-origin-device Seq transport cursors). New capability planes (HTTP/SSE relay, native daemon, StrapFS, hosted SaaS) are unblocked from the freeze's perspective; sequence them against the remaining Pass-6 P2/P3 backlog (6 open) and the Pass-4 XL items. **The compaction/snapshot-exchange item (`P4-SYNC-02`/`P4-HUB-11`) SHIPPED 2026-07-04** (PRs #65/#73–#76) — it was also the designed recovery path for the byzantine-hub residuals in `15_SECURITY_THREAT_MODEL.md`, which is now real. Remaining XL items: OS-enforced agent sandbox (`P4-GIT-03`) — **macOS Seatbelt, Linux bubblewrap, Linux Landlock fallback, Linux seccomp syscall denylist, unsigned local `sandbox.violation` telemetry, and tighter read confinement SHIPPED 2026-07-05** (`agent run`, `--sandbox auto|off|require` default-on where available; `DEVSTRAP_SANDBOX_SECCOMP=off` opts out of the denylist; `--read-confine` default-on for readonly; the named remaining trio is complete — only the containerization direction stays open) — property/model-checked convergence (`P4-QUAL-02`) shipped 2026-07-04 (PR #94, with the reconcileSamePath HLC-monotonic follow-up in PR #95), and the zero-infrastructure hub carrier's first slice (`AD-1`, the private-git-repo backend) shipped 2026-07-04. - **AD-1 — zero-infrastructure Hub backend for first-run adoption: COMPLETE 2026-07-05.** Both carriers are live behind the pluggable `Hub` interface. The private-git-repo carrier: `hub: git+ssh://…` (also `git+https://`, `git+file://`, scp-like, `?branch=`) syncs through any private git repo the user can already push to — `GitCarrierHub` composes the proven `R2Hub` semantics over a filesystem `S3Client` in a local clone, with a fetch/commit/push write loop (push-ref CAS replaces conditional PUT; non-fast-forward retries re-apply idempotent, path-disjoint mutations) and a `--force-with-lease` orphan-squash on `hub compact` to bound carrier history. The local-folder / cloud-drive-folder carrier: `hub: folder:` roots the same `fsObjectStore` directly in a shared directory (a cloud-drive folder or network mount is the replication transport, so there is no push loop — each op is lock → delegate → unlock), with the cross-process lock and observation floor kept in the LOCAL home cache (never replicated) and only ciphertext objects in the shared folder; `FolderHub` reuses the extracted `fsLock` cross-process lock helper. See `03_SYSTEM_ARCHITECTURE.md` (Hub backends) for the full design. Backlog rows: `[x] zero-infrastructure Hub backend — private-git-repo carrier (AD-1, 2026-07-04)`, `[x] live dogfood against a real private GitHub repo (spec/19 §F.2, 2026-07-04)`, `[x] git carrier as the documented quickstart default — r2:// demoted to scale/power (README/init hints/spec-13/spec-19, 2026-07-04)`; `[x] devstrap hub init bootstrap convenience (2026-07-04)`; `[x] local-folder / cloud-drive-folder carrier (AD-1 complete, 2026-07-05)`. - **DIRECTION — distribution + OSS onboarding workstream (AD-8).** Adoption is bottlenecked on distribution and contributor friction, not features. Planned goals: cut `v0.1.0` through the existing GoReleaser pipeline (**v0.1.0 SHIPPED 2026-07-05** — tap/cask + `curl|sh` installer + completions live; the pipeline's same-commit rc → stable promotion was hardened live via the `GORELEASER_CURRENT_TAG` pin, PR #108); make the spec-drift/work-log gate **advisory on fork PRs** (**SHIPPED 2026-07-05** — `cmd/spec-drift --advisory` downgrades findings to `::warning::` annotations and exits 0; the CI workflow flips it on only when `github.event.pull_request.head.repo.full_name != github.repository`, so same-repo PRs and pushes stay blocking; `CONTRIBUTING.md` documents the fast path — the maintainer completes the bookkeeping at merge); extract a user-facing `docs/` tier (install / quickstart / self-hosting) distinct from the `spec/` design corpus (**SHIPPED 2026-07-05** — `docs/install.md`, `docs/quickstart.md`, `docs/self-hosting.md`); write a short human `ARCHITECTURE.md` (**SHIPPED 2026-07-05** — repo-root explanation tier with per-section depth pointers into `spec/`; the README's Install/Quickstart/Architecture sections now link the new tier and a Documentation pointer block); enable GitHub Discussions + good-first-issue labels (**SHIPPED 2026-07-05** — Discussions enabled, issue-template contact link, three curated starter issues #111–#113 labeled from the open backlog: `P5-CLI-03`, `P5-CLI-01` render rollout, `P4-QUAL-07` contextcheck); reframe `AGENTS.md` as the maintainer's agent workflow rather than a contributor obligation (**SHIPPED 2026-07-05** — scope banner at the top of `AGENTS.md`); and plan for bus-factor by recruiting a second write-access maintainer. -- **DIRECTION — multi-device completeness wave (2026-07-05).** With Pass 6 closed (43/43), the AD-2 freeze complete, and AD-1/AD-8 shipped, the next wave closes the two remaining genuinely-unbuilt gaps in the Phase-2 multi-device story (both still "Not implemented yet" in `00_START_HERE.md`): [x] `ENV-SYNC-01` (SHIPPED 2026-07-05) — synced encrypted env-bundle exchange: a new `env.profile.updated` event carries the captured profile's `age_blob:` ref + binding metadata over the existing blob plane (capture already encrypts to every approved device's age recipient via `envRecipients`), with LWW apply by source-event coordinate, `mustVerifyEvent` inclusion, revoke-rewrap promotion to hub-carried semantics, and a snapshot-plane env pointer so profiles survive compaction; `env hydrate`/`materialize` on the receiving device then work unchanged. [ ] `TRUST-01` — synced device-trust propagation: `device.revoked`/`device.lost` events (signed, `mustVerifyEvent`) so one device's revoke reaches the whole fleet; sticky/monotonic apply (only `pending`/`approved` transition; a local fingerprint ceremony is the only resurrection path); `device.approved` deliberately NOT propagated — approval stays the local P4-SEC-04 ceremony; receiving devices flag `secret_bindings.needs_rotation`. +- **DIRECTION — multi-device completeness wave (2026-07-05).** With Pass 6 closed (43/43), the AD-2 freeze complete, and AD-1/AD-8 shipped, the next wave closes the two remaining genuinely-unbuilt gaps in the Phase-2 multi-device story (both still "Not implemented yet" in `00_START_HERE.md`): [x] `ENV-SYNC-01` (SHIPPED 2026-07-05) — synced encrypted env-bundle exchange: a new `env.profile.updated` event carries the captured profile's `age_blob:` ref + binding metadata over the existing blob plane (capture already encrypts to every approved device's age recipient via `envRecipients`), with LWW apply by source-event coordinate, `mustVerifyEvent` inclusion, revoke-rewrap promotion to hub-carried semantics, and a snapshot-plane env pointer so profiles survive compaction; `env hydrate`/`materialize` on the receiving device then work unchanged. [x] `TRUST-01` (SHIPPED 2026-07-05) — synced device-trust propagation: `device.revoked`/`device.lost` events (signed, `mustVerifyEvent`) so one device's revoke reaches the whole fleet; sticky/monotonic apply (only `pending`/`approved` transition; a local fingerprint ceremony is the only resurrection path); `device.approved` deliberately NOT propagated — approval stays the local P4-SEC-04 ceremony; receiving devices flag `secret_bindings.needs_rotation`. diff --git a/spec/15_SECURITY_THREAT_MODEL.md b/spec/15_SECURITY_THREAT_MODEL.md index c096f11..600f10c 100644 --- a/spec/15_SECURITY_THREAT_MODEL.md +++ b/spec/15_SECURITY_THREAT_MODEL.md @@ -149,7 +149,7 @@ R2/event and blob push concurrency (`P6-HUB-03`, shipped): bounded errgroup fan- Residual risk: a malicious approved device can decrypt bundles it is authorized to receive until revoked, and **age has no native revocation**. Bound this by per-profile recipient scoping, re-encrypting every affected env/draft bundle to the reduced recipient set after revocation, emitting superseding `env.profile.updated`/`draft.snapshot.created` events before hub cleanup so peers never replay deleted refs, and requiring provider/service-side value rotation for secrets that may already have been exposed. -Reality (`SECU-03`/`SECU-05`/`HUB-03`): event signature verification **fails closed once any device has ever been enrolled** — `verifyEventSignature` requires a valid signature from a known, approved, non-local device for **all** event types once `hasEnrolledDevices` is true; unknown devices, devices with no signing key, and non-approved devices are rejected (not applied). Enrollment is **sticky** (`P6-SYNC-03`, shipped): `hasEnrolledDevices` counts `trust_state IN ('approved','revoked','lost')` — a revoked/lost row proves a deliberate local operator trust decision (no sync/remote path can inject one) — so revoking or losing the last approved device keeps the window closed — post-revoke traffic from the revoked (or any unknown) device quarantines instead of applying. The local device is exempt from the signing-key requirement (pre-enrollment grace). Destructive event types (`project.deleted`, `project.renamed`) require verification unconditionally. The remaining gap is the **pre-enrollment bootstrap window** (`SEC-04`), now narrowed: the **joiner half is closed** by the documented founder-pinning ceremony — a keyless joiner runs `init --join --code --fingerprint ` or `devices enroll … --approve` BEFORE its first sync; the grant path is founder-gated so the joiner mints and grants nothing, but the approved founder row flips `hasEnrolledDevices`, so `verifyEventSignature` and `EncryptedHub.Verify` fail closed before the joiner's first pull (pinned in `devices_pin_founder_test.go` and the `sync_join_flow` e2e). In a fleet with more than one existing device the joiner pins **every** existing approved device the same way — device records are not synced, so events signed by a device the joiner has not yet pinned quarantine as `event_verification_failure` conflicts and are replayed automatically when that device is later enrolled and approved (the `devices approve` replay path; recoverable and visible in `conflicts list`, never silently lost). Before pinning (or on a founder before any peer is approved), non-destructive events from unknown devices are still accepted so a fresh device can sync its first tree; the residual is authenticated full-state snapshot and remote trust propagation, not the local pairing ceremony. The hub must be treated as **zero-knowledge / semi-trusted** (ciphertext + routing metadata only); mTLS device certs should enforce revocation at the transport layer. +Reality (`SECU-03`/`SECU-05`/`HUB-03`): event signature verification **fails closed once any device has ever been enrolled** — `verifyEventSignature` requires a valid signature from a known, approved, non-local device for **all** event types once `hasEnrolledDevices` is true; unknown devices, devices with no signing key, and non-approved devices are rejected (not applied). Enrollment is **sticky** (`P6-SYNC-03`, shipped): `hasEnrolledDevices` counts `trust_state IN ('approved','revoked','lost')` — a revoked/lost row proves a deliberate local operator trust decision (no sync/remote path can inject one) — so revoking or losing the last approved device keeps the window closed — post-revoke traffic from the revoked (or any unknown) device quarantines instead of applying. The local device is exempt from the signing-key requirement (pre-enrollment grace). Destructive/trust-affecting event types (`project.deleted`, `project.renamed`, `env.profile.updated`, `device.revoked`, `device.lost`) require verification unconditionally. The remaining gap is the **pre-enrollment bootstrap window** (`SEC-04`), now narrowed: the **joiner half is closed** by the documented founder-pinning ceremony — a keyless joiner runs `init --join --code --fingerprint ` or `devices enroll … --approve` BEFORE its first sync; the grant path is founder-gated so the joiner mints and grants nothing, but the approved founder row flips `hasEnrolledDevices`, so `verifyEventSignature` and `EncryptedHub.Verify` fail closed before the joiner's first pull (pinned in `devices_pin_founder_test.go` and the `sync_join_flow` e2e). In a fleet with more than one existing device the joiner pins **every** existing approved device the same way — device records are not synced, so events signed by a device the joiner has not yet pinned quarantine as `event_verification_failure` conflicts and are replayed automatically when that device is later enrolled and approved (the `devices approve` replay path; recoverable and visible in `conflicts list`, never silently lost). Before pinning (or on a founder before any peer is approved), non-destructive events from unknown devices are still accepted so a fresh device can sync its first tree; the residual was authenticated full-state snapshot (shipped, `P4-SYNC-02`) and remote trust propagation (shipped, TRUST-01 — revocations sync; approval stays local by design), not the local pairing ceremony. The hub must be treated as **zero-knowledge / semi-trusted** (ciphertext + routing metadata only); mTLS device certs should enforce revocation at the transport layer. Multi-tenant isolation (future SaaS direction, `SCALE-*`): when the hub serves more than one owner, **confidentiality** is by construction — every blob and event is client-side age-encrypted before upload and namespaced by `workspace_id`, so a zero-knowledge hub cannot decrypt across tenants even if its access controls fail. Integrity and availability are not automatic: a leaked bucket-wide key can still delete, overwrite, withhold, or reorder ciphertext. Hosted mode therefore requires prefix-scoped temporary credentials, signed hash chains, fail-closed verification, snapshots/backups, retention discipline, rate limits, and cell/tenant scoping. @@ -173,11 +173,11 @@ Attacker = the untrusted hub (withholding or serving stale ack objects) or a rev ### Threat: a wrong or hostile `workspace_id` (`P4-SEC-07` pairing, by design) -Attacker = a mistaken operator, or the untrusted hub steering a device onto the wrong prefix. Devices converge only when they share one `workspace_id`, which keys every hub object under `workspaces//` (`19_CLOUD_PROVISIONING_GUIDE.md` §E). The id is a **prefix selector, not an authenticated field**: it is **excluded from event signatures by design** (remote events are re-stamped `WorkspaceID=""` on apply, so signing it would break verification across devices), and it is exchanged out-of-band alongside the enrollment key exchange (Syncthing-style — a non-secret identifier whose authorization comes from key verification, not from the id itself). This is safe because it selects a namespace, it does not grant one: pointing a device at a **wrong** id yields an empty prefix (no content), and pointing it at a **hostile** id yields only ciphertext the device cannot decrypt without a granted WCK, and events it cannot verify without the founder's pinned keys — so a bad id surfaces as an empty pull or a quarantined `event_verification_failure`/`undecryptable` conflict, never as accepted content or another workspace's plaintext. Content protection is therefore carried entirely by fail-closed signature verification, the `enc.v2` AAD binding, and the founder-pinning ceremony (`E.2`), not by the id. Residual: the pre-enrollment bootstrap window — a keyless joiner that has not yet pinned the founder still accepts non-destructive events; the local pairing ceremony that closes it is fully shipped (`P4-SEC-04` parts 1+2: fingerprint confirm + pairing codes). Bootstrap-time **state acquisition** is now signature-authenticated end-to-end (`P4-SYNC-02`): a device that bootstraps from a hub snapshot verifies the retention manifest against a locally pinned approved device before importing, so the snapshot path never widens the open-ingest window. The remaining work is remote trust propagation, not the ceremony or the state-acquisition path. +Attacker = a mistaken operator, or the untrusted hub steering a device onto the wrong prefix. Devices converge only when they share one `workspace_id`, which keys every hub object under `workspaces//` (`19_CLOUD_PROVISIONING_GUIDE.md` §E). The id is a **prefix selector, not an authenticated field**: it is **excluded from event signatures by design** (remote events are re-stamped `WorkspaceID=""` on apply, so signing it would break verification across devices), and it is exchanged out-of-band alongside the enrollment key exchange (Syncthing-style — a non-secret identifier whose authorization comes from key verification, not from the id itself). This is safe because it selects a namespace, it does not grant one: pointing a device at a **wrong** id yields an empty prefix (no content), and pointing it at a **hostile** id yields only ciphertext the device cannot decrypt without a granted WCK, and events it cannot verify without the founder's pinned keys — so a bad id surfaces as an empty pull or a quarantined `event_verification_failure`/`undecryptable` conflict, never as accepted content or another workspace's plaintext. Content protection is therefore carried entirely by fail-closed signature verification, the `enc.v2` AAD binding, and the founder-pinning ceremony (`E.2`), not by the id. Residual: the pre-enrollment bootstrap window — a keyless joiner that has not yet pinned the founder still accepts non-destructive events; the local pairing ceremony that closes it is fully shipped (`P4-SEC-04` parts 1+2: fingerprint confirm + pairing codes). Bootstrap-time **state acquisition** is now signature-authenticated end-to-end (`P4-SYNC-02`): a device that bootstraps from a hub snapshot verifies the retention manifest against a locally pinned approved device before importing, so the snapshot path never widens the open-ingest window. Remote REVOCATION propagation is now shipped (TRUST-01, `device.revoked`/`device.lost`); approval deliberately stays a local ceremony, so the remaining work here is nil. ### Threat: one bad signed event wedges every device's sync (`P6-SYNC-01`, mitigated) -Attacker = a revoked/lost device that keeps pushing signed events (or a hub replaying one). Previously any signature/trust failure in `ApplyEvents` aborted the whole batch before the cursor advanced, so a single poisoned event re-pulled and re-failed forever — a fleet-wide availability break requiring no key compromise. **Mitigated:** permanent verification failures (signature/trust/content-hash, wrapped in `state.ErrEventVerification`) and divergent duplicate IDs are now quarantined per-event as `event_verification_failure` conflicts (full event JSON retained for replay) while the rest of the batch applies and the cursor advances safely; `devices approve` replays a newly-approved device's quarantined events. Residual: `devices revoke` is local-only — a synced `device.revoked` trust event remains future work. +Attacker = a revoked/lost device that keeps pushing signed events (or a hub replaying one). Previously any signature/trust failure in `ApplyEvents` aborted the whole batch before the cursor advanced, so a single poisoned event re-pulled and re-failed forever — a fleet-wide availability break requiring no key compromise. **Mitigated:** permanent verification failures (signature/trust/content-hash, wrapped in `state.ErrEventVerification`) and divergent duplicate IDs are now quarantined per-event as `event_verification_failure` conflicts (full event JSON retained for replay) while the rest of the batch applies and the cursor advances safely; `devices approve` replays a newly-approved device's quarantined events. The synced trust event is now shipped (TRUST-01): `devices revoke`/`lost` emit `device.revoked`/`device.lost` (mustVerify, same-tx with the local flip), every receiving device applies it sticky/monotonically (local device exempt; approval never propagates — it stays the local P4-SEC-04 ceremony) and flags `secret_bindings.needs_rotation` once. Residuals: mutual revocation across different pull windows can leave bystanders divergent (fail-closed either way, loud, recovered by the two-step local re-approval — re-approving one side replays its counter-revoke); a FAILED WCK rotation during revoke leaves the old epoch active, so events pushed before a later successful rotation (including the revoke event itself) stay readable by the revoked device — the trust flip is deliberately kept (refusing the revoke would keep a compromised device approved, which is worse) and the CLI warns loudly with the `keys rotate` remedy (adversarial-review finding; fail-closed rotation is a candidate follow-up); bounded conflict-row aggregation for a still-pushing revoked device. **Related (owned elsewhere):** `P6-HUB-01` — hub-side blob GC data-loss (availability, owned in `spec/03`, shipped); `P6-SYNC-03` — revoking the last approved device previously reopened the fail-open bootstrap window (owned in `spec/07`, **shipped**: sticky enrollment, see above). @@ -198,7 +198,7 @@ Attacker = anything that can alter the seven values a device pastes during enrol **Mitigation (shipped):** approval is gated on out-of-band confirmation of a **device fingerprint** that binds *both* the Ed25519 signing key and the age recipient — `sha256("devstrap/device-fp/v1" || 0x00 || canonicalSigning || 0x00 || canonicalRecipient)`, rendered as unpadded uppercase base32 in 13 dash-separated groups of 4. Both inputs are canonicalized (parse-then-re-encode) so cosmetic encoding differences do not change the value. `devices approve`, `enroll --approve`, and `init --join --code --fingerprint` compute the fingerprint from the row/flags/code being approved (never the local keystore) and refuse the trust-state change unless it is confirmed — via `--fingerprint ` (constant-time compare), an interactive `yes` on a TTY, or, for `devices approve`/`enroll`, a non-TTY hard refusal with a copy-paste remedy. The operator reads the fingerprint off the far device (`devices recipient --fingerprint`) or its `devices pairing-code` stderr and compares character-for-character. Because the channel is untrusted, this is a **full 256-bit fingerprint, never a short authentication string (SAS)**: a truncated code would let an attacker grind a colliding key pair offline and pass the comparison, so no truncation is offered. `SECU-05`: a keyless placeholder row cannot be approved at all (nothing to bind). -`P4-SEC-04` part 2 is now shipped: `devices pairing-code` emits a one-paste `devstrap-pair1:` blob carrying workspace id, device id, display name, OS, arch, age recipient, and signing public key; `init --join --code` adopts the workspace id and pins (or pending-enrolls) the founder; `devices enroll --code "$CODE" --approve --fingerprint "$FP"` performs founder-side enrollment in one command. The blob is **deliberately unauthenticated** and carries no fingerprint. The fingerprint binds ONLY the two keys (it must stay byte-compatible with part 1's `devices recipient --fingerprint`), so the confirmation catches any key substitution — the only tampering that could forge trust. The other carried fields are NOT fingerprint-bound; tampering with them cannot mint trust but can break the ceremony **visibly**: a swapped device id pins an approved row no real signer matches, so the founder's events quarantine (`event_verification_failure`, replayed after a correct re-pair) while fail-closed verification stays engaged; a swapped workspace id lands the joiner on a wrong/empty hub prefix, detected by `doctor --remote`'s `workspace id match` heuristic, and even a hostile prefix yields only quarantined ciphertext (the pinned founder keys are the real ones — anything else failed the fingerprint); name/os/arch are cosmetic labels. `init --join --code` prints every carried field next to the confirmation prompt and tells the operator to cross-check the workspace id against `devstrap status` on the founder, and `UpsertDevice` refuses the local device id at both the CLI and SQL layers, so a blob cannot clobber the local identity row. Remedy for any tampered-field breakage: re-run the ceremony with a fresh code. This closes the local pairing-channel residual from parts 1+2; authenticated snapshot exchange is now shipped (`P4-SYNC-02` — a bootstrapping device verifies the snapshot's retention manifest against a locally pinned approved device before importing), leaving remote trust propagation as the separate future work. +`P4-SEC-04` part 2 is now shipped: `devices pairing-code` emits a one-paste `devstrap-pair1:` blob carrying workspace id, device id, display name, OS, arch, age recipient, and signing public key; `init --join --code` adopts the workspace id and pins (or pending-enrolls) the founder; `devices enroll --code "$CODE" --approve --fingerprint "$FP"` performs founder-side enrollment in one command. The blob is **deliberately unauthenticated** and carries no fingerprint. The fingerprint binds ONLY the two keys (it must stay byte-compatible with part 1's `devices recipient --fingerprint`), so the confirmation catches any key substitution — the only tampering that could forge trust. The other carried fields are NOT fingerprint-bound; tampering with them cannot mint trust but can break the ceremony **visibly**: a swapped device id pins an approved row no real signer matches, so the founder's events quarantine (`event_verification_failure`, replayed after a correct re-pair) while fail-closed verification stays engaged; a swapped workspace id lands the joiner on a wrong/empty hub prefix, detected by `doctor --remote`'s `workspace id match` heuristic, and even a hostile prefix yields only quarantined ciphertext (the pinned founder keys are the real ones — anything else failed the fingerprint); name/os/arch are cosmetic labels. `init --join --code` prints every carried field next to the confirmation prompt and tells the operator to cross-check the workspace id against `devstrap status` on the founder, and `UpsertDevice` refuses the local device id at both the CLI and SQL layers, so a blob cannot clobber the local identity row. Remedy for any tampered-field breakage: re-run the ceremony with a fresh code. This closes the local pairing-channel residual from parts 1+2; authenticated snapshot exchange is now shipped (`P4-SYNC-02` — a bootstrapping device verifies the snapshot's retention manifest against a locally pinned approved device before importing), and remote revocation propagation is shipped (TRUST-01); approval deliberately never propagates. ### Threat: split-custody wedge mints a divergent device identity on headless Linux (`P6-XP-04`, mitigated) diff --git a/spec/16_TEST_PLAN.md b/spec/16_TEST_PLAN.md index 6939022..4fa7fbb 100644 --- a/spec/16_TEST_PLAN.md +++ b/spec/16_TEST_PLAN.md @@ -284,6 +284,8 @@ This is the most important test. 3. cli: blobRefFromEvent extracts env blob refs for encrypted profiles but not provider profiles 4. txtar: env_exchange.txtar enrolls two homes, syncs through --hub-file, and hydrates captured values on the second device with no conflicts 5. `TestSnapshotRoundTripsEnvProfile` proves BuildSnapshot -> ImportSnapshot carries the env pointer into a fresh store idempotently; `TestImportSnapshotEnvLWW` proves the pointer merges by its own coordinate (an older pointer never regresses, a newer pointer wins even on a losing entry row) +6. sync trust propagation (TRUST-01): `TestApplyDeviceRevokedFlipsTrustAndFlagsRotation`, sticky replay (`...ReplayDoesNotReflagRotation`), unknown-target placeholder revoke, untrusted-signer quarantine, local-target no-op, single-batch mutual-revocation determinism (both orders), post-revoke same-batch quarantine isolation (`internal/sync/trust_apply_test.go`); `TestApplyRemoteDeviceTrustTxMatrix` (state); `TestDeviceRevokeEmitsTrustEvent` (cli, same-tx emission) +7. txtar: sync_trust_propagation.txtar — three devices, full mutual pinning; A revokes B; C learns via sync (devices list shows revoked, doctor flags rotation), B's subsequent push quarantines on C ``` ### Draft sync diff --git a/spec/18_WORK_LOG.md b/spec/18_WORK_LOG.md index 0e1edb9..964627f 100644 --- a/spec/18_WORK_LOG.md +++ b/spec/18_WORK_LOG.md @@ -31,6 +31,28 @@ Follow-ups: Entries are newest-first: each code-modifying cycle prepends ONE dated entry at the top. +## 2026-07-05 — feat(devices): synced device-trust propagation (TRUST-01) + +Changed: +- `internal/sync/events.go`: new `device.revoked`/`device.lost` events + `DeviceTrustPayload` (state derives from the event TYPE — one source of truth) + `NewDeviceTrustEvent`; apply case ensures a placeholder for an unknown target, applies the sticky flip, and flags `needs_rotation` ONLY when a row actually changed (replays never re-flag cleared rotations). `device.approved` is deliberately NOT an event — propagating approvals would let one compromised device enroll attackers fleet-wide; approval stays the local P4-SEC-04 fingerprint ceremony. +- `internal/state/store.go`: `SetDeviceTrustStateTx` (factored transactional core, refuse-local guard kept), `ApplyRemoteDeviceTrustTx` (sticky UPDATE `WHERE trust_state IN ('pending','approved')`; the local device NEVER flips from a remote event — a hub cannot talk a device into distrusting itself; returns `changed`), `MarkEncryptedBindingsNeedingRotationTx`; `mustVerifyEvent` gains both trust types. +- `internal/cli/devices.go`: `devices revoke`/`lost` write the trust flip + insert the synced event in ONE transaction (P6-DATA-03), BEFORE the WCK rotation — a rotation failure can never orphan the trust write, and the trust event's seq precedes the new epoch's grants; stderr notes the propagation. +- Semantics (design record): sticky/monotonic — revoked/lost are terminal for remote transitions, only a local approve ceremony resurrects; pending→revoked is the fail-closed direction (hasEnrolledDevices already counts revoked rows). Mutual revocation converges deterministically within one batch (HLC-earlier revoke wins; the counter-revoke fails verification once its signer flips and quarantines); across pull windows bystanders can diverge — ACCEPTED residual, fail-closed either way, loud (quarantine rows preserved), operator re-approves the survivor. No trust CRDTs by design (Keybase downgrade-lease analysis: full race-proofing needs an ordering service; wrong trade-off for a single-user fleet). +- Specs: spec/00/07/09/13/15/16 reconciled (the spec/07 "revoke is local-only" gap, the spec/15 P6-SYNC-01 residual, and spec/13's future-work line are retired); spec/14 `TRUST-01` flipped shipped. + +Validated: +- `internal/sync/trust_apply_test.go`: flip+rotation flagging, sticky replay no-reflag, unknown-target placeholder, untrusted-signer quarantine, local-target no-op, mutual-revocation both-order determinism, post-revoke same-batch quarantine isolation. `TestApplyRemoteDeviceTrustTxMatrix` (state) pins the transition matrix incl. remote-approve rejection. `TestDeviceRevokeEmitsTrustEvent` (cli) pins same-tx emission. +- e2e `sync_trust_propagation.txtar`: three devices, full mutual pinning; A revokes B → C learns via sync, doctor flags rotation, B's subsequent push quarantines on C. +- gofmt; darwin+linux builds; golangci-lint 0 issues; `go test -race ./...`; spec-drift. + +- Post-dogfood fix (live R2 run F.3 caught it): `UpsertEnvProfileTx`'s replace-all-bindings upsert was silently WIPING `needs_rotation` — a revoke flags the bindings, then the rewrap's superseding `env.profile.updated` re-inserted fresh rows with the flag cleared, on the revoker AND on every receiving device (breaking P5-PROD-03 doctor surfacing; the txtar's `stdout 'rotation'` assertion was too weak to catch it — it matched the label in "rotation 0"). The upsert now carries each var's flag forward (clearing stays the explicit device-local `env rotate` action); pinned by `TestUpsertEnvProfileTxPreservesNeedsRotation` and the hardened txtar assertion (`secrets needing rotation [1-9]`). +- Post-review (Codex adversarial, dual-review): (HIGH, partially accepted) a failed WCK rotation during revoke leaves the old epoch active so pre-rotation pushes (incl. the revoke event) stay readable by the revoked device — the fail-closed-revoke suggestion was REJECTED (refusing the revoke keeps a compromised device approved, strictly worse; rotation failure is a pre-existing P4-SEC-07 warn path); accepted the loudness half: the CLI now names the exposure and the `keys rotate` remedy, spec/15 documents the residual, fail-closed/auto-retry rotation filed as follow-up. (HIGH, residual confirmed + hardened) cross-window mutual-revocation divergence stays the documented accepted trade (no trust CRDTs/ordering service for a single-user fleet), but the review exposed an undocumented recovery trap — re-approving one side replays its quarantined counter-revoke, flipping the other — now pinned by `TestApplyMutualRevocationCrossWindowDivergesLoudly` (both bystanders diverge LOUDLY with the loser preserved in an open conflict) and documented as the two-step recovery in spec/07. + +Follow-ups: +- Fail-closed or auto-retrying WCK rotation on revoke (adversarial-review follow-up). +- Bounded conflict-row aggregation for a still-pushing revoked device (pre-existing). +- Cross-window mutual-revocation divergence: documented accepted residual (spec/07). + ## 2026-07-05 — feat(env): cross-device env-profile exchange (ENV-SYNC-01) Changed: From e717d4d673224abca4f7f0b46ef8f036fac58aea Mon Sep 17 00:00:00 2001 From: Artem Matskevych <52060443+Reederey87@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:25:18 -0400 Subject: [PATCH 2/4] =?UTF-8?q?review:=20reviewer=20Minors=20=E2=80=94=20s?= =?UTF-8?q?hipped-event=20catalog=20+=20malformed-trust-payload=20quaranti?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spec/07 shipped-event catalog gains device.revoked/device.lost (they had only been removed from the planned block). - A signed-but-malformed trust payload (decode failure / empty target) now wraps ErrEventVerification so it quarantines-as-consumed instead of aborting the pull batch — only an approved signer can produce one, which is exactly the compromised-device class these events cut off. Pinned by TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort. Co-Authored-By: Claude Fable 5 --- internal/sync/events.go | 11 +++++++-- internal/sync/trust_apply_test.go | 38 +++++++++++++++++++++++++++++ spec/07_NAMESPACE_AND_SYNC_MODEL.md | 2 ++ spec/18_WORK_LOG.md | 1 + 4 files changed, 50 insertions(+), 2 deletions(-) diff --git a/internal/sync/events.go b/internal/sync/events.go index 9156a09..6004fc2 100644 --- a/internal/sync/events.go +++ b/internal/sync/events.go @@ -888,11 +888,18 @@ func applyEventTx(ctx context.Context, tx *state.Tx, event state.Event) error { // flagged only when a row ACTUALLY changed, so replays can never // re-flag values an operator already rotated and cleared. var payload DeviceTrustPayload + // A malformed payload here is permanently malformed and only an + // APPROVED signer can produce it (mustVerify) — exactly the + // compromised-device class trust events exist to cut off. Wrap in + // ErrEventVerification so it quarantines-as-consumed (preserved in a + // conflict) instead of aborting the batch and wedging the pull + // (reviewer finding; the env/draft malformed-payload convention is + // tracked in #133). if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { - return fmt.Errorf("decode event %s: %w", event.ID, err) + return fmt.Errorf("%w: decode trust event %s: %v", state.ErrEventVerification, event.ID, err) } if payload.DeviceID == "" { - return fmt.Errorf("decode event %s: empty target device id", event.ID) + return fmt.Errorf("%w: trust event %s: empty target device id", state.ErrEventVerification, event.ID) } if err := tx.EnsureRemoteDeviceTx(ctx, payload.DeviceID); err != nil { return err diff --git a/internal/sync/trust_apply_test.go b/internal/sync/trust_apply_test.go index 91f44ec..272b022 100644 --- a/internal/sync/trust_apply_test.go +++ b/internal/sync/trust_apply_test.go @@ -320,3 +320,41 @@ func TestApplyMutualRevocationCrossWindowDivergesLoudly(t *testing.T) { } } } + +// TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort (reviewer +// finding): a signed-but-malformed trust payload must quarantine-as-consumed, +// never abort the batch — the signer is by definition an approved device, and +// a permanent decode failure re-pulled forever would be the P6-SYNC-01 wedge. +func TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort(t *testing.T) { + ctx := context.Background() + st, device := newSyncStore(t) + signingA := addRemoteDeviceForApplyTest(t, st, "device-a", "approved") + now := time.Now().UnixMilli() + bad := state.Event{ + ID: "evt_bad_trust", + DeviceID: "device-a", + Seq: 1, + HLC: now << hlcLogicalBits, + Type: EventDeviceRevoked, + PayloadJSON: `{"device_id":`, // truncated JSON + } + bad.ContentHash = state.ContentHash(bad.PayloadJSON) + sig, err := devicekeys.Sign(signingA.Private, "devstrap:event:v2", state.EventSignaturePayloadV2(bad)) + if err != nil { + t.Fatal(err) + } + bad.DeviceSig = sig + empty := signedDeviceTrustEvent(t, signingA, "evt_empty_target", "device-a", 2, now+1, EventDeviceRevoked, "") + good := projEvent(t, device.ID, EventProjectAdded, now+2, "work/acme/after", "github.com/acme/after") + good.Seq = 1 + _, stats, err := ApplyEventsWithStats(ctx, st, []state.Event{bad, empty, good}, nil) + if err != nil { + t.Fatal(err) + } + if stats.Quarantined != 2 { + t.Fatalf("stats=%+v, want both malformed trust events quarantined", stats) + } + if _, err := st.ProjectByPath(ctx, "work/acme/after"); err != nil { + t.Fatalf("batch must continue past malformed trust events: %v", err) + } +} diff --git a/spec/07_NAMESPACE_AND_SYNC_MODEL.md b/spec/07_NAMESPACE_AND_SYNC_MODEL.md index fc65e1e..5777a0e 100644 --- a/spec/07_NAMESPACE_AND_SYNC_MODEL.md +++ b/spec/07_NAMESPACE_AND_SYNC_MODEL.md @@ -224,6 +224,8 @@ project.deleted draft.snapshot.created # encrypted working-tree bundle (non-git / draft fallback — Layer C) env.profile.updated # encrypted/provider env profile metadata; blob plane carries ciphertext device.key.granted # age-wrapped Workspace Content Key for a recipient+(epoch, kid) (P4-SEC-07/P6-SEC-02) +device.revoked # synced trust flip: sticky/monotonic apply, local device exempt (TRUST-01) +device.lost # same plane as device.revoked; revoked <-> lost churn is a no-op conflict.created conflict.resolved ``` diff --git a/spec/18_WORK_LOG.md b/spec/18_WORK_LOG.md index 964627f..cb31965 100644 --- a/spec/18_WORK_LOG.md +++ b/spec/18_WORK_LOG.md @@ -46,6 +46,7 @@ Validated: - gofmt; darwin+linux builds; golangci-lint 0 issues; `go test -race ./...`; spec-drift. - Post-dogfood fix (live R2 run F.3 caught it): `UpsertEnvProfileTx`'s replace-all-bindings upsert was silently WIPING `needs_rotation` — a revoke flags the bindings, then the rewrap's superseding `env.profile.updated` re-inserted fresh rows with the flag cleared, on the revoker AND on every receiving device (breaking P5-PROD-03 doctor surfacing; the txtar's `stdout 'rotation'` assertion was too weak to catch it — it matched the label in "rotation 0"). The upsert now carries each var's flag forward (clearing stays the explicit device-local `env rotate` action); pinned by `TestUpsertEnvProfileTxPreservesNeedsRotation` and the hardened txtar assertion (`secrets needing rotation [1-9]`). +- Post-review (opus reviewer, dual-review): (Minor) spec/07's shipped-event catalog now lists `device.revoked`/`device.lost` (the diff had only removed them from the planned block); (Minor) a signed-but-malformed trust payload (decode failure / empty target) now wraps `ErrEventVerification` so it quarantines-as-consumed instead of aborting the batch — only an APPROVED signer can produce one (mustVerify), exactly the compromised-device class these events cut off; pinned by `TestApplyDeviceTrustMalformedPayloadQuarantinesWithoutAbort` (the env/draft malformed-payload convention is tracked in #133). - Post-review (Codex adversarial, dual-review): (HIGH, partially accepted) a failed WCK rotation during revoke leaves the old epoch active so pre-rotation pushes (incl. the revoke event) stay readable by the revoked device — the fail-closed-revoke suggestion was REJECTED (refusing the revoke keeps a compromised device approved, strictly worse; rotation failure is a pre-existing P4-SEC-07 warn path); accepted the loudness half: the CLI now names the exposure and the `keys rotate` remedy, spec/15 documents the residual, fail-closed/auto-retry rotation filed as follow-up. (HIGH, residual confirmed + hardened) cross-window mutual-revocation divergence stays the documented accepted trade (no trust CRDTs/ordering service for a single-user fleet), but the review exposed an undocumented recovery trap — re-approving one side replays its quarantined counter-revoke, flipping the other — now pinned by `TestApplyMutualRevocationCrossWindowDivergesLoudly` (both bystanders diverge LOUDLY with the loser preserved in an open conflict) and documented as the two-step recovery in spec/07. Follow-ups: From e4029586e32db481941bc39f1e1eda827c9d70c8 Mon Sep 17 00:00:00 2001 From: Artem Matskevych <52060443+Reederey87@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:26:26 -0400 Subject: [PATCH 3/4] lint: defer-Close helper for the rotation-flag read (sqlclosecheck, CodeRabbit) Co-Authored-By: Claude Fable 5 --- internal/state/store.go | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/internal/state/store.go b/internal/state/store.go index 9352084..5837c9d 100644 --- a/internal/state/store.go +++ b/internal/state/store.go @@ -1766,25 +1766,9 @@ WHERE id = ?; // (breaking the P5-PROD-03 doctor surfacing). Carry the flag forward // per var name; clearing stays the explicit, device-local operator // action (`env rotate` -> ClearRotationForProject), per spec/09. - rotFlags := map[string]int{} - rotRows, rerr := tx.tx.QueryContext(ctx, `SELECT var_name, needs_rotation FROM secret_bindings WHERE env_profile_id = ?;`, existingID) + rotFlags, rerr := tx.envBindingRotationFlags(ctx, existingID) if rerr != nil { - return EnvProfile{}, fmt.Errorf("read env binding rotation flags: %w", rerr) - } - for rotRows.Next() { - var varName string - var flag int - if err := rotRows.Scan(&varName, &flag); err != nil { - _ = rotRows.Close() - return EnvProfile{}, fmt.Errorf("scan env binding rotation flag: %w", err) - } - rotFlags[varName] = flag - } - if err := rotRows.Err(); err != nil { - return EnvProfile{}, err - } - if err := rotRows.Close(); err != nil { - return EnvProfile{}, fmt.Errorf("close env binding rotation flags: %w", err) + return EnvProfile{}, rerr } if _, err := tx.tx.ExecContext(ctx, `DELETE FROM secret_bindings WHERE env_profile_id = ?;`, existingID); err != nil { return EnvProfile{}, fmt.Errorf("replace env bindings: %w", err) @@ -1821,6 +1805,26 @@ VALUES (?, ?, ?, ?, 1, ?, ?, ?); }, nil } +// envBindingRotationFlags reads each binding's needs_rotation by var name so +// the replace-all upsert can carry the flags forward (TRUST-01 dogfood fix). +func (tx *Tx) envBindingRotationFlags(ctx context.Context, envProfileID string) (map[string]int, error) { + rows, err := tx.tx.QueryContext(ctx, `SELECT var_name, needs_rotation FROM secret_bindings WHERE env_profile_id = ?;`, envProfileID) + if err != nil { + return nil, fmt.Errorf("read env binding rotation flags: %w", err) + } + defer func() { _ = rows.Close() }() + flags := map[string]int{} + for rows.Next() { + var varName string + var flag int + if err := rows.Scan(&varName, &flag); err != nil { + return nil, fmt.Errorf("scan env binding rotation flag: %w", err) + } + flags[varName] = flag + } + return flags, rows.Err() +} + func (s *Store) EnvProfileForProject(ctx context.Context, namespaceID string) (EnvProfile, []SecretBinding, error) { workspaceID, err := s.WorkspaceID(ctx) if err != nil { From a994d173a501963338bd6a5f5b6edd2daac45cdb Mon Sep 17 00:00:00 2001 From: Artem Matskevych <52060443+Reederey87@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:37:16 -0400 Subject: [PATCH 4/4] lint: wrap the inner decode error too (errorlint) Co-Authored-By: Claude Fable 5 --- internal/sync/events.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/sync/events.go b/internal/sync/events.go index 6004fc2..8a77aab 100644 --- a/internal/sync/events.go +++ b/internal/sync/events.go @@ -896,7 +896,7 @@ func applyEventTx(ctx context.Context, tx *state.Tx, event state.Event) error { // (reviewer finding; the env/draft malformed-payload convention is // tracked in #133). if err := json.Unmarshal([]byte(event.PayloadJSON), &payload); err != nil { - return fmt.Errorf("%w: decode trust event %s: %v", state.ErrEventVerification, event.ID, err) + return fmt.Errorf("%w: decode trust event %s: %w", state.ErrEventVerification, event.ID, err) } if payload.DeviceID == "" { return fmt.Errorf("%w: trust event %s: empty target device id", state.ErrEventVerification, event.ID)