radicle_integration.md
1 # Auths-Radicle Integration Plan 2 3 ## 1. Problem Statement 4 5 Verification logic alone -- even if perfectly implemented and deployed as WASM to every node -- cannot solve revocation propagation in a P2P network. A verifier can only check facts it knows about: if a node has never received a revocation attestation, its verifier will correctly validate a signature from the revoked device because, from that node's perspective, the device is still authorized. The problem is not verification correctness but *state availability*. Revocation is an *event* that must physically propagate through the gossip network, and until it arrives, every honest node with stale state will make a locally-correct but globally-wrong authorization decision. Therefore, the integration must define explicit rules for what a node should do when its identity state may be incomplete, rather than relying on "verification works, so we're safe." 6 7 --- 8 9 ## 2. Source of Truth and Storage Model 10 11 ### Chosen Model: Dedicated identity repository with project-level namespace binding (per RIP-X) 12 13 RIP-X specifies that a KERI identity lives in its own Radicle repository, and projects reference it via a DID namespace under `refs/namespaces/did-keri-<prefix>/`. This plan follows that design exactly. 14 15 **Where identity events live:** Each Auths identity (the KERI event log and device attestations) lives in a **dedicated identity repository** replicated via Radicle, one per identity. The repository layout follows RIP-X: 16 17 ``` 18 <rid> # KERI identity repository 19 └─ refs 20 ├─ keri 21 │ └─ kel # KEL commit history (tip = latest event) 22 └─ keys 23 ├─ <nid> # 2-way attestation for device A 24 │ └─ signatures 25 │ ├─ did-key # Device's signature blob 26 │ └─ did-keri # Identity's signature blob 27 └─ <nid> # 2-way attestation for device B 28 └─ signatures 29 ├─ did-key 30 └─ did-keri 31 ``` 32 33 **Where project-level binding lives:** Per RIP-X, each project repository gains a new namespace entry for each KERI identity that participates: 34 35 ``` 36 <project-rid> 37 └─ refs 38 └─ namespaces 39 ├─ did-keri-EXq5... # Identity namespace (new, per RIP-X) 40 │ └─ refs 41 │ ├─ rad 42 │ │ └─ id # Blob pointing to the identity repo RID 43 │ └─ heads/... # Canonical refs for this identity 44 ├─ <nid-A> # Device A's fork (existing pattern) 45 │ └─ refs 46 └─ <nid-B> # Device B's fork (existing pattern) 47 └─ refs 48 ``` 49 50 The `refs/namespaces/did-keri-<prefix>/refs/rad/id` blob contains the RID of the identity repository. This is how a node discovers which identity repo to fetch. 51 52 ### Why this model 53 54 | Criterion | RIP-X model (chosen) | Alternative: embed in each project | 55 |---|---|---| 56 | **Revocation correctness** | Single authoritative source; revocations propagate once via identity repo | Revocation must be pushed to every project repo independently; easy to miss one | 57 | **Minimal codebase change** | Heartwood already has `IdentityNamespace` parsing for `did-keri-` prefixes; identity repos are just normal Radicle repos | Requires modifying every project's identity doc schema and replication logic | 58 | **Operational simplicity** | Identity owner manages one repo; projects just reference it via namespace | Every project must be updated on revocation | 59 | **RIP-X compliance** | Direct match | Deviates from the RIP | 60 61 The key insight from fn-4 (Heartwood's fetch/protocol epic): when a node fetches a project and encounters a `refs/namespaces/did-keri-<prefix>/refs/rad/id` blob, it queues a best-effort fetch of the referenced identity repo. This creates automatic replication dependency -- nodes that seed a project will discover and seed the identity repo without manual intervention. 62 63 --- 64 65 ## 3. Roles and Responsibilities 66 67 ### auths crates (auths-core, auths-id, auths-verifier) 68 69 **Owns:** 70 - Identity truth: creation, storage, and validation of KERI event logs (inception, rotation, interaction events) 71 - Attestation lifecycle: creation, dual-signing (per RIP-X: `keri.sign(RID, did:key)` + `key.sign(RID, did:keri)`), revocation 72 - Policy evaluation: capability checks, expiration checks, revocation checks against *locally available* state 73 - DID resolution: converting `did:keri:` and `did:key:` to public key material 74 - Key state computation: `validate_kel()` produces `KeyState` from an ordered event sequence 75 76 **Does not own:** P2P replication, network transport, peer discovery, or any concept of "freshness" relative to the network. 77 78 ### heartwood crates (radicle, radicle-fetch, radicle-node, radicle-cob) 79 80 **Owns:** 81 - P2P replication: gossip protocol, `RefsAnnouncement`, fetch pipeline 82 - Cryptographic signature verification: Ed25519 verification of `SignedRefs` and COB commits 83 - Project identity documents: `Doc` with delegates (including `Did::Keri` once fn-1 lands), threshold, visibility, payloads 84 - Repository storage: namespaced refs (including `did-keri-` identity namespaces per RIP-X), signed refs branches, Git object storage 85 - Namespace classification: `NamespaceKind::Peer(NodeId)` vs `NamespaceKind::Identity(Did)` (fn-4) 86 - Seeding policy: which repos to replicate, which peers to follow 87 - Identity repo auto-fetch: when encountering a DID namespace, queue fetch of the referenced identity repo (fn-4, best-effort) 88 89 **Does not own:** KERI event validation, capability-based authorization, or revocation semantics beyond its existing delegate threshold model. 90 91 ### auths-radicle (the bridge) 92 93 **Responsible for:** 94 95 1. **DID translation:** Converting Radicle's `[u8; 32]` Ed25519 keys to Auths `did:key:z...` format and back. This is the type-system boundary. 96 97 2. **Ref path constants:** Exporting RIP-X ref paths (`refs/keri/kel`, `refs/keys/<nid>/signatures`) so both codebases use identical strings. 98 99 3. **Identity state loading:** Reading the KERI event log and device attestations from the identity repository (accessed as a local Git repo that Radicle has replicated). Computing current `KeyState` via `validate_kel()`. 100 101 4. **Authorization evaluation:** For a given signer key, answering: "Is this device currently authorized to perform this action on this project?" This involves: 102 - Finding the device's attestation under `refs/keys/<nid>/signatures` 103 - Verifying both signatures (RIP-X 2-way attestation) 104 - Checking revocation status 105 - Checking expiration 106 - Checking capability (e.g., `sign_commit`) 107 - Evaluating against the current `KeyState` 108 109 5. **Staleness detection:** Determining whether the locally-available identity state may be stale. The bridge compares what it has locally against what the gossip layer knows is available (see Section 4). 110 111 6. **Result production:** Returning a `VerifyResult` (Verified / Rejected / Quarantine) that Radicle's fetch pipeline can act on. The bridge never modifies repository state; it only reads and decides. 112 113 **Refuses to do:** 114 - Sign anything (zero new crypto) 115 - Modify Radicle repository state directly 116 - Make network requests (it reads locally-replicated Git data only) 117 - Override Radicle's own signature verification (Radicle checks Ed25519; the bridge checks authorization) 118 119 --- 120 121 ## 4. Stale-State Policy: Compare and Choose 122 123 ### Policy A: Eventual Consistency (fail-open) 124 125 **Description:** Each node decides based solely on its local identity state. If a node hasn't received a revocation, it accepts the update. Disagreements resolve as revocations propagate. 126 127 **User experience:** Seamless in the happy path. Users never see delays or rejections due to missing state. However, a revoked device can continue pushing updates to nodes that haven't received the revocation until gossip catches up. 128 129 **Failure modes:** 130 - A revoked device's updates are accepted by stale nodes and become part of their local view. When the revocation arrives, those updates are already integrated. Cleaning them up requires manual intervention. 131 - In adversarial scenarios, a compromised device could race the revocation, pushing malicious updates to as many nodes as possible before the revocation propagates. 132 - An attacker can target nodes that haven't seeded identity repos, or exploit nodes with temporarily unreadable identity state (disk corruption, missing refs), and get accepted "because fallback." 133 134 **Node A vs Node B scenario:** Node A (has revocation) rejects the update. Node B (stale) accepts it. When Node B eventually receives the revocation, it has already accepted the update. There is no automatic rollback. The accepted update persists unless a delegate explicitly reverts it. 135 136 **Complexity:** Lowest. No additional state tracking. No quarantine mechanism. But cleanup after revocation races is undefined, and the system cannot honestly claim to "prevent unauthorized pushes" -- it only detects them after the fact. 137 138 ### Policy B: Freshness Requirement (fail-closed on staleness) 139 140 **Description:** Nodes refuse to make authorization decisions unless their identity state is "fresh enough." Freshness is defined as: the node has synced the identity repository within a configurable window, OR there are no pending identity repo updates in the gossip layer. 141 142 **User experience:** Nodes that are offline or poorly connected may reject valid updates because they can't confirm freshness. This creates friction in truly offline or intermittently-connected environments -- exactly the environments Radicle is designed for. 143 144 **Failure modes:** 145 - Offline nodes become unable to accept any updates, even from non-revoked devices. 146 - Creates a soft availability dependency on the identity repository being reachable, undermining Radicle's offline-first design. 147 - Clock-based freshness is unreliable in P2P networks with no shared clock. 148 149 **Node A vs Node B scenario:** Node A (has revocation) rejects. Node B checks freshness: if it hasn't synced recently, it quarantines. This is safe but blocks legitimate work when nodes are offline. 150 151 **Complexity:** Medium-high. Requires freshness tracking, configurable windows, and graceful degradation. Adds a soft liveness dependency. 152 153 ### Policy C: Proof-Carrying Authorization (fail-closed on missing proof) 154 155 **Description:** Every signed update carries enough evidence for any node to verify authorization *without* consulting external identity state. Specifically, each update includes: the device's current attestation, and a KEL snapshot up to the event that anchors that attestation. A node verifies the proof bundle independently. 156 157 **User experience:** Updates are self-contained. Nodes can verify authorization even while fully offline. No freshness anxiety and no quarantine friction. 158 159 **Failure modes:** 160 - Proof size: carrying attestations + KEL snapshots with every update adds overhead. 161 - Revocation latency: a revoked device can continue producing valid-looking proofs until the revocation is anchored in the KEL. The window is bounded by the time between compromise and revocation anchoring. 162 - Complexity of proof construction: the signing client must assemble proof bundles. 163 - **Protocol impact:** Proof bundles must be stored as auxiliary Git objects alongside the update, not embedded in `SignedRefs` or the wire format (see below for why). 164 165 **Node A vs Node B scenario:** Node B receives the update along with the proof bundle. If the proof was assembled before the revocation was anchored, it's valid and Node B accepts. Once the revocation is anchored, new proof bundles from that device fail. The vulnerability window equals the time between compromise and KEL update. 166 167 **Complexity:** Highest. But provides the strongest offline guarantee. 168 169 ### Decision 170 171 **MVP: Policy A (Eventual Consistency) for observe mode + Policy B-lite (quarantine-on-known-staleness) for enforce mode** 172 173 Projects that opt into Auths identity choose an enforcement level: 174 175 #### Observe mode (default for MVP) 176 177 The bridge checks identity state if available and produces `Verified` / `Rejected` / `Warn` results, but the node always applies the update regardless. Results are logged and surfaced in `Validations`. This is a detection-and-flagging system, not an authorization boundary. 178 179 The system **does not claim to prevent unauthorized pushes** in observe mode. It detects and flags them with eventual convergence. 180 181 #### Enforce mode (recommended for projects that need revocation guarantees) 182 183 When a project opts into enforce mode, the bridge is a hard authorization boundary: 184 185 - `Verified` -> apply the update 186 - `Rejected` -> reject the update (add validation error, prune the remote) 187 - **Identity state unavailable** (identity repo not seeded, not yet fetched, or unreadable) -> **quarantine**: reject the update with a specific `Quarantine` validation error indicating which identity repo RID is needed. The node can retry after fetching the identity repo. 188 189 This is fail-closed for projects that opt in. The bridge does not "fall back to signature-only" for enforce-mode projects -- that would undermine the entire security model. If a project declares an Auths binding in enforce mode and the identity repo is missing, updates from KERI-attested devices are rejected until the identity state is available. 190 191 **Backward compatibility:** Projects without any Auths binding are completely unaffected (no bridge call). Projects in observe mode are never blocked. Only enforce mode adds rejection, and it's opt-in per project. 192 193 #### Enforce mode DoS resistance 194 195 Enforce mode introduces a new DoS surface: if "identity repo missing/unreadable/behind => quarantine," an attacker can try to keep nodes perpetually quarantined by: 196 197 - **Preventing identity repo fetch** (network partition, peer selection attacks): The mitigation is that quarantine is *per-update*, not per-project. The node continues to accept updates from `Did::Key` delegates (which don't require the bridge) and from devices whose identity state *is* locally available and current. Only the specific updates that can't be authorized are quarantined. The node is never fully blocked from a project. 198 - **Constantly advancing the identity repo** to keep nodes "behind": This requires the attacker to be a delegate of the identity repo (only delegates can push signed refs). If a delegate is malicious, the identity is already compromised -- this is not a new attack surface. A non-delegate cannot advance the identity repo's signed refs. 199 - **Corrupting local identity repo storage**: If the identity repo on disk becomes unreadable, the bridge returns `Quarantine` for enforce mode. This is correct behavior -- "I can't read the identity state" should not mean "accept everything." The node operator resolves this by re-fetching the identity repo (`rad sync --fetch`). This is recoverable, not permanent. 200 201 **Quarantine timeout**: If a node has been quarantining updates for a specific identity for more than a configurable period (default: 24 hours) without being able to resolve the quarantine (e.g., identity repo is persistently unreachable), the node logs a critical warning. It does *not* auto-downgrade to observe mode -- that would defeat the purpose. The operator must explicitly intervene: either fix the fetch problem, downgrade to observe mode, or remove the KERI delegate from the project. This is an operational decision, not an automatic one. 202 203 ### Staleness detection (replaces min_kel_seq-as-freshness) 204 205 The previous draft used `min_kel_seq` from the project binding as a freshness heuristic. This does not actually detect the "Node B accepts revoked device" case -- a node can be at sequence 3 (above the binding minimum of 2) while the revocation is at sequence 4. 206 207 `min_kel_seq` serves a different purpose: **binding integrity**. It prevents a node from accepting identity state that predates the binding itself (e.g., an attacker feeding a truncated KEL). It is not a freshness tool. 208 209 For actual staleness detection, the bridge uses a **gossip-informed heuristic**: 210 211 > **Warn when the node knows there is a newer identity repo tip available (via `RefsAnnouncement`) but hasn't fetched it yet.** 212 213 This is concretely actionable: 214 - Radicle's gossip layer already announces `RefsAt(remote, oid)` for every repo. The node can compare its local identity repo tip against the announced tip. 215 - If they differ, the bridge returns `Warn` (observe mode) or `Quarantine` (enforce mode) with metadata: "identity repo `rad:z3gq...` has a newer tip `abc123` available -- fetch it." 216 - No shared clocks needed. No configurable windows. Just: "I know I'm behind." 217 - If no announcement has been received (fully disconnected), the node has no reason to suspect staleness and proceeds based on local state. 218 219 #### Which peers can trigger staleness signals 220 221 Not all `RefsAnnouncement` sources are equally trustworthy. An attacker could spam false "newer tip" announcements to force quarantine on enforce-mode nodes (a DoS vector). The policy: 222 223 1. **Only announcements from peers that are delegates of the identity repo (or tracked/followed peers for that repo) are considered credible.** Radicle already tracks which peers are delegates via the identity repo's own `Doc.delegates`. An announcement from a random peer is ignored for staleness purposes. 224 2. **A single credible announcement is sufficient to trigger a staleness signal.** We do not require multiple independent announcements. Rationale: the identity repo has a small, known delegate set (typically 1-3). Requiring a quorum would add complexity without meaningful security gain -- if an identity delegate is compromised, the identity itself has bigger problems than false staleness signals. 225 3. **An attacker who is not a delegate of the identity repo cannot trigger quarantine.** They can announce whatever they want, but the bridge filters by delegate status before acting on the signal. 226 227 This means the staleness signal is only as trustworthy as the identity repo's delegate set -- which is the same trust root as the identity itself. 228 229 ### Later hardening path (backward-compatible) 230 231 **Phase 2: Proof-carrying authorization (Policy C) as additive auxiliary Git objects.** 232 233 Proof bundles are stored as **additional Git blobs in the identity repo** (not in `SignedRefs` or the wire format). A ref like `refs/keys/<nid>/proof-bundle` contains the attestation + KEL snapshot. Nodes that understand proof bundles verify them; nodes that don't (older versions) ignore the extra refs and fall back to direct identity repo lookup. 234 235 This avoids modifying `SignedRefs` canonicalization or the gossip wire format. `SignedRefs` uses a text-canonical format (`<oid> <ref>\n` lines) that is safe to extend with new refs but should not have its structure changed. The gossip `RefsAnnouncement` is binary-encoded and carries only `(rid, [(remote, oid)])` -- adding fields would require a protocol version bump. Proof bundles as auxiliary Git objects require neither. 236 237 --- 238 239 ## 5. Concrete Propagation Flow 240 241 ### 5A. Normal Operation: Device Authorization and Update Acceptance 242 243 **Step 1: Identity creation.** 244 Alice creates a KERI identity. Per RIP-X, this generates: 245 - A KERI inception event with her initial Ed25519 key pair 246 - A `did:keri:<prefix>` identity DID derived from the SAID (Blake3 hash) of the inception event 247 - The KEL is stored as a commit chain under `refs/keri/kel` 248 249 **Step 2: Identity repository publication.** 250 Alice publishes her identity as a Radicle repository using `rad identity init-keri` (fn-5 CLI). This creates a Radicle `RepoId` (RID) for her identity and seeds it to the network. 251 252 **Step 3: Device authorization.** 253 Alice has a second device (laptop). Per RIP-X, she creates a 2-way attestation: 254 - `keri.sign(RID, did:key)` -- identity key signs the binding 255 - `key.sign(RID, did:keri)` -- device key signs the binding 256 - Both signatures are stored as blobs under `refs/keys/<laptop-nid>/signatures/` in the identity repo 257 - She pushes the updated identity repo: `git push rad` 258 259 **Step 4: Project setup with identity namespace.** 260 Alice creates a Radicle project. Per RIP-X, a new namespace is created: 261 - `refs/namespaces/did-keri-<prefix>/refs/rad/id` -> blob containing the identity repo RID 262 - `refs/namespaces/did-keri-<prefix>/refs/heads/...` -> canonical refs for the identity 263 - The project's `Doc` delegates include `Did::Keri(prefix)` alongside any `Did::Key` delegates 264 265 **Step 5: Signed update from authorized device.** 266 Alice's laptop pushes a commit to the project. Radicle creates `SignedRefs` signed by the laptop's Ed25519 key. The update appears under `refs/namespaces/<laptop-nid>/...`. 267 268 **Step 6: Node receives update.** 269 Bob's node receives Alice's `RefsAnnouncement`. During the fetch pipeline: 270 271 1. Radicle verifies the Ed25519 signature on `SignedRefs` (existing logic, unchanged) 272 2. Radicle classifies the remote's namespace. The laptop's `<nid>` is a `Peer` namespace. 273 3. The fetch pipeline encounters `refs/namespaces/did-keri-<prefix>/refs/rad/id` and queues a fetch of Alice's identity repo (fn-4 design, best-effort) 274 4. Per fn-3, `SignedRefs::verify_with_identity()` is called. It tries the fast path (exact delegate key match) first. Since the laptop isn't a direct delegate, it falls back to the auths-radicle bridge: 275 a. Bridge reads Alice's identity repo from Bob's local storage 276 b. Bridge loads the KEL and computes `KeyState` via `validate_kel()` 277 c. Bridge converts the laptop's `[u8; 32]` key to `did:key:z6Mk...` 278 d. Bridge loads the 2-way attestation from `refs/keys/<laptop-nid>/signatures` 279 e. Bridge verifies both signatures, checks: not revoked, not expired, has `sign_commit` capability 280 f. Bridge returns `VerifyResult::Verified` 281 5. Radicle proceeds with ref validation and applies the update 282 283 ### 5B. Revocation 284 285 **Step 1: Revocation creation.** 286 Alice discovers her laptop was compromised. From her primary device, she runs `rad identity device revoke <laptop-nid>` (fn-5). This: 287 - Sets `revoked_at` on the attestation and re-signs with the KERI identity key 288 - Anchors the revocation in the KEL via an interaction event (IXN) 289 - KEL sequence advances (e.g., from 2 to 3) 290 291 **Step 2: Publication.** 292 Alice pushes the updated identity repository to Radicle. The identity repo's `SignedRefs` update, and a `RefsAnnouncement` propagates through the gossip network. 293 294 **Step 3: Network propagation.** 295 Nodes that seed Alice's identity repository receive the `RefsAnnouncement`, fetch the updated KEL and revocation. Their local state now reflects the revocation. 296 297 **Step 4: Post-revocation verification (node has revocation).** 298 When any node with the updated identity state receives an update signed by the revoked laptop key: 299 1. Radicle verifies the Ed25519 signature (passes -- the signature is cryptographically valid) 300 2. The bridge is consulted: 301 a. Bridge loads the attestation for the laptop's DID 302 b. Bridge finds `revoked_at` is set 303 c. Bridge returns `VerifyResult::Rejected { reason: "Device <laptop-nid> was revoked at 2026-03-01T12:00:00Z" }` 304 3. Radicle rejects the ref update (enforce mode) or records a validation warning (observe mode) 305 306 ### 5C. The Stale-Node Scenario 307 308 **Setup:** 309 - Alice revoked her laptop at KEL sequence 3 310 - Node A (Carol) has synced Alice's identity repo and has KEL up to sequence 3 (includes revocation) 311 - Node B (Dave) has not synced Alice's identity repo recently; his local copy is at KEL sequence 2 (no revocation) 312 - The compromised laptop pushes an update to the project, and Dave's node receives it 313 314 **What happens at Dave's node (stale state):** 315 316 1. **Signature verification passes.** The Ed25519 signature is valid. 317 318 2. **Bridge is consulted.** 319 a. Bridge locates Alice's identity repo in Dave's local storage 320 b. Bridge loads the KEL: sequence 2 (no revocation event present) 321 c. Bridge loads attestation for the laptop: not revoked (in Dave's view), not expired, has capabilities 322 323 3. **Gossip-informed staleness check:** 324 - If Dave's node has received a `RefsAnnouncement` for Alice's identity repo with a newer tip OID than what Dave has locally, the bridge detects staleness. 325 - If no such announcement has been received (Dave is fully disconnected), Dave has no reason to suspect staleness. 326 327 4. **Outcome depends on mode:** 328 329 **Observe mode:** Bridge returns `VerifyResult::Verified` (or `Warn` if staleness detected). Dave accepts the update. When Dave eventually syncs Alice's identity repo, he gets the revocation. From that point forward, the laptop is rejected. The previously-accepted update remains -- Dave's node cannot automatically roll it back. 330 331 **Enforce mode with staleness detected:** Bridge returns `VerifyResult::Quarantine { reason: "Identity repo has newer tip available; fetch before deciding" }`. Dave's node rejects the update. When Dave syncs the identity repo and gets the revocation, the laptop would be rejected anyway. If the laptop had *not* been revoked, re-fetching the identity repo would resolve the quarantine and Dave could accept on retry. 332 333 **Enforce mode without staleness detected (fully disconnected):** Bridge returns `VerifyResult::Verified` based on local state. This is the irreducible risk of any eventually-consistent system -- Dave has no reason to suspect his state is wrong and no way to check. The window closes when connectivity resumes. 334 335 **Blast radius of stale acceptance:** 336 337 The damage from accepting an update from a revoked device depends on what the revoked device can do: 338 339 - **If the revoked device is a project delegate (via `Did::Keri`):** It can update canonical refs, subject to threshold. A compromised delegate is a serious incident regardless of Auths -- the project must have a threshold > 1 to survive this. Auths adds revocation capability; it doesn't change the blast radius of a compromised delegate. 340 - **If the revoked device is a non-delegate contributor:** Its updates live in its own `refs/namespaces/<nid>/...` namespace. Radicle's delegate threshold mechanism means non-delegate refs don't affect the canonical project state *by default*. However, this containment has limits: 341 - **No auto-merge from non-delegate namespaces.** Radicle does not auto-merge patches or auto-build from non-delegate forks without explicit delegate action (merging a patch requires a delegate to check it out, review, and push to their own namespace). So a revoked non-delegate device cannot inject code into canonical state without a human delegate approving the merge. 342 - **UI visibility matters.** Non-delegate namespaces are visible in `rad patch`, `rad ls`, and other CLI output. A stale-accepted update from a revoked device will appear as a valid-looking patch or fork until the revocation propagates and the node flags it. Users could be misled into reviewing or merging it. **Mitigation:** When a previously-accepted update is retroactively flagged (identity repo syncs and reveals revocation), the node should surface a prominent warning: "Patch/fork from `<nid>` was accepted before device revocation was known. Treat contents as untrusted." This warning should appear in `rad patch show`, `rad inbox`, and any UX surface that displays the contributor's data. 343 - **Object-level contamination.** Even though refs are namespaced, Git objects are shared. A malicious commit from a revoked device could reference the same tree as legitimate commits, or be a parent of future legitimate commits if a delegate inadvertently merges before the revocation arrives. This is a Git-level property, not something the bridge can prevent -- it reinforces that the operator should treat any stale-accepted data as potentially harmful. 344 345 - **In both cases:** Any accepted update from a revoked device must be treated as potentially harmful. The project owner must explicitly revert or repair after the revocation propagates. There is no automatic rollback. The bridge logs every `Verified` decision with enough context (device DID, identity DID, KEL sequence at decision time) to support a post-hoc audit: "which updates were accepted from device X before its revocation was known?" 346 347 ### 5D. Revocation Race Window: Formal Bound 348 349 The system prevents unauthorized pushes only under specific conditions. Here is the precise bound: 350 351 **Guarantee:** A push signed by a revoked device is rejected by every node that satisfies *at least one* of: 352 353 1. **The node has fetched the identity repo at or past the KEL sequence containing the revocation seal.** This is the primary path. The revocation is an IXN event in the KEL; once the node has it, the device is rejected. 354 355 2. **The node has received a credible gossip signal (from an identity repo delegate) that a newer identity repo tip exists, AND the project is in enforce mode.** The push is quarantined until the node fetches the update. If the update contains the revocation, the push is rejected. If the update does *not* contain the revocation (the newer tip was for an unrelated event), the quarantine resolves and the push is accepted. 356 357 **No guarantee:** A fully-disconnected node with no gossip signal operates on cached authority. It has no mechanism to learn about the revocation and will accept the push as locally valid. This is the irreducible risk of any system that operates offline. 358 359 **Bound on the vulnerability window:** 360 361 ``` 362 T_vulnerable = T_propagation + T_fetch 363 364 Where: 365 T_propagation = time for RefsAnnouncement to reach the node via gossip 366 (typically seconds on a connected network; unbounded if disconnected) 367 T_fetch = time for the node to fetch the updated identity repo after 368 receiving the announcement (typically seconds; bounded by 369 Git transfer speed) 370 ``` 371 372 For connected nodes on a well-seeded network, `T_vulnerable` is on the order of seconds to low minutes. For nodes with intermittent connectivity, it equals their disconnection interval. For permanently-offline nodes, it is infinite -- they run on cached authority indefinitely. 373 374 **This is not a weakness unique to this design.** It is the same bound as every revocation system that operates without a synchronous online check (TLS CRL/OCSP, PGP key revocation, SSH certificate revocation). The contribution of this design is making the bound *explicit* and providing the enforce-mode quarantine to narrow it for nodes that do have gossip connectivity. 375 376 --- 377 378 ## 6. Minimal Hook Point in Radicle 379 380 **Where:** Per fn-3's design, the hook is in `SignedRefs::verify_with_identity()` -- a new method that extends the existing `SignedRefs::verify()`. The existing method handles the fast path (exact delegate key match); the new method adds a fallback path for KERI-attested devices. 381 382 Concretely, in the fetch pipeline (fn-4, `FetchState::run()`): after `SignedRefs<Unverified>` is loaded for a remote, and after the Ed25519 signature is verified, the pipeline checks whether the signer's `NodeId` is: 383 1. A direct delegate (`Did::Key` in `Doc.delegates`) -> accept via existing path 384 2. An attested device of a `Did::Keri` delegate -> consult the auths-radicle bridge 385 3. Neither -> reject (non-delegate, non-attested) 386 387 **What the bridge call does:** 388 1. Read the identity repo RID from `refs/namespaces/did-keri-<prefix>/refs/rad/id` 389 2. Load the identity repo from local Radicle storage 390 3. Run authorization checks (KEL validation, attestation verification, revocation/expiry/capability checks) 391 4. Return `VerifyResult` mapped to Radicle's existing `Validations` / prune flow: 392 - `Verified` -> accept as a valid contributor under this KERI identity 393 - `Rejected` -> prune the remote, add validation error (enforce mode), or add warning (observe mode) 394 - `Quarantine` -> prune the remote, add validation error indicating which identity repo to fetch (enforce mode only) 395 396 **What "reject" means precisely:** 397 398 In Radicle, "accepting an update" involves multiple layers. The bridge operates at the **ref update** layer, not the transport layer. Specifically: 399 400 1. **Git objects are always fetched and stored.** The Git packfile transfer happens before the bridge is consulted. Objects (commits, trees, blobs) are written to the local Git object store regardless of the bridge verdict. This is unavoidable -- the fetch protocol transfers objects before refs are validated. Blocking at the object level would require aborting the Git protocol mid-stream, which is fragile and leaks metadata anyway. 401 402 2. **Ref updates are gated by the bridge.** After objects are stored, the fetch pipeline decides which refs to update. This is where the bridge's verdict takes effect: 403 - `Verified`: The remote's refs (under `refs/namespaces/<nid>/...`) are updated to point to the fetched objects. 404 - `Rejected` / `Quarantine`: The remote's ref updates are **pruned** -- the local refs are not advanced. The fetched objects become unreachable (no ref points to them) and will be garbage-collected by Git's normal GC. 405 406 3. **Canonical refs are never updated for rejected remotes.** The canonical refs (under `refs/namespaces/did-keri-<prefix>/refs/heads/...`) aggregate only from verified device namespaces. A rejected device's objects never appear in the canonical view. 407 408 4. **COBs (issues, patches) authored by rejected devices are not applied.** COB operations check the same authorization path. A rejected device's COB entries are pruned alongside its refs. 409 410 This means: rejected updates leave transient Git objects on disk (until GC), but no refs, no canonical state, and no COB state are affected. The UI never sees them. 411 412 **If the bridge is unavailable** (identity repo not in local storage, bridge returns error): 413 - **Observe mode:** Accept the update, log a warning. The bridge is informational only. 414 - **Enforce mode:** Reject the update. The project opted into hard authorization. "Unable to verify" is not "verified." This is fail-closed for projects that require it. 415 - **No Auths binding (no DID namespace in project):** The bridge is never called. Behavior is identical to current Heartwood. This is the default for all existing projects. 416 417 **Feature flag:** The bridge integration is behind the fn-1 work (extending `Did` to an enum). Once `Did::Keri` exists in Heartwood's type system, the multi-device verification path is structurally available. There is no separate feature flag needed in `radicle-fetch` because the bridge call is gated on the presence of a `Did::Keri` delegate in the project's `Doc` -- no KERI delegate means no bridge call. 418 419 --- 420 421 ## 7. Execution Roadmap 422 423 ### Relationship to Existing Heartwood Epics (fn-1 through fn-5) 424 425 This plan's epics are designed to complement, not duplicate, the Heartwood `.flow/` epics. The mapping: 426 427 | Heartwood epic | This plan's coverage | 428 |---|---| 429 | fn-1 (Core types: `Did` enum, `IdentityNamespace`) | Not duplicated. This plan assumes fn-1 is completed in Heartwood. | 430 | fn-2 (KERI storage: `KeriIdentityStore`, `GitKeriIdentityStore`) | Partially overlaps with our Epic 2 (identity state loading). Our Epic 2 focuses on the auths-radicle bridge side; fn-2 focuses on the Heartwood storage adapter side. | 431 | fn-3 (SignedRefs verification: `DeviceAuthorityChecker`) | Directly consumed by our Epic 3 (authorization checks). fn-3.2 explicitly wires `auths-radicle::DefaultBridge` as the backend. | 432 | fn-4 (Fetch/protocol: identity repo auto-fetch, namespace classification) | Directly consumed by our Epic 5 (Radicle integration seam). | 433 | fn-5 (CLI: `rad identity init-keri`, `device add/revoke/list`) | Not duplicated. This plan assumes fn-5 is completed in Heartwood. | 434 435 The epics below focus on work in the **auths** repository (auths-radicle, auths-id, auths-verifier) and cross-repo integration testing. 436 437 --- 438 439 ### Epic 1: RIP-X Ref Layout and Attestation Format Alignment 440 441 **Objective:** Align auths-radicle's ref paths and attestation format with RIP-X's specification. 442 443 **Why it matters:** RIP-X defines specific ref paths (`refs/keri/kel`, `refs/keys/<nid>/signatures`) and a specific 2-way attestation format (`keri.sign(RID, did:key)` + `key.sign(RID, did:keri)`). The existing auths-id code uses different paths (`refs/did/keri/<prefix>/kel`, `refs/auths/devices/nodes/<did>`). The bridge must map between these or the existing auths-id code must support the RIP-X layout. 444 445 **Success metrics:** 446 - Ref path constants for RIP-X layout exist in auths-radicle 447 - Attestation serialization/deserialization handles the RIP-X 2-blob format (separate `did-key` and `did-keri` signature blobs) 448 - Round-trip: create attestation in RIP-X format, store under RIP-X refs, read it back 449 450 **Exit criteria:** 451 - Constants match RIP-X spec exactly 452 - Bridge can read attestations stored in RIP-X format 453 - 100% of format tests pass 454 455 #### Tasks 456 457 **Task 1.1: Define RIP-X ref path constants in auths-radicle** 458 - *Why:* Both codebases must agree on ref paths. auths-radicle is the source of truth for the mapping. 459 - *Acceptance criteria:* Constants for `KERI_KEL_REF` (`refs/keri/kel`), `KEYS_PREFIX` (`refs/keys`), `SIGNATURES_REF` (`signatures`), `DID_KEY_BLOB` (`did-key`), `DID_KERI_BLOB` (`did-keri`). Documented with their RIP-X section references. 460 - *Test plan:* 461 - Unit: constants match RIP-X spec strings exactly 462 - Unit: path construction helpers produce valid Git refnames 463 - *Affected areas:* `crates/auths-radicle/src/refs.rs` (new file, per fn-5.2) 464 465 **Task 1.2: Attestation-to-bytes / from-bytes for RIP-X format** 466 - *Why:* RIP-X stores attestation signatures as two separate Git blobs, not as a single JSON attestation. auths-verifier needs to support this. 467 - *Acceptance criteria:* `Attestation::to_bytes()` / `from_bytes()` round-trip for the RIP-X 2-blob format. The canonical payload for signing is `(RID, other_did)` as specified in RIP-X. 468 - *Test plan:* 469 - Unit: serialize attestation to two blobs, deserialize back, signatures verify 470 - Unit: reject truncated/corrupt blobs 471 - Unit: reject mismatched RID (tamper detection) 472 - *Affected areas:* `crates/auths-verifier/src/` (per fn-5.3), `crates/auths-radicle/src/bridge.rs` 473 474 **Task 1.3: `GitKel::with_ref()` constructor for custom ref paths** 475 - *Why:* auths-id's KEL reader currently uses `refs/did/keri/<prefix>/kel`. For RIP-X, it needs to read from `refs/keri/kel`. 476 - *Acceptance criteria:* `GitKel` accepts an optional custom ref path, defaulting to the existing path but allowing `refs/keri/kel` for RIP-X repositories. 477 - *Test plan:* 478 - Unit: default path works as before (no regression) 479 - Unit: custom path reads KEL from `refs/keri/kel` 480 - Unit: invalid ref path returns error 481 - *Affected areas:* `crates/auths-id/src/keri/` (per fn-6.1, fn-6.2, fn-6.3) 482 483 --- 484 485 ### Epic 2: Identity State Loading from Radicle-Replicated Repos 486 487 **Objective:** Enable the bridge to read KERI identity state from locally-replicated Radicle repositories. 488 489 **Why it matters:** The bridge makes authorization decisions based on identity state. It must be able to open a Radicle-replicated identity repo (by RID), load the KEL, compute `KeyState`, and load device attestations -- all from local Git storage. 490 491 **Success metrics:** 492 - `AuthsStorage` impl can load `KeyState` and attestations from a Radicle-replicated repo 493 - Handles missing repos, corrupt KELs, and missing attestations with domain-specific errors 494 - Reports KEL sequence for staleness comparison 495 496 **Exit criteria:** 497 - Bridge loads `KeyState` from a test identity repo in RIP-X layout 498 - All error scenarios return appropriate `BridgeError` variants 499 - Integration test: create identity repo, replicate it (simulate by copying), load via bridge 500 501 #### Tasks 502 503 **Task 2.1: `AuthsStorage` implementation for Radicle-backed repos** 504 - *Why:* The existing `AuthsStorage` trait in auths-radicle needs a concrete impl that reads from Radicle's Git storage 505 - *Acceptance criteria:* Given a local filesystem path to a Radicle-stored identity repo, loads: (a) KEL events from `refs/keri/kel` commit chain, (b) device attestations from `refs/keys/<nid>/signatures`, (c) computes `KeyState` via `validate_kel()`. Returns `BridgeError` variants for all failure modes. 506 - *Test plan:* 507 - Unit: load valid KEL, verify `KeyState` fields match expected 508 - Unit: load 2-way attestation for known device NID 509 - Unit: missing identity repo -> `BridgeError::IdentityLoad` 510 - Unit: corrupt/truncated KEL -> `BridgeError::PolicyEvaluation` (wrapping `ValidationError`) 511 - Unit: missing attestation for unknown NID -> `BridgeError::AttestationLoad` 512 - Integration: create full identity repo with inception + attestation, load via bridge, verify 513 - *Affected areas:* `crates/auths-radicle/src/verify.rs` 514 515 **Task 2.2: `find_identity_for_device()` implementation** 516 - *Why:* The bridge needs to discover which KERI identity (if any) a given NodeId is attested under. Per fn-5.1, this method is added to the `RadicleAuthsBridge` trait. 517 - *Acceptance criteria:* Given a device's NodeId and a project repository, scan `refs/namespaces/did-keri-*/refs/rad/id` to find identity repos, then check each identity repo's `refs/keys/<nid>` for a matching attestation. Returns the KERI DID or `None`. 518 - *Test plan:* 519 - Unit: device attested under one identity -> returns that identity's DID 520 - Unit: device not attested under any identity -> returns `None` 521 - Unit: device attested under multiple identities (edge case) -> returns first match with warning 522 - Unit: identity repo not locally available -> returns `None` (not error) 523 - *Affected areas:* `crates/auths-radicle/src/bridge.rs` (per fn-5.1) 524 525 --- 526 527 ### Epic 3: Authorization Checks in the Bridge 528 529 **Objective:** Implement the full authorization evaluation pipeline. 530 531 **Why it matters:** This is the core value: answering "is this device authorized?" using Auths identity state, per fn-3's `DeviceAuthorization` design. 532 533 **Success metrics:** 534 - Bridge correctly authorizes valid devices 535 - Bridge correctly rejects revoked, expired, and unauthorized devices 536 - Bridge correctly checks capabilities 537 - Works with fn-3.2's `CompositeAuthorityChecker` integration 538 539 **Exit criteria:** 540 - All authorization scenarios produce correct `VerifyResult` 541 - 100% of authorization tests pass including edge cases and tamper scenarios 542 543 #### Tasks 544 545 **Task 3.1: Full verification pipeline (wired to Radicle-backed storage)** 546 - *Why:* `DefaultBridge::verify_signer()` must work against the `AuthsStorage` impl from Epic 2 547 - *Acceptance criteria:* `verify_signer()` executes: DID translation -> identity repo lookup -> KEL validation -> attestation load -> RIP-X 2-way signature verification -> policy evaluation -> result. 548 - *Test plan:* 549 - Unit: valid device with `sign_commit` capability -> `Verified` 550 - Unit: revoked device -> `Rejected` 551 - Unit: expired attestation -> `Rejected` 552 - Unit: device with wrong capability -> `Rejected` 553 - Unit: unknown device (no attestation) -> `Rejected` 554 - Unit: valid device after key rotation (KEL sequence > 0) -> `Verified` 555 - Tamper: modified attestation blob (signature mismatch) -> `Rejected` 556 - Tamper: swapped `did-key` and `did-keri` blobs -> `Rejected` 557 - *Affected areas:* `crates/auths-radicle/src/verify.rs`, `crates/auths-radicle/src/bridge.rs` 558 559 **Task 3.2: Capability-scoped authorization** 560 - *Why:* Different operations require different capabilities 561 - *Acceptance criteria:* Bridge accepts a required capability and checks it against the attestation. `sign_commit` for ref updates; `sign_release` for release tags. 562 - *Test plan:* 563 - Unit: device with `sign_commit` pushing refs -> `Verified` 564 - Unit: device with only `sign_release` pushing refs -> `Rejected` 565 - Unit: device with `[sign_commit, sign_release]` pushing release tag -> `Verified` 566 - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs` 567 568 **Task 3.3: Threshold verification for multi-delegate projects** 569 - *Why:* Radicle projects can require M-of-N delegates 570 - *Acceptance criteria:* `verify_multiple_signers()` and `meets_threshold()` work with the full pipeline 571 - *Test plan:* 572 - Unit: 3 signers, threshold 2, all valid -> passes 573 - Unit: 3 signers, threshold 2, one revoked -> passes (2 valid remain) 574 - Unit: 3 signers, threshold 2, two revoked -> fails 575 - Unit: mixed `Did::Key` + `Did::Keri` delegates in threshold -> both types checked correctly 576 - *Affected areas:* `crates/auths-radicle/src/verify.rs` 577 578 --- 579 580 ### Epic 4: Stale-State Handling and Enforcement Modes 581 582 **Objective:** Implement observe/enforce modes and gossip-informed staleness detection. 583 584 **Why it matters:** Without explicit staleness handling, the system silently makes wrong decisions when identity state is incomplete. Without enforcement modes, users cannot choose their security/availability tradeoff. 585 586 **Success metrics:** 587 - Observe mode: never blocks updates, always logs warnings 588 - Enforce mode: rejects updates when identity state is unavailable or known-stale 589 - Gossip-informed staleness: warns when local identity repo tip differs from announced tip 590 - `min_kel_seq` correctly enforces binding integrity (not freshness) 591 592 **Exit criteria:** 593 - All mode/staleness combinations tested 594 - Stale-state test matrix passes 595 - Warning and quarantine messages include actionable information 596 597 #### Tasks 598 599 **Task 4.1: Enforcement mode configuration** 600 - *Why:* Projects must choose their security/availability tradeoff 601 - *Acceptance criteria:* Bridge accepts an enforcement mode (observe/enforce) per verification call. In observe mode, `Rejected` results are downgraded to `Warn`. In enforce mode, `Rejected` and `Quarantine` are hard rejections. 602 - *Test plan:* 603 - Unit: observe mode + revoked device -> `Warn` (not `Rejected`) 604 - Unit: enforce mode + revoked device -> `Rejected` 605 - Unit: observe mode + missing identity repo -> `Warn` 606 - Unit: enforce mode + missing identity repo -> `Quarantine` 607 - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs` 608 609 **Task 4.2: Gossip-informed staleness detection** 610 - *Why:* `min_kel_seq` doesn't detect the "Node B at seq 3, revocation at seq 4" case. We need a better signal. 611 - *Acceptance criteria:* Bridge accepts an optional `known_remote_tip: Option<Oid>` (the latest identity repo tip OID seen via gossip). If provided and differs from the local identity repo tip, the bridge returns a staleness warning (`Warn` in observe, `Quarantine` in enforce). 612 - *Test plan:* 613 - Unit: local tip == remote tip -> no staleness warning 614 - Unit: local tip != remote tip -> staleness detected 615 - Unit: no remote tip known (disconnected) -> no staleness warning (can't know) 616 - Unit: remote tip provided, identity repo missing locally -> staleness + missing state 617 - *Affected areas:* `crates/auths-radicle/src/verify.rs` 618 619 **Task 4.3: Binding integrity via `min_kel_seq`** 620 - *Why:* Prevents accepting identity state that predates the project binding (e.g., attacker feeding truncated KEL) 621 - *Acceptance criteria:* Bridge compares `local_kel_sequence` against `min_kel_seq` from the project binding. If local < minimum, the result is `Rejected` (not just `Warn`) -- this is a tamper indicator, not a freshness signal. 622 - *Test plan:* 623 - Unit: local seq 5, min seq 2 -> passes 624 - Unit: local seq 2, min seq 2 -> passes (at minimum) 625 - Unit: local seq 1, min seq 2 -> `Rejected` (binding integrity violation) 626 - Unit: local seq 0 (only inception), min seq 3 -> `Rejected` 627 - *Affected areas:* `crates/auths-radicle/src/verify.rs` 628 629 **Task 4.4: Stale-state integration tests** 630 - *Why:* Must prove the system behaves correctly in the Node A vs Node B scenario under both modes 631 - *Acceptance criteria:* Integration tests simulating two nodes with different identity state, verifying correct behavior per mode 632 - *Test plan:* 633 - Integration (observe): stale node accepts revoked device's update with `Warn`; after sync, rejects 634 - Integration (enforce, staleness detected): stale node quarantines; after sync, rejects 635 - Integration (enforce, no staleness signal): stale node accepts (irreducible risk); after sync, rejects 636 - Integration: node with identity repo below `min_kel_seq` -> `Rejected` in both modes 637 - Tamper: forged KEL event mid-chain -> `validate_kel()` fails -> `Rejected` regardless of mode 638 - *Affected areas:* `crates/auths-radicle/tests/` 639 640 --- 641 642 ### Epic 5: Minimal Radicle Integration Seam 643 644 **Objective:** Wire the bridge into Heartwood's fetch pipeline at the point defined by fn-3 and fn-4. 645 646 **Why it matters:** This is where the bridge actually gets called. The seam must align with Heartwood's existing epic plan. 647 648 **Success metrics:** 649 - `DeviceAuthorization` (fn-3) calls `auths-radicle::DefaultBridge::verify_signer()` 650 - Fetch pipeline (fn-4) passes enforcement mode and gossip tip to the bridge 651 - Projects without KERI delegates are unaffected 652 653 **Exit criteria:** 654 - Heartwood's existing test suite passes 655 - Projects with KERI delegates trigger bridge verification 656 - Observe/enforce mode is respected in the fetch pipeline 657 658 #### Tasks 659 660 **Task 5.1: Wire `DefaultBridge` into `DeviceAuthorization` (fn-3.2 counterpart)** 661 - *Why:* fn-3.2 in Heartwood creates `CompositeAuthorityChecker` which calls into auths-radicle. This task ensures the bridge API matches what Heartwood expects. 662 - *Acceptance criteria:* `DefaultBridge<RadicleAuthsStorage>::verify_signer()` signature is compatible with fn-3.2's expected API. Accepts `node_id: &[u8; 32]`, `repo_id: &str`, `now: DateTime<Utc>`, `enforcement_mode: Mode`, `known_remote_tip: Option<Oid>`. 663 - *Test plan:* 664 - Unit: mock Heartwood caller invokes bridge with correct parameter types 665 - Integration: end-to-end from `DeviceAuthorization::is_authorized()` through bridge to result 666 - *Affected areas:* `crates/auths-radicle/src/bridge.rs`, `crates/auths-radicle/src/verify.rs` 667 668 **Task 5.2: Pass gossip state to bridge in fetch pipeline (fn-4 counterpart)** 669 - *Why:* The bridge needs the `known_remote_tip` from gossip to detect staleness. This is available in the fetch pipeline from `RefsAnnouncement` data. 670 - *Acceptance criteria:* When the fetch pipeline calls the bridge, it includes the latest announced tip OID for the identity repo (if known from prior `RefsAnnouncement`s). 671 - *Test plan:* 672 - Integration: fetch with gossip-announced identity repo tip -> bridge receives it 673 - Integration: fetch without prior gossip data -> bridge receives `None` 674 - *Affected areas:* Heartwood: `crates/radicle-fetch/src/state.rs`. Auths: `crates/auths-radicle/src/bridge.rs` 675 676 --- 677 678 ### Epic 6: End-to-End Demo and Test Scenarios 679 680 **Objective:** Prove the integration works in realistic scenarios including the stale-node case. 681 682 **Why it matters:** End-to-end tests are the only way to prove the system works across crate boundaries and under realistic conditions. 683 684 **Success metrics:** 685 - All three demo scenarios (authorization, revocation, stale node) pass under both modes 686 - Tests run in CI without external dependencies 687 688 **Exit criteria:** 689 - End-to-end test suite passes on all CI platforms 690 - Demo script is executable and produces documented output 691 692 #### Tasks 693 694 **Task 6.1: Multi-device authorization end-to-end test** 695 - *Why:* Proves device authorization works through the full stack 696 - *Acceptance criteria:* Test creates a KERI identity (inception event + KEL), creates a 2-way attestation in RIP-X format, creates a project with a DID namespace, signs an update from the authorized device, and verifies the bridge accepts it. 697 - *Test plan:* 698 - E2E: full flow from identity creation to update acceptance 699 - E2E: unauthorized device (no attestation) is rejected 700 - E2E: device with wrong capabilities is rejected 701 - *Affected areas:* `crates/auths-radicle/tests/` 702 703 **Task 6.2: Revocation end-to-end test** 704 - *Why:* Proves revocation stops a device from being authorized 705 - *Acceptance criteria:* Test creates identity, authorizes device, verifies acceptance, revokes device, verifies rejection. Tests under both observe and enforce modes. 706 - *Test plan:* 707 - E2E: device accepted before revocation, rejected after (enforce mode) 708 - E2E: device accepted before revocation, warned after (observe mode) 709 - E2E: revocation of one device does not affect other authorized devices 710 - E2E: re-authorization after revocation (new attestation) works 711 - *Affected areas:* `crates/auths-radicle/tests/` 712 713 **Task 6.3: Stale-node end-to-end test** 714 - *Why:* Proves the system behaves safely under the core adversarial scenario 715 - *Acceptance criteria:* Test simulates two nodes with different identity state and gossip knowledge. Verifies correct behavior per enforcement mode. 716 - *Test plan:* 717 - E2E (observe): stale node accepts with `Warn`, converges to `Rejected` after sync 718 - E2E (enforce, staleness detected): stale node quarantines, resolves after sync 719 - E2E (enforce, no staleness signal): stale node accepts (irreducible risk, document this) 720 - E2E: node with identity repo below `min_kel_seq` -> hard reject in both modes 721 - Tamper: forged KEL event -> `Rejected` regardless of mode 722 - *Affected areas:* `crates/auths-radicle/tests/` 723 724 **Task 6.4: CI integration** 725 - *Why:* All tests must run in CI to prevent regressions 726 - *Acceptance criteria:* CI workflow runs the auths-radicle test suite. Tests work on Ubuntu, macOS, and Windows. 727 - *Test plan:* 728 - CI: all tests pass on all three platforms 729 - CI: tests complete within reasonable time (< 5 minutes) 730 - *Affected areas:* `.github/workflows/`, `crates/auths-radicle/` 731 732 --- 733 734 ### PR Slicing Plan 735 736 The work is sliced into 10 PRs, ordered by dependency. PRs target the **auths** repository unless noted. 737 738 | PR | Title | Epic | Description | Depends on | 739 |----|-------|------|-------------|------------| 740 | **PR 1** | RIP-X ref path constants and attestation format | 1 | `refs.rs` with RIP-X constants. `Attestation::to_bytes()` / `from_bytes()` for 2-blob format. | None | 741 | **PR 2** | `GitKel::with_ref()` for custom KEL ref paths | 1 | Allow auths-id's KEL reader to use `refs/keri/kel` instead of default path. | None | 742 | **PR 3** | `AuthsStorage` impl for Radicle-replicated repos | 2 | Load KEL + attestations from identity repos in RIP-X layout. | PR 1, PR 2 | 743 | **PR 4** | `find_identity_for_device()` and DID namespace scanning | 2 | Scan project namespaces to find which identity a device belongs to. | PR 3 | 744 | **PR 5** | Full authorization pipeline | 3 | Wire `verify_signer()` through the Radicle-backed storage. All auth scenarios tested. | PR 3, PR 4 | 745 | **PR 6** | Observe/enforce modes + gossip-informed staleness | 4 | Enforcement mode config, `known_remote_tip` staleness, `min_kel_seq` binding integrity. | PR 5 | 746 | **PR 7** | Stale-state integration tests | 4 | Node A vs Node B simulation under both modes. Tamper tests. | PR 6 | 747 | **PR 8** | Bridge API alignment with Heartwood fn-3.2 | 5 | Ensure bridge signature matches `DeviceAuthorization` expectations. Gossip tip passthrough. | PR 6 | 748 | **PR 9** | End-to-end tests | 6 | Full E2E: authorization, revocation, stale-node under both modes. | PR 7, PR 8 | 749 | **PR 10** | CI integration and demo script | 6 | CI workflow, demo documentation, cross-platform verification. | PR 9 | 750 751 ``` 752 PR 1 ──┐ 753 PR 2 ──┼──► PR 3 ──► PR 4 ──► PR 5 ──► PR 6 ──► PR 7 ──► PR 9 ──► PR 10 754 │ │ ▲ 755 │ └──► PR 8 ────────┘ 756 ``` 757 758 --- 759 760 ## 8. Demo Script 761 762 **Title:** "Multi-device authorization, revocation, and stale-node safety in 5 minutes" 763 764 **Prerequisites:** Two terminal windows (simulating two nodes). Radicle CLI with KERI support installed (fn-5 commands available). Both nodes running (`rad node start`). 765 766 --- 767 768 **Act 1: Setup and Authorization (Node A = Alice, Node B = Bob)** 769 770 > "Alice creates her KERI identity and authorizes her laptop." 771 772 On Node A: 773 ``` 774 rad identity init-keri 775 # Shows: Created KERI identity did:keri:EXq5... 776 # Shows: Identity repo rad:z3gq... seeded to network 777 # Shows: Current device automatically attested (KEL sequence 1) 778 779 rad identity device add --key <laptop-nid> 780 # Shows: 2-way attestation created for <laptop-nid> 781 # Shows: Attestation anchored at KEL sequence 2 782 # Shows: Run `rad identity device confirm` on the laptop to complete 783 ``` 784 785 On Laptop (complete 2-way attestation): 786 ``` 787 rad identity device confirm --identity did:keri:EXq5... 788 # Shows: Device attestation signed and stored 789 ``` 790 791 > "She creates a project with her KERI identity as a delegate." 792 793 On Node A: 794 ``` 795 mkdir my-project && cd my-project && git init && echo "hello" > README.md && git add . && git commit -m "init" 796 rad init --name "my-project" 797 rad id update --allow did:keri:EXq5... 798 # Shows: Project delegate added: did:keri:EXq5... 799 # Shows: DID namespace refs/namespaces/did-keri-EXq5.../refs/rad/id -> rad:z3gq... 800 ``` 801 802 > "Bob seeds Alice's project. His node auto-discovers and fetches the identity repo." 803 804 On Node B: 805 ``` 806 rad seed rad:<project-rid> 807 rad sync --fetch 808 # Shows: Fetched project rad:<project-rid> 809 # Shows: Discovered KERI identity did:keri:EXq5... -> fetching identity repo rad:z3gq... 810 # Shows: Identity repo fetched, KEL at sequence 2 811 ``` 812 813 > "Alice's laptop pushes a commit." 814 815 On Laptop: 816 ``` 817 cd my-project 818 echo "feature code" > feature.rs 819 git add . && git commit -m "Add feature" 820 git push rad main 821 ``` 822 823 > "Bob's node accepts it -- the laptop is authorized via the KERI identity." 824 825 On Node B: 826 ``` 827 rad sync --fetch 828 # Shows: Fetched 1 update for my-project 829 # Shows: Signer <laptop-nid> VERIFIED (attested under did:keri:EXq5...) 830 ``` 831 832 **Act 2: Revocation** 833 834 > "Alice discovers her laptop was compromised. She revokes it." 835 836 On Node A: 837 ``` 838 rad identity device revoke <laptop-nid> 839 # Shows: Device <laptop-nid> revoked at 2026-03-01T14:00:00Z 840 # Shows: Revocation anchored at KEL sequence 3 841 # Shows: Updated identity repo pushed to network 842 ``` 843 844 > "Bob syncs and gets the revocation." 845 846 On Node B: 847 ``` 848 rad sync --fetch 849 # Shows: Identity repo rad:z3gq... updated to KEL sequence 3 850 # Shows: Device <laptop-nid> marked as revoked 851 ``` 852 853 > "The compromised laptop tries to push again." 854 855 On Laptop (simulating compromised device): 856 ``` 857 echo "malicious code" > backdoor.rs 858 git add . && git commit -m "Innocent update" 859 git push rad main 860 ``` 861 862 > "Bob's node rejects it (enforce mode)." 863 864 On Node B: 865 ``` 866 rad sync --fetch 867 # Shows: Signer <laptop-nid> REJECTED (device revoked at 2026-03-01T14:00:00Z) 868 # Shows: 0 updates applied from <laptop-nid> 869 ``` 870 871 **Act 3: Stale Node Safety** 872 873 > "Charlie is a new node that seeds the project but hasn't yet fetched the latest identity repo." 874 875 On Node C (or simulate by pausing identity repo sync on Node B): 876 ``` 877 rad seed rad:<project-rid> 878 # Charlie fetches the project but his identity repo is stale (sequence 2, pre-revocation) 879 # Meanwhile, gossip has announced a newer identity repo tip... 880 ``` 881 882 > "The compromised laptop pushes to Charlie." 883 884 ``` 885 rad sync --fetch 886 ``` 887 888 **Enforce mode (recommended):** 889 ``` 890 # Shows: Signer <laptop-nid> QUARANTINED 891 # Shows: Identity repo rad:z3gq... has newer tip available (announced via gossip) 892 # Shows: Fetch identity repo before accepting updates from KERI-attested devices 893 # Shows: 0 updates applied 894 ``` 895 896 > "Charlie fetches the identity repo and the quarantine resolves." 897 898 ``` 899 rad sync --fetch # Now fetches identity repo 900 # Shows: Identity repo rad:z3gq... updated to KEL sequence 3 901 # Shows: Device <laptop-nid> is revoked -- quarantined update would have been rejected anyway 902 ``` 903 904 **Observe mode (alternative):** 905 ``` 906 # Shows: Signer <laptop-nid> VERIFIED (WARNING: identity state may be stale) 907 # Shows: Warning: identity repo rad:z3gq... has newer tip available 908 # Shows: 1 update applied with warning 909 910 # After syncing identity repo: 911 # Shows: Device <laptop-nid> revoked -- previously-accepted update flagged for review 912 ``` 913 914 > "Key takeaways:" 915 > 1. "In enforce mode, the stale node quarantined the update because it knew (via gossip) that its identity state was behind. No malicious data was accepted." 916 > 2. "In observe mode, the update was accepted but flagged. The system detected and logged the risk. After convergence, all nodes agree." 917 > 3. "The irreducible risk: a fully-disconnected node with no gossip signal cannot know it's stale. This is fundamental to any eventually-consistent system. The mitigation is to seed identity repos and maintain gossip connectivity."