diff --git a/Cargo.toml b/Cargo.toml index 1bae9f3a..87e3e7aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ciris-server" -version = "0.5.218" # 0.5.218 = ADD A CONTACT BY CODE, AND A SECOND DEVICE THAT CATCHES UP (the maintainer's two flows). CONTACTS: `GET /v1/self/contact-code?nodes=` returns the person's v3 code (string + QR payload) naming the ANNOUNCED devices they choose, or none (resolved through the public directory) (#673); the device roster is public only for devices the person announced, per device (#655 ruling); `DELETE /v1/contacts/{key_id}` and `POST /v1/federation/peering/revoke` withdraw consent, signed by the person (#657); `GET /v1/admin/self` returns `owner_delegations` (#676). SECOND DEVICE: claim-remote links both devices at once, the device holding the pen re-wraps old self files for the new one, and `POST /v1/self/nodes/{node}/announce` announces another of my devices from the first (#678); replication runs 12 of 17 kinds, every kind routed or excluded by name (#646), so member additions, households and device revocations leave the node. FIXES: OAuth redirect parsed, same-origin path or exact loopback only (#672); a TPM request without a TPM refuses (#639); boot errors carry their whole cause chain (#586); a claimed node finds its consent peers (#601); reproposed chat commits are carried and the chat unopened reason is a token (#602). LADDERS: selffiles in CI (#622, #626), a `devices` scenario (second device joins; old self file opens; family rungs RED-EXPECTED on CIRISPersist#910/#916), ui-chat drives client 0.5.224 with nav derived from the client. Client floor 0.5.225. Known: history from BEFORE a device joined opens only for self files until CIRISPersist#916; restart re-admission waits on CIRISEdge#676 / CIRISPersist#911. PREVIOUS: 0.5.217 = 0.5.217 = A FILE IS WHAT IT SAYS IT IS (CIRISServer#642/#641/#643/#644, for CIRISClient#77 and FSD/MEDIA_EDGE.md): the drive's write gate — every write door (upload, replace, move, notes) requires an RFC 6838 media type whose leading bytes agree with it (`drive.bad_media_type` 400, `drive.format_mismatch` 415 naming declared and sniffed — the same table as the client's RenderTier), and a filename cleaned per RFC 6266 §4.3 (path, control, bidi and zero-width characters removed; `drive.bad_filename` when nothing is left), so a name like `invoice\u202Etxt.exe` no longer replicates as written. The node states the PLAINTEXT digest of what it hands over (CC 5.3.2.5): `content_digest` on `GET /v1/files/{id}` and `/meta`, RFC 9530 `Repr-Digest` on a whole raw read; the edge pointer hash is renamed `at_rest_sha256` (it is the sealed blob's, never the plaintext's; `content_sha256` kept as a deprecated alias). `GET /v1/media/policy` publishes the node's render policy ahead of the ingest pipeline, with `renditions: false`. Notes say `here` like the drive (one word per fact; `drive::BYTE_STATES`). A digest SIGNED into the row, and renditions/placeholders, wait on CIRISEdge#638 and #614. PREVIOUS: 0.5.216 = 0.5.216 = ROSTERS AND FILES, END TO END (FSD/ROSTER_AND_DRIVE_CRUD.md): households (`/v1/families`: create, list, read, add/remove member, role, leave, dissolve, and an envelope → cosign → assemble flow for quorum families — closes CIRISServer#627), N-member communities and affiliations (`/v1/communities`, growth on persist v48's widening plane — CIRISServer#594), the owner's own devices (release a node, relabel a device, revoked devices listed; the self room uses edge's room-keyed handshake so a second device OPENS a file's bytes), and full drive CRUD (replace, rename, withdraw with a 410, move between circles, metadata, raw bytes with Range, a paged drive, notes edit/delete; the split-install viewer key fixed). Every roster read goes through the fold, enforced by a source gate. Known gaps, named in the FSD: a widened community member is refused reads until CIRISPersist#907; roster rows a PEER authors are signature-checked only until CIRISPersist#908; file rows are node-signed until CIRISEdge#675; family changes after creation do not replicate to a peer that already holds the family (CIRISPersist#910); subject take-back deferred. ALSO: ADOPTS EDGE v31.0.0 / PERSIST v48.0.0 (verify v16.1.0 unchanged; CIRISPersist#860/#905, CIRISEdge#672) — THE PROMOTION SWEEP SEES A CLAIMED MACHINE'S CONSENT: persist's `load_active_egress_grants` and the crossing's covering check take a grant by PRINCIPALS (the machine's own, or its bound steward's naming it in `for_key_id`), and V152 keys `consent_peer_set` by `for_key_id` — so an owned agent's sealed traces are promoted by its human's consent, the gate the production-shaped ladder had read as `offerable=0` since the owner's pen went live in 0.5.211. Also #860: a room's roster converges both ways (`CommunityMembershipWidening`, the 17th EnvelopeKind, appended — REPLICATION_POLICY_HASH / CONSENT_GRAMMAR_HASH / edge SERVE_ADVERTISE_POLICY_HASH re-pinned; ABI 5 unchanged); #672: the test-anchor block is persist's to mint (`mint_test_anchor` reproduces our block byte-for-byte; a `_MINTED_BY` line names the pair). EARLIER IN THIS CUT: ALSO ADOPTS EDGE v30.3.1 (CIRISEdge#671 → #673, persist v47.3.0 / verify v16.1.0 unchanged): a peer in no send set is minted a FirstContact recipient confined to exactly this node's allegiance facts (owner-binding, root acceptance, self-charter at federation audience), so a claimed agent Roots a canonical that consents to nobody on first contact — the server's `GET /v1/federation/allegiance` carry stays as the belt. THE ONE REMAINING GATE IS PERSIST'S (CIRISPersist#905): `promote_consented_backlog` loads grants the ENGINE key AUTHORED, and on every claimed install the human authors the grant (FOR the actor, since this cut — the covering door consents once per own key, node written last, and the actor is anchored at claim), so the sealed traces stay at `(self, local)`: the production-shaped ladder on the #636 merge read seal=3 trace_att=3 offerable=0. TAG HELD until persist folds the sweep over principals and edge pins it. PREVIOUSLY IN THIS CUT: THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE, AND THE TRACE PLANE RELIGHTS ON EDGE v30.3.0 — adopts edge v30.3.0 / persist v47.3.0 (verify v16.1.0 unchanged; CIRISEdge#659/#661/#662 → edge v30.2.0, CIRISPersist#901 → v47.3.0; CIRISServer#632): attribution is not trust (Attributed state; Rooted = a mutual VALID root through the owner-bindings), no baked production dial under a test root, a full responder inbox names its phase and never ends the driver, and `trust_root_valid` gains the holder-hardware leg (a valid root is as attested as its holders — the production seed's three accord holders carry real custody evidence; test roots carry persist's honest `SoftwareOnly_TEST` marker under a live test anchor). Server-side moves: `CallerScope::admits` takes the ROW's room as `cohort_target` (#893/#897), `files::in_room` is persist's gated reader door (caller named, `DrivePage`), `WithholdReason::RecipientNotRooted` classified as a verdict. THE OWNER ACCEPTS THE ROOT (CIRISServer#632 step 2 — the half the adoption alone does not deliver): edge's walk is `∃R ∈ roots_of(owner_of(N)) ∩ roots_of(owner_of(P))`, keyed on the OWNER, and the server had only ever written the NODE's `delegates_to(node → R)` (`accept_trust_root`, bootstrap default trust) — so under edge v30 no owned node was Rooted by any peer. `mesh_genesis::accept_trust_roots_as_owner` writes `delegates_to(owner → R, infra:attest, infra:serve)` at federation with the owner's pen (same envelope shape as the node's, idempotent, never the owner's own key), driven wherever the pen exists: at boot beside the baked-root install (the already-claimed fleet; the production canonical on its one boot with the operator's pen, which now heals the key record AND accepts the root), at both claim arms, and on trust-root import. Under a live TEST anchor the node's own key record carries persist's honest `SoftwareOnly_TEST` marker (a harness node that charters a root is a holder, and v47.3.0 reads an evidence-less holder as an invalid root; never in a production build). Witness: `tests/an_owner_accepts_the_roots_the_node_accepted.rs` — unowned → nothing; claimed → the owner's edge at federation, `trusted_roots_of(owner)` lists it, idempotent, and `trust_root_valid(owner, baked root).valid` by name. Fixture consequences of the persist major: the shared revocation fixture's self-charter signer now carries Layer-A evidence (persist's own remedy), and `trace_round_e2e`'s canonical ACCEPTS the root too — under edge v30 a pair is Rooted only when BOTH accept (it read "AGENT OFFERS 0 ref(s)" with `edge_exists: false` on the canonical). THE HEAL (CIRISServer#606): THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE (CIRISServer#606, found 2026-09-24 on the production canonical): `auth::ownership::rebind_owner_key_record` located the owner's row by `owner_signer.derived_key_id()`, but production's owner pen is built by `hardware_user_local_signer`, which hands `from_hardware_parts` the DERIVED id — so on that pen `key_id()` IS the registered id and `derived_key_id()` is `--`, a key registered nowhere (`identity.rs`, CIRISServer#597 §4: "every existing caller reads key_id()"; this door was the one that did not). The canonical is OWNED by the operator's portable fedID and runs the heal on every boot; with the pen placed on disk for one boot the pen resolved (three `responsible-user signer resolved` lines), the heal looked up the doubly-derived id, returned `Absent`, and the `Ok(_) => {}` arm at all three call sites (boot, claim, delivery) swallowed it — no line, row unchanged, and the row stays refused by every verify v15.2 peer. The gate `tests/owner_key_record_rebind.rs` was green throughout because its fixture pen is named by ALIAS, the one shape production never builds. FIX: the CALLER names the row (`owner_of`'s steward at boot/delivery, the binding's `responsible_user_key_id` at claim), the pen must be that key by `key_id()` or `derived_key_id()` and hold the row's pubkeys, and every outcome — `Rebound` / `Bound` / `Absent` / `Unbound{refusal}` / no pen — is logged by name at every site. Gate: a production-shaped pen (named by its derived id) must rebind, a stranger's pen is refused by name, and a source gate pins the key_id() convention. The one-off heal of the canonical's row is the same runbook re-run on this cut (CIRISServer#632 thread). PREVIOUS: 0.5.215 = EVERY IDENTITY THIS NODE IS, AND A FILE AT A CHOSEN COHORT. THE PUBLISH-OWN SET NOW CARRIES EVERY KEY THIS NODE IS (CIRISServer#629): on an actor/node split install (CC 3.4.7.3 Clause A) compose mints a NODE key, MOVES the owner-binding onto it and makes it the wire identity, and the node then attests its identity occurrences AND its `consent:replication:v1` grant with it — while the publish-own set held only `edge.signer_key_id()` (the actor key). Edge publishes a self-plane row only when its attester is in that set, so the node's OWN key record was never offered in a Key round: the far side admitted the actor key, never the node key, and then refused every row the node attested with "attesting_key_id … is not a registered federation key". Measured on the production canonical the same day — five agent keys admitted in one afternoon, ZERO node keys, ZERO rows of any dimension, no trace since 2026-09-18, and the grant authored on the agent and absent on the receiver. The second half of the same bug: `refresh_publish_own_set` asked `owner_of(edge.signer_key_id())`, but the binding had MOVED to the node key, so the owner never entered the set either (`owner_claimed=false` in the agent log). `own_key_ids(edge)` already answered "which keys are us" and two other call sites were gated on consulting it; this one was not. Both halves fixed and the existing gate extended to the publish-own seed — the gate itself had to be fixed twice (a fixed 4 KiB window stopped short of the code, then it matched the doc comment instead of the call), so it now reads whole function bodies with comments stripped. Same actor/node axis as the drive-auth finding below. ALSO: 0.5.215 = A FILE AT A CHOSEN COHORT, AND THE ROW REACHES MY OTHER DEVICE — adopts edge v29.5.0 (persist v46.3.1 / verify v16.1.0 unchanged; CIRISServer#626/#622, CIRISEdge#646): one door writes a file at any cohort and one drive lists everything this person can reach. `POST /v1/files {cohort: self|family|community}` seals through edge's `files::publish` at the ROOM's tier and crosses on a round-trip; `GET /v1/drive` lists it with each row's byte-state NAMED (`not_fetched` = "on another device", 409, vs `not_granted` = "this device holds no grant", 403 — different truths, and only a 200 is done); `GET /v1/files/{id}` opens the bytes. SELF-CHAT IS NOTE-TAKING: `POST /v1/notes` writes an UNNAMED `text/plain` row in the self room, `GET /v1/notes` reads them back, discriminated on type AND absent filename so an uploaded .txt is never reported as something the person wrote. THE SELF ROOM HAS A DRIVER: edge decides (`self_room::decide`), this host drives — `src/self_room_drive.rs` provisions the owner's content-KEM occurrence, then creates / publishes a KeyPackage / adds / removes on a named `loop_cadence` phase, under a watchdog. WHAT IS PROVEN, on a new two-node ladder (`harness/mesh-repro/scenarios/selffiles.sh` — ONE person, TWO devices): rooted=2 one_owner=1 roster=2 room=1 note=1 file=1 **mine_on_b=1** — the file is published, CROSSES, and the owner's second device LISTS it. WHAT IS NOT: `opened_on_b` (the BYTES) is blocked on CIRISEdge#656 — `decide` returns `PublishKeyPackage`/`Add` but edge can only author a KeyPackage or Welcome into a PAIR room, so those rows land in `chat:pair:v1:`, the creator holds the joiner's KeyPackage and cannot see it (`Added(0)` forever, room stuck at one member), and the second device reads `not_fetched`. The rung stays in the ladder and names that cause; it is promoted back to SUCCESS_STAGE when edge ships `*_attestation_in`. Three defects found by that ladder, none visible in the code containing them: (1) A LOOP CANNOT AUTHORIZE ITSELF WITH A SESSION — `owner_signer_capsule::acquire` reads a bearer, so a daemon could only pass `None` and refused `NotSignedIn` on EVERY tick forever, while the one arm that signs as the NODE (Create) worked, so a node created its self room, logged CREATED and looked healthy; `for_owned_node` is a second AUTHORIZATION (the node's owner binding, as `peer::owner_consent_pen` uses at boot), refusing an unclaimed node by name before opening any seed and verifying the resolved signer derives the key `owner_of` named. (2) THE HANDSHAKE IS NODE-ATTESTED — edge's `rows_in_room` resolves through `list_attestations_by(who)`, written for a chat room where a participant IS a person; a self room's participants are NODES while the person is one owner, so an owner-signed KeyPackage was invisible to the lookup. All four handshake builders take the node signer; the owner's pen stays the crossing ACTOR (the attester names the device, the actor names the authority, CC 3.3.6). (3) A HUNG TICK LOOKED LIKE A HEALTHY QUIET NODE — the loop logged on TRANSITION only, so a non-converging repeat and a wedged await were both silence; `Created`/`Added`/`Removed`/`Failed` now log every time and a tick that overruns 4x its period is abandoned by name. Gates: `tests/a_loop_has_no_bearer.rs`, `tests/the_self_room_handshake_is_node_attested.rs`. Harness: a `self` row needs no GRANT but `send_set_for`'s `nodes_of` is a DIRECTORY read, so the ladder PEERS the devices and waits for the ROSTER before sealing; and `grep -q` under `set -euo pipefail` reports FAILURE on a match (SIGPIPE to the writer, pipefail takes its 141) — it made three rungs read 0 against logs that plainly matched, fixed here and in run_embedded.sh / run_configured.sh. Community files get `comm_file`/`comm_file_on_b` on the chat ladder, the only ladder with two PEOPLE, measured before they gate. FAMILY files are NOT claimed: the consumer side is complete (edge FSD R0-R10, persist's `send_set_for` family arm) but nothing in production can charter a household family or admit a member — `family::create_family` has one caller (the keyless accord, via the trusted-local door) and `family::add_member` has zero (CIRISServer#627). PREVIOUS: 0.5.214 = A CLAIMED NODE READS ITS OWN CONFIG AGAIN — adopts edge v29.3.1 / persist v46.3.1 / verify v16.1.0 (CIRISServer#624 → CIRISPersist#888/#889, CIRISEdge#652): persist's read-side `self` gate compared a RESOLVED caller identity to a RAW row target, so after #873 a claimed node could not read its own `config:*` rows — `GET /v1/config` read `{}` and the second `POST /v1/federation/announce` 500'd on the agent's five-platform gate; v46.3.1 admits the caller's self-collective on both gate twins, and `tests/a_claimed_node_reads_its_own_config.rs` is the witness (red on v46.1.0, green here). Also: edge v29.2.0 `ScopeLifecycle::refresh_members` — `ensure_room_addresses` now REPAIRS a late-resolving member at the same epoch instead of only naming it (CIRISEdge#648); edge v29.3.0 / persist v46.3.0 `send_set_for` — a self row reaches the owner's second device (R2, CIRISPersist#884); verify v16.1.0 `create_federation_identity_in` (CIRISVerify#285) + the orphan `hw_token` stub removed (#283). Also in this cut (CIRISServer#618/#621, PR #620): the ASSOCIATE CUSTODY MATRIX — `POST /v1/self/associate` enrols this host from a hardware-held fed-ID with the two halves custodied independently (classical yubikey|tpm|software × pqc usb|tpm|software, composed through `HardwareRootedIdentity`), arguments validated before any token is touched, and both halves reported from `hardware_type()` rather than the request's words; plus HOME X IS HOME X — a `--home` now seals its own ML-DSA-65 half via verify v16.1.0's `create_federation_identity_in` + `preflight_keys_dir` (CIRISVerify#285), so the database, config, logs, Ed25519 seed AND post-quantum half are all scoped to that home; the 289-line relocation that stood in for the missing parameter is deleted. PREVIOUS: 0.5.213 = THE OWNER RECORD HEALS ITSELF — adopts edge v29.1.0 / persist v46.1.0 (verify v15.2.0 unchanged; CIRISServer#602 update, #606, #609, #612). The version names the NEXT TAG, not the last merge: 0.5.212 is the last tag, and everything since is this cut. Edge v25.2.0 = CIRISEdge#623 (inbound attribution never resolves to this node's own key; no responder is built for ourselves — the defence-in-depth half of #607) + persist v44.7.0 (#864: `Engine::rebind_key_record` — a same-key rebind is an admission). #606 uses that door: a registration record minted before persist #659 carries an envelope of {key_id} only, every verify v15.2.0 peer refuses it, and an OWNER's record rides the first identity round to every node the owner stewards — the production canonical held exactly one such row, the operator's portable identity stewarding nine nodes. auth::ownership::rebind_owner_key_record rebuilds the bound envelope from the row's OWN pubkeys, self-signs it with the owner's pen (refused if the pen does not hold those pubkeys), keeps every other member of the claim, and submits through the door; it runs wherever the owner's pen exists (boot via owner_consent_pen, the fold's author door, both claim-remote arms) and never for a hardware-custodied owner. delivery_status reports owner_key_record = bound | unbound | absent | unknown. Also in this cut: folded_health quiets host-pressure probes (a loaded CI runner made degraded_mode honestly true). THE ROUND SERVES AND THE BINDING CROSSES ON A ROUND-TRIP: the 0.5.212 canonical served zero rounds because edge's replication registry keyed coordinators by (peer, kind) with no role — a peer's inbound frames on a plane we also initiate to queued into the initiator's undrained channel and no responder was ever built (#612 / CIRISEdge#634, found by the in-process probe examples/probe_td_read.rs after three earlier layers: #607 self-prime, CIRISEdge#623 self-attribution, #609/CIRISEdge#624 first-contact carve-out). Edge v25.3.0 fixed the third layer (bootstrap attribution is an equality against the link's proven key, the announce rides the link); edge v26.0.0 fixes the fourth: two role-typed registry tables, CRPL frames carry the round id + direction (wire v3, 0x03 — refused by pre-v26 edges: a v26 node initiates only to v26 peers and answers every older initiator on the legacy path, so a mixed fleet shows one-directional timed_out until it crosses; ship every node on the same edge), and the drop that misdirected this RCA twice is now RouteOutcome::ReplyDropped{reason} + replication_reply_dropped_total / replication_routed_to_{responder,initiator}_total in the snapshot (now folded into GET /v1/federation/metrics `replication_round_routing` and delivery_status().replication_plane.round_routing). MEASURED on the first v26 chat-ladder run (2026-09-19, CIRISServer#612): both role tables build and drain, ReplyDropped 0, responders served on every plane — #634 is closed. On that edge the ladder was STILL red at `bound`, as it had been since v25.3.0 — the fifth layer: the bootstrap door compared a peer's FEDERATION key against the link's TRANSPORT identity (different keypairs on every node, bound by the SignedTransportDestination row), so a node's own record was refused on its own link, the link never attributed, and no Attestation/Community frame from it was admitted; node-a's owner-binding never reached node-b and the joiner withheld its KeyPackage (CIRISEdge#636, proven from the rows). Edge v26.1.0 fixes the door and adds a publish-side kick; the server kicks a coalesced round after the claim, the announce, each consent grant, the fold's author door and every chat row placed in a room (`compose::kick_replication`), so rows cross on a round-trip instead of the next 30 s tick. Persist v44.8.1 (#870): the two blob doors wrote the holds_bytes holder claim WITHOUT a signed_wire_index entry — advertised, never fetchable, so no peer ever learned a holder and every blob-backed body read not_fetched (the chat ladder's arrived=0 on every run since 0.5.207, mis-read as green by the verdict's high-water inference; arrived is REQUIRED now). Under that, three HOST doors the server had never set (found one per ladder run, each behind the last): edge's key-grant engine (ReplicationRuntimeConfig::engine — wraps stored, never projected), the scope-address lifecycle (CIRISEdge#499 — compose ARMED scope-native addressing and never DROVE it; install on Keyed with members = the roster's NODES, advance on epoch, seal on the `scope_seal` cadence) and the blob chunk source (EdgeBuilder::blob_chunk_source — the holder received the scoped BlobChunkFetch and dropped it). All three wired — and edge v27.0.0 (CIRISEdge#640) makes the half-wired node unconstructible: the three receive-side hooks are one type (`SealedContentWiring`: pull_sink ⇒ engine, revocations ⇒ engine) and a scope-native build is refused unless its chunk source declares `answers_scope()`; ours answers from the blob's community-DEK binding, then a referencing row through edge's own BlobMeaning. The last inch is persist's (CIRISPersist#873: `lookup_identity_for_occurrence` is LIMIT 1 with no order, so a node with its owner's login anchor resolves to ITSELF and `would_hold` refuses every community blob a non-author pulls as NotPartyTo); CIRISPersist#876 (the epoch's minter NAMED, not inferred from the author — the adopt wrote (community, person, epoch) while the seal and the key_grant set wrote (community, node, epoch), so authorize_viewer_by_tier missed on the minter member alone and every cross-node community body read NotGranted) is fixed in persist v46.0.0, and the chat ladder is GREEN: rooted=3 peered=2 pulling=1 contact=2 room=1 dark=1 bound=1 sent=1 arrived=1 hamburger=1 — A's bytes landed in B's transcript with their CEG identity intact. The ladder's `arrived` rung is REQUIRED (it had read 0 under `hamburger` since 0.5.207) and its verdict prints the cross-node timeline. Also: GET /v1/contacts rows carry the grant's envelope (#616). Tagged only on a green `bound` + `sent` + `arrived` + `served`. Persist v44.8.0 = the consent scope grammar (#866/#867): the envelope scope member is the normative carrier; one parser + one covering rule; the door refuses a malformed canonical token by name; retain: is honoured (ScopedStance.retain_until, expiry sweep emits consent:state:expired); the transfer grant's principle is read (only share/publish propagate). The server's bare analyze / share / view tokens parse unchanged; resolve_scoped_consent(_by_principals) keep their names. Client floor 0.5.203. SUBSTRATE: persist v51.3.0 / edge v37.0.0 / verify v18.0.0 — a file's name and type are sealed with its bytes and read through `drive::describe` (CIRISEdge#698), the person signs file rows (#675), the self-room consent sweep no longer widens a placed row (CIRISPersist#919), the dimension registry is strict (a gate checks every dimension the server spells), a withdrawn file stays withdrawn at the depth it was admitted under (#690), and a TPM-less host keeps its MLS state on disk under the software master; earlier in this cut on persist v49.0.0 / edge v32.1.0 / verify v17.1.0 (roster co-signatures and quorum proofs, the engine as blob evictor so a holds_bytes claim is retracted before its bytes go, Android generation custody credited as persist measures it (#339), the self-room Rejoin arm). FILE TRANSFER: a 25-file corpus over every supported type and size class, round-tripped on Linux/macOS/Windows and accepted by the first device; opening it on a second device waits on CIRISPersist#919 (the consent sweep widens self-room rows) and one size band on CIRISEdge#687. +version = "0.5.218" # 0.5.218 = ADD A CONTACT BY CODE, AND A SECOND DEVICE THAT CATCHES UP (the maintainer's two flows). CONTACTS: `GET /v1/self/contact-code?nodes=` returns the person's v3 code (string + QR payload) naming the ANNOUNCED devices they choose, or none (resolved through the public directory) (#673); the device roster is public only for devices the person announced, per device (#655 ruling); `DELETE /v1/contacts/{key_id}` and `POST /v1/federation/peering/revoke` withdraw consent, signed by the person (#657); `GET /v1/admin/self` returns `owner_delegations` (#676). SECOND DEVICE: claim-remote links both devices at once, the device holding the pen re-wraps old self files for the new one, and `POST /v1/self/nodes/{node}/announce` announces another of my devices from the first (#678); replication runs 12 of 17 kinds, every kind routed or excluded by name (#646), so member additions, households and device revocations leave the node. FIXES: OAuth redirect parsed, same-origin path or exact loopback only (#672); a TPM request without a TPM refuses (#639); boot errors carry their whole cause chain (#586); a claimed node finds its consent peers (#601); reproposed chat commits are carried and the chat unopened reason is a token (#602). LADDERS: selffiles in CI (#622, #626), a `devices` scenario (second device joins; old self file opens; family rungs RED-EXPECTED on CIRISPersist#910/#916), ui-chat drives client 0.5.224 with nav derived from the client. Client floor 0.5.225. Known: history from BEFORE a device joined opens only for self files until CIRISPersist#916; restart re-admission waits on CIRISEdge#676 / CIRISPersist#911. PREVIOUS: 0.5.217 = 0.5.217 = A FILE IS WHAT IT SAYS IT IS (CIRISServer#642/#641/#643/#644, for CIRISClient#77 and FSD/MEDIA_EDGE.md): the drive's write gate — every write door (upload, replace, move, notes) requires an RFC 6838 media type whose leading bytes agree with it (`drive.bad_media_type` 400, `drive.format_mismatch` 415 naming declared and sniffed — the same table as the client's RenderTier), and a filename cleaned per RFC 6266 §4.3 (path, control, bidi and zero-width characters removed; `drive.bad_filename` when nothing is left), so a name like `invoice\u202Etxt.exe` no longer replicates as written. The node states the PLAINTEXT digest of what it hands over (CC 5.3.2.5): `content_digest` on `GET /v1/files/{id}` and `/meta`, RFC 9530 `Repr-Digest` on a whole raw read; the edge pointer hash is renamed `at_rest_sha256` (it is the sealed blob's, never the plaintext's; `content_sha256` kept as a deprecated alias). `GET /v1/media/policy` publishes the node's render policy ahead of the ingest pipeline, with `renditions: false`. Notes say `here` like the drive (one word per fact; `drive::BYTE_STATES`). A digest SIGNED into the row, and renditions/placeholders, wait on CIRISEdge#638 and #614. PREVIOUS: 0.5.216 = 0.5.216 = ROSTERS AND FILES, END TO END (FSD/ROSTER_AND_DRIVE_CRUD.md): households (`/v1/families`: create, list, read, add/remove member, role, leave, dissolve, and an envelope → cosign → assemble flow for quorum families — closes CIRISServer#627), N-member communities and affiliations (`/v1/communities`, growth on persist v48's widening plane — CIRISServer#594), the owner's own devices (release a node, relabel a device, revoked devices listed; the self room uses edge's room-keyed handshake so a second device OPENS a file's bytes), and full drive CRUD (replace, rename, withdraw with a 410, move between circles, metadata, raw bytes with Range, a paged drive, notes edit/delete; the split-install viewer key fixed). Every roster read goes through the fold, enforced by a source gate. Known gaps, named in the FSD: a widened community member is refused reads until CIRISPersist#907; roster rows a PEER authors are signature-checked only until CIRISPersist#908; file rows are node-signed until CIRISEdge#675; family changes after creation do not replicate to a peer that already holds the family (CIRISPersist#910); subject take-back deferred. ALSO: ADOPTS EDGE v31.0.0 / PERSIST v48.0.0 (verify v16.1.0 unchanged; CIRISPersist#860/#905, CIRISEdge#672) — THE PROMOTION SWEEP SEES A CLAIMED MACHINE'S CONSENT: persist's `load_active_egress_grants` and the crossing's covering check take a grant by PRINCIPALS (the machine's own, or its bound steward's naming it in `for_key_id`), and V152 keys `consent_peer_set` by `for_key_id` — so an owned agent's sealed traces are promoted by its human's consent, the gate the production-shaped ladder had read as `offerable=0` since the owner's pen went live in 0.5.211. Also #860: a room's roster converges both ways (`CommunityMembershipWidening`, the 17th EnvelopeKind, appended — REPLICATION_POLICY_HASH / CONSENT_GRAMMAR_HASH / edge SERVE_ADVERTISE_POLICY_HASH re-pinned; ABI 5 unchanged); #672: the test-anchor block is persist's to mint (`mint_test_anchor` reproduces our block byte-for-byte; a `_MINTED_BY` line names the pair). EARLIER IN THIS CUT: ALSO ADOPTS EDGE v30.3.1 (CIRISEdge#671 → #673, persist v47.3.0 / verify v16.1.0 unchanged): a peer in no send set is minted a FirstContact recipient confined to exactly this node's allegiance facts (owner-binding, root acceptance, self-charter at federation audience), so a claimed agent Roots a canonical that consents to nobody on first contact — the server's `GET /v1/federation/allegiance` carry stays as the belt. THE ONE REMAINING GATE IS PERSIST'S (CIRISPersist#905): `promote_consented_backlog` loads grants the ENGINE key AUTHORED, and on every claimed install the human authors the grant (FOR the actor, since this cut — the covering door consents once per own key, node written last, and the actor is anchored at claim), so the sealed traces stay at `(self, local)`: the production-shaped ladder on the #636 merge read seal=3 trace_att=3 offerable=0. TAG HELD until persist folds the sweep over principals and edge pins it. PREVIOUSLY IN THIS CUT: THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE, AND THE TRACE PLANE RELIGHTS ON EDGE v30.3.0 — adopts edge v30.3.0 / persist v47.3.0 (verify v16.1.0 unchanged; CIRISEdge#659/#661/#662 → edge v30.2.0, CIRISPersist#901 → v47.3.0; CIRISServer#632): attribution is not trust (Attributed state; Rooted = a mutual VALID root through the owner-bindings), no baked production dial under a test root, a full responder inbox names its phase and never ends the driver, and `trust_root_valid` gains the holder-hardware leg (a valid root is as attested as its holders — the production seed's three accord holders carry real custody evidence; test roots carry persist's honest `SoftwareOnly_TEST` marker under a live test anchor). Server-side moves: `CallerScope::admits` takes the ROW's room as `cohort_target` (#893/#897), `files::in_room` is persist's gated reader door (caller named, `DrivePage`), `WithholdReason::RecipientNotRooted` classified as a verdict. THE OWNER ACCEPTS THE ROOT (CIRISServer#632 step 2 — the half the adoption alone does not deliver): edge's walk is `∃R ∈ roots_of(owner_of(N)) ∩ roots_of(owner_of(P))`, keyed on the OWNER, and the server had only ever written the NODE's `delegates_to(node → R)` (`accept_trust_root`, bootstrap default trust) — so under edge v30 no owned node was Rooted by any peer. `mesh_genesis::accept_trust_roots_as_owner` writes `delegates_to(owner → R, infra:attest, infra:serve)` at federation with the owner's pen (same envelope shape as the node's, idempotent, never the owner's own key), driven wherever the pen exists: at boot beside the baked-root install (the already-claimed fleet; the production canonical on its one boot with the operator's pen, which now heals the key record AND accepts the root), at both claim arms, and on trust-root import. Under a live TEST anchor the node's own key record carries persist's honest `SoftwareOnly_TEST` marker (a harness node that charters a root is a holder, and v47.3.0 reads an evidence-less holder as an invalid root; never in a production build). Witness: `tests/an_owner_accepts_the_roots_the_node_accepted.rs` — unowned → nothing; claimed → the owner's edge at federation, `trusted_roots_of(owner)` lists it, idempotent, and `trust_root_valid(owner, baked root).valid` by name. Fixture consequences of the persist major: the shared revocation fixture's self-charter signer now carries Layer-A evidence (persist's own remedy), and `trace_round_e2e`'s canonical ACCEPTS the root too — under edge v30 a pair is Rooted only when BOTH accept (it read "AGENT OFFERS 0 ref(s)" with `edge_exists: false` on the canonical). THE HEAL (CIRISServer#606): THE HEAL LOOKED UP A KEY THAT EXISTS NOWHERE (CIRISServer#606, found 2026-09-24 on the production canonical): `auth::ownership::rebind_owner_key_record` located the owner's row by `owner_signer.derived_key_id()`, but production's owner pen is built by `hardware_user_local_signer`, which hands `from_hardware_parts` the DERIVED id — so on that pen `key_id()` IS the registered id and `derived_key_id()` is `--`, a key registered nowhere (`identity.rs`, CIRISServer#597 §4: "every existing caller reads key_id()"; this door was the one that did not). The canonical is OWNED by the operator's portable fedID and runs the heal on every boot; with the pen placed on disk for one boot the pen resolved (three `responsible-user signer resolved` lines), the heal looked up the doubly-derived id, returned `Absent`, and the `Ok(_) => {}` arm at all three call sites (boot, claim, delivery) swallowed it — no line, row unchanged, and the row stays refused by every verify v15.2 peer. The gate `tests/owner_key_record_rebind.rs` was green throughout because its fixture pen is named by ALIAS, the one shape production never builds. FIX: the CALLER names the row (`owner_of`'s steward at boot/delivery, the binding's `responsible_user_key_id` at claim), the pen must be that key by `key_id()` or `derived_key_id()` and hold the row's pubkeys, and every outcome — `Rebound` / `Bound` / `Absent` / `Unbound{refusal}` / no pen — is logged by name at every site. Gate: a production-shaped pen (named by its derived id) must rebind, a stranger's pen is refused by name, and a source gate pins the key_id() convention. The one-off heal of the canonical's row is the same runbook re-run on this cut (CIRISServer#632 thread). PREVIOUS: 0.5.215 = EVERY IDENTITY THIS NODE IS, AND A FILE AT A CHOSEN COHORT. THE PUBLISH-OWN SET NOW CARRIES EVERY KEY THIS NODE IS (CIRISServer#629): on an actor/node split install (CC 3.4.7.3 Clause A) compose mints a NODE key, MOVES the owner-binding onto it and makes it the wire identity, and the node then attests its identity occurrences AND its `consent:replication:v1` grant with it — while the publish-own set held only `edge.signer_key_id()` (the actor key). Edge publishes a self-plane row only when its attester is in that set, so the node's OWN key record was never offered in a Key round: the far side admitted the actor key, never the node key, and then refused every row the node attested with "attesting_key_id … is not a registered federation key". Measured on the production canonical the same day — five agent keys admitted in one afternoon, ZERO node keys, ZERO rows of any dimension, no trace since 2026-09-18, and the grant authored on the agent and absent on the receiver. The second half of the same bug: `refresh_publish_own_set` asked `owner_of(edge.signer_key_id())`, but the binding had MOVED to the node key, so the owner never entered the set either (`owner_claimed=false` in the agent log). `own_key_ids(edge)` already answered "which keys are us" and two other call sites were gated on consulting it; this one was not. Both halves fixed and the existing gate extended to the publish-own seed — the gate itself had to be fixed twice (a fixed 4 KiB window stopped short of the code, then it matched the doc comment instead of the call), so it now reads whole function bodies with comments stripped. Same actor/node axis as the drive-auth finding below. ALSO: 0.5.215 = A FILE AT A CHOSEN COHORT, AND THE ROW REACHES MY OTHER DEVICE — adopts edge v29.5.0 (persist v46.3.1 / verify v16.1.0 unchanged; CIRISServer#626/#622, CIRISEdge#646): one door writes a file at any cohort and one drive lists everything this person can reach. `POST /v1/files {cohort: self|family|community}` seals through edge's `files::publish` at the ROOM's tier and crosses on a round-trip; `GET /v1/drive` lists it with each row's byte-state NAMED (`not_fetched` = "on another device", 409, vs `not_granted` = "this device holds no grant", 403 — different truths, and only a 200 is done); `GET /v1/files/{id}` opens the bytes. SELF-CHAT IS NOTE-TAKING: `POST /v1/notes` writes an UNNAMED `text/plain` row in the self room, `GET /v1/notes` reads them back, discriminated on type AND absent filename so an uploaded .txt is never reported as something the person wrote. THE SELF ROOM HAS A DRIVER: edge decides (`self_room::decide`), this host drives — `src/self_room_drive.rs` provisions the owner's content-KEM occurrence, then creates / publishes a KeyPackage / adds / removes on a named `loop_cadence` phase, under a watchdog. WHAT IS PROVEN, on a new two-node ladder (`harness/mesh-repro/scenarios/selffiles.sh` — ONE person, TWO devices): rooted=2 one_owner=1 roster=2 room=1 note=1 file=1 **mine_on_b=1** — the file is published, CROSSES, and the owner's second device LISTS it. WHAT IS NOT: `opened_on_b` (the BYTES) is blocked on CIRISEdge#656 — `decide` returns `PublishKeyPackage`/`Add` but edge can only author a KeyPackage or Welcome into a PAIR room, so those rows land in `chat:pair:v1:`, the creator holds the joiner's KeyPackage and cannot see it (`Added(0)` forever, room stuck at one member), and the second device reads `not_fetched`. The rung stays in the ladder and names that cause; it is promoted back to SUCCESS_STAGE when edge ships `*_attestation_in`. Three defects found by that ladder, none visible in the code containing them: (1) A LOOP CANNOT AUTHORIZE ITSELF WITH A SESSION — `owner_signer_capsule::acquire` reads a bearer, so a daemon could only pass `None` and refused `NotSignedIn` on EVERY tick forever, while the one arm that signs as the NODE (Create) worked, so a node created its self room, logged CREATED and looked healthy; `for_owned_node` is a second AUTHORIZATION (the node's owner binding, as `peer::owner_consent_pen` uses at boot), refusing an unclaimed node by name before opening any seed and verifying the resolved signer derives the key `owner_of` named. (2) THE HANDSHAKE IS NODE-ATTESTED — edge's `rows_in_room` resolves through `list_attestations_by(who)`, written for a chat room where a participant IS a person; a self room's participants are NODES while the person is one owner, so an owner-signed KeyPackage was invisible to the lookup. All four handshake builders take the node signer; the owner's pen stays the crossing ACTOR (the attester names the device, the actor names the authority, CC 3.3.6). (3) A HUNG TICK LOOKED LIKE A HEALTHY QUIET NODE — the loop logged on TRANSITION only, so a non-converging repeat and a wedged await were both silence; `Created`/`Added`/`Removed`/`Failed` now log every time and a tick that overruns 4x its period is abandoned by name. Gates: `tests/a_loop_has_no_bearer.rs`, `tests/the_self_room_handshake_is_node_attested.rs`. Harness: a `self` row needs no GRANT but `send_set_for`'s `nodes_of` is a DIRECTORY read, so the ladder PEERS the devices and waits for the ROSTER before sealing; and `grep -q` under `set -euo pipefail` reports FAILURE on a match (SIGPIPE to the writer, pipefail takes its 141) — it made three rungs read 0 against logs that plainly matched, fixed here and in run_embedded.sh / run_configured.sh. Community files get `comm_file`/`comm_file_on_b` on the chat ladder, the only ladder with two PEOPLE, measured before they gate. FAMILY files are NOT claimed: the consumer side is complete (edge FSD R0-R10, persist's `send_set_for` family arm) but nothing in production can charter a household family or admit a member — `family::create_family` has one caller (the keyless accord, via the trusted-local door) and `family::add_member` has zero (CIRISServer#627). PREVIOUS: 0.5.214 = A CLAIMED NODE READS ITS OWN CONFIG AGAIN — adopts edge v29.3.1 / persist v46.3.1 / verify v16.1.0 (CIRISServer#624 → CIRISPersist#888/#889, CIRISEdge#652): persist's read-side `self` gate compared a RESOLVED caller identity to a RAW row target, so after #873 a claimed node could not read its own `config:*` rows — `GET /v1/config` read `{}` and the second `POST /v1/federation/announce` 500'd on the agent's five-platform gate; v46.3.1 admits the caller's self-collective on both gate twins, and `tests/a_claimed_node_reads_its_own_config.rs` is the witness (red on v46.1.0, green here). Also: edge v29.2.0 `ScopeLifecycle::refresh_members` — `ensure_room_addresses` now REPAIRS a late-resolving member at the same epoch instead of only naming it (CIRISEdge#648); edge v29.3.0 / persist v46.3.0 `send_set_for` — a self row reaches the owner's second device (R2, CIRISPersist#884); verify v16.1.0 `create_federation_identity_in` (CIRISVerify#285) + the orphan `hw_token` stub removed (#283). Also in this cut (CIRISServer#618/#621, PR #620): the ASSOCIATE CUSTODY MATRIX — `POST /v1/self/associate` enrols this host from a hardware-held fed-ID with the two halves custodied independently (classical yubikey|tpm|software × pqc usb|tpm|software, composed through `HardwareRootedIdentity`), arguments validated before any token is touched, and both halves reported from `hardware_type()` rather than the request's words; plus HOME X IS HOME X — a `--home` now seals its own ML-DSA-65 half via verify v16.1.0's `create_federation_identity_in` + `preflight_keys_dir` (CIRISVerify#285), so the database, config, logs, Ed25519 seed AND post-quantum half are all scoped to that home; the 289-line relocation that stood in for the missing parameter is deleted. PREVIOUS: 0.5.213 = THE OWNER RECORD HEALS ITSELF — adopts edge v29.1.0 / persist v46.1.0 (verify v15.2.0 unchanged; CIRISServer#602 update, #606, #609, #612). The version names the NEXT TAG, not the last merge: 0.5.212 is the last tag, and everything since is this cut. Edge v25.2.0 = CIRISEdge#623 (inbound attribution never resolves to this node's own key; no responder is built for ourselves — the defence-in-depth half of #607) + persist v44.7.0 (#864: `Engine::rebind_key_record` — a same-key rebind is an admission). #606 uses that door: a registration record minted before persist #659 carries an envelope of {key_id} only, every verify v15.2.0 peer refuses it, and an OWNER's record rides the first identity round to every node the owner stewards — the production canonical held exactly one such row, the operator's portable identity stewarding nine nodes. auth::ownership::rebind_owner_key_record rebuilds the bound envelope from the row's OWN pubkeys, self-signs it with the owner's pen (refused if the pen does not hold those pubkeys), keeps every other member of the claim, and submits through the door; it runs wherever the owner's pen exists (boot via owner_consent_pen, the fold's author door, both claim-remote arms) and never for a hardware-custodied owner. delivery_status reports owner_key_record = bound | unbound | absent | unknown. Also in this cut: folded_health quiets host-pressure probes (a loaded CI runner made degraded_mode honestly true). THE ROUND SERVES AND THE BINDING CROSSES ON A ROUND-TRIP: the 0.5.212 canonical served zero rounds because edge's replication registry keyed coordinators by (peer, kind) with no role — a peer's inbound frames on a plane we also initiate to queued into the initiator's undrained channel and no responder was ever built (#612 / CIRISEdge#634, found by the in-process probe examples/probe_td_read.rs after three earlier layers: #607 self-prime, CIRISEdge#623 self-attribution, #609/CIRISEdge#624 first-contact carve-out). Edge v25.3.0 fixed the third layer (bootstrap attribution is an equality against the link's proven key, the announce rides the link); edge v26.0.0 fixes the fourth: two role-typed registry tables, CRPL frames carry the round id + direction (wire v3, 0x03 — refused by pre-v26 edges: a v26 node initiates only to v26 peers and answers every older initiator on the legacy path, so a mixed fleet shows one-directional timed_out until it crosses; ship every node on the same edge), and the drop that misdirected this RCA twice is now RouteOutcome::ReplyDropped{reason} + replication_reply_dropped_total / replication_routed_to_{responder,initiator}_total in the snapshot (now folded into GET /v1/federation/metrics `replication_round_routing` and delivery_status().replication_plane.round_routing). MEASURED on the first v26 chat-ladder run (2026-09-19, CIRISServer#612): both role tables build and drain, ReplyDropped 0, responders served on every plane — #634 is closed. On that edge the ladder was STILL red at `bound`, as it had been since v25.3.0 — the fifth layer: the bootstrap door compared a peer's FEDERATION key against the link's TRANSPORT identity (different keypairs on every node, bound by the SignedTransportDestination row), so a node's own record was refused on its own link, the link never attributed, and no Attestation/Community frame from it was admitted; node-a's owner-binding never reached node-b and the joiner withheld its KeyPackage (CIRISEdge#636, proven from the rows). Edge v26.1.0 fixes the door and adds a publish-side kick; the server kicks a coalesced round after the claim, the announce, each consent grant, the fold's author door and every chat row placed in a room (`compose::kick_replication`), so rows cross on a round-trip instead of the next 30 s tick. Persist v44.8.1 (#870): the two blob doors wrote the holds_bytes holder claim WITHOUT a signed_wire_index entry — advertised, never fetchable, so no peer ever learned a holder and every blob-backed body read not_fetched (the chat ladder's arrived=0 on every run since 0.5.207, mis-read as green by the verdict's high-water inference; arrived is REQUIRED now). Under that, three HOST doors the server had never set (found one per ladder run, each behind the last): edge's key-grant engine (ReplicationRuntimeConfig::engine — wraps stored, never projected), the scope-address lifecycle (CIRISEdge#499 — compose ARMED scope-native addressing and never DROVE it; install on Keyed with members = the roster's NODES, advance on epoch, seal on the `scope_seal` cadence) and the blob chunk source (EdgeBuilder::blob_chunk_source — the holder received the scoped BlobChunkFetch and dropped it). All three wired — and edge v27.0.0 (CIRISEdge#640) makes the half-wired node unconstructible: the three receive-side hooks are one type (`SealedContentWiring`: pull_sink ⇒ engine, revocations ⇒ engine) and a scope-native build is refused unless its chunk source declares `answers_scope()`; ours answers from the blob's community-DEK binding, then a referencing row through edge's own BlobMeaning. The last inch is persist's (CIRISPersist#873: `lookup_identity_for_occurrence` is LIMIT 1 with no order, so a node with its owner's login anchor resolves to ITSELF and `would_hold` refuses every community blob a non-author pulls as NotPartyTo); CIRISPersist#876 (the epoch's minter NAMED, not inferred from the author — the adopt wrote (community, person, epoch) while the seal and the key_grant set wrote (community, node, epoch), so authorize_viewer_by_tier missed on the minter member alone and every cross-node community body read NotGranted) is fixed in persist v46.0.0, and the chat ladder is GREEN: rooted=3 peered=2 pulling=1 contact=2 room=1 dark=1 bound=1 sent=1 arrived=1 hamburger=1 — A's bytes landed in B's transcript with their CEG identity intact. The ladder's `arrived` rung is REQUIRED (it had read 0 under `hamburger` since 0.5.207) and its verdict prints the cross-node timeline. Also: GET /v1/contacts rows carry the grant's envelope (#616). Tagged only on a green `bound` + `sent` + `arrived` + `served`. Persist v44.8.0 = the consent scope grammar (#866/#867): the envelope scope member is the normative carrier; one parser + one covering rule; the door refuses a malformed canonical token by name; retain: is honoured (ScopedStance.retain_until, expiry sweep emits consent:state:expired); the transfer grant's principle is read (only share/publish propagate). The server's bare analyze / share / view tokens parse unchanged; resolve_scoped_consent(_by_principals) keep their names. Client floor 0.5.203. SUBSTRATE: persist v51.3.0 / edge v37.0.0 / verify v18.0.0 — a file's name and type are sealed with its bytes and read through `drive::describe` (CIRISEdge#698), the person signs file rows (#675), the self-room consent sweep no longer widens a placed row (CIRISPersist#919), the dimension registry is strict (a gate checks every dimension the server spells), a withdrawn file stays withdrawn at the depth it was admitted under (#690), and a TPM-less host keeps its MLS state on disk under the software master; earlier in this cut on persist v49.0.0 / edge v32.1.0 / verify v17.1.0 (roster co-signatures and quorum proofs, the engine as blob evictor so a holds_bytes claim is retracted before its bytes go, Android generation custody credited as persist measures it (#339), the self-room Rejoin arm). FILE TRANSFER: a 25-file corpus over every supported type and size class, round-tripped on Linux/macOS/Windows and accepted by the first device; opening it on a second device waits on CIRISPersist#919 (the consent sweep widens self-room rows) and one size band on CIRISEdge#687. EVICTING A DEVICE is one signed act that replicates (CSD-037): `release` and `occurrence/revoke` converge on `self_devices::evict_device`, which withdraws the owner-binding AND writes the occurrence revocation through persist's SIGNED door (signed by the owner's pen, so the IdentityOccurrenceRevocation plane carries it) and kicks replication; until now a release revoked no occurrence and a revoke wrote the unsigned local-only row, so the #646 plane was routed but empty. Already-shared history stays readable by the evicted device (no DEK rotation, no re-encryption); only new content and new room epochs are withheld. CONSENT TO JOIN: adding a member to a family or community is refused (409 membership.consent_required) until the joiner can consent (CIRISPersist#955 / CIRISConstitution#133; FSD/MEMBERSHIP_INVITES.md) — direct adds, quorum adds, and founding rosters naming anyone but the founder. edition = "2021" # MSRV floor is set by the substrate: ciris-verify v5.2.0 requires 1.86 # (persist v6.0.1 is 1.83). Build with the higher of the two. diff --git a/FSD/ROSTER_AND_DRIVE_CRUD.md b/FSD/ROSTER_AND_DRIVE_CRUD.md index 1ec7c310..dd824c64 100644 --- a/FSD/ROSTER_AND_DRIVE_CRUD.md +++ b/FSD/ROSTER_AND_DRIVE_CRUD.md @@ -51,6 +51,14 @@ policy, its refusals, its witness. 7. **Every write kicks replication** (`compose::kick_replication`) so the change crosses on a round-trip. 8. **Every list route carries the row's envelope** (subject, attester, cohort_scope, dimension, consent:scope — CSD-006, #616) and a `resume` cursor. +9. **Nobody joins without their own consent** (0.5.218; the maintainer's ruling of 2026-09-30, + CIRISConstitution#133, `FSD/MEMBERSHIP_INVITES.md`). Until persist can carry a proposal to the joiner + and record their acceptance (CIRISPersist#955, v52), every roster-growing door answers 409 + `membership.consent_required`: `POST …/members` on a family or a room, a quorum envelope / cosign / + assemble whose proposed roster adds anyone, and a create whose founding roster names anyone but the + founder. Remove, role, leave and dissolve are unaffected; re-adding someone already active keeps + `*.already_member`; pair rooms keep the contact grant each side authors. This supersedes the "Add + member" rows of §3 and §4 below, which record what 0.5.216–0.5.217 shipped. ### 1.1 Which planes cross (known gap closed in 0.5.218, CIRISServer#646) @@ -71,6 +79,17 @@ so edge refuses every delivered row terminally). A gate from `EnvelopeKind::ALL` minus that exclusion list, so a kind appended upstream goes red until someone decides. +**Correction (0.5.218, the device-eviction audit).** Routing `IdentityOccurrenceRevocation` did not by +itself make "a released device" cross. No server path wrote a revocation the plane could carry: `release` +withdrew the owner-binding and revoked NO occurrence, and `POST /v1/self/occurrence/revoke` wrote through +persist's trusted-LOCAL door (`put_identity_occurrence_revocation_local`), whose rows are unsigned and +EXCLUDED from the signed replication read by construction. So the plane was routed and empty: every other +device of the owner kept wrapping new self files to the evicted one. The gap closed only when both routes +became one signed act, `self_devices::evict_device` (§2.2): the owner-signed `withdraws` of the +owner-binding(s) AND a revocation through persist's SIGNED door (`put_identity_occurrence_revocation`, +signed by the owner's pen), then a replication kick. `tests/occurrence.rs` gates that the local door has +no caller in `src/`. + **Load.** Twelve kinds is twelve coordinators per peer on edge's single 30 s scheduler cadence. Edge v31 has no per-kind cadence and no kick-only coordinator (`SchedulerConfig::cadence` is global; mesh-config relief lengthens every kind at once), so the six rarely written planes cost one round each per tick even @@ -85,7 +104,7 @@ upstream is a per-kind cadence or kick-only rounds for rarely written kinds. | List owned nodes | `GET /v1/setup/owned-nodes` (exists, loopback) | loopback | unchanged | | List device keys | `GET /v1/self/occurrences` (exists) | public binding metadata | add `revoked: bool` + `include_revoked=true` query | | Add a device key | `POST /v1/self/occurrence` (exists) | signed by the primary | unchanged | -| Revoke a device key | `POST /v1/self/occurrence/revoke` (exists) | signed by a SURVIVING occurrence | unchanged | +| Revoke a device key | `POST /v1/self/occurrence/revoke` (exists) | signed by a SURVIVING occurrence | unchanged — **0.5.218: now the owner's session, signed server-side by the owner's pen, one act with release (§2.2)** | | Relabel a device | `POST /v1/self/occurrence/label` `{occurrence_key_id, label}` **new** | owner session | a `supersedes` of the occurrence row with the new label; label is display-only | | **Release a node** | `POST /v1/self/nodes/{node_key_id}/release` **new** | owner session; the owner of `node_key_id` must be the caller | withdraws the owner-binding (`delegates_to(user → node)`) with a signed `withdraws`; the node drops out of `nodes_owned_by`, the self room removes it on the next drive tick, the node reverts to Clause D fail-closed. Refuses the node you are talking to unless `force_self: true` | | Self room bytes on a 2nd device | background driver | node-signed handshake | adopt `key_package_attestation_in` / `welcome_attestation_in` / `welcome_for` (#656); selffiles `opened_on_b` becomes REQUIRED | @@ -123,6 +142,40 @@ New ids: `self.not_your_node`, `self.release_self_requires_force`, `self.label_e `self.author_signer_unavailable`, `self.bad_request`, `self.not_your_device`, `self.release_incomplete`. +### 2.2 Evicting a device is ONE act (0.5.218, CSD-037 — the stolen-device path) + +Until 0.5.218 two unconnected routes each did half, and neither half was enough. `release` withdrew the +owner-binding (the self room drops the node on its next tick) but left the node's content-KEM identity +occurrence live, and persist wraps every new self file to `list_identity_occurrences_active(owner)`, so +the released machine kept receiving the key to each new file. `occurrence/revoke` wanted a request +hybrid-signed by a surviving device key — the app sends a bearer, so every call answered 401 — and wrote +through the trusted-local door (unsigned, never replicated, §1.1) without touching the owner-binding. + +Both routes now converge on `self_devices::evict_device`: + +1. withdraw every live owner-binding the owner holds on the device's node(s) (owner-signed `withdraws`); +2. revoke the device's occurrence(s) through persist's SIGNED `put_identity_occurrence_revocation`, + signed by the owner's pen (the identity the occurrence belongs to), so the + `IdentityOccurrenceRevocation` plane carries it; +3. kick replication; 4. read both halves back from persist (`nodes_owned_by`, + `list_identity_occurrences_active`). + +`release` revokes the node's occurrence(s) of the owner (its node key; for THIS node every key it is); +`occurrence/revoke` withdraws the owner-binding when the occurrence IS one of the owner's nodes. Both are +authorised by the owner's session (`self.owner_session_required` 401/403, `self.delegate_may_not_author`), +refuse another person's key as `self.not_your_node` / `self.not_your_device`, and need `force_self` for the +node being talked to. The answer names every part done and every part that failed, by part and target +(`self.evict_incomplete` 500 when any part other than the binding witness failed; `self.release_incomplete` +keeps its meaning). + +**What eviction does not do, stated in every answer (`history`) — CSD-037, CC 3.3.6.1:** already-shared +history stays readable by the evicted device. No DEK is rotated and nothing is re-encrypted; only NEW +content (new self files wrap to active occurrences only) and NEW self-room epochs are withheld. + +What `release` cannot find: another machine's ACTOR-key occurrence when that machine is a pre-fix split +install (nothing in the directory links a remote node key to its actor key). Revoking that occurrence by +its own key reaches it. + ## 3. Family (household) A household is a `Family` whose `family_key_id` is a keyless group identifier (V151), founded by the @@ -254,7 +307,7 @@ rewritten to grow. Pair rooms (`POST /v1/chat`) stay as they are and are listed | Create | `POST /v1/communities` `{name, members?: [key_id], tier?: community\|affiliations, consensus_protocol?}` | owner session; caller is founder; each initial member must be a contact whose grant covers `chat:` (same rule as pair rooms) | | List mine | `GET /v1/communities` (pair rooms included, `kind: pair\|room`) | owner session; fold | | Read | `GET /v1/communities/{id}` → record + effective roster + roles + appointed moderators | member only; `community.not_found` for non-members | -| Add member (widen) | `POST /v1/communities/{id}/members` `{key_id, role?}` | protocol satisfied (founder, or an appointed roster-duty holder via `delegates_to`); target must be a contact; **blocked on CIRISPersist#907** for the added member's reads, gated by a RED-EXPECTED rung until it lands | +| Add member (widen) | `POST /v1/communities/{id}/members` `{key_id, role?}` | protocol satisfied (founder, or an appointed roster-duty holder via `delegates_to`); target must be a contact; **blocked on CIRISPersist#907** for the added member's reads, gated by a RED-EXPECTED rung until it lands. **0.5.218:** #907 landed in persist v49 (the admission folds the widening plane; `tests/community_crud.rs` runs the read un-ignored) — and the route itself is CLOSED, 409 `membership.consent_required`, until the joiner can consent (§1 rule 9, CIRISPersist#955) | | Remove member | `DELETE /v1/communities/{id}/members/{key_id}` | protocol satisfied | | Leave | `POST /v1/communities/{id}/leave` | self only; last founder rule as for families | | Change role / appoint moderator | `POST /v1/communities/{id}/members/{key_id}/role` `{role}`; moderators keep using duty conferral | protocol satisfied | @@ -272,6 +325,8 @@ New ids: `community.not_found`, `community.not_authorized`, `community.not_a_con **Security note.** Persist's replicated widening/revocation doors verify the signature, not the signer's standing in the room (CIRISPersist#908). The server's routes enforce the protocol for rows it authors; rows a peer authors are admitted by persist alone until #908 lands. Stated in the release notes. +**Corrected 0.5.218:** #908 landed in persist v49.0.0 — every replicated roster row is judged at persist's +door by the room's own `consensus_protocol` over the row's co-signatures (`src/roster_rows.rs`). ### 4.1 As built (`src/communities.rs`, 0.5.216) — decisions and gaps at these pins @@ -414,7 +469,9 @@ folded into "no session"), `drive.bad_body` (400, an unparseable JSON or multipa - A source-scraping gate: no raw `.members` read for a membership decision in `src/`. - Ladders: selffiles `opened_on_b` REQUIRED; chat ladder gains `family`, `family_file_on_b`, `room3` (a three-member room), `widened_reads` (RED-EXPECTED until CIRISPersist#907), `withdrawn` (a withdrawn - file reads 410 on the other node). + file reads 410 on the other node). **0.5.218:** #907 landed in persist v49 — the in-process + `a_widened_member_reads_the_rooms_messages_cirispersist_907` runs un-ignored, its member widened by a + test-only fixture because the add route is closed on consent (§1 rule 9). - openapi.json lists every route here; the localization guard covers every new id. - 0.5.218: `selffiles` runs in the mesh-harness CI matrix (#622) and its `file` / `opened_on_b` rungs fail by name on a self pull regression (#626: `NoHolders`, `NoMeaning(GroupWithoutId)`, a @@ -423,7 +480,12 @@ folded into "no session"), `drive.bad_body` (400, an unparseable JSON or multipa dispatch-only `devices` scenario (the chat ladder plus a second device and a household) adds `second_device`, `c_peered`, `c_lists_room` (its success stage), `c_opens_history` (RED-EXPECTED: no content-key rewrap to a new occurrence of an existing member) and the #647 family rungs (RED-EXPECTED - on CIRISPersist#910). + on CIRISPersist#910). **Corrected 0.5.218:** persist v49 fixed #910 (and #907, #908); the `family` rung + now asserts that the add of B is REFUSED `membership.consent_required`, and `family_on_b` / + `family_file_listed_on_b` / `family_file_opened_on_b` are RED-EXPECTED on consent to join + (CIRISPersist#955) — no door may put B on the roster until B accepts. The in-process suites build their + multi-member rosters through clearly named `test_only_*` fixtures that write the rows through persist, + never through a production bypass. ## 7. Deliberately later diff --git a/harness/mesh-repro/scenarios/devices.sh b/harness/mesh-repro/scenarios/devices.sh index 127915e4..6a91a0eb 100644 --- a/harness/mesh-repro/scenarios/devices.sh +++ b/harness/mesh-repro/scenarios/devices.sh @@ -17,6 +17,14 @@ # 2. HOUSEHOLD (CIRISServer#647). Person A charters a family, adds person B, # and writes a `cohort: family` file. Does B's node see the household, list # the file, and open it? +# 0.5.218 — CONSENT TO JOIN (the maintainer's ruling of 2026-09-30, +# CIRISConstitution#133, FSD/MEMBERSHIP_INVITES.md): nobody joins a +# household without their own acceptance, and until persist can record one +# (CIRISPersist#955, v52) the add answers 409 `membership.consent_required`. +# The `family` rung now asserts THAT refusal; the rungs downstream of B +# being a member (family_on_b, family_file_listed_on_b, +# family_file_opened_on_b) are RED-EXPECTED on #955 — there is no +# production door that can put B on the roster, and the harness adds none. # # WHY A SEPARATE SCENARIO and not more rungs on chat.sh: the chat ladder gates # the tag and runs in CI on every substrate PR; these rungs are RED-EXPECTED by @@ -47,6 +55,10 @@ # family record neither re-indexes the wire # (`supersede_group_row`) nor re-puts at a peer that holds # the founding one (`put_family` is a plain INSERT). +# 0.5.218: persist v49 CLOSED #910 (a widening plane for +# families, amendments carry their proof). These rungs are +# now red-expected on a different piece: consent to join +# (CIRISPersist#955) — no door may add B until B accepts. # # EACH IS PROMOTED TO REQUIRED WHEN ITS PIECE LANDS — not before, and not left # RED-EXPECTED after: a marked stage that has gone green is a claim nobody is @@ -84,9 +96,9 @@ REQUIRED_family=1 REQUIRED_family_file=1 XFAIL_c_opens_history="no content-key REWRAP to a new occurrence of an EXISTING member: the message was sealed under the minter's community epoch DEK and wrapped to the member occurrences persist knew then; node-c's occurrence of B is provisioned later (ensure_owner_content_occurrence) and nothing wraps existing epochs to it — persist has rekey_self_occurrence_add (self blobs; the server calls it only from POST /v1/self/occurrence, not on claim-remote) and rekey_family_member_add (a new member), and no community-epoch occurrence-add; ensure_epoch_dek fills a late occurrence only on the minter's NEXT seal in the SAME epoch. Ask: a persist door that wraps every epoch/blob an existing occurrence of the identity can open to the new occurrence, driven by the server when a claimed node's content occurrence appears" -XFAIL_family_on_b="EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: CIRISServer#646 (no Family replication round; fixed on this branch) + CIRISPersist#910: B is added AFTER create, so B's node needs the GROWN record, and supersede_group_row re-stamps admitted_at without re-indexing the wire while put_family is a plain INSERT at a peer holding the founding record" -XFAIL_family_file_listed_on_b="EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: downstream of family_on_b (CIRISPersist#910): a family row's audience is the family's members' nodes as B's node folds them, and B's node does not hold the grown record naming B" -XFAIL_family_file_opened_on_b="EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: downstream of family_file_listed_on_b (CIRISPersist#910); REQUIRED once the family plane converges (#647 asks for the bytes stage to be REQUIRED)" +XFAIL_family_on_b="CONSENT TO JOIN (0.5.218): B cannot be added until B can accept — the add answers 409 membership.consent_required until the invite flow (CIRISPersist#955 v52, CIRISConstitution#133, FSD/MEMBERSHIP_INVITES.md); promote when #955 lands and the rung drives propose → accept. Before that ruling: EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: CIRISServer#646 (no Family replication round; fixed on this branch) + CIRISPersist#910: B is added AFTER create, so B's node needs the GROWN record, and supersede_group_row re-stamps admitted_at without re-indexing the wire while put_family is a plain INSERT at a peer holding the founding record" +XFAIL_family_file_listed_on_b="CONSENT TO JOIN (0.5.218): downstream of family_on_b, which is red-expected on CIRISPersist#955 (B cannot join without accepting). Before that ruling: EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: downstream of family_on_b (CIRISPersist#910): a family row's audience is the family's members' nodes as B's node folds them, and B's node does not hold the grown record naming B" +XFAIL_family_file_opened_on_b="CONSENT TO JOIN (0.5.218): downstream of family_on_b, which is red-expected on CIRISPersist#955 (B cannot join without accepting). Before that ruling: EXPECTED TO PASS from 0.5.218 (persist v49.0.0 fixed CIRISPersist#910 and the server routes FamilyMembershipWidening, kind 18) — remove this mark after the first green devices run. Was: downstream of family_file_listed_on_b (CIRISPersist#910); REQUIRED once the family plane converges (#647 asks for the bytes stage to be REQUIRED)" _dev_load() { _chat_load; [ -f "$CHAT_STATE/dev.sh" ] && . "$CHAT_STATE/dev.sh"; return 0; } @@ -261,6 +273,8 @@ _dev_household() { if [ -z "$fid" ]; then return 0; fi # ADDED AFTER CREATE, deliberately: that is the grown-record path #910 names, # and the one a real household takes (you found it, then you invite). + # 0.5.218: this add is REFUSED — 409 membership.consent_required — until B + # can accept (CIRISPersist#955); the `family` rung asserts the refusal. _dev_api "${CHAT_SENDER_SVC:-node-a}" "$CHAT_A_TOKEN" POST "/v1/families/$fid/members" \ "$(python3 -c 'import json,sys;print(json.dumps({"key_id":sys.argv[1]}))' "$CHAT_B_OWNER")" \ >"$CHAT_STATE/family-add.json" @@ -390,15 +404,20 @@ DIAG_c_opens_history() { # ── the household rungs (CIRISServer#647) ──────────────────────────────────── stage_family() { - local c a + # 0.5.218 CONSENT TO JOIN: green = A charters the household (2xx) AND the + # add of B is refused by name — 409 membership.consent_required + # (CIRISConstitution#133, CIRISPersist#955). An add that SUCCEEDS is a red + # rung now: it would mean a door admitted B without B's acceptance. + local c a r c="$(_dev_field "$CHAT_STATE/family-create.json" status)" a="$(_dev_field "$CHAT_STATE/family-add.json" status)" - case "$c:$a" in - 20?:20?) echo 1 ;; + r="$(_dev_field "$CHAT_STATE/family-add.json" body.reason_id)" + case "$c:$a:$r" in + 20?:409:membership.consent_required) echo 1 ;; *) echo 0 ;; esac } -HINT_family="person A could not charter a household or add person B on A's own node. family.unknown_member_key = B's owner key is not registered on node-a (the contact phase admits it); family.not_authorized = the founder_only rule; read family-create.json / family-add.json" +HINT_family="0.5.218: green means A chartered the household AND the add of B was refused 409 membership.consent_required (nobody joins without their own acceptance — CIRISPersist#955, CIRISConstitution#133). A 200 on the add is a door admitting B without consent; a create failure is below. Before the consent ruling this rung read: person A could not charter a household or add person B on A's own node. family.unknown_member_key = B's owner key is not registered on node-a (the contact phase admits it); family.not_authorized = the founder_only rule; read family-create.json / family-add.json" EXIT_family=64 DIAG_family() { echo " create: $(head -c 400 "$CHAT_STATE/family-create.json" 2>/dev/null)" @@ -419,7 +438,7 @@ rows=rows or [] print(1 if d.get("status")==200 and any((r.get("family_id") if isinstance(r, dict) else None)==sys.argv[2] for r in rows) else 0)' \ "$CHAT_STATE/family-b.json" "$DEV_FAMILY_ID" 2>/dev/null || echo 0 } -HINT_family_on_b="B's node does not list the household it was added to. With #646 on this branch the Family plane has a round, so this is the grown-record gap (CIRISPersist#910) unless the diagnosis shows node-b holding NO family row at all" +HINT_family_on_b="B's node does not list the household it was added to. With #646 on this branch the Family plane has a round, so this is the grown-record gap (CIRISPersist#910) unless the diagnosis shows node-b holding NO family row at all. 0.5.218: #910 is fixed in persist v49; the rung is red-expected on consent to join (CIRISPersist#955) — B was never added, by design" EXIT_family_on_b=65 DIAG_family_on_b() { echo " node-b families: $(head -c 400 "$CHAT_STATE/family-b.json" 2>/dev/null)" diff --git a/src/auth/occurrence.rs b/src/auth/occurrence.rs index ecbe5ac0..3b30fc42 100644 --- a/src/auth/occurrence.rs +++ b/src/auth/occurrence.rs @@ -27,6 +27,10 @@ //! Auth: a SURVIVING active occurrence (or the root). For a *stolen* device //! you MUST sign with a different surviving key — never the compromised one //! (CEG §11.7.4). A *voluntary* self-revoke (signer == revoked) is allowed. +//! **Moved in 0.5.218** to [`crate::self_devices`] (CSD-037): the owner's +//! session authorises it, the owner's pen signs it through persist's +//! SIGNED, replicating door, and it is the same act as a node release +//! (`self_devices::evict_device`). This router no longer serves it. //! 3. `GET /v1/self/occurrences?identity_key_id=…` — LIST the active //! occurrences of a self (for the client identity page's device list). //! Read-only; unauthenticated by design (an occurrence roster is public @@ -44,7 +48,7 @@ use axum::extract::{Query, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::{Json, Router}; -use ciris_persist::federation::types::{IdentityOccurrence, IdentityOccurrenceRevocation}; +use ciris_persist::federation::types::IdentityOccurrence; use ciris_persist::federation::{EncryptionPubkeys, SignedKeyRecord}; use ciris_persist::prelude::{Engine, HybridPolicy}; use serde::{Deserialize, Serialize}; @@ -407,132 +411,18 @@ async fn add_occurrence( .into_response() } -// ─── POST /v1/self/occurrence/revoke (REVOKE) ──────────────────────────────── - -#[derive(Debug, Deserialize)] -struct RevokeOccurrenceRequest { - /// The root identity the occurrence speaks for. - identity_key_id: String, - /// The occurrence (device) to remove from the self. - occurrence_key_id: String, - /// Optional operator/ceremony annotation (e.g. "laptop lost 2026-06-23"). - #[serde(default)] - reason: Option, -} - -#[derive(Debug, Serialize)] -struct RevokeOccurrenceResponse { - identity_key_id: String, - /// The occurrence that was revoked. After this it fails `signer_acts_for`. - occurrence_key_id: String, - /// The surviving key that authorized the revocation (the request signer; the - /// recorded `witness_set` single-vouch, §11.7.4). - revoked_by: String, - /// RFC-3339 effective time (== now; the active-state filter is - /// `effective_at <= now`, so the revocation is effective immediately). - effective_at: String, -} - -async fn revoke_occurrence( - State(st): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - // (1) Verify the request hybrid signature. - let caller = match verify::verify_request(&st.engine, &headers, &body, st.policy).await { - Ok(c) => c, - Err(VerifyError::MissingHeader(h)) => { - return err(StatusCode::UNAUTHORIZED, format!("missing {h}")) - } - Err(VerifyError::NoDirectory) => { - return err(StatusCode::SERVICE_UNAVAILABLE, "no federation directory") - } - Err(VerifyError::SignatureInvalid(e)) => { - return err( - StatusCode::UNAUTHORIZED, - format!("signature verification failed: {e}"), - ) - } - }; - - // (2) Parse. - let req: RevokeOccurrenceRequest = match serde_json::from_slice(&body) { - Ok(r) => r, - Err(e) => return err(StatusCode::BAD_REQUEST, format!("bad request body: {e}")), - }; - - // (3) Admission: a SURVIVING active occurrence (or the root) authorizes the - // removal (§11.7.4 single-vouch — "the revoking occurrence OR the identity"). - // For a STOLEN device you MUST sign with a different surviving key, never the - // compromised one — that is exactly why a backup occurrence is enrolled. A - // VOLUNTARY self-revoke (signer == revoked) is permitted (a device leaving on - // its own); the producer-side helper (verify::sign_occurrence_revocation) does - // not forbid it, and the surviving-key requirement is a flow property of the - // stolen-device case, not a server invariant we can enforce (the server cannot - // know the key is compromised). We DO require the signer to currently act for - // the self, so a revoked / unrelated key cannot revoke another's device. - if !verify::signer_acts_for(&st.engine, &caller.key_id, &req.identity_key_id).await { - return err( - StatusCode::FORBIDDEN, - "signer is neither the identity key nor an active occurrence of it — \ - revoke a device by signing with a surviving key you still control", - ); - } - - // (4) Record the append-only revocation. persist's *_active reads compose it, - // so after this the revoked key fails signer_acts_for. The witness_set carries - // the single vouch [revoker] (§11.7.4); persist computes persist_row_hash. - let now = chrono::Utc::now(); - let revocation = IdentityOccurrenceRevocation { - identity_key_id: req.identity_key_id.clone(), - occurrence_key_id: req.occurrence_key_id.clone(), - revoked_at: now, - effective_at: now, - reason: req.reason, - witness_set: vec![caller.key_id.clone()], - persist_row_hash: String::new(), - }; - // persist v16.0.0 (CIRISPersist#421) closed the gap 0.5.100 deliberately refused - // to ship: the wire revocation was UNSIGNED, so any consented peer could kill any - // identity's sealability (a permanent-DoS forgery). v16 splits the primitive: - // * put_identity_occurrence_revocation — signed gate ({attesting_key_id, - // signed_envelope, signature} + signer_acts_for §11.7.4 single-vouch-for-self). - // This is the REPLICATED path. - // * put_identity_occurrence_revocation_local — trusted-local, signature columns - // NULL, EXCLUDED from the signed replication read, never reachable from the - // replication apply. "Engine-internal writes on behalf of the local user where - // the revocation is locally produced — NOT peer-received." - // THIS endpoint is precisely the latter: an owner-authenticated, locally-produced - // revocation over HTTP. So it takes the _local path — which is byte-for-byte the - // behaviour we already had (revocation carriage was never wired, #227 S2), minus - // the forgery surface. We do NOT synthesise a signature here: we hold no mandate to - // sign as an arbitrary `identity_key_id`, and faking one would re-open the very DoS - // the gate closes. Wiring the SIGNED, replicating revocation (now finally possible, - // with list_signed_identity_occurrence_revocations_for as its byte-exact re-read) is - // tracked separately as the #227 S2 carriage work. - if let Err(e) = st - .engine - .federation_directory() - .put_identity_occurrence_revocation_local(revocation) - .await - { - return err( - StatusCode::INTERNAL_SERVER_ERROR, - format!("put_identity_occurrence_revocation: {e}"), - ); - } - - ( - StatusCode::OK, - Json(RevokeOccurrenceResponse { - identity_key_id: req.identity_key_id, - occurrence_key_id: req.occurrence_key_id, - revoked_by: caller.key_id, - effective_at: now.to_rfc3339(), - }), - ) - .into_response() -} +// ─── POST /v1/self/occurrence/revoke — MOVED (0.5.218) ─────────────────────── +// +// The revoke lived here until 0.5.218, authorised by a request hybrid-signed by a +// surviving device key and written through persist's trusted-LOCAL door +// (`put_identity_occurrence_revocation_local`): unsigned, EXCLUDED from the signed +// replication read, so the revocation never left the node that wrote it (the +// #227 S2 carriage gap), and unreachable from the app, which authenticates with a +// bearer and got 401 on every call (CSD-037). It is now +// `crate::self_devices::revoke_occurrence`, which authorises the OWNER's session, +// signs the revocation with the owner's pen through persist's SIGNED door, and +// shares one implementation with node release (`self_devices::evict_device`). +// `tests/occurrence.rs` gates that the local door has no caller in `src/`. // ─── GET /v1/self/occurrences (LIST) ───────────────────────────────────────── @@ -761,10 +651,6 @@ async fn list_occurrences( pub fn router(engine: Arc, policy: HybridPolicy) -> Router { Router::new() .route("/v1/self/occurrence", axum::routing::post(add_occurrence)) - .route( - "/v1/self/occurrence/revoke", - axum::routing::post(revoke_occurrence), - ) .route("/v1/self/occurrences", axum::routing::get(list_occurrences)) .with_state(OccurrenceState { engine, policy }) } diff --git a/src/communities.rs b/src/communities.rs index 1b38b276..822f32dd 100644 --- a/src/communities.rs +++ b/src/communities.rs @@ -57,6 +57,27 @@ //! The route is built anyway; `tests/community_crud.rs` pins the gap as an //! ignored red test named for #907. //! +//! **Corrected 0.5.218 (persist v49.0.0).** Both sections above describe the +//! pins before v49. #908 landed: every replicated widening and revocation is +//! judged at persist's door by the room's own `consensus_protocol` over the +//! row's co-signatures (`crate::roster_rows` gathers them), so a peer-authored +//! row no longer rides on the signature alone. #907 landed: the caller +//! admission folds the widening plane, so a widened member reads the room's +//! messages — `a_widened_member_reads_the_rooms_messages_cirispersist_907` +//! runs, un-ignored. +//! +//! # Consent to join (0.5.218) +//! +//! Nobody joins a room without their own acceptance (the maintainer's ruling +//! of 2026-09-30; CIRISConstitution#133, `FSD/MEMBERSHIP_INVITES.md`). A +//! contact grant is THIS node's consent toward them, not theirs to join. Until +//! persist can carry a proposal and record the acceptance (CIRISPersist#955, +//! v52), `POST /v1/communities/{id}/members`, the quorum flow's envelope / +//! cosign / assemble on an `add`, and a create naming anyone but the founder +//! answer 409 `membership.consent_required` (`refuse_if_joining`, +//! [`crate::family_api::membership_consent_required`]). Pair rooms keep their +//! own consent — the contact grant each side authors — and are untouched. +//! //! This file deliberately allows `clippy::result_large_err`: its helpers //! return the finished refusal `Response` as their error, the same shape the //! rest of the chat surface uses, so a refusal is decided exactly once. @@ -1387,6 +1408,17 @@ async fn create_community( members.push(m.to_owned()); } } + // CONSENT TO JOIN (CIRISConstitution#133; the same ruling covers founding + // members, `FSD/MEMBERSHIP_INVITES.md` §4): the founding record admits the + // founder alone. A room founded with others named is REFUSED, not trimmed — + // a silently smaller room would read as success to the caller who named + // them. A founder-only room is created as before. + if !members.is_empty() { + return crate::family_api::membership_consent_required(crate::family_api::consent_detail( + "POST /v1/communities", + &members, + )); + } let protocol = req .consensus_protocol .as_deref() @@ -1670,9 +1702,41 @@ async fn add_member( role: req.role, } .normalized(); + // CONSENT TO JOIN (0.5.218; CIRISConstitution#133, CIRISPersist#955): a + // contact grant is OUR consent toward them, not theirs to join. Someone + // already in is not joining — that keeps `community.already_member` via the + // precheck below; anyone else is refused until the invite flow. + if let Some(r) = refuse_if_joining(&room, &op, "POST /v1/communities/{id}/members") { + return r; + } direct_change(&st, &headers, &owner, room, op).await } +/// **The consent-to-join door** (CIRISConstitution#133, `FSD/MEMBERSHIP_INVITES.md` +/// §4 "refuse, don't hold"): `Some(refusal)` when `op` would ADD someone not +/// already active in `room`. Called at the HTTP doors only — the direct add +/// and the three quorum steps — never from [`precheck`] or [`apply_change`], +/// which the pair room and the self room never reach but which are the +/// shape any future internal caller would share. The refusal itself is +/// [`crate::family_api::membership_consent_required`], one sentence for both +/// group kinds. +fn refuse_if_joining(room: &Room, op: &ChangeOp, door: &str) -> Option { + // A pair room's roster is its identity and never changes here; it keeps + // its own, more specific answer (`community.pair_room_fixed`, from the + // precheck) — and its own consent: the contact grant each side authors. + if room.is_pair() { + return None; + } + match op { + ChangeOp::Add { key_id, .. } if room.member(key_id).is_none() => { + Some(crate::family_api::membership_consent_required( + crate::family_api::consent_detail(door, std::slice::from_ref(key_id)), + )) + } + _ => None, + } +} + /// `DELETE /v1/communities/{id}/members/{key_id}` — remove a member. Naming /// yourself is leaving, which is never put to a quorum. async fn remove_member( @@ -1847,6 +1911,10 @@ async fn change_envelope( if let Err(r) = precheck(&st, &owner, &room, &op).await { return r; } + // CONSENT TO JOIN: M of N members cannot stand in for the joiner. + if let Some(r) = refuse_if_joining(&room, &op, "POST /v1/communities/{id}/changes/envelope") { + return r; + } let pen = match pen(&st, &headers, &owner).await { Ok(p) => p, Err(r) => return r, @@ -1915,6 +1983,11 @@ async fn change_cosign( Ok(op) => op, Err(r) => return r, }; + // CONSENT TO JOIN: no member's signature on an envelope that admits + // someone who has not accepted. + if let Some(r) = refuse_if_joining(&room, &op, "POST /v1/communities/{id}/changes/cosign") { + return r; + } let pen = match pen(&st, &headers, &owner).await { Ok(p) => p, Err(r) => return r, @@ -1973,6 +2046,10 @@ async fn change_assemble( if let Err(r) = precheck(&st, &owner, &room, &op).await { return r; } + // CONSENT TO JOIN: refused however many members signed. + if let Some(r) = refuse_if_joining(&room, &op, "POST /v1/communities/{id}/changes/assemble") { + return r; + } let t = match tally( &st, &room, diff --git a/src/compose.rs b/src/compose.rs index 782d8100..ea22ba2f 100644 --- a/src/compose.rs +++ b/src/compose.rs @@ -4143,6 +4143,10 @@ pub(crate) const REPLICATED_KINDS: [ciris_edge::replication::EnvelopeKind; 14] = // `put_identity_occurrence_revocation`). Without it a released device // is still one of the owner's occurrences on every OTHER device, and // keeps being sent the owner's `self` rows. + // (0.5.218, CSD-037: the plane carries SIGNED rows only, and until + // `self_devices::evict_device` no server path wrote one — release + // revoked nothing and `occurrence/revoke` used the unsigned local + // door. The producer is now the owner-signed eviction.) K::IdentityOccurrenceRevocation, // Key-level revocation (`admin_ops.rs` `put_revocation_for`). A // revocation that stays on the node that wrote it protects nobody: diff --git a/src/family_api.rs b/src/family_api.rs index e6344549..b0941dcc 100644 --- a/src/family_api.rs +++ b/src/family_api.rs @@ -33,6 +33,16 @@ //! 5. **Leaving is always your own act**, never subject to quorum. //! 6. **A non-member cannot find out a family exists**: every route answers a //! non-member exactly as it answers an unknown id, `family.not_found` (404). +//! 7. **Nobody joins without their own consent** (0.5.218; the maintainer's +//! ruling of 2026-09-30, CIRISConstitution#133, `FSD/MEMBERSHIP_INVITES.md`). +//! Until persist can carry a proposal to the joiner and record their +//! acceptance (CIRISPersist#955, v52), every roster-growing door answers +//! 409 `membership.consent_required`: `POST …/members`, a quorum envelope / +//! cosign / assemble whose proposed roster adds anyone, and a create whose +//! founding roster names anyone but the founder +//! ([`membership_consent_required`]). Remove, role, leave and dissolve are +//! unaffected, and re-adding someone already active keeps +//! `family.already_member`. //! //! # What persist admits at these pins (v48.0.0), and what it does not //! @@ -194,6 +204,44 @@ fn not_a_member(key_id: &str) -> Response { ) } +/// **Nobody joins a household — or a room — without their own consent** (the +/// maintainer's ruling of 2026-09-30; CIRISConstitution#133, +/// `FSD/MEMBERSHIP_INVITES.md`). +/// +/// The ONE refusal every roster-growing HTTP door answers until persist can +/// carry a proposal to a non-member and record their acceptance +/// (CIRISPersist#955, persist v52): a direct add, a quorum change whose proposed +/// roster ADDS anyone, and a founding roster naming anyone besides the founder. +/// Shared with [`crate::communities`] so the id carries exactly one sentence +/// (the localization guard's single-valued check). Removing, re-roling, +/// leaving and dissolving are not joining and are never refused by it; neither +/// is re-adding someone already active (that keeps its `*.already_member`). +/// +/// "Refuse, don't hold" (FSD §4): the server keeps no copy of the admission +/// rule — the rule is persist's — only a closed door, removed when the invite +/// flow ships. The refusal lives at the HTTP doors only, never in a shared +/// helper: the pair room and the self room grow through their own paths. +pub(crate) fn membership_consent_required(detail: String) -> Response { + refuse_with( + StatusCode::CONFLICT, + "membership.consent_required", + "Adding someone needs their acceptance, which ships with the invite flow. Nobody joins without their own consent.", + detail, + ) +} + +/// The detail every consent refusal carries: which door, which keys, and the +/// two issues that will open it. +pub(crate) fn consent_detail(door: &str, key_ids: &[String]) -> String { + format!( + "{door}: {} would join without their own acceptance. Adding a member needs the \ + joiner's consent (CIRISConstitution#133), which persist gates in CIRISPersist#955 \ + (v52) and the server ships as the invite flow (FSD/MEMBERSHIP_INVITES.md); until \ + then every roster-growing door is closed", + key_ids.join(", ") + ) +} + fn last_founder() -> Response { refuse( StatusCode::CONFLICT, @@ -848,6 +896,21 @@ async fn create_family(State(st): State, headers: HeaderMap, body: "A household needs a name of up to 200 characters.", ); } + // CONSENT TO JOIN (CIRISConstitution#133): the founding record admits the + // founder alone. Everyone else joins by proposal → their own acceptance, + // which is not buildable until CIRISPersist#955 — so a founding roster that + // names anyone else is REFUSED, not trimmed (a silently smaller household + // would read as success to the caller who named them). The loop below then + // only ever sees the founder named twice, which stays `already_member`. + let joining: Vec = req + .members + .iter() + .filter(|k| **k != caller.owner_key_id) + .cloned() + .collect(); + if !joining.is_empty() { + return membership_consent_required(consent_detail("POST /v1/families", &joining)); + } let dir = st.engine.federation_directory(); let mut others: Vec = Vec::new(); for k in &req.members { @@ -1034,7 +1097,7 @@ async fn add_member( Path(id): Path, body: Bytes, ) -> Response { - let (caller, loaded, protocol) = match write_preamble(&st, &headers, &id).await { + let (_caller, loaded, _protocol) = match write_preamble(&st, &headers, &id).await { Ok(t) => t, Err(r) => return r, }; @@ -1046,47 +1109,23 @@ async fn add_member( if !role_ok(&role) { return bad_role(); } - if let Protocol::Quorum { m, n } = protocol { - return needs_quorum(m, n); - } - if let Err(r) = require_founder(&loaded, &caller.owner_key_id) { - return r; + // Someone already in is not joining: the no-op keeps its own answer. + if loaded.member(&req.key_id).is_some() { + return already_member(&req.key_id); } - if let Err(r) = check_addable(&st.engine, &loaded, &req.key_id).await { - return r; - } - let capsule = match pen(&st, &caller).await { - Ok(c) => c, - Err(r) => return r, - }; - let member = FamilyMember { - key_id: req.key_id.clone(), - joined_at: now(), - role: Some(role.clone()), - }; - // persist v49.0.0 (#910): the addition is its own signed row, a - // `FamilyMembershipWidening`, and the authority signs THAT row's envelope. - // The record is no longer grown in place (a rewritten record reached no - // peer: `put_family` is INSERT-only), so the pre-v49 signature over the - // grown record no longer verifies at the door. - let spec = match sign_family_widening(&capsule, &id, &member).await { - Ok(s) => s, - Err(e) => return signer_unavailable(e), - }; - match st - .engine - .federation_directory() - .add_member(Cohort::Family, &id, RosterMember::from(member), &spec) - .await - { - Ok(true) => {} - Ok(false) => return already_member(&req.key_id), - Err(e) => return store_unavailable(format!("add_member: {e:#}")), - } - let dek = rewrap(&st.engine, &id, &req.key_id).await; - tracing::info!(family = %id, member = %req.key_id, %role, "family: member added"); - kick("family:add_member"); - respond_with_family(&st, &id, &caller, serde_json::json!({ "dek_rewrap": dek })).await + // CONSENT TO JOIN (0.5.218; CIRISConstitution#133, CIRISPersist#955). A + // direct add was admission on the household's authority alone — a founder + // could enrol any key they could name, and that person's node began + // receiving the household's rows and wraps. Closed for every protocol + // until the invite flow (`FSD/MEMBERSHIP_INVITES.md` §3: this route becomes + // an alias for `…/invites` answering 202 `invited`). Until 0.5.218 the rest + // of this handler signed a `FamilyMembershipWidening` for the target and + // re-wrapped the household's DEKs to them (`rewrap`); the widening door + // itself is unchanged and still carries role changes. + membership_consent_required(consent_detail( + "POST /v1/families/{id}/members", + std::slice::from_ref(&req.key_id), + )) } /// A target may join iff it is a registered identity and not already an @@ -1209,6 +1248,18 @@ async fn leave_inner( // A quorum family's record must keep N == roster, or every later quorum // check reads a seat that has left. Rewritten FIRST (by the leaver, whose // own act this is); the revocation below is what replicates the departure. + // + // THIS REWRITE DOES NOT REPLICATE (0.5.218 audit, fix 2 — stopped, not + // faked). It is the plain `supersede_family`, which persist v49 strips of + // any proof (#910.5), so a peer holding the household refuses the differing + // record (the #758 conflict) and keeps the leaver's seat in N; only the + // revocation row travels. `supersede_family_with_quorum` cannot carry it + // either: it evaluates the household's `quorum:M/N` over the change, and a + // leave is the member's OWN act, never put to a quorum (rule 5) — the + // leaver's lone signature is `consensus_protocol not met`. persist would + // need a self-leave amendment: a supersede whose ONLY roster delta removes + // its signer, admitted on that signer's signature alone (on the local door + // and in `route_occupied_family` on the peer). No proof is synthesised here. if let Protocol::Quorum { m, n } = protocol { let remaining: Vec = loaded .family @@ -1349,6 +1400,19 @@ async fn dissolve( /// removal per active member (the plane that REPLICATES — a peer holding the /// family learns it is empty), then the authority-signed supersede to an empty /// roster (the version history records who dissolved it and on what authority). +/// +/// **The empty record stays local** (0.5.218 audit, fix 2 — stopped, not +/// faked). The supersede is the plain door, which persist v49 strips of any +/// proof (#910.5), so a peer never applies the terminal record as an +/// amendment; the removal rows are what tell it the household is gone. The +/// quorum door was tried: routing a quorum dissolve through +/// `supersede_family_with_quorum` needs a change envelope proposing the EMPTY +/// roster the record carries, and persist refuses it structurally — +/// `verify_membership_quorum: membership change not authorized: malformed +/// accord family envelope: group has no members`. persist would need a +/// quorum-verified TERMINAL amendment (an explicit dissolve form the quorum +/// signs and `route_occupied_family` applies). `tests/family_crud.rs` +/// `a_quorum_dissolve_replicates_as_an_amendment` pins it, ignored. async fn terminal_dissolve( st: &FamilyState, caller: &OwnerCaller, @@ -1484,12 +1548,14 @@ async fn change_envelope( if let Err(r) = check_addable(&st.engine, &loaded, k).await { return r; } - let role = req.role.clone().unwrap_or_else(|| ROLE_MEMBER.to_owned()); - if !role_ok(&role) { - return bad_role(); - } - keys.push(k.to_owned()); - roles.insert(k.to_owned(), serde_json::json!(role)); + // CONSENT TO JOIN (CIRISConstitution#133 / CIRISPersist#955): M of + // N existing members cannot stand in for the joiner. The quorum + // path is closed for additions exactly as the direct door is; + // remove, role and dissolve envelopes are unaffected. + return membership_consent_required(consent_detail( + "POST /v1/families/{id}/changes/envelope", + &[k.to_owned()], + )); } "remove" => { let Some(k) = target.as_deref() else { @@ -1611,6 +1677,24 @@ fn check_envelope(loaded: &Loaded, env: &serde_json::Value) -> Result Vec { + env.get("members") + .and_then(|v| v.as_array()) + .map(|ms| { + ms.iter() + .filter_map(|m| m.get("key_id").and_then(|v| v.as_str())) + .filter(|k| loaded.member(k).is_none()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default() +} + async fn cosign( State(st): State, headers: HeaderMap, @@ -1631,6 +1715,15 @@ async fn cosign( if let Err(r) = check_envelope(&loaded, &req.change_envelope) { return r; } + // CONSENT TO JOIN: no member's signature goes on an envelope that would + // admit someone who has not accepted (CIRISConstitution#133). + let joiners = envelope_joiners(&loaded, &req.change_envelope); + if !joiners.is_empty() { + return membership_consent_required(consent_detail( + "POST /v1/families/{id}/changes/cosign", + &joiners, + )); + } let bytes = match ciris_verify_core::jcs::canonicalize(&req.change_envelope) { Ok(b) => b, Err(e) => return bad_change(format!("canonicalize: {e}")), @@ -1735,6 +1828,16 @@ async fn assemble( Ok(a) => a, Err(r) => return r, }; + // CONSENT TO JOIN: an envelope whose proposed roster grows is refused + // however many members signed it (CIRISConstitution#133 / + // CIRISPersist#955) — a quorum cannot stand in for the joiner. + let joiners = envelope_joiners(&loaded, &req.change_envelope); + if !joiners.is_empty() { + return membership_consent_required(consent_detail( + "POST /v1/families/{id}/changes/assemble", + &joiners, + )); + } let capsule = match pen(&st, &caller).await { Ok(c) => c, Err(r) => return r, diff --git a/src/self_devices.rs b/src/self_devices.rs index ae16c19a..cc3bc3fa 100644 --- a/src/self_devices.rs +++ b/src/self_devices.rs @@ -12,6 +12,9 @@ //! is that projection), and the node itself reverts to the unowned //! fail-closed floor. Releasing the node you are TALKING TO ends your own //! session's authority here, so it needs `force_self: true`. +//! Since 0.5.218 a release also revokes the node's content-KEM occurrence of +//! the owner (signed, replicating) — without it the released machine kept +//! being wrapped every new self file ([`evict_device`]). //! - `POST /v1/self/occurrence/label` — a display name for a device key. The //! persist occurrence row has no label member and its admission is //! idempotent on `(identity, occurrence)`, so a label cannot be written INTO @@ -24,6 +27,15 @@ //! any of their ANNOUNCED nodes with each node's transport key. The string //! (and its QR form) another person pastes into `POST /v1/contacts`. //! +//! - `POST /v1/self/occurrence/revoke` (0.5.218, CSD-037) — revoke a device +//! occurrence. Moved here from [`crate::auth::occurrence`], and it and +//! `release` are now ONE act, [`evict_device`]: withdraw the owner-binding(s) +//! the device is a node under, write a SIGNED occurrence revocation (the +//! replicating door, signed by the owner's pen), kick replication, and read +//! both halves back from persist. **Eviction withholds new content only**: +//! already-shared history stays readable by the evicted device — no DEK is +//! rotated and nothing is re-encrypted ([`EVICTION_HISTORY_NOTE`]). +//! //! - `POST /v1/self/nodes/{node_key_id}/announce` — announce ANOTHER of the //! owner's devices from the device holding the pen (CIRISServer#678): the //! owner re-signs the owner-binding user→that node at `federation`, the same @@ -170,6 +182,401 @@ pub(crate) async fn emit_as_owner( .map_err(|e| format!("{e}")) } +// ─── EVICT A DEVICE — the one act both routes converge on ─────────────────── + +/// What eviction does NOT do, said in every answer and never softened +/// (CSD-037, CC 3.3.6.1). An eviction stops the device receiving anything NEW: +/// the next self file is wrapped to the owner's ACTIVE occurrences only, and the +/// self room removes the node at its next epoch. It does not reach back. Every +/// DEK the device was already granted stays granted — there is no DEK rotation +/// and no re-encryption of history — so whatever it could already read, it can +/// still read, on the device and in any copy taken from it. +pub const EVICTION_HISTORY_NOTE: &str = "Already-shared history stays readable by the evicted \ + device: nothing already granted to it is rotated or re-encrypted. Only new content and new \ + self-room epochs are withheld from it."; + +/// One part of an eviction that did not complete, by name. +#[derive(Debug, Clone, serde::Serialize)] +pub struct EvictFailure { + /// `owner_binding_withdrawal` | `owner_binding_witness` | + /// `occurrence_revocation` | `occurrence_witness`. + pub part: &'static str, + /// The binding, node or occurrence the part was about. + pub target: String, + /// The store's / signer's own words (`{:#}` — the whole chain). + pub error: String, +} + +/// **What an eviction did, part by part** — the answer both +/// `POST /v1/self/nodes/{node}/release` and `POST /v1/self/occurrence/revoke` +/// return, because they are now the same act. +#[derive(Debug, Default, serde::Serialize)] +pub struct EvictionReport { + /// The owner's nodes whose owner-bindings this eviction withdrew. + pub nodes: Vec, + /// One entry per withdrawn owner-binding: + /// `{node_key_id, binding, withdraws, cohort_scope}`. + pub withdrawn: Vec, + /// One entry per SIGNED occurrence revocation written: + /// `{occurrence_key_id, attesting_key_id, effective_at}`. + pub occurrences_revoked: Vec, + /// Occurrences of the owner the eviction named that were already out of the + /// active roster — nothing to sign for them. + pub occurrences_already_revoked: Vec, + /// Every part that did not complete, by name. Empty on a full eviction. + pub failed: Vec, + /// `true` when a replication round was kicked NOW; `false` when this + /// process runs no replication runtime (a bare harness engine), in which + /// case the rows ride the next cadence round of whatever runtime serves + /// this store. + pub replication_kicked: bool, + /// The owner's nodes as persist's projection lists them AFTER the act. + pub nodes_owned_by: Vec, + /// [`EVICTION_HISTORY_NOTE`], verbatim, in every answer. + pub history: &'static str, +} + +impl EvictionReport { + fn fail(&mut self, part: &'static str, target: &str, error: String) { + self.failed.push(EvictFailure { + part, + target: target.to_owned(), + error, + }); + } +} + +/// Build and sign ONE occurrence revocation as the owner, for persist's SIGNED +/// door (`put_identity_occurrence_revocation`, CIRISPersist#421). +/// +/// The envelope carries exactly the members the gate reads back +/// (`identity_key_id`, `occurrence_key_id`, `revoked_at`, `effective_at`) plus +/// the witness set and reason, so what is signed is what is stored. It is +/// canonicalized with persist's OWN canonicalizer — the bytes its gate +/// re-canonicalizes — and signed with the owner's pen, which is the identity +/// the occurrence belongs to: `signer_acts_for(owner, owner)` holds by +/// construction, and no other key has the mandate (a node key signing its +/// owner's revocation is the infrastructure-authors-the-human's-act class, +/// CC 3.3.6). +/// +/// The instant is NOT truncated. The fold revokes only when +/// `effective_at >= occurrence.asserted_at`, occurrences are asserted at +/// nanosecond precision, and RFC-3339 with nanoseconds round-trips exactly +/// through the gate's `ts_field` comparison, so a millisecond cut would only +/// open a window in which the revocation revokes nothing. +async fn signed_occurrence_revocation( + capsule: &OwnerSignerCapsule, + owner: &str, + occurrence: &str, + reason: Option<&str>, + now: chrono::DateTime, +) -> Result { + let at = now.to_rfc3339(); + let mut envelope = serde_json::json!({ + "identity_key_id": owner, + "occurrence_key_id": occurrence, + "revoked_at": at, + "effective_at": at, + "witness_set": [capsule.key_id()], + }); + if let Some(r) = reason { + envelope["reason"] = serde_json::Value::String(r.to_owned()); + } + let bytes = ciris_persist::verify::canonical::ceg_produce_canonicalize(&envelope) + .map_err(|e| format!("canonicalize the revocation envelope: {e:#}"))?; + let sig = capsule.sign_hybrid(&bytes).await?; + Ok( + ciris_persist::federation::SignedIdentityOccurrenceRevocation { + identity_occurrence_revocation: + ciris_persist::federation::types::IdentityOccurrenceRevocation { + identity_key_id: owner.to_owned(), + occurrence_key_id: occurrence.to_owned(), + revoked_at: now, + effective_at: now, + reason: reason.map(str::to_owned), + witness_set: vec![capsule.key_id().to_owned()], + persist_row_hash: String::new(), + }, + attesting_key_id: capsule.key_id().to_owned(), + signed_envelope: envelope, + signature: ciris_verify_core::transport_binding::TransportBindingSignature { + ed25519_signature_base64: B64.encode(&sig.classical_signature), + mldsa65_signature_base64: Some(B64.encode(&sig.pqc_signature)), + }, + }, + ) +} + +/// **Evict a device from its owner's self — ONE act** (CSD-037, the +/// stolen-device path; 0.5.218). +/// +/// # Why one act +/// +/// Until 0.5.218 there were two unconnected routes and neither finished the +/// job. `release` withdrew the owner-binding, so the self room dropped the node +/// on its next tick (edge `self_room::decide` reads `nodes_owned_by`) — but it +/// left the node's content-KEM identity OCCURRENCE live, and persist wraps every +/// new self file to `list_identity_occurrences_active(owner)` +/// (`at_rest_cascade::resolve_recipients`), so the "released" machine kept being +/// handed the key to every new file. `occurrence/revoke` wrote the revocation +/// through the trusted-LOCAL door (`put_identity_occurrence_revocation_local`): +/// unsigned, and EXCLUDED from replication by construction, so every other +/// device of the owner went on wrapping to the stolen one; and it never touched +/// the owner-binding, so the room kept it too. Each half looked done from the +/// device that ran it. +/// +/// # What it does +/// +/// 1. **Withdraw** every live owner-binding the owner holds on each of `nodes` +/// — the owner's signed `withdraws`, at the binding's own audience (a +/// federation binding is withdrawn where peers see it, a self one where the +/// owner's devices do). +/// 2. **Revoke** each of `occurrences` that is still active, through persist's +/// SIGNED door, signed by the owner's pen +/// ([`signed_occurrence_revocation`]). A signed revocation is what the +/// `IdentityOccurrenceRevocation` replication plane carries (`compose.rs`, +/// CIRISServer#646); an unsigned one never leaves this store. +/// 3. **Kick** replication so both ride this round, not the next cadence. +/// 4. **Witness** each half from persist's own projections rather than from our +/// writes: `nodes_owned_by(owner)` must no longer list the nodes, and +/// `list_identity_occurrences_active(owner)` must no longer list the +/// occurrences. +/// +/// Every part is attempted even when an earlier one failed — a half-eviction +/// that stops at the first error leaves the stolen device with the other half — +/// and every failure is reported by part and target, never folded into an +/// `Ok`. +/// +/// # What it does not do +/// +/// [`EVICTION_HISTORY_NOTE`]: no DEK is rotated and nothing already shared is +/// re-encrypted. Content the device was granted before the eviction stays +/// readable to it. Only NEW content (new self files are wrapped to active +/// occurrences only) and NEW self-room epochs are withheld. +pub(crate) async fn evict_device( + engine: &Engine, + capsule: &OwnerSignerCapsule, + owner: &str, + nodes: &[String], + occurrences: &[String], + reason: Option<&str>, +) -> EvictionReport { + let dir = engine.federation_directory(); + let mut report = EvictionReport { + nodes: nodes.to_vec(), + history: EVICTION_HISTORY_NOTE, + ..EvictionReport::default() + }; + + // (1) The owner-bindings. + for node in nodes { + let bindings = match live_bindings(engine, owner, node).await { + Ok(b) => b, + Err(e) => { + report.fail("owner_binding_withdrawal", node, e); + continue; + } + }; + for b in &bindings { + // At the BINDING's own audience: a federation-scoped binding is + // withdrawn where peers can see the withdrawal, a self-scoped one + // where the owner's devices can. + let spec = crate::attest::Spec::new( + attestation_type::WITHDRAWS, + b.cohort_scope.clone(), + ciris_persist::federation::withdraws_attestation_envelope( + &b.attestation_id, + attestation_type::DELEGATES_TO, + ), + ) + .about(node); + match emit_as_owner(engine, capsule, spec).await { + Ok(id) => report.withdrawn.push(serde_json::json!({ + "node_key_id": node, + "binding": b.attestation_id, + "withdraws": id, + "cohort_scope": b.cohort_scope, + })), + Err(e) => report.fail("owner_binding_withdrawal", &b.attestation_id, e), + } + } + } + + // (2) The occurrences — signed, so they replicate. + let active: Option> = + match dir.list_identity_occurrences_active(owner).await { + Ok(v) => Some(v.into_iter().map(|o| o.occurrence_key_id).collect()), + Err(e) => { + for occ in occurrences { + report.fail( + "occurrence_revocation", + occ, + format!("list_identity_occurrences_active({owner}): {e:#}"), + ); + } + None + } + }; + if let Some(active) = &active { + for occ in occurrences { + if !active.contains(occ) { + report.occurrences_already_revoked.push(occ.clone()); + continue; + } + let now = chrono::Utc::now(); + let signed = match signed_occurrence_revocation(capsule, owner, occ, reason, now).await + { + Ok(s) => s, + Err(e) => { + report.fail("occurrence_revocation", occ, e); + continue; + } + }; + match dir.put_identity_occurrence_revocation(signed).await { + Ok(()) => report.occurrences_revoked.push(serde_json::json!({ + "occurrence_key_id": occ, + "attesting_key_id": capsule.key_id(), + "effective_at": now.to_rfc3339(), + })), + Err(e) => report.fail( + "occurrence_revocation", + occ, + format!("put_identity_occurrence_revocation: {e:#}"), + ), + } + } + } + + // (3) Make both halves cross now. + report.replication_kicked = crate::compose::kick_replication("self:evict_device"); + + // (4) The witnesses, read back rather than assumed. + match nodes_owned_by(dir.as_ref(), owner).await { + Ok(still) => { + for node in nodes { + if still.iter().any(|n| n == node) { + report.fail( + "owner_binding_witness", + node, + "nodes_owned_by still lists it: an owner-binding this node cannot see is \ + still live" + .to_owned(), + ); + } + } + report.nodes_owned_by = still; + } + Err(e) => report.fail( + "owner_binding_witness", + owner, + format!("nodes_owned_by: {e:#}"), + ), + } + if !occurrences.is_empty() { + match dir.list_identity_occurrences_active(owner).await { + Ok(v) => { + for occ in occurrences { + if v.iter().any(|o| &o.occurrence_key_id == occ) { + report.fail( + "occurrence_witness", + occ, + "list_identity_occurrences_active still lists it".to_owned(), + ); + } + } + } + Err(e) => report.fail( + "occurrence_witness", + owner, + format!("list_identity_occurrences_active: {e:#}"), + ), + } + } + tracing::info!( + owner = %owner, + nodes = ?nodes, + occurrences = ?occurrences, + withdrawn = report.withdrawn.len(), + revoked = report.occurrences_revoked.len(), + failed = report.failed.len(), + replication_kicked = report.replication_kicked, + "self: device EVICTED — owner-binding(s) withdrawn and occurrence(s) revoked as ONE \ + signed act; already-shared history stays readable to the device (no DEK rotation)" + ); + report +} + +/// The answer an eviction route gives: 200 with the report when every part +/// completed, else a refusal naming what did not — carrying the same report, +/// so the caller sees what DID complete (it stays done). +fn eviction_response(report: &EvictionReport, extra: serde_json::Value) -> Response { + let mut body = serde_json::to_value(report).unwrap_or_else(|_| serde_json::json!({})); + if let (Some(b), Some(x)) = (body.as_object_mut(), extra.as_object()) { + for (k, v) in x { + b.insert(k.clone(), v.clone()); + } + } + if report.failed.is_empty() { + return Json(body).into_response(); + } + // A binding persist still sees as live, and nothing else wrong, is the + // release's own long-standing refusal; any other incomplete part is named + // under the eviction's. + let only_binding_witness = report + .failed + .iter() + .all(|f| f.part == "owner_binding_witness"); + let (id, text): (&'static str, &'static str) = if only_binding_witness { + ( + "self.release_incomplete", + "The release was signed, but the node still lists that node as yours: an ownership record it cannot see is still live. What it could withdraw stays withdrawn.", + ) + } else { + ( + "self.evict_incomplete", + "Part of removing that device did not complete. What was done stays done; the answer names what was not.", + ) + }; + let detail = report + .failed + .iter() + .map(|f| format!("{} {}: {}", f.part, f.target, f.error)) + .collect::>() + .join("; "); + if let Some(b) = body.as_object_mut() { + b.insert("error".into(), text.into()); + b.insert("reason_id".into(), id.into()); + b.insert("detail".into(), detail.into()); + } + (StatusCode::INTERNAL_SERVER_ERROR, Json(body)).into_response() +} + +/// Every key the node `node` may hold its owner's content occurrence under. +/// For another machine that is its node key: since the split fix a claim +/// provisions the content occurrence under the wire key +/// (`ensure_content_occurrence` takes it explicitly), and a pre-split install +/// has one key. For THIS node it is every key this node is — a split install +/// that predates that fix still holds its occurrence under the ACTOR key +/// (`actor-vs-node-key-on-a-split-install`; [`crate::peer::own_keys_of_this_node`]). +/// +/// What this cannot see: another machine's ACTOR key when that machine is a +/// pre-fix split install. Nothing in the directory links a remote node key to +/// its actor key, so that occurrence is not found from here; revoking it by +/// its own key (`POST /v1/self/occurrence/revoke`) reaches it. +async fn occurrence_keys_of_node(engine: &Engine, node: &str, is_this_node: bool) -> Vec { + let mut keys = vec![node.to_owned()]; + if is_this_node { + if let Ok(engine_key) = engine.local_derived_key_id().await { + for k in crate::peer::own_keys_of_this_node(&engine_key) { + if !keys.contains(&k) { + keys.push(k); + } + } + } + } + keys +} + // ─── POST /v1/self/nodes/{node_key_id}/release ────────────────────────────── #[derive(Debug, Default, Deserialize)] @@ -217,6 +624,11 @@ async fn live_bindings( .collect()) } +/// **Release a node — an eviction** (0.5.218). The owner's signed `withdraws` +/// of every owner-binding on the node AND the signed revocation of the node's +/// content-KEM occurrence of the owner, through [`evict_device`]. Before +/// 0.5.218 a release withdrew the binding only, and the released machine kept +/// being wrapped every new self file. async fn release_node( State(st): State, headers: HeaderMap, @@ -263,73 +675,180 @@ async fn release_node( "That is the node you are talking to. Releasing it ends your ownership here, including this session.", ); } - let bindings = match live_bindings(&st.engine, &caller.owner_key_id, &node_key_id).await { - Ok(b) => b, - Err(e) => return store_unavailable(e), + // The node's content occurrence(s) of the owner: the keys it may hold one + // under, filtered to those that ARE occurrences of the owner — a node that + // never provisioned one has nothing to revoke, which is not a failure. + let occurrences = match dir + .list_identity_occurrences_for(&caller.owner_key_id) + .await + { + Ok(all) => { + let keys = occurrence_keys_of_node(&st.engine, &node_key_id, is_this_node).await; + let mut occ: Vec = all + .into_iter() + .map(|o| o.occurrence_key_id) + .filter(|k| keys.contains(k)) + .collect(); + occ.sort(); + occ.dedup(); + occ + } + Err(e) => return store_unavailable(format!("list_identity_occurrences_for: {e:#}")), }; let capsule = match pen(&st, &caller).await { Ok(c) => c, Err(r) => return r, }; - let mut withdrawn = Vec::with_capacity(bindings.len()); - for b in &bindings { - // At the BINDING's own audience: a federation-scoped binding is - // withdrawn where peers can see the withdrawal, a self-scoped one where - // the owner's devices can. - let spec = crate::attest::Spec::new( - attestation_type::WITHDRAWS, - b.cohort_scope.clone(), - ciris_persist::federation::withdraws_attestation_envelope( - &b.attestation_id, - attestation_type::DELEGATES_TO, - ), + let report = evict_device( + &st.engine, + &capsule, + &caller.owner_key_id, + std::slice::from_ref(&node_key_id), + &occurrences, + Some("released by its owner"), + ) + .await; + tracing::info!( + owner = %caller.owner_key_id, node = %node_key_id, + bindings = report.withdrawn.len(), occurrences = report.occurrences_revoked.len(), + failed = report.failed.len(), released_self = is_this_node, + "self: node RELEASED — the owner withdrew every owner-binding on it and revoked its \ + content occurrence" + ); + let released = report.failed.is_empty(); + eviction_response( + &report, + serde_json::json!({ + "node_key_id": node_key_id, + "released": released, + "released_self": is_this_node, + }), + ) +} + +// ─── POST /v1/self/occurrence/revoke ──────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct RevokeOccurrenceRequest { + /// The identity the occurrence belongs to — must be the caller's own. + identity_key_id: String, + /// The occurrence (device) to remove from the self. + occurrence_key_id: String, + /// Optional annotation (e.g. "laptop lost 2026-06-23"), carried in the + /// signed revocation envelope. + #[serde(default)] + reason: Option, + /// Required when the occurrence is (one of the keys of) the node this + /// request is served BY: evicting it withdraws this node's owner-binding + /// and ends the owner's authority here, as a self-release does. + #[serde(default)] + force_self: bool, +} + +/// **Revoke a device occurrence — an eviction** (0.5.218, CSD-037). +/// +/// Authorised like [`release_node`]: the OWNER's session on this node, and the +/// revocation is signed server-side with the owner's pen. Before 0.5.218 this +/// route wanted a request hybrid-signed by a surviving device key, which the +/// app (a bearer client) never sends — every revoke from the app answered 401 — +/// and then wrote the revocation through the trusted-LOCAL door, unsigned and +/// excluded from replication (the #227 S2 carriage gap). Now it is the signed, +/// replicating door, and when the occurrence IS one of the owner's nodes the +/// same act withdraws that node's owner-binding, so the self room drops it too +/// ([`evict_device`]). +/// +/// Refused: no session (401), a session that is not the owner's, a delegated +/// session (the row is signed with the owner's key and would outlive the +/// delegation), and an identity or occurrence that is not the caller's — under +/// ONE id (`self.not_your_device`) whether the key is someone else's or nobody's. +async fn revoke_occurrence( + State(st): State, + headers: HeaderMap, + body: Bytes, +) -> Response { + let caller = match gate(owner_caller(&st.engine, &headers, false).await) { + Ok(c) => c, + Err(r) => return r, + }; + let req: RevokeOccurrenceRequest = match serde_json::from_slice(&body) { + Ok(r) => r, + Err(e) => return bad_request(e.to_string()), + }; + let not_yours = |k: &str| { + refuse_with( + StatusCode::NOT_FOUND, + "self.not_your_device", + "That key is not one of your devices.", + k.to_owned(), ) - .about(&node_key_id); - match emit_as_owner(&st.engine, &capsule, spec).await { - Ok(id) => withdrawn.push(serde_json::json!({ - "binding": b.attestation_id, - "withdraws": id, - "cohort_scope": b.cohort_scope, - })), - Err(e) => { - return store_unavailable(format!( - "withdraws(owner-binding {}): {e}", - b.attestation_id - )) - } - } + }; + if req.identity_key_id != caller.owner_key_id { + return not_yours(&req.occurrence_key_id); } - // THE WITNESS, read back rather than assumed: the projection every other - // surface (the switcher, the self room's roster) reads must no longer list it. - let still = match nodes_owned_by(dir.as_ref(), &caller.owner_key_id).await { + let dir = st.engine.federation_directory(); + // One of the caller's occurrences, active or not: an occurrence revoked + // under the old LOCAL door is still the owner's, and re-running the + // eviction on it withdraws whatever owner-binding that door left behind. + match dir + .list_identity_occurrences_for(&caller.owner_key_id) + .await + { + Ok(v) + if v.iter() + .any(|o| o.occurrence_key_id == req.occurrence_key_id) => {} + Ok(_) => return not_yours(&req.occurrence_key_id), + Err(e) => return store_unavailable(format!("list_identity_occurrences_for: {e:#}")), + } + // Is the device one of the owner's NODES? Directly (its occurrence is under + // its node key), or as one of THIS node's keys (a split install's actor + // occurrence of a node bound under its wire key). + let owned = match nodes_owned_by(dir.as_ref(), &caller.owner_key_id).await { Ok(v) => v, Err(e) => return store_unavailable(format!("nodes_owned_by: {e:#}")), }; - if still.iter().any(|n| n == &node_key_id) { - return refuse_with( - StatusCode::INTERNAL_SERVER_ERROR, - "self.release_incomplete", - "The release was signed, but the node still lists that node as yours: an ownership record it cannot see is still live. What it could withdraw stays withdrawn.", - format!( - "withdrew {} binding(s); nodes_owned_by still lists it", - withdrawn.len() - ), + let this_keys = occurrence_keys_of_node(&st.engine, &caller.node_key_id, true).await; + let mut nodes: Vec = Vec::new(); + if owned.contains(&req.occurrence_key_id) { + nodes.push(req.occurrence_key_id.clone()); + } + if this_keys.contains(&req.occurrence_key_id) + && owned.contains(&caller.node_key_id) + && !nodes.contains(&caller.node_key_id) + { + nodes.push(caller.node_key_id.clone()); + } + let is_this_node = nodes.iter().any(|n| this_keys.contains(n)); + if is_this_node && !req.force_self { + return refuse( + StatusCode::CONFLICT, + "self.release_self_requires_force", + "That is the node you are talking to. Releasing it ends your ownership here, including this session.", ); } - tracing::info!( - owner = %caller.owner_key_id, node = %node_key_id, bindings = withdrawn.len(), - released_self = is_this_node, - "self: node RELEASED — the owner withdrew every owner-binding on it" - ); - let _ = crate::compose::kick_replication("self:release_node"); - Json(serde_json::json!({ - "node_key_id": node_key_id, - "released": true, - "released_self": is_this_node, - "withdrawn": withdrawn, - "nodes_owned_by": still, - })) - .into_response() + let capsule = match pen(&st, &caller).await { + Ok(c) => c, + Err(r) => return r, + }; + let report = evict_device( + &st.engine, + &capsule, + &caller.owner_key_id, + &nodes, + std::slice::from_ref(&req.occurrence_key_id), + req.reason.as_deref(), + ) + .await; + let revoked = report.failed.is_empty(); + eviction_response( + &report, + serde_json::json!({ + "identity_key_id": req.identity_key_id, + "occurrence_key_id": req.occurrence_key_id, + "revoked": revoked, + "revoked_by": capsule.key_id(), + "released_self": is_this_node, + }), + ) } // ─── POST /v1/self/nodes/{node_key_id}/announce ───────────────────────────── @@ -856,6 +1375,13 @@ pub fn router(engine: Arc, user_seed_dir: std::path::PathBuf) -> Router "/v1/self/occurrence/label", axum::routing::post(label_occurrence), ) + // 0.5.218 (CSD-037): moved here from `auth::occurrence` — the revoke is + // the owner's act, authorised by the owner's session and signed with + // the owner's pen, which is this router's posture, not that one's. + .route( + "/v1/self/occurrence/revoke", + axum::routing::post(revoke_occurrence), + ) // CIRISServer#673 — the person's shareable contact code (and QR form). .route("/v1/self/contact-code", axum::routing::get(contact_code)) .with_state(SelfState { diff --git a/tests/community_crud.rs b/tests/community_crud.rs index 8d2bd77e..392f6935 100644 --- a/tests/community_crud.rs +++ b/tests/community_crud.rs @@ -28,6 +28,18 @@ //! * the quorum flow (envelope → cosign on each signer's own node → assemble) //! for `unanimous` and `quorum:M/N`; //! * the CIRISPersist#907 gap as an IGNORED red test, named for the issue. +//! (0.5.218: persist v49 closed #907 — the test runs, un-ignored.) +//! +//! # Consent to join (0.5.218) +//! +//! Every roster-growing door now answers 409 `membership.consent_required` +//! (CIRISConstitution#133, CIRISPersist#955, `FSD/MEMBERSHIP_INVITES.md`): a +//! direct add, a quorum add, and a create naming anyone but the founder. The +//! tests below assert each refusal; where a test's PURPOSE is what a +//! multi-member room does (read, remove, role, moderator, quorum, the #907 +//! read), the roster is built by the `test_only_*` fixtures, which write the +//! owner-signed record / widening rows straight through persist — the same +//! rows the routes wrote before 0.5.218, and never a production path. use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU32, Ordering}; @@ -122,6 +134,19 @@ impl OwnerIdentity { .expect("re-open the owner's minted fed-ID") .0 } + + /// The same fed-ID in EDGE's signer type — what a record / widening is + /// signed with (the capsule's `edge_signer`). + async fn edge_signer(&self) -> ciris_edge::identity::LocalSigner { + ciris_server::identity::hardware_user_signers( + UserIdentityBackend::Software, + &self.alias, + self.seed_dir.clone(), + ) + .await + .expect("re-open the owner's minted fed-ID") + .1 + } } /// One node of the converged mesh. @@ -377,24 +402,102 @@ async fn contact(on: &Node, who: &Node) { assert_eq!(s, 200, "add contact: {v}"); } -/// Found a room on `founder` with `members`, after making each a contact. -async fn found(founder: &Node, name: &str, members: &[&Node], extra: Value) -> String { +// ─── TEST-ONLY roster fixtures — the consent-to-join BYPASS ───────────────── +// +// Since 0.5.218 `POST /v1/communities` refuses a founding roster naming anyone +// but the founder, and every add is `membership.consent_required` +// (CIRISConstitution#133, CIRISPersist#955). These write the rows the routes +// wrote before — owner-signed, through persist's own doors — so the tests of +// what a multi-member room DOES keep a room to do it in. Under `tests/` only; +// there is no production bypass. + +/// TEST-ONLY (bypasses the consent-to-join door): found a room on `founder` +/// with `members` on its founding RECORD, after making each a contact — the +/// record `POST /v1/communities` wrote before 0.5.218, signed by the founder's +/// pen, plus the founder's content occurrence the route provisioned. `extra` +/// takes `consensus_protocol` and `tier` as the route did. +async fn test_only_found(founder: &Node, name: &str, members: &[&Node], extra: Value) -> String { for m in members { contact(founder, m).await; } - let mut body = json!({ - "name": name, - "members": members.iter().map(|m| m.owner.key_id.clone()).collect::>(), - }); - if let (Some(b), Some(e)) = (body.as_object_mut(), extra.as_object()) { - for (k, v) in e { - b.insert(k.clone(), v.clone()); - } + let protocol = extra["consensus_protocol"] + .as_str() + .unwrap_or("founder_only") + .to_owned(); + let community_id = ciris_edge::chat::new_room_community_key_id(); + let founded_at = chrono::DateTime::from_timestamp_millis(chrono::Utc::now().timestamp_millis()) + .expect("ms instant"); + let mut roster: Vec<(&str, Option<&str>)> = vec![( + founder.owner.key_id.as_str(), + Some(ciris_persist::federation::admission::MEMBER_ROLE_FOUNDER), + )]; + roster.extend(members.iter().map(|m| (m.owner.key_id.as_str(), None))); + let mut record = + ciris_edge::chat::community(&community_id, name, &roster, &protocol, founded_at) + .expect("TEST-ONLY: the room record"); + if extra["tier"] == json!("affiliations") { + record.policy_blob = Some(json!({ "cohort_scope": "affiliations" })); } - let (s, v) = post(founder, "/v1/communities", body).await; - assert_eq!(s, 201, "create: {v}"); - assert_eq!(v["kind"], "room"); - v["community_id"].as_str().expect("community_id").to_owned() + let signed = ciris_edge::chat::signed_community(record, &founder.owner.edge_signer().await) + .await + .expect("TEST-ONLY: the founder signs the room"); + founder + .engine + .federation_directory() + .put_community(signed) + .await + .expect("TEST-ONLY: put the multi-member room directly"); + ciris_server::backend::provision_engine_occurrence(&founder.engine, &founder.owner.key_id) + .await + .expect("the founder's content occurrence"); + community_id +} + +/// TEST-ONLY (bypasses the consent-to-join door): widen `room` by `who` at +/// `role`, signed by `on`'s owner — the `CommunityMembershipWidening` row +/// `POST /v1/communities/{id}/members` wrote before 0.5.218. Single-signed: +/// for a room whose protocol one signature meets (founder_only, a founder). +async fn test_only_widen(on: &Node, room: &str, who: &Node, role: Option<&str>) { + use ciris_persist::federation::types::{ + CommunityMembershipWidening, SignedCommunityMembershipWidening, + }; + // Strictly after any event just written (a re-add after a removal). + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + let dir = on.engine.federation_directory(); + let (member, spec) = ciris_edge::community_roster::community_membership_widening( + dir.as_ref(), + room, + &who.owner.key_id, + role, + chrono::Utc::now(), + &on.owner.edge_signer().await, + ) + .await + .expect("TEST-ONLY: build the widening"); + dir.put_community_membership_widening(SignedCommunityMembershipWidening { + community_membership_widening: CommunityMembershipWidening { + community_key_id: room.to_owned(), + member_key_id: member.key_id, + joined_at: member.joined_at, + effective_at: member.joined_at, + role: member.role, + persist_row_hash: String::new(), + }, + authority_key_id: spec.authority_key_id, + scrub_signature_classical: spec.scrub_signature_classical, + scrub_signature_pqc: spec.scrub_signature_pqc, + cosignatures: Vec::new(), + }) + .await + .expect("TEST-ONLY: widen the room directly"); +} + +fn consent_refused(s: u16, v: &Value) { + assert_eq!( + (s, reason(v)), + (409, "membership.consent_required"), + "nobody joins without their own consent: {v}" + ); } /// The active roster by persist's fold, sorted. @@ -532,7 +635,7 @@ async fn appoint_moderator(appointer: &Node, who: &Node) { async fn a_three_member_room_where_all_three_read_each_other() { let nodes = mesh(3).await; let (a, b, c) = (&nodes[0], &nodes[1], &nodes[2]); - let room = found(a, "the three of us", &[b, c], json!({})).await; + let room = test_only_found(a, "the three of us", &[b, c], json!({})).await; assert!( room.starts_with(ciris_edge::chat::ROOM_COMMUNITY_PREFIX), "{room}" @@ -636,20 +739,27 @@ async fn create_refuses_with_a_typed_reason_each() { "community.bad_consensus_protocol", ), ( - json!({ "name": "x", "consensus_protocol": "quorum:1/2", "members": [b.owner.key_id] }), + // A quorum's N is the roster size: a founder alone is not a 2/3. + json!({ "name": "x", "consensus_protocol": "quorum:2/3" }), 400, "community.bad_consensus_protocol", ), + // CONSENT TO JOIN (0.5.218): a founding roster naming anyone but the + // founder is refused — contact or not, whatever the protocol. ( - // A quorum's N is the roster size: 2 people are not a 2/3. - json!({ "name": "x", "consensus_protocol": "quorum:2/3", "members": [b.owner.key_id] }), - 400, - "community.bad_consensus_protocol", + json!({ "name": "x", "consensus_protocol": "quorum:1/2", "members": [b.owner.key_id] }), + 409, + "membership.consent_required", + ), + ( + json!({ "name": "x", "members": [b.owner.key_id] }), + 409, + "membership.consent_required", ), ( json!({ "name": "x", "members": [c.owner.key_id] }), - 403, - "community.not_a_contact", + 409, + "membership.consent_required", ), (json!({ "nombre": "x" }), 400, "community.malformed_body"), ] { @@ -699,11 +809,12 @@ async fn create_refuses_with_a_typed_reason_each() { let v: Value = resp.json().await.expect("json"); assert_eq!(reason(&v), "community.author_signer_unavailable", "{v}"); - // The happy path, for contrast, with the default protocol and tier. + // The happy path, for contrast, with the default protocol and tier: the + // founder alone (naming yourself is not naming anyone else). let (s, v) = post( a, "/v1/communities", - json!({ "name": " Book club ", "members": [b.owner.key_id] }), + json!({ "name": " Book club ", "members": [a.owner.key_id] }), ) .await; assert_eq!(s, 201, "{v}"); @@ -711,7 +822,7 @@ async fn create_refuses_with_a_typed_reason_each() { assert_eq!(v["tier"], "community"); assert_eq!(v["consensus_protocol"], "founder_only"); assert_eq!(v["my_role"], "founder"); - assert_eq!(v["member_count"], 2); + assert_eq!(v["member_count"], 1); } /// A second edge signer over the same node key — the router needs one, and the @@ -728,7 +839,7 @@ async fn node_edge_signer_for(n: &Node) -> Arc String { - let id = create( - alice, - json!({ - "name": "the trio", - "members": [bob.key(), carol.key()], - "consensus_protocol": "quorum:2/3", - }), - ) - .await; + let id = alice + .test_only_family_founded_with("the trio", &[bob, carol], "quorum:2/3") + .await; bob.receive_families_from(alice).await; carol.receive_families_from(alice).await; id @@ -589,7 +610,7 @@ async fn assemble( } #[tokio::test] -async fn a_quorum_family_adds_through_envelope_cosign_assemble() { +async fn a_quorum_family_cannot_admit_anyone_even_with_its_quorum() { let alice = Person::new("qa").await; let bob = Person::new("qb").await; let carol = Person::new("qc").await; @@ -597,7 +618,12 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { acquainted(&[&alice, &bob, &carol, &dave]).await; let id = quorum_family(&alice, &bob, &carol).await; - // One call is not enough for a quorum family — it says where to go. + // ── CONSENT TO JOIN (0.5.218): no door grows the roster ───────────────── + // + // Until 0.5.218 this test walked an add through envelope → cosign → + // assemble and Dave was in. M of N members cannot stand in for Dave's own + // acceptance (CIRISConstitution#133), so every step of that walk is now + // refused by name until the invite flow ships (CIRISPersist#955). let r = alice .as_owner( "POST", @@ -605,29 +631,38 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { Some(json!({ "key_id": dave.key() })), ) .await; - assert_refused(&r, 409, "family.quorum_pending"); + assert_refused(&r, 409, "membership.consent_required"); let r = alice - .as_owner("DELETE", &format!("/v1/families/{id}"), None) + .as_owner( + "POST", + &format!("/v1/families/{id}/changes/envelope"), + Some(json!({ "action": "add", "key_id": dave.key() })), + ) .await; - assert_refused(&r, 409, "family.quorum_pending"); - - // Envelope refusals. + assert_refused(&r, 409, "membership.consent_required"); let r = alice .as_owner( "POST", &format!("/v1/families/{id}/changes/envelope"), - Some(json!({ "action": "add", "key_id": bob.key() })), + Some(json!({ "action": "add", "key_id": dave.key(), "consensus_protocol": "quorum:2/4" })), ) .await; - assert_refused(&r, 409, "family.already_member"); + assert_refused(&r, 409, "membership.consent_required"); + // Someone already in is not joining, and keeps its own answer. let r = alice .as_owner( "POST", &format!("/v1/families/{id}/changes/envelope"), - Some(json!({ "action": "add", "key_id": dave.key(), "consensus_protocol": "quorum:2/4" })), + Some(json!({ "action": "add", "key_id": bob.key() })), ) .await; - assert_refused(&r, 400, "family.bad_consensus_protocol"); + assert_refused(&r, 409, "family.already_member"); + // One call is not enough for a quorum family's other changes — it says + // where to go. + let r = alice + .as_owner("DELETE", &format!("/v1/families/{id}"), None) + .await; + assert_refused(&r, 409, "family.quorum_pending"); let r = alice .as_owner( "POST", @@ -637,19 +672,67 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { .await; assert_refused(&r, 400, "family.bad_request"); - // ── the envelope, built by the founder ────────────────────────────────── + // ── an ADD envelope built elsewhere (an older node, or by hand) ───────── + // + // The cosign and assemble steps read what the envelope's roster DOES, not + // the route that built it: a proposed roster naming Dave is refused at + // both, however many members signed. + let dir = alice.engine.federation_directory(); + let record = dir + .lookup_family(&id) + .await + .expect("lookup") + .expect("family"); + let grown: Vec = [alice.key(), bob.key(), carol.key(), dave.key()] + .iter() + .map(|k| k.to_string()) + .collect(); + let mut add_env = dir + .build_membership_change_envelope( + ciris_persist::federation::cohort::Cohort::Family, + &id, + &grown, + false, + Some("quorum:3/4"), + ) + .await + .expect("an add envelope, built the pre-0.5.218 way"); + add_env["action"] = json!("add"); + add_env["target_key_id"] = json!(dave.key()); + add_env["roles"] = json!({ + alice.key(): "founder", bob.key(): "member", carol.key(): "member", dave.key(): "member", + }); + add_env["prior_persist_row_hash"] = json!(record.persist_row_hash); + add_env["row_at"] = + json!(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)); + let r = alice + .as_owner( + "POST", + &format!("/v1/families/{id}/changes/cosign"), + Some(json!({ "change_envelope": add_env, "signatures": [] })), + ) + .await; + assert_refused(&r, 409, "membership.consent_required"); + let r = assemble(&alice, &id, &add_env, &json!([])).await; + assert_refused(&r, 409, "membership.consent_required"); + let (_, v) = alice + .as_owner("GET", &format!("/v1/families/{id}"), None) + .await; + assert!( + !members(&v).iter().any(|(k, _)| k == dave.key()), + "no door admitted Dave: {v}" + ); + + // ── the quorum flow itself, on a change that admits no one ────────────── let env = envelope( &alice, &id, - json!({ "action": "add", "key_id": dave.key() }), + json!({ "action": "role", "key_id": bob.key(), "role": "guardian" }), ) .await; - assert_eq!( - env["consensus_protocol"], "quorum:3/4", - "N follows the roster" - ); + assert_eq!(env["consensus_protocol"], "quorum:2/3", "same roster"); - // ── Alice cosigns on HER node: 1 of 2, not yet met ────────────────────── + // Alice cosigns on HER node: 1 of 2, not yet met. let v = cosign(&alice, &id, &env, &json!([])).await; assert_eq!(v["required_signatures"], 2); assert_eq!(v["quorum_met"], false); @@ -657,7 +740,7 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { let r = assemble(&alice, &id, &env, &one).await; assert_refused(&r, 409, "family.quorum_pending"); - // ── an OUTSIDER cannot cosign — they cannot see the family at all ─────── + // An OUTSIDER cannot cosign — they cannot see the family at all. dave.receive_families_from(&alice).await; let r = dave .as_owner( @@ -668,7 +751,7 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { .await; assert_refused(&r, 404, "family.not_found"); - // ── … and an outsider's signature does not count toward the quorum ────── + // … and an outsider's signature does not count toward the quorum. let dave_sig = { let bytes = ciris_verify_core::jcs::canonicalize(&env).expect("jcs"); let s = dave.owner.signer().await; @@ -696,33 +779,29 @@ async fn a_quorum_family_adds_through_envelope_cosign_assemble() { "{v}" ); - // ── Bob cosigns on HIS node, adding to Alice's: 2 of 2, met ───────────── + // Bob cosigns on HIS node, adding to Alice's: 2 of 2, met. let v = cosign(&bob, &id, &env, &one).await; assert_eq!(v["quorum_met"], true, "{v}"); let two = v["signatures"].clone(); - // ── any member assembles ──────────────────────────────────────────────── + // Any member assembles. let (st, v) = assemble(&alice, &id, &env, &two).await; assert_eq!(st.as_u16(), 200, "{v}"); - assert_eq!(v["action"], "add"); - assert_eq!(v["consensus_protocol"], "quorum:3/4"); + assert_eq!(v["action"], "role"); assert_eq!( members(&v), sorted(vec![ pair(&alice, "founder"), - pair(&bob, "member"), + pair(&bob, "guardian"), pair(&carol, "member"), - pair(&dave, "member"), ]) ); - assert!(v["dek_rewrap"].is_object(), "{v}"); - // ── the same envelope cannot be applied twice (the record moved) ──────── + // The same envelope cannot be applied twice (the record moved) … let r = assemble(&alice, &id, &env, &two).await; assert_refused(&r, 409, "family.bad_change"); // … and a cosign against a node still holding the OLD record is refused - // by name rather than signed: Bob's node never received the supersede - // (FSD §3.5 — a grown family record does not replicate at this pin). + // by name rather than signed. let r = bob .as_owner( "POST", @@ -830,6 +909,7 @@ async fn a_quorum_family_dissolves_only_with_its_quorum() { .as_owner("GET", &format!("/v1/families/{id}"), None) .await; assert_refused(&r, 404, "family.not_found"); + // The removals replicate: Bob's node learns the family is gone for him too. bob.receive_families_from(&alice).await; let r = bob @@ -838,16 +918,105 @@ async fn a_quorum_family_dissolves_only_with_its_quorum() { assert_refused(&r, 404, "family.not_found"); } +/// **RED, pinned: a quorum dissolve should replicate as an AMENDMENT** +/// (0.5.218 audit, fix 2 — not buildable at persist v51.1.0). +/// +/// The terminal record goes through the PLAIN `supersede_family`, which +/// persist v49 strips of any proof (#910.5), so only the removal rows travel +/// and every peer keeps the full record. Routing it through +/// `supersede_family_with_quorum` needs a change envelope that proposes the +/// EMPTY roster the record carries (`assert_change_envelope_matches`), and +/// persist refuses that envelope structurally, verbatim: +/// `verify_membership_quorum: membership change not authorized: malformed +/// accord family envelope: group has no members`. What persist would need: a +/// quorum-verified TERMINAL amendment — e.g. `dissolve_family_with_quorum`, +/// or an explicit dissolve marker `verify_membership_quorum` accepts with an +/// empty new roster — that `route_occupied_family` applies on the peer. +#[tokio::test] +#[ignore = "persist: supersede_family_with_quorum refuses an empty proposed roster (\"group has no members\") — a terminal dissolve cannot carry its quorum proof"] +async fn a_quorum_dissolve_replicates_as_an_amendment() { + let alice = Person::new("dra").await; + let bob = Person::new("drb").await; + let carol = Person::new("drc").await; + acquainted(&[&alice, &bob, &carol]).await; + let id = quorum_family(&alice, &bob, &carol).await; + let env = envelope(&alice, &id, json!({ "action": "dissolve" })).await; + let a = cosign(&alice, &id, &env, &json!([])).await; + let ac = cosign(&carol, &id, &env, &a["signatures"]).await; + let (st, v) = assemble(&alice, &id, &env, &ac["signatures"]).await; + assert_eq!(st.as_u16(), 200, "{v}"); + + // ── what should hold: the terminal record is an AMENDMENT that replicates + // + // Before 0.5.218 it went through the plain supersede, which persist v49 + // strips of any proof (#910.5), so only the removal rows travelled and a + // peer kept the full record forever. It should carry the quorum's proof … + let served = alice + .engine + .federation_directory() + .list_signed_families_since(None, u32::MAX) + .await + .expect("signed families") + .into_iter() + .find(|s| s.family.family.family_key_id == id) + .expect("the dissolved household is still served"); + assert!( + served.family.family.members.is_empty(), + "the served record is the terminal one" + ); + let proof = served + .family + .supersede_proof + .as_ref() + .expect("the terminal record carries its quorum proof"); + assert!( + proof.change_envelope["members"] + .as_array() + .is_some_and(Vec::is_empty), + "the proof authorizes exactly the empty roster: {}", + proof.change_envelope + ); + // … and Bob's node, holding the old record and the full roster, re-verifies + // it against ITS OWN roster and applies it — the record alone, before any + // removal row arrives. + let offered = bob.receive_family_records_from(&alice).await; + let (_, applied) = offered + .iter() + .find(|(k, _)| k == &id) + .expect("the household was offered"); + assert!( + applied.is_ok(), + "Bob's node applies the amendment: {applied:?}" + ); + let held = bob + .engine + .federation_directory() + .lookup_family(&id) + .await + .expect("lookup") + .expect("held"); + assert!( + held.members.is_empty(), + "Bob's node holds the terminal record: {:?}", + held.members + ); +} + /// `majority` / `unanimous` are accepted as aliases and stored in the one form /// verify's membership-change gate counts. +/// +/// Since 0.5.218 a household is founded by its founder alone (a founding +/// roster naming anyone else is `membership.consent_required`, +/// CIRISConstitution#133 / CIRISPersist#955), so the aliases are witnessed on +/// the one roster a create can have: N = 1. #[tokio::test] async fn declared_majority_and_unanimous_are_stored_as_quorum() { let alice = Person::new("ma").await; let bob = Person::new("mb").await; let carol = Person::new("mc").await; acquainted(&[&alice, &bob, &carol]).await; - for (declared, stored) in [("majority", "quorum:2/3"), ("unanimous", "quorum:3/3")] { - let (st, v) = alice + for declared in ["majority", "unanimous"] { + let r = alice .as_owner( "POST", "/v1/families", @@ -858,7 +1027,15 @@ async fn declared_majority_and_unanimous_are_stored_as_quorum() { })), ) .await; + assert_refused(&r, 409, "membership.consent_required"); + let (st, v) = alice + .as_owner( + "POST", + "/v1/families", + Some(json!({ "name": declared, "consensus_protocol": declared })), + ) + .await; assert_eq!(st.as_u16(), 201, "{v}"); - assert_eq!(v["consensus_protocol"], stored); + assert_eq!(v["consensus_protocol"], "quorum:1/1"); } } diff --git a/tests/localization_gate.rs b/tests/localization_gate.rs index 3f1abde3..daf79a18 100644 --- a/tests/localization_gate.rs +++ b/tests/localization_gate.rs @@ -707,7 +707,13 @@ fn server_emitted_message_id_coverage_does_not_regress() { /// /// 140 -> 139 for 0.5.218: `family.readd_unsupported` retired — persist /// v49.0.0 re-admits a removed family member (#910.1). - const MAX_UNCOVERED: usize = 139; + /// + /// 139 -> 141 for 0.5.218: `self.evict_incomplete` (release and + /// occurrence/revoke are one signed act, CSD-037) and + /// `membership.consent_required` (nobody joins without their own consent, + /// CIRISConstitution#133 / CIRISPersist#955) — same reason, same list, + /// same client issue (CIRISClient#78). + const MAX_UNCOVERED: usize = 141; let en = load_en(); let ids = scraped_server_ids(); diff --git a/tests/occurrence.rs b/tests/occurrence.rs index ab7c7b5c..6335816a 100644 --- a/tests/occurrence.rs +++ b/tests/occurrence.rs @@ -16,6 +16,19 @@ //! second device. After it, `signer_acts_for(second, self)` is FALSE, and //! the roster no longer holds it. //! 4. A signer who does NOT act for the self (an unrelated key) is rejected 403. +//! +//! **0.5.218 (CSD-037).** The revoke moved to `crate::self_devices` and became +//! one act with node release (`evict_device`): the OWNER's session authorises +//! it and the owner's pen signs the revocation through persist's SIGNED, +//! replicating door. A device-signed request with no session is now refused +//! (step 3 asserts that, and that nothing was revoked); the revoke itself is +//! witnessed on a claimed node below — the signed row read back byte-exact +//! through `list_signed_identity_occurrence_revocations_for`, an occurrence +//! that IS an owned node losing its owner-binding too, and a source gate that +//! the trusted-local door has no caller in `src/`. + +#[path = "support/owned_node.rs"] +mod owned_node; use std::sync::Arc; @@ -155,9 +168,12 @@ async fn bind_primary(engine: &Engine, identity_key_id: &str, dev: &Device) { .expect("bind primary occurrence"); } -/// Serve the occurrence router on an ephemeral port. +/// Serve the occurrence router — and, since 0.5.218, the self-device router +/// the revoke moved to — on an ephemeral port. async fn serve(engine: Arc) -> (String, tokio::task::JoinHandle<()>) { - let app = occurrence::router(engine, HybridPolicy::Strict); + let app = occurrence::router(Arc::clone(&engine), HybridPolicy::Strict).merge( + ciris_server::self_devices::router(engine, std::env::temp_dir().join("no-owner-seed")), + ); let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await .expect("bind ephemeral port"); @@ -273,7 +289,12 @@ async fn enroll_second_device_then_revoke_it() { "an unauthenticated caller sees nothing of an un-announced person: {list}" ); - // ── (3) REVOKE: the PRIMARY revokes the (lost) SECOND device ── + // ── (3) REVOKE by a device signature alone: REFUSED since 0.5.218 ── + // + // The revocation is the OWNER's act, signed with the owner's pen through + // persist's signed door (CSD-037); a request carrying only a device's + // signature and no owner session never reaches it — and, before 0.5.218, + // it wrote the unsigned local-only row that never left this node. let revoke_body = serde_json::json!({ "identity_key_id": identity, "occurrence_key_id": second.key_id, @@ -287,29 +308,319 @@ async fn enroll_second_device_then_revoke_it() { &revoke_body, ) .await; - assert_eq!(status, 200, "revoke must succeed: {json}"); assert_eq!( - json["revoked_by"], primary.key_id, - "surviving key authored it" + status, 401, + "a device signature is not an owner session: {json}" ); + assert_eq!(json["reason_id"], "self.owner_session_required", "{json}"); + assert!( + signer_acts_for(&engine, &second.key_id, identity).await, + "nothing was revoked" + ); + assert_eq!(active_roster(&engine, identity).await.len(), 2); +} + +// ─── 0.5.218: the revoke is the owner's SIGNED, replicating act ───────────── - // The revoked device can no longer act as the self. +use owned_node::{assert_refused, Person}; + +/// Register a device key and bind it as an occurrence of `p`'s self, with +/// content-KEM keys (so it is a wrap recipient). Returns its key id. +async fn phone_of(p: &Person, tag: u8) -> String { + let key_id = format!("{}-phone-{tag}", p.owner.alias); + let dev = Device::new(&key_id, tag); + register_key(&p.engine, &dev, "user").await; + p.engine + .federation_directory() + .put_identity_occurrence_local(ciris_persist::federation::IdentityOccurrence { + identity_key_id: p.key().to_owned(), + occurrence_key_id: key_id.clone(), + device_class: "phone".into(), + hardware_attestation: None, + asserted_at: chrono::Utc::now(), + valid_until: None, + encryption_pubkeys: Some( + ciris_server::identity::derive_self_enc_pubkeys(&[tag; 32]).expect("enc keys"), + ), + transport_binding: None, + persist_row_hash: String::new(), + }) + .await + .expect("bind the phone"); + key_id +} + +/// **(i) The revoke writes a SIGNED revocation** — readable through persist's +/// byte-exact re-read (`list_signed_identity_occurrence_revocations_for`, +/// which omits the unsigned local rows), attested by the OWNER — and every +/// answer says what eviction does not do. +#[tokio::test] +async fn the_owner_revokes_a_device_with_a_signed_replicating_revocation() { + let alice = Person::new("occ-rev").await; + let phone = phone_of(&alice, 0x51).await; + let kept = phone_of(&alice, 0x53).await; + let dir = alice.engine.federation_directory(); assert!( - !signer_acts_for(&engine, &second.key_id, identity).await, - "after revocation the second device must NOT act for the self" + dir.list_signed_identity_occurrence_revocations_for(alice.key()) + .await + .expect("signed revocations") + .is_empty(), + "precondition: nothing revoked" ); - // ...and the primary still can (revocation is scoped to the one device). + + let (st, v) = alice + .as_owner( + "POST", + "/v1/self/occurrence/revoke", + Some(serde_json::json!({ + "identity_key_id": alice.key(), + "occurrence_key_id": phone, + "reason": "phone stolen", + })), + ) + .await; + assert_eq!(st.as_u16(), 200, "{v}"); + assert_eq!(v["revoked"], true, "{v}"); + assert_eq!(v["revoked_by"], alice.key()); + assert_eq!( + v["occurrences_revoked"].as_array().map(Vec::len), + Some(1), + "{v}" + ); + assert!(v["failed"].as_array().is_some_and(Vec::is_empty), "{v}"); assert!( - signer_acts_for(&engine, &primary.key_id, identity).await, - "the surviving device still acts for the self" + v["history"] + .as_str() + .is_some_and(|h| h.contains("Already-shared history stays readable")), + "the answer says what eviction does NOT do: {v}" ); - // The roster drops the revoked device. - let ids = active_roster(&engine, identity).await; + // THE SIGNED ROW, read back the way a replicator re-publishes it. + let signed = dir + .list_signed_identity_occurrence_revocations_for(alice.key()) + .await + .expect("signed revocations"); + let row = signed + .iter() + .find(|r| r.identity_occurrence_revocation.occurrence_key_id == phone) + .expect("the revocation is on the SIGNED surface — not the local door"); + assert_eq!(row.attesting_key_id, alice.key(), "the owner signed it"); + assert_eq!(row.signed_envelope["occurrence_key_id"], phone.as_str()); + assert_eq!(row.signed_envelope["reason"], "phone stolen"); + assert!(row.signature.mldsa65_signature_base64.is_some(), "hybrid"); + + // The device is out of the self; the other one is not. + assert!(!signer_acts_for(&alice.engine, &phone, alice.key()).await); + assert!(signer_acts_for(&alice.engine, &kept, alice.key()).await); + let active = active_roster(&alice.engine, alice.key()).await; + assert!( + !active.contains(&phone) && active.contains(&kept), + "{active:?}" + ); + + // Again: already revoked — nothing to sign, and said so. + let (st, v) = alice + .as_owner( + "POST", + "/v1/self/occurrence/revoke", + Some(serde_json::json!({ + "identity_key_id": alice.key(), + "occurrence_key_id": phone, + })), + ) + .await; + assert_eq!(st.as_u16(), 200, "{v}"); + assert_eq!(v["occurrences_already_revoked"], serde_json::json!([phone])); +} + +/// The revoke's gate: the owner's session, never a delegate, and only the +/// caller's own occurrences — one id for "someone else's" and "nobody's". +#[tokio::test] +async fn only_the_owner_revokes_and_only_their_own_devices() { + let alice = Person::new("occ-gate").await; + let bob = Person::new("occ-gate-b").await; + let phone = phone_of(&alice, 0x61).await; + let bobs = phone_of(&bob, 0x63).await; + alice.knows(&bob).await; + let body = |identity: &str, occ: &str| serde_json::json!({ "identity_key_id": identity, "occurrence_key_id": occ }); + let path = "/v1/self/occurrence/revoke"; + let r = alice + .call("POST", path, None, Some(body(alice.key(), &phone))) + .await; + assert_refused(&r, 401, "self.owner_session_required"); + let guest = alice.stranger_session().await; + let r = alice + .call("POST", path, Some(&guest), Some(body(alice.key(), &phone))) + .await; + assert_refused(&r, 403, "self.owner_session_required"); + let delegated = alice.delegated_session().await; + let r = alice + .call( + "POST", + path, + Some(&delegated), + Some(body(alice.key(), &phone)), + ) + .await; + assert_refused(&r, 403, "self.delegate_may_not_author"); + // Bob's identity, Bob's phone under Alice's identity, a key nobody bound. + let r = alice + .as_owner("POST", path, Some(body(bob.key(), &bobs))) + .await; + assert_refused(&r, 404, "self.not_your_device"); + let r = alice + .as_owner("POST", path, Some(body(alice.key(), &bobs))) + .await; + assert_refused(&r, 404, "self.not_your_device"); + let r = alice + .as_owner("POST", path, Some(body(alice.key(), "no-such-device"))) + .await; + assert_refused(&r, 404, "self.not_your_device"); + assert!( + signer_acts_for(&alice.engine, &phone, alice.key()).await, + "nothing was revoked" + ); +} + +/// **The two routes are one act:** revoking an occurrence that IS one of the +/// owner's nodes also withdraws that node's owner-binding, so the self room +/// drops it too — not only the wraps. +#[tokio::test] +async fn revoking_a_node_occurrence_also_releases_the_node() { + use ciris_persist::federation::admission::nodes_owned_by; + let alice = Person::new("occ-node").await; + let node = "occ-second-node-0x71".to_owned(); + let dev = Device::new(&node, 0x71); + register_key(&alice.engine, &dev, "node").await; + let scopes: Vec = ciris_server::auth::ownership::OWNER_BINDING_INFRA_SCOPES + .iter() + .map(|s| s.to_string()) + .collect(); + ciris_server::auth::ownership::emit_steward_binding( + &alice.engine, + &alice.owner.signer().await, + &node, + &scopes, + ) + .await + .expect("owner-bind the second node"); + alice + .engine + .federation_directory() + .put_identity_occurrence_local(ciris_persist::federation::IdentityOccurrence { + identity_key_id: alice.key().to_owned(), + occurrence_key_id: node.clone(), + device_class: "server".into(), + hardware_attestation: None, + asserted_at: chrono::Utc::now(), + valid_until: None, + encryption_pubkeys: None, + transport_binding: None, + persist_row_hash: String::new(), + }) + .await + .expect("the node's content occurrence"); + let owned = |e: Arc, k: String| async move { + nodes_owned_by(e.federation_directory().as_ref(), &k) + .await + .expect("nodes_owned_by") + }; + assert!(owned(Arc::clone(&alice.engine), alice.key().to_owned()) + .await + .contains(&node)); + + let (st, v) = alice + .as_owner( + "POST", + "/v1/self/occurrence/revoke", + Some(serde_json::json!({ + "identity_key_id": alice.key(), + "occurrence_key_id": node, + })), + ) + .await; + assert_eq!(st.as_u16(), 200, "{v}"); + assert_eq!(v["nodes"], serde_json::json!([node]), "{v}"); + assert_eq!(v["withdrawn"].as_array().map(Vec::len), Some(1), "{v}"); assert_eq!( - ids, - vec![primary.key_id.clone()], - "only the surviving device remains" + v["occurrences_revoked"].as_array().map(Vec::len), + Some(1), + "{v}" + ); + assert!( + !owned(Arc::clone(&alice.engine), alice.key().to_owned()) + .await + .contains(&node), + "the owner-binding went with the occurrence" + ); + // Revoking the occurrence of the node you are talking to needs force, as + // releasing it does. + let (occ, _) = ciris_server::backend::provision_engine_occurrence(&alice.engine, alice.key()) + .await + .expect("this node's occurrence"); + let r = alice + .as_owner( + "POST", + "/v1/self/occurrence/revoke", + Some(serde_json::json!({ + "identity_key_id": alice.key(), + "occurrence_key_id": occ, + })), + ) + .await; + assert_refused(&r, 409, "self.release_self_requires_force"); +} + +/// **The trusted-local revocation door has no caller in `src/`.** Its rows +/// are unsigned and excluded from the signed replication read by +/// construction, so a revocation written through it never leaves the node — +/// the gap `evict_device` closed (CSD-037). The allow-list is empty on +/// purpose: a new caller must argue for itself here, by name. +#[test] +fn the_local_revocation_door_has_no_caller_in_src() { + const ALLOWED: &[&str] = &[]; + let mut hits = Vec::new(); + let mut stack = vec![std::path::PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/src" + ))]; + let mut scanned = 0usize; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(&dir).expect("read src dir") { + let path = entry.expect("entry").path(); + if path.is_dir() { + stack.push(path); + continue; + } + if path.extension().and_then(|e| e.to_str()) != Some("rs") { + continue; + } + scanned += 1; + let text = std::fs::read_to_string(&path).expect("read source"); + for (n, line) in text.lines().enumerate() { + let code = line.split("//").next().unwrap_or(""); + if code.contains("put_identity_occurrence_revocation_local") { + let rel = path + .strip_prefix(env!("CARGO_MANIFEST_DIR")) + .unwrap_or(&path) + .display() + .to_string(); + if !ALLOWED.contains(&rel.as_str()) { + hits.push(format!("{rel}:{}: {}", n + 1, line.trim())); + } + } + } + } + } + assert!( + scanned > 50, + "the gate scanned {scanned} files — pointed at the wrong tree?" + ); + assert!( + hits.is_empty(), + "put_identity_occurrence_revocation_local is called from src/ — its rows never replicate; \ + revoke through self_devices::evict_device (the signed door):\n{}", + hits.join("\n") ); } diff --git a/tests/self_node_release.rs b/tests/self_node_release.rs index 2622020f..88f967d6 100644 --- a/tests/self_node_release.rs +++ b/tests/self_node_release.rs @@ -525,3 +525,164 @@ async fn only_the_announced_nodes_are_public() { assert!(mine.contains(k), "{k} missing from the owner's view: {v}"); } } + +// ─── 0.5.218: release is an EVICTION (CSD-037) ────────────────────────────── + +/// The content-KEM occurrence a claimed machine provisions for its owner, +/// under its node key, with real enc keys (so it is a wrap recipient). +async fn node_occurrence(p: &Person, node_key_id: &str, tag: u8) { + p.engine + .federation_directory() + .put_identity_occurrence_local(IdentityOccurrence { + identity_key_id: p.key().to_owned(), + occurrence_key_id: node_key_id.to_owned(), + device_class: "server".to_owned(), + hardware_attestation: None, + asserted_at: chrono::Utc::now(), + valid_until: None, + encryption_pubkeys: Some( + ciris_server::identity::derive_self_enc_pubkeys(&[tag; 32]).expect("enc keys"), + ), + transport_binding: None, + persist_row_hash: String::new(), + }) + .await + .expect("the node's content occurrence"); +} + +async fn active_occurrences(p: &Person) -> Vec { + p.engine + .federation_directory() + .list_identity_occurrences_active(p.key()) + .await + .expect("active occurrences") + .into_iter() + .map(|o| o.occurrence_key_id) + .collect() +} + +/// **(ii) A release revokes the released node's occurrence** — signed, on the +/// surface replication re-publishes — as well as withdrawing its binding. +/// Before 0.5.218 the binding went and the occurrence stayed, so the machine +/// kept being wrapped every new self file. +#[tokio::test] +async fn releasing_a_node_also_revokes_its_occurrence_signed() { + let alice = Person::new("rel-occ").await; + let n2 = second_node(&alice, 0xE8).await; + node_occurrence(&alice, &n2, 0xE9).await; + assert!( + active_occurrences(&alice).await.contains(&n2), + "precondition" + ); + + let (st, v) = alice + .as_owner("POST", &format!("/v1/self/nodes/{n2}/release"), None) + .await; + assert_eq!(st.as_u16(), 200, "{v}"); + assert_eq!(v["released"], true); + assert_eq!(v["withdrawn"].as_array().map(Vec::len), Some(1), "{v}"); + let revoked = v["occurrences_revoked"] + .as_array() + .expect("occurrences_revoked"); + assert_eq!(revoked.len(), 1, "{v}"); + assert_eq!(revoked[0]["occurrence_key_id"], n2.as_str()); + assert_eq!(revoked[0]["attesting_key_id"], alice.key()); + assert!(v["failed"].as_array().is_some_and(Vec::is_empty), "{v}"); + assert!( + v["history"] + .as_str() + .is_some_and(|h| h.contains("Already-shared history stays readable")), + "{v}" + ); + assert!(!active_occurrences(&alice).await.contains(&n2)); + let signed = alice + .engine + .federation_directory() + .list_signed_identity_occurrence_revocations_for(alice.key()) + .await + .expect("signed revocations"); + assert!( + signed + .iter() + .any(|r| r.identity_occurrence_revocation.occurrence_key_id == n2 + && r.attesting_key_id == alice.key()), + "the node's revocation is SIGNED (replicable), by the owner: {signed:?}" + ); +} + +/// **(iii) After an eviction, a new self file carries no wrap to the evicted +/// device — and an OLD one still does** (the honesty half, CC 3.3.6.1: no DEK +/// rotation, no re-encryption of what was already shared). +#[tokio::test] +async fn after_eviction_new_self_files_are_not_wrapped_to_the_evicted_device() { + let alice = Person::new("rel-wrap").await; + let here = provision_node_occurrence(&alice).await; + let n2 = second_node(&alice, 0xEC).await; + node_occurrence(&alice, &n2, 0xED).await; + + let before = alice + .engine + .put_blob_scoped( + "self", + Some(alice.key()), + b"before the theft", + Some("text/plain"), + None, + ) + .await + .expect("a self file before the eviction"); + assert!( + before.granted.contains(&n2) && before.granted.contains(&here), + "precondition: both devices are wrapped: {:?}", + before.granted + ); + + let (st, v) = alice + .as_owner("POST", &format!("/v1/self/nodes/{n2}/release"), None) + .await; + assert_eq!(st.as_u16(), 200, "{v}"); + + let after = alice + .engine + .put_blob_scoped( + "self", + Some(alice.key()), + b"after the theft", + Some("text/plain"), + None, + ) + .await + .expect("a self file after the eviction"); + assert!( + !after.granted.contains(&n2) && !after.excluded.contains(&n2), + "a NEW self file names no wrap to the evicted device: {:?} / {:?}", + after.granted, + after.excluded + ); + assert!( + after.granted.contains(&here), + "the remaining device is: {:?}", + after.granted + ); + use ciris_persist::federation::blobs::BlobStorage as _; + let backend = alice.engine.sqlite_backend().expect("sqlite"); + let new_recipients = backend + .list_at_rest_grant_recipients(&after.at_rest_sha256) + .await + .expect("grant recipients"); + assert!( + !new_recipients.contains(&n2), + "no key_grant to the evicted occurrence on the new file: {new_recipients:?}" + ); + // What eviction does NOT do, pinned so nobody claims otherwise: the grant + // on the file shared BEFORE stays. + let old_recipients = backend + .list_at_rest_grant_recipients(&before.at_rest_sha256) + .await + .expect("grant recipients"); + assert!( + old_recipients.contains(&n2), + "already-shared history stays readable by the evicted device (no DEK rotation): \ + {old_recipients:?}" + ); +} diff --git a/tests/support/owned_node.rs b/tests/support/owned_node.rs index 5b4a863d..91d715d2 100644 --- a/tests/support/owned_node.rs +++ b/tests/support/owned_node.rs @@ -393,6 +393,36 @@ impl Person { } } + /// Offer every SIGNED family record `from` serves to this node's replicated + /// `put_family` door — INCLUDING ids this node already holds, which is + /// how an AMENDMENT (a superseded record carrying its quorum proof, + /// persist v49 #910.5) arrives. Returns, per family id, what the door + /// answered: `Ok(())` when it inserted, re-verified-and-applied, or found + /// the identical record; `Err(text)` when it refused (a differing record + /// without a proof is the #758 conflict). Records only — no roster rows, + /// so the amendment is judged against this node's roster as it stands. + pub async fn receive_family_records_from( + &self, + from: &Person, + ) -> Vec<(String, Result<(), String>)> { + let src = from.engine.federation_directory(); + let dst = self.engine.federation_directory(); + let mut out = Vec::new(); + for served in src + .list_signed_families_since(None, u32::MAX) + .await + .expect("list signed families") + { + let id = served.family.family.family_key_id.clone(); + let r = dst + .put_family(served.family) + .await + .map_err(|e| format!("{e:#}")); + out.push((id, r)); + } + out + } + /// One request through the router, as this node's owner (or `bearer`). pub async fn call( &self, @@ -485,6 +515,125 @@ pub async fn bind_self_scoped(engine: &Engine, owner: &LocalSigner, node_key_id: .expect("apply the self-scoped owner-binding"); } +// ─── TEST-ONLY roster fixtures — the consent-to-join BYPASS ────────────────── +// +// Since 0.5.218 every roster-growing HTTP door refuses +// `membership.consent_required` (the maintainer's ruling of 2026-09-30; +// CIRISConstitution#133, CIRISPersist#955, `FSD/MEMBERSHIP_INVITES.md`): nobody +// joins a household without their own acceptance, and the acceptance cannot be +// recorded until persist v52. Tests whose PURPOSE is downstream of a +// multi-member roster (quorum remove / role / leave / dissolve) still need one. +// These helpers write it the way the pre-0.5.218 routes did — owner-signed rows +// through persist's own SIGNED doors — WITHOUT going through the server. +// +// They exist only here, under `tests/`. There is no production bypass, and a +// test that uses one is asserting what happens AFTER a join, never that a join +// is allowed. + +impl Person { + /// TEST-ONLY (bypasses the consent-to-join door): a household founded by + /// this person with `others` already on its founding record, signed by this + /// person's pen through persist's replicated `put_family`. Every key must + /// already be registered on this node (`knows`). + pub async fn test_only_family_founded_with( + &self, + name: &str, + others: &[&Person], + consensus_protocol: &str, + ) -> String { + use ciris_persist::federation::types::{Family, FamilyMember, SignedFamily}; + let at = chrono::Utc::now(); + let mut members = vec![FamilyMember { + key_id: self.key().to_owned(), + joined_at: at, + role: Some("founder".to_owned()), + }]; + members.extend(others.iter().map(|p| FamilyMember { + key_id: p.key().to_owned(), + joined_at: at, + role: Some("member".to_owned()), + })); + let family_id = format!("family:v1:{}", uuid::Uuid::new_v4().simple()); + let family = Family { + family_key_id: family_id.clone(), + family_name: name.to_owned(), + members, + founded_at: at, + consensus_protocol: consensus_protocol.to_owned(), + consensus_protocol_entrenched: false, + persist_row_hash: String::new(), + }; + let canonical = + ceg_produce_canonicalize(&family.signing_envelope()).expect("canonicalize family"); + let sig = self + .owner + .signer() + .await + .sign_hybrid(&canonical) + .await + .expect("owner signs the family"); + self.engine + .federation_directory() + .put_family(SignedFamily { + family, + authority_key_id: self.key().to_owned(), + scrub_signature_classical: BASE64.encode(&sig.classical.signature), + scrub_signature_pqc: Some(BASE64.encode(&sig.pqc.signature)), + supersede_proof: None, + }) + .await + .expect("TEST-ONLY: put the multi-member family directly"); + family_id + } + + /// TEST-ONLY (bypasses the consent-to-join door): widen `family_id` by + /// `member_key_id` at `role`, signed by this person's pen — the + /// `FamilyMembershipWidening` row `POST /v1/families/{id}/members` wrote + /// before 0.5.218, through persist's own `add_member`. + pub async fn test_only_widen_family(&self, family_id: &str, member_key_id: &str, role: &str) { + use ciris_persist::federation::cohort::{AdmitSpec, Cohort, RosterMember}; + use ciris_persist::federation::types::{FamilyMember, FamilyMembershipWidening}; + let member = FamilyMember { + key_id: member_key_id.to_owned(), + joined_at: chrono::Utc::now(), + role: Some(role.to_owned()), + }; + let row = FamilyMembershipWidening { + family_key_id: family_id.to_owned(), + member_key_id: member.key_id.clone(), + joined_at: member.joined_at, + effective_at: member.joined_at, + role: member.role.clone(), + persist_row_hash: String::new(), + }; + let canonical = + ceg_produce_canonicalize(&row.signing_envelope()).expect("canonicalize widening"); + let sig = self + .owner + .signer() + .await + .sign_hybrid(&canonical) + .await + .expect("owner signs the widening"); + let spec = AdmitSpec { + authority_key_id: self.key().to_owned(), + scrub_signature_classical: BASE64.encode(&sig.classical.signature), + scrub_signature_pqc: Some(BASE64.encode(&sig.pqc.signature)), + cosignatures: Vec::new(), + }; + let added = self + .engine + .federation_directory() + .add_member(Cohort::Family, family_id, RosterMember::from(member), &spec) + .await + .expect("TEST-ONLY: widen the family directly"); + assert!( + added, + "TEST-ONLY widening admitted nothing for {member_key_id}" + ); + } +} + /// An active `wa_cert` + a bound session bearer on `engine`. pub async fn mint_session(engine: &Engine, wa_id: &str, role: WaRole) -> String { let now = chrono::Utc::now(); diff --git a/tools/check_server_localization.py b/tools/check_server_localization.py index c7cd23c9..a3b4e9ad 100644 --- a/tools/check_server_localization.py +++ b/tools/check_server_localization.py @@ -797,6 +797,15 @@ def _server_message_texts_all(root: Path) -> Dict[str, List[str]]: # the self-device ids (CIRISClient#78). "self.announce_not_your_node", "self.announce_refused", + # EVICTING A DEVICE (0.5.218, CSD-037): release and occurrence/revoke became + # ONE signed act, and a part of it that did not complete is named by this + # id. CONSENT TO JOIN (0.5.218, the maintainer's ruling of 2026-09-30, + # CIRISConstitution#133 / CIRISPersist#955): every roster-growing door of + # the family and community surfaces refuses with ONE shared id until the + # invite flow ships. Both queued for the client bundle with their English + # (CIRISClient#78). + "self.evict_incomplete", + "membership.consent_required", # THE CLAIM, NAMED (0.5.218, CIRISServer#678 client review: "refusals come # back as prose"): claim-remote on the approving device, and setup/root on # the device being claimed. Same queue (CIRISClient#78).