Skip to content

feat(devices): self-healing WCK rotation after revoke (#134)#137

Merged
Reederey87 merged 1 commit into
mainfrom
fix/i134-wck-rotation-pending
Jul 6, 2026
Merged

feat(devices): self-healing WCK rotation after revoke (#134)#137
Reederey87 merged 1 commit into
mainfrom
fix/i134-wck-rotation-pending

Conversation

@Reederey87

@Reederey87 Reederey87 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Fixes #134.

What

When devices revoke/lost's WCK rotation failed, nothing persisted that a rotation was owed — the old epoch stayed active and every event pushed until a manual devstrap keys rotate remained readable by the revoked device. Age-triggered rotation wouldn't fire for ~90 days.

How

Option 1 (auto-retry) plus the cheap preflight (option 3) from the issue:

  • Owed-rotation marker: a wck_rotation_pending local_meta row (JSON {epoch, since}; no schema migration — the generic GetLocalMeta/SetLocalMeta accessors from P4-SYNC-02 already exist). New idempotent state.Store.DeleteLocalMeta.
  • Sync auto-retry: maybeRotateWorkspaceKey rotates when a rotation is owed regardless of epoch age — and even with keys.rotate_max_age=0 (disabling periodic rotation must not disable committed revoke containment).
  • Rotate-only resolution (dual-review HIGH, both reviewers converged): the marker clears only on this device's own successful Rotate (sync retry, keys rotate, or a later revoke's rotation — every local Rotate excludes locally-revoked devices, exactly the proof required). Deliberately never by observing a newer epoch: a peer that hasn't pulled the revoke yet can rotate for age reasons and grant the new epoch to the revoked device, so "newer epoch" is not proof of exclusion. Worst case of ignoring a legitimate peer rotation: one redundant epoch.
  • Early-vs-mid-commit failure split (review Medium/Major): an owed retry that fails early (epoch unchanged — the malformed-recipient class) warns loudly and lets the cycle continue, so the device.revoked event itself still pushes and run-loop doesn't abort; a failure with the epoch advanced (mid-commit half-mint, grants possibly unpublished) stays fatal for the cycle.
  • Preflight (warnMalformedRemainingRecipients via new workspacekeys.ValidateRecipient): the likeliest Rotate failure — a malformed age recipient on a remaining approved device — is named on stderr before the trust write. Advisory only: the revoke always proceeds (refusing would keep a compromised device approved, per the PR feat(devices): synced device-trust propagation (TRUST-01) #132 adversarial ruling).
  • Doctor: new workspace key rotation check warns owed since <ts> with the sync-auto-retry + keys rotate remedy; silent when nothing is owed.

Accepted residual (spec/15): events pushed between the failed revoke rotation and this device's next successful rotation remain readable by the revoked device — now bounded and self-healing instead of "until someone remembers".

Tests

TestDeviceRevokeRotationFailureMarksPendingAndSyncRetries, TestMaybeRotateWarnsAndContinuesCycleOnEarlyOwedFailure, TestWCKRotationPendingSurvivesNewerEpoch (the HIGH's regression pin), TestKeysRotateClearsOwedRotation, TestWCKRotationPendingMalformedRecordStaysPending, TestDoctorWarnsWCKRotationPending, TestDeleteLocalMetaIdempotent.

Specs

spec/07 (revoke lifecycle), spec/13 (doctor inventory), spec/15 (residual re-bounded), spec/18 work log.

Validation

gofmt clean · golangci-lint run 0 issues · go test -race ./... ok · spec-drift --base origin/main pass · dual review (opus reviewer + Codex adversarial; both HIGHs fixed pre-merge, dispositions in the work log).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Device revoke/lost now checks remaining device recipients and warns if any look invalid before continuing.
    • Sync can now automatically retry a previously failed workspace key rotation.
    • doctor now reports when a workspace key rotation is pending and suggests a fix.
  • Bug Fixes

    • Improved handling for failed workspace key rotations so retries can continue later instead of getting stuck.
    • Successful manual key rotation now clears pending rotation state.

A failed revoke-path WCK rotation left the old epoch active with only a
one-shot warning; events stayed readable by the revoked device until a
manual keys rotate. Persist the owed rotation in a wck_rotation_pending
local_meta marker; sync's rotation gate retries it every cycle (even
with keys.rotate_max_age=0), clearing it only on this device's own
successful Rotate — never on merely observing a newer epoch (dual-review
HIGH: an uninformed peer's age rotation can grant the new epoch to the
revoked device). An early retry failure warns and lets the cycle
continue so the device.revoked event still propagates; a mid-commit
failure (epoch advanced) stays fatal. devices revoke preflights the
remaining approved recipients before the trust write; doctor warns while
the rotation is owed.

Fixes #134

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Reederey87 Reederey87 enabled auto-merge (squash) July 6, 2026 11:00
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds recipient preflight validation and a persistent "owed WCK rotation" marker mechanism triggered by failed workspace key rotations during device revoke/lost. Sync retries owed rotations, doctor reports pending rotations, and keys rotate clears the marker. Includes new store method, tests, and spec updates.

Changes

Self-healing WCK Rotation After Revoke

Layer / File(s) Summary
Recipient validation and revoke preflight
internal/workspacekeys/keyring.go, internal/cli/devices.go
Adds ValidateRecipient and wires warnMalformedRemainingRecipients preflight into revoke/lost before the trust write.
Pending rotation marker storage
internal/state/store.go, internal/cli/wck_rotation.go
Adds DeleteLocalMeta and implements mark/read/clear helpers for a JSON-encoded wck_rotation_pending local meta marker.
Revoke rotation failure handling
internal/cli/devices.go
Marks or clears the pending marker based on rotation outcome during revoke/lost, with updated warning messaging.
Sync owed-rotation retry
internal/cli/sync.go
Reworks maybeRotateWorkspaceKey to retry owed rotations regardless of age, distinguishing early vs. mid-commit failures.
Doctor and keys rotate integration
internal/cli/doctor.go, internal/cli/keys.go
Adds checkWCKRotationPending doctor warning and clears the marker after manual keys rotate.
Tests and spec updates
internal/cli/wck_rotation_test.go, spec/07_NAMESPACE_AND_SYNC_MODEL.md, spec/13_CLI_DAEMON_API.md, spec/15_SECURITY_THREAT_MODEL.md, spec/18_WORK_LOG.md
Adds integration tests covering the full retry/marker lifecycle and updates specs/work log describing the new behavior.

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

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant DevicesCmd
  participant Keyring
  participant LocalMeta
  participant Sync
  participant Doctor

  Operator->>DevicesCmd: devstrap devices revoke/lost
  DevicesCmd->>DevicesCmd: warnMalformedRemainingRecipients (preflight)
  DevicesCmd->>DevicesCmd: flip trust state
  DevicesCmd->>Keyring: Rotate()
  alt rotation fails
    Keyring-->>DevicesCmd: error
    DevicesCmd->>LocalMeta: markWCKRotationPending(epoch, since)
    DevicesCmd-->>Operator: warn, revoked device may still read events
  else rotation succeeds
    Keyring-->>DevicesCmd: ok
    DevicesCmd->>LocalMeta: clearWCKRotationPending()
  end

  Sync->>LocalMeta: wckRotationPendingSince()
  LocalMeta-->>Sync: pending, since
  Sync->>Keyring: Rotate() (owed retry)
  alt success
    Sync->>LocalMeta: clearWCKRotationPending()
  else early failure
    Sync-->>Sync: warn, continue cycle
  else epoch-advanced failure
    Sync-->>Sync: fatal cycle error
  end

  Doctor->>LocalMeta: wckRotationPendingSince()
  LocalMeta-->>Doctor: owed since ts
  Doctor-->>Operator: warning + remedy (sync or keys rotate)
Loading

Possibly related PRs

  • Reederey87/DevStrap#25: Both PRs modify the WCK rotation on revoke/lost path in internal/cli/devices.go that this PR extends with owed-rotation tracking.
  • Reederey87/DevStrap#56: Both PRs modify maybeRotateWorkspaceKey in internal/cli/sync.go and related doctor/keys rotation behavior.
  • Reederey87/DevStrap#132: Both PRs modify revoke/lost rotation failure handling in internal/cli/devices.go.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes an unrelated spec/13 doctor remote-health doc rewrite outside the WCK rotation revoke scope. Move the unrelated spec/13 doctor remote-health documentation changes into a separate PR focused on the WCK rotation work.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is concise and accurately summarizes the main change: self-healing WCK rotation after revoke.
Description check ✅ Passed The description covers summary, implementation, tests, and safety concerns, though it doesn't use the template's exact headings.
Linked Issues check ✅ Passed The PR implements the linked issue's key goals: owed-rotation marker, sync auto-retry, preflight validation, and doctor warnings.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/i134-wck-rotation-pending

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

@Reederey87 Reederey87 merged commit e29389b into main Jul 6, 2026
6 of 7 checks passed
@Reederey87 Reederey87 deleted the fix/i134-wck-rotation-pending branch July 6, 2026 11:06

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

807-843: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Persist the owed-rotation marker when CurrentEpoch fails. If this read errors, the revoke still leaves the old key active, but sync/doctor never see an owed rotation, so the retry/surfacing path is lost. A best-effort pending row here would keep the revoke self-healing.

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

In `@internal/cli/devices.go` around lines 807 - 843, The CurrentEpoch read
failure path in rotateWorkspaceKeyOnRevoke currently only logs and returns,
which drops the owed-rotation state. Update rotateWorkspaceKeyOnRevoke to
best-effort persist the pending rotation marker when kr.CurrentEpoch(ctx)
returns an error, using the same store-backed helper used after Rotate failures
so sync/doctor can retry and surface it. Keep the existing warning log, but add
the pending-rotation write before returning, and ensure the epoch value is only
used when the read succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/cli/sync.go`:
- Around line 356-358: The key-epoch check in sync handling is conflating two
different failures and discarding the `CurrentKeyEpoch` read error. Update the
logic around `store.CurrentKeyEpoch(ctx)` to report a distinct message when
`aerr != nil`, wrapping `aerr` directly, and only use the “epoch advanced”
wording when `after != epoch`. Keep the fail-closed behavior, but make the error
path in `sync.go` clearly distinguish the DB read failure from the mid-commit
epoch change so operators debugging the revoke flow see the right cause.

---

Outside diff comments:
In `@internal/cli/devices.go`:
- Around line 807-843: The CurrentEpoch read failure path in
rotateWorkspaceKeyOnRevoke currently only logs and returns, which drops the
owed-rotation state. Update rotateWorkspaceKeyOnRevoke to best-effort persist
the pending rotation marker when kr.CurrentEpoch(ctx) returns an error, using
the same store-backed helper used after Rotate failures so sync/doctor can retry
and surface it. Keep the existing warning log, but add the pending-rotation
write before returning, and ensure the epoch value is only used when the read
succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 490bb316-376f-480b-b70e-3f5b76c01ae3

📥 Commits

Reviewing files that changed from the base of the PR and between 89aad55 and 3347daf.

📒 Files selected for processing (12)
  • internal/cli/devices.go
  • internal/cli/doctor.go
  • internal/cli/keys.go
  • internal/cli/sync.go
  • internal/cli/wck_rotation.go
  • internal/cli/wck_rotation_test.go
  • internal/state/store.go
  • internal/workspacekeys/keyring.go
  • spec/07_NAMESPACE_AND_SYNC_MODEL.md
  • spec/13_CLI_DAEMON_API.md
  • spec/15_SECURITY_THREAT_MODEL.md
  • spec/18_WORK_LOG.md

Comment thread internal/cli/sync.go
Comment on lines +356 to +358
if after, aerr := store.CurrentKeyEpoch(ctx); aerr != nil || after != epoch {
return false, fmt.Errorf("workspace key rotation failed mid-commit (epoch advanced %d→%d; grants may not have published): %w", epoch, after, rerr)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Misleading error and dropped read error when CurrentKeyEpoch fails.

When aerr != nil, after is its zero value, so the message reads e.g. "epoch advanced 5→0; grants may not have published" — a mid-commit claim that didn't actually happen — and the real DB read error (aerr) is discarded (only rerr is wrapped). Failing closed here is correct, but the two cases should be reported distinctly so an operator debugging a revoke-containment failure isn't sent down the wrong path.

🩹 Distinguish the read-failure case
-		if after, aerr := store.CurrentKeyEpoch(ctx); aerr != nil || after != epoch {
-			return false, fmt.Errorf("workspace key rotation failed mid-commit (epoch advanced %d→%d; grants may not have published): %w", epoch, after, rerr)
-		}
+		after, aerr := store.CurrentKeyEpoch(ctx)
+		if aerr != nil {
+			return false, fmt.Errorf("workspace key rotation failed and re-reading the epoch failed, so a mid-commit half-mint cannot be ruled out (rotate error: %v): %w", rerr, aerr)
+		}
+		if after != epoch {
+			return false, fmt.Errorf("workspace key rotation failed mid-commit (epoch advanced %d→%d; grants may not have published): %w", epoch, after, rerr)
+		}
📝 Committable suggestion

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

Suggested change
if after, aerr := store.CurrentKeyEpoch(ctx); aerr != nil || after != epoch {
return false, fmt.Errorf("workspace key rotation failed mid-commit (epoch advanced %d→%d; grants may not have published): %w", epoch, after, rerr)
}
after, aerr := store.CurrentKeyEpoch(ctx)
if aerr != nil {
return false, fmt.Errorf("workspace key rotation failed and re-reading the epoch failed, so a mid-commit half-mint cannot be ruled out (rotate error: %v): %w", rerr, aerr)
}
if after != epoch {
return false, fmt.Errorf("workspace key rotation failed mid-commit (epoch advanced %d→%d; grants may not have published): %w", epoch, after, rerr)
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/cli/sync.go` around lines 356 - 358, The key-epoch check in sync
handling is conflating two different failures and discarding the
`CurrentKeyEpoch` read error. Update the logic around
`store.CurrentKeyEpoch(ctx)` to report a distinct message when `aerr != nil`,
wrapping `aerr` directly, and only use the “epoch advanced” wording when `after
!= epoch`. Keep the fail-closed behavior, but make the error path in `sync.go`
clearly distinguish the DB read failure from the mid-commit epoch change so
operators debugging the revoke flow see the right cause.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

devices revoke: fail-closed or auto-retrying WCK rotation (adversarial-review follow-up)

1 participant