Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
62 changes: 62 additions & 0 deletions cmd/devstrap/testdata/script/sync_trust_propagation.txtar
Original file line number Diff line number Diff line change
@@ -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'
40 changes: 37 additions & 3 deletions internal/cli/devices.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
51 changes: 51 additions & 0 deletions internal/cli/devices_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}
115 changes: 105 additions & 10 deletions internal/state/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand All @@ -750,15 +760,15 @@ 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
}
if deviceID == current.ID {
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 {
Expand All @@ -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")
Expand Down Expand Up @@ -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, '')
Expand Down Expand Up @@ -1708,9 +1759,21 @@ 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, rerr := tx.envBindingRotationFlags(ctx, existingID)
if rerr != nil {
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)
}
preservedRotation = rotFlags
}
for _, varName := range varNames {
bindingID, err := id.New("sec")
Expand All @@ -1719,16 +1782,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)
}
}
Expand All @@ -1742,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 {
Expand Down Expand Up @@ -1820,8 +1903,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;
Expand Down Expand Up @@ -3259,7 +3353,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
Expand Down
Loading
Loading