0.8.0: interactive command surface, recoverable identity, device lifecycle - #164
Merged
Conversation
Recoverable identity init/restore (BIP39 + HKDF, deferred commit after custody check), clean Start/Share/Connect surface with hidden pair/recv aliases, TTY launcher and review flows with replay commands, mount plan unification with a client-side read-only gate, and durable delegated join ceilings that survive reconnect (#142). Nothing is built or tested yet; this parks the work against a sweep.
Add the missing Cmd::Mint out field, fix the resolve_mount_plan borrow, add real JSON output for addr so addr --json keeps its contract, move the interactive/json conflict out of clap (subcommand json shadowing broke debug asserts in man rendering), switch to bip39 words(), gitignore target-ux, and commit the lockfile updates from the new deps. cli tests 402 passed, filament-id 31, filament-cap 84.
…deadline, periodic liveness observation Q4 implemented as overruled: sign the CEILING, local policy tightens, never loosens, same shape as caps. AuthKey gains a signed max_offline (v2 canonical bytes, v1 keys still parse and verify via a version discriminator; mint() defaults to the 30d cap, mint_with_bounds takes an explicit ceiling + the persistence choice). The joined device record now persists the bounding pair with the conferring half in ONE object: principalMaxOffline (effective budget) and principalMaxOfflineCeiling (signed), alongside the existing kind/ceiling/ expires. The in-place writer preserves all five on a plain cert update. Q1 coherence: effective_principal_deadline = min(cert_expiry, not_after, last_seen + max_offline), and the returned clock names which bound is in charge so the future status display never lies about 'time left'. Wired into both principal-restore sites that set identity_cert_expires. Q3 liveness: the daemon periodically refreshes lastSeen for devices with a live link (8s tick, link-up state, NOT message receipt), so an idle but connected device never decays against its budget. devices_touch gained a clock-injectable form for the tests. Tests (all green, plus the REQUIRED red run): - delegated_ceiling_preserved_through_plain_cert_update: a plain cert update with no delegated arg preserves principalKind + ceiling + budget. Deliberate break (writer drops principal fields) made it go red first: 'a plain cert update must preserve principalKind=delegated, left None, right Some' then reverted. - periodic_observation_keeps_idle_connected_device_live: link held up with ZERO traffic past a 10s budget stays LIVE (lastSeen advanced by observation alone); after the link drops, the budget binds and it lapses. - effective_deadline_names_the_binding_clock: min of the three, clock named. - max_offline_ceiling_signed_and_roundtrips: tampered ceiling fails sig. - legacy_v1_key_without_max_offline_still_verifies: v1 keys parse + verify. cli 382 unit tests + all suites green, filament-cap 86, filament-id 31.
…enrollment side SWEEPER (option b, decided deliberately): sweep_lapsed marks delegated records whose effective deadline has passed as principalState=lapsed + lapsedAt, keeps the record as evidence (a vanished record is indistinguishable from one never there), never touches revoked or already-lapsed records. Daemon sweeps every 30s. Enforcement was already the deadline at the gate; the sweep makes LAPSED visible. DURABLE REVOKE: devices revoke <name> / devices restore <name>. Writes a revoked flag that survives cert renewals (unlike certRevoked, which a renewal clears). device_cert_revoked now reads either marker, so the gate refuses the device on every reconnect until restore. REVOKED IS NOT REVIVABLE: the enrollment path refuses a re-join whose device_pub matches a revoked record (enrollment_refusal), telling the owner only 'devices restore' undoes it. LAPSED revives by name continuity. FRESH JOIN BOUNDS WIN: the delegated write OVERWRITES the whole bounding set from the new signed key and clears the terminal state, never merging. A device re-joining with a narrower key cannot inherit its wider old ceiling. Red run captured: preserving instead of overwriting went red on 'fresh join must OVERWRITE the old ceiling', reverted. in-memory only (nothing durable, gone at daemon restart per Q2); persistent keys write the durable record. The ack carries persistent and the device-side join validation enforces it matches !auth_key.ephemeral. enroll --remember no longer discards its argument: for an ephemeral key it explains a remembered device needs a join invitation. Tests: sweep_marks_lapsed_and_keeps_evidence, fresh_join_bounds_win_over_ revived_record (+ red run), revoked_record_is_not_revivable_by_enrollment. cli 385 unit tests + all suites green.
…nfig-dir tests DEPART (advisory, kept last): a joined device can tell the owner it is shutting down so the slot is freed NOW instead of paying the offline budget. filament depart signs a possession message with the device's overlay key, connects to the owner (its joined-owner record), and sends a 'depart' control frame. The daemon verifies the signature, marks the record lapsed immediately via mark_lapsed_now, and acks. Never load-bearing: if the message never arrives (crash, power cut, hostile device), the offline budget lapses it anyway; depart is an optimization for the polite path only. mark_lapsed_now keeps the record (option b) and the gate still denies by deadline, not by conflating lapsed with revoked. TEST SERIALIZATION: FILAMENT_CONFIG_DIR is process-global, and with several tests now pointing it at their own temp dirs they raced (a second test overwriting the env mid-test made the first read the wrong store). Added a poison-tolerant test mutex held by every env-mutating test. Tests: mark_lapsed_now_frees_slot_and_keeps_evidence, joined_owner_record_finds_the_owner_not_self. cli 387 unit + all suites green, filament-id 31, filament-cap 86.
…; revocation is absolute chief-ux caught that BOTH certRevoked=false writes were introduced on this branch (origin/main never writes false): the in-place cert update cleared a revoke on renewal, the exact case a revoked device trying to get back in would do. Deleting both. Deleting them exposed a second latent bug: device_cert_revoked failed closed on field ABSENCE (unwrap_or(true)), so every known record without the field was 'revoked' -- including all 6 live production records. The new-record false was silently compensating. Fixed the reader: record present + field absent = NOT revoked; record missing = revoked (fail closed). Absence is now correct, which un-revokes legacy records for real. One marker, one meaning: certRevoked is a decision about the DEVICE. devices revoke/restore write it (principalState/revokedAt are display only). Gate, enrollment_refusal, and the sweeper all read certRevoked only. Revocation is now ABSOLUTE in the gate: cap_gate_effective denies a revoked device before any grant is consulted (previously cert_revoked only fed the fleet auto-trust path, so a revoked device with a standing grant stayed authorized). 'devices revoke' is therefore not conditional on the owner also remembering to remove grants. Required test: revoked_device_survives_cert_renewal -- revoke, renew the cert through the ordinary path, assert still revoked and still refused even with a grant. RED run captured by restoring the clearing line: 'a cert renewal must NOT clear a durable revoke', reverted. cli 388 unit + all suites, filament-cap 86, filament-id 31.
…hows the budget devices now renders a delegated device's real state by quoting effective_principal_deadline, never a restated bound: '30d left (offline budget)' when the liveness budget binds, '4d left (cert expires)' when the cert does (the Q1 coherence requirement made honest), 'lapsed' for swept or computed-past-deadline records, 'revoked' for revoked ones. Verified in a real terminal against a crafted delegated store. join review and the invite display now show 'budget 30d (key allows up to 30d)', legal only because the ceiling is enforced on both sides (signed into the key, persisted in the record, applied by the deadline function and the gate). invite --json carries maxOffline. fmt_short_duration added for the copy. cli 388 unit + all suites green.
The rebase left TWO 'if cert_revoked' blocks in cap_gate_effective: my redesign's original pre-ceiling block and main's post-ceiling block (they were in different places, so git applied both with no conflict). The earlier one won, silently running a DIFFERENT placement than the reviewed arrangement. Keep the single reviewed guard (after the delegated-principal ceiling, before fleet_ok || has_explicit_grant, with the legacy_allowed shadow); delete the pre-ceiling duplicate. Tests are all green (400 unit).
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
filament | 5199c13 | Commit Preview URL Branch Preview URL |
Aug 07 2026, 09:03 PM |
The revocation ordering window: a revoked device reconnecting via a fresh typed code was authorized until its identity resolved, because shadow mode never issued the identity challenge, the first offer was decided before the DirectReady challenge, and cert_revoked_for(None) is false. FIX, all in recv_cmd: - DirectReady now issues the 0x02 possession challenge in BOTH modes for a typed-code link with no identity (previously authoritative-only), so the sender's cert resolves and revocation binds in shadow too. - The file-offer handler holds the first offer while identity is unresolved on the code path (bounded 3s), so the gate never decides a legacy-trusted offer un-resolved. - device_cert_revoked: an unknown device (no record) is NOT revoked. #156's fix was the field-absence half; the no-record fail-closed default denied every fresh code peer once identity resolves. Revocation is a decision about a KNOWN device; deleting a record also deletes the pair secret, so a revoked device cannot reauthenticate anyway. - The #161 probe (debug_assert at the gate) stays permanently, scoped to live flows (gate_live, set by main) so the harness catches the ordering without unit tests tripping on deliberately-constructed state. - Harness: peers are now initialized (init-first surface), and a first-op test proves a durably revoked device's first transfer is denied. Verified: pair_and_transfer_smoke green with the probe live (identity resolves via the ceremony, probe quiet). 423 unit + crates green; harness 11 passed / 3 failed where the 3 are the known box-specific warm-link/PTY failures that pass in CI.
A NON-EXISTENT devices.json means no device records at all: every peer is unknown, not revoked (a fresh init has no store until the first pair). An EXISTING store that is unreadable or unparseable FAILS CLOSED to revoked: a corrupt store must not silently un-revoke every device. The previous build returned true for both (denying every transfer to a fresh device) and the first attempt returned false for both (un-revoking on corruption). Pinned with a test covering all three states: absent store, corrupt store, empty parseable store.
…(advisor) p.exists() returns false on permission-denied too, which would take the absent branch and un-revoke every device on an unreadable store. Use metadata and distinguish NotFound (genuinely absent: no records, unknown, not revoked) from every other error (exists but unreadable: fail closed). Extended the test with the exists-but-unreadable class (a directory at the path: metadata succeeds, read fails).
…cards revocation revoked_device_first_transfer_is_denied timed out on macOS (pairing ceremony does not complete within 60s on a cold macOS runner), the same known weakness the sibling shell_daemon_live_pairing_no_restart already skips with a documented reason. Skip on macOS with the honest note; the unexercised list goes in the release notes verbatim. Linux and Windows keep the test. devices forget now says when it discards a revocation: deleting the record removes the certRevoked marker, and a forgotten revoked device looks like a first-time peer again. The copy makes that trade visible (anti-theatre on an omission, advisor's point). Verified live: forget of a revoked device prints 'forgot X and its revocation; it can now be added or joined again', forget of a plain device prints the ordinary message.
The redesign branch was based before #167 merged, so it carried the stale README/release-body/cli-README with recv/pair/dovm. Merging would have reverted the doc fix. Take origin/main's corrected versions; the README quickstart (send --code, receive, add --name, up) is verified to parse against the new surface.
…iring ceremony revoked_device_first_transfer_is_denied timed out on CI (macOS and ubuntu) because the live add --word ceremony's code mint did not complete within 60s on cold runners. The pairing ceremony is not what the test asserts; it only exists to give B a resolvable record for A. Build that record directly from A's own device cert into B's devices.json, then revoke, then assert the first transfer is denied. The assertion is unchanged; the setup no longer depends on ceremony timing. Kept the macOS skip? No - the macOS skip is now unnecessary since the ceremony is gone; the test's remaining flow is the same transfer path pair_and_transfer_smoke uses, which passes on macOS. Wait: the skip was added for this test specifically. Remove it: the timeout cause was the ceremony, which no longer exists. Removing the skip restores macOS coverage of the revocation assertion.
The skip was added because the live pairing ceremony timed out on cold macOS runners. That ceremony no longer exists in this test (the record is constructed directly), and the remaining flow is the same code-transfer path pair_and_transfer_smoke uses, which is verified on macOS. Restore macOS coverage of the revocation-first-transfer assertion.
…e hold expires fail-closed The macOS assertion failure exposed a real hole the skip was hiding: on the WebRTC/relay transport, a fresh typed-code peer's identity NEVER resolved (no pair-proof exists for a code peer, and the #39 rule only issues the WebRTC challenge there), so cert_revoked_for(None)=false, the #157 Deny never fired, and a revoked device's first transfer landed. The direct path was fixed by the DirectReady challenge; the WebRTC path had no equivalent. TWO-PART FIX: 1. Issue the possession challenge at PAKE CONFIRM for a typed-code peer whose identity is unresolved (the peer just authenticated, so its responder loop is live; at ChannelReady it is not ready and the 3s hold expires first). Idempotent with the DirectReady site. This resolves identity on the WebRTC transport, so revocation binds there. 2. The #161 hold now FAILS CLOSED on expiry: if it clears with identity still unresolved on the code path, the offer is DECLINED, not decided. #156 makes unidentified read as not-revoked by design; without this, a timeout becomes an admission. The operator is present by construction (they typed the code), so the cost is a retry, not a lost unattended transfer. Verified all four: direct + WebRTC, legit + revoked. The revoked device's first transfer is denied on BOTH transports; legit transfers complete on both. The macOS assertion now passes for the right reason.
The direct-blocked fallback establishes the WebRTC data channel AFTER PAKE confirm, so the PAKE-confirm challenge was lost and the fail-closed hold expired while the transport was still coming up, declining every legitimate fallback transfer (CI: 'peer did not come back within 45s' on all three platforms). Two-part refinement: 1. ChannelReady re-issues the possession challenge on the typed-code path when recv_pake_done is already true (the channel arrived after the ceremony, exactly the fallback case). Gated so the normal WebRTC path (ChannelReady before PAKE) never gets an early challenge the sender cannot answer - the PAKE-confirm challenge covers that path. 2. The hold clock starts only when the link is READY (the ceremony can run), not at first offer. While the transport is establishing, the offer is held with no clock; when ready, the 3s deadline runs and expiry fails closed with identity still unresolved. Verified all four locally (direct + WebRTC, legit + revoked). The blocked fallback is CI-validated (this box cannot complete challenges: pair-proof fails).
Security entry The entry now states the exact bound (a peer whose identity does not resolve within the 3s hold is DECLINED, never admitted), scopes the fix as a limitation of the fix rather than a version range (0.7.7 did not bind on any transport; 0.8.0 binds on both), and records why old senders are not a compat break: too-old peers abort at the ceremony with an update message, and 0.7.7 senders implement the possession challenge responder (verified in the shipped cli-v0.7.7: respond_to_identity_challenge at main.rs:1877, invoked on the send path at 11519).
… 20s The blocked-direct fallback broke on all three platforms because TWO coupled 3s clocks fought: the challenge's pending_proven entry expired, a re-issue then clobbered the in-flight nonce (a peer answering the first challenge verifies against the second and never reaches Proven), and the offer's own deadline declined a legitimate slow link. Each timing patch moved one clock while the other stayed at 3s, which is why three rounds each traded one broken path for another. PROVEN_CHALLENGE_DEADLINE (20s, module-level) now sets the challenge entry deadline, the two entry-removal spawns, and the offer hold (RECV_IDENTITY_HOLD_DEADLINE) in lockstep. The re-issue dedupe then never clobbers a live nonce; a slow-but-capable peer's answer is accepted within the window; a revoked device is still denied (it just waits longer to be); a ceremony that genuinely never resolves fails closed at the cap. This is the backstop, not a decision boundary - identity resolution normally settles in well under a second. All four controls pass locally (direct + WebRTC, legit + revoked). The blocked fallback is CI-validated (this box cannot complete the challenge ceremony).
…ocumented residual Four timing attempts each traded one broken path for another (the fail-closed deny broke legitimate fallback transfers on all three platforms because the typed-code challenge is lost when the transport is not yet up at PAKE confirm, and the ChannelReady re-issue dedupes against the stale entry). The terminal-outcome model is the right fix and is not a 0.8.0 change. REVERT to the pre-fail-closed form: the offer is held until identity resolves (20s window, in lockstep with the challenge entry so the re-issue cannot clobber the nonce), then decided WITH identity - a revoked device's cert resolves and the gate's absolute Deny fires; a peer whose identity does not complete within the window is decided by the normal gate (consent and grants). The residual is documented: a revoked device whose identity ceremony does not complete on a given path (slow fallback link, lost challenge) is admitted, precondition-bounded. The revoked first-transfer test still passes on both transports (identity resolves via the challenges and the cert-revoked Deny fires); the blocked fallback proceeds instead of declining. CI is the arbiter for the blocked test; locally this box cannot complete the challenge ceremony.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
0.8.0: interactive command surface, recoverable identity, device lifecycle
A deliberate clean-break redesign of the command surface plus the device lifecycle it needs. This is the headline change of 0.8.0; it intentionally does not ride a patch release because the surface breaks compatibility (no aliases).
Command surface
init(recoverable identity, BIP39 12-word English recovery phrase, deferred commit until custody check),add(device/person with a human present),invite(bounded invitation),join(claim a bounded invitation),id(show identity + certified devices, recover).send,receive,shell,reach,expose,mount.devices,requests,grant/revoke,status.addr,doctor.identity/pair/recvnames are gone.recvisreceive;pairisadd;identityisid.filamenton a terminal opens a picker, ortourshows the fingerprint and adapted next actions.Recoverable identity (#147 line)
PendingIdentity: random BIP39 mnemonic, HKDF-SHA256 recovery seed -> Ed25519 seed, commit only after the custody check.id recoverrestores from the phrase (owner-only file/FD or TTY paste).Device lifecycle (#142, #155)
principalKind,principalCeiling,principalExpires,principalMaxOffline,principalMaxOfflineCeiling) in one atomic record; reconnects restore it from the record, never from memory.min(cert expiry, last_seen + max_offline, not_after), the binding clock named indevicesoutput.lapsedas evidence, never deleted.devices revoke/restore;certRevokedis the single marker, survives cert renewal, and the gate denies a revoked device absolutely before any grant.filament departsigns and announces; never load-bearing.joinnames the ceiling at join time; fresh-join bounds overwrite the old set; LAPSED revives, REVOKED does not.#142 and #155
delegated_ceiling_survives_record_roundtrip,missing_same_owner_record_fails_closed);joinis present, not absent.ephemeralflag is never read — non-ephemeral enrollment is declared, signed, and unimplemented #155 (ephemeral vs persistent enrollment): closed. The enrollment side readsak.ephemeral; ephemeral admission is in-memory only and gone at restart; the device side validates the ack matches the key.Tests
pair_and_transfer_smokepasses on this branch.cert_revokedcheck after the delegated-principal ceiling).