Skip to content

0.8.0: interactive command surface, recoverable identity, device lifecycle - #164

Merged
Abdk4Moura merged 27 commits into
mainfrom
pr/ux-redesign
Aug 8, 2026
Merged

0.8.0: interactive command surface, recoverable identity, device lifecycle#164
Abdk4Moura merged 27 commits into
mainfrom
pr/ux-redesign

Conversation

@Abdk4Moura

Copy link
Copy Markdown
Owner

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

  • Start: 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).
  • Share: send, receive, shell, reach, expose, mount.
  • Devices: devices, requests, grant/revoke, status.
  • Mesh: addr, doctor.
  • The old identity/pair/recv names are gone. recv is receive; pair is add; identity is id.
  • A TTY launcher: bare filament on a terminal opens a picker, or tour shows the fingerprint and adapted next actions.
  • Guided flows: interactive file pickers, relation labels, review screens that show the exact replay command, QR only transient and consequence-labelled.

Recoverable identity (#147 line)

  • PendingIdentity: random BIP39 mnemonic, HKDF-SHA256 recovery seed -> Ed25519 seed, commit only after the custody check.
  • id recover restores from the phrase (owner-only file/FD or TTY paste).
  • Recovery/invitation secrets never in argv, env, JSON, logs, stdout, or replay commands.

Device lifecycle (#142, #155)

  • Bounded principal: a joined device carries a persisted ceiling (principalKind, principalCeiling, principalExpires, principalMaxOffline, principalMaxOfflineCeiling) in one atomic record; reconnects restore it from the record, never from memory.
  • Effective deadline: min(cert expiry, last_seen + max_offline, not_after), the binding clock named in devices output.
  • Liveness: periodic observation (lastSeen refreshed while connected, not traffic-driven), so idle-but-connected devices never decay.
  • Sweeper: past-deadline delegated records marked lapsed as evidence, never deleted.
  • Durable revoke: devices revoke/restore; certRevoked is the single marker, survives cert renewal, and the gate denies a revoked device absolutely before any grant.
  • Advisory depart: filament depart signs and announces; never load-bearing.
  • join names the ceiling at join time; fresh-join bounds overwrite the old set; LAPSED revives, REVOKED does not.

#142 and #155

Tests

  • cli 400 unit tests + all integration suites; filament-id 31; filament-cap 86.
  • Red-first lifecycle tests: preservation (a plain cert update keeps the ceiling), fresh-join bounds overwrite, cert renewal does not clear a durable revoke, idle-connected device stays live past budget, revoked device survives renewal even with a grant.
  • Harness pair_and_transfer_smoke passes on this branch.
  • Post-rebase the revocation guard is the single reviewed arrangement (one cert_revoked check after the delegated-principal ceiling).

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).
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 7, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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.
@Abdk4Moura
Abdk4Moura merged commit fa9ca4e into main Aug 8, 2026
14 checks passed
@Abdk4Moura
Abdk4Moura deleted the pr/ux-redesign branch August 8, 2026 02:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant