An easy-to-host PDS on the ATProtocol, MacOS. Grandma-approved.

feat(crypto): add verify_genesis_op and VerifiedGenesisOp (MM-90 Phase 1, step 1)

authored by malpercio.dev and committed by

Tangled ce48e442 faa9bb76

+3785 -5
+1 -1
crates/crypto/src/lib.rs
··· 9 9 pub use keys::{ 10 10 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 11 11 }; 12 - pub use plc::{build_did_plc_genesis_op, PlcGenesisOp}; 12 + pub use plc::{build_did_plc_genesis_op, verify_genesis_op, PlcGenesisOp, VerifiedGenesisOp}; 13 13 pub use shamir::{combine_shares, split_secret, ShamirShare};
+128 -4
crates/crypto/src/plc.rs
··· 25 25 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 26 26 use ciborium::ser::into_writer; 27 27 use p256::{ 28 - ecdsa::{signature::Signer, Signature, SigningKey}, 28 + ecdsa::{signature::Signer, signature::Verifier, Signature, SigningKey, VerifyingKey}, 29 29 FieldBytes, 30 30 }; 31 - use serde::Serialize; 31 + use serde::{Deserialize, Serialize}; 32 32 use sha2::{Digest, Sha256}; 33 + use multibase; 33 34 34 35 use crate::{CryptoError, DidKeyUri}; 35 36 ··· 44 45 pub signed_op_json: String, 45 46 } 46 47 48 + /// P-256 multicodec varint prefix for did:key URIs. 49 + /// 0x1200 encoded as LEB128 varint = [0x80, 0x24]. 50 + /// 51 + /// This constant is redefined here rather than promoted to `pub(crate)` in 52 + /// `keys.rs` to avoid cross-module coupling between two sibling functional 53 + /// modules. Each module owns its own copy; if the value ever needs to change, 54 + /// both sites are easy to find via the shared literal `[0x80, 0x24]`. 55 + const P256_MULTICODEC_PREFIX: &[u8] = &[0x80, 0x24]; 56 + 47 57 // ── Internal serialization types ──────────────────────────────────────────── 48 58 // 49 59 // Field declaration order matches DAG-CBOR canonical ordering: ··· 57 67 // "rotationKeys" → 12 bytes 58 68 // "verificationMethods" → 19 bytes 59 69 60 - #[derive(Serialize, Clone)] 70 + #[derive(Serialize, Deserialize, Clone)] 61 71 struct PlcService { 62 72 // "type" → 4 bytes 63 73 #[serde(rename = "type")] ··· 89 99 // "rotationKeys" → 12 bytes 90 100 // "verificationMethods" → 19 bytes 91 101 92 - #[derive(Serialize)] 102 + #[derive(Serialize, Deserialize)] 103 + #[serde(deny_unknown_fields)] 93 104 struct SignedPlcOp { 94 105 sig: String, 95 106 prev: Option<String>, ··· 106 117 107 118 // ── Public API ─────────────────────────────────────────────────────────────── 108 119 120 + /// The result of verifying a client-submitted did:plc genesis operation. 121 + /// 122 + /// Returned by [`verify_genesis_op`]. Fields are extracted directly from the 123 + /// verified signed op; the relay uses them for semantic validation and DID 124 + /// document construction. 125 + pub struct VerifiedGenesisOp { 126 + /// The derived DID, e.g. `"did:plc:abcdefghijklmnopqrstuvwx"`. 127 + pub did: String, 128 + /// Full `rotationKeys` array from the op. 129 + pub rotation_keys: Vec<String>, 130 + /// Full `alsoKnownAs` array from the op. 131 + pub also_known_as: Vec<String>, 132 + /// Full `verificationMethods` map from the op. 133 + pub verification_methods: BTreeMap<String, String>, 134 + /// Endpoint from `services["atproto_pds"]`, if present. 135 + pub atproto_pds_endpoint: Option<String>, 136 + } 137 + 109 138 /// Build and sign a did:plc genesis operation, returning the signed operation 110 139 /// JSON and the derived DID. 111 140 /// ··· 202 231 Ok(PlcGenesisOp { 203 232 did, 204 233 signed_op_json, 234 + }) 235 + } 236 + 237 + /// Verify a client-submitted did:plc signed genesis operation. 238 + /// 239 + /// Parses `signed_op_json` into a [`SignedPlcOp`] (rejecting unknown fields), 240 + /// reconstructs the unsigned operation with the same DAG-CBOR field ordering 241 + /// as [`build_did_plc_genesis_op`], verifies the ECDSA-SHA256 signature against 242 + /// `rotation_key`, derives the DID (SHA-256 of signed CBOR → base32-lowercase 243 + /// first 24 chars), and returns the extracted operation fields. 244 + /// 245 + /// # Errors 246 + /// Returns `CryptoError::PlcOperation` for any parse, format, or cryptographic failure. 247 + pub fn verify_genesis_op( 248 + signed_op_json: &str, 249 + rotation_key: &DidKeyUri, 250 + ) -> Result<VerifiedGenesisOp, CryptoError> { 251 + // Step 1: Parse the signed op, rejecting unknown fields (AC1.5). 252 + let signed_op: SignedPlcOp = serde_json::from_str(signed_op_json) 253 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signed op JSON: {e}")))?; 254 + 255 + // Step 2: Base64url-decode the signature field. 256 + let sig_bytes = URL_SAFE_NO_PAD 257 + .decode(&signed_op.sig) 258 + .map_err(|e| CryptoError::PlcOperation(format!("invalid sig base64url: {e}")))?; 259 + 260 + // Step 3: Parse the 64-byte r‖s fixed-size ECDSA signature. 261 + let signature = Signature::try_from(sig_bytes.as_slice()) 262 + .map_err(|e| CryptoError::PlcOperation(format!("invalid ECDSA signature bytes: {e}")))?; 263 + 264 + // Step 4: Reconstruct the unsigned operation from signed op fields. 265 + // Field order must match UnsignedPlcOp's DAG-CBOR canonical ordering. 266 + let unsigned_op = UnsignedPlcOp { 267 + prev: signed_op.prev.clone(), 268 + op_type: signed_op.op_type.clone(), 269 + services: signed_op.services.clone(), 270 + also_known_as: signed_op.also_known_as.clone(), 271 + rotation_keys: signed_op.rotation_keys.clone(), 272 + verification_methods: signed_op.verification_methods.clone(), 273 + }; 274 + 275 + // Step 5: CBOR-encode the unsigned op — byte-exact match to what was signed. 276 + let mut unsigned_cbor = Vec::new(); 277 + into_writer(&unsigned_op, &mut unsigned_cbor) 278 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 279 + 280 + // Step 6: Parse rotation key URI → P-256 VerifyingKey. 281 + let key_str = rotation_key 282 + .0 283 + .strip_prefix("did:key:") 284 + .ok_or_else(|| { 285 + CryptoError::PlcOperation("rotation key missing did:key: prefix".to_string()) 286 + })?; 287 + let (_, multikey_bytes) = multibase::decode(key_str) 288 + .map_err(|e| CryptoError::PlcOperation(format!("decode rotation key multibase: {e}")))?; 289 + if multikey_bytes.get(..2) != Some(P256_MULTICODEC_PREFIX) { 290 + return Err(CryptoError::PlcOperation( 291 + "rotation key is not a P-256 key (wrong multicodec prefix)".to_string(), 292 + )); 293 + } 294 + let verifying_key = VerifyingKey::from_sec1_bytes(&multikey_bytes[2..]) 295 + .map_err(|e| CryptoError::PlcOperation(format!("invalid P-256 public key: {e}")))?; 296 + 297 + // Step 7: Verify the ECDSA-SHA256 signature (SHA-256 applied internally by p256). 298 + verifying_key 299 + .verify(&unsigned_cbor, &signature) 300 + .map_err(|e| CryptoError::PlcOperation(format!("signature verification failed: {e}")))?; 301 + 302 + // Step 8: CBOR-encode the signed op and derive the DID. 303 + let mut signed_cbor = Vec::new(); 304 + into_writer(&signed_op, &mut signed_cbor) 305 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 306 + 307 + let hash = Sha256::digest(&signed_cbor); 308 + let base32_encoding = { 309 + let mut spec = data_encoding::Specification::new(); 310 + spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 311 + spec.encoding() 312 + .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 313 + }; 314 + let encoded = base32_encoding.encode(hash.as_ref()); 315 + let did = format!("did:plc:{}", &encoded[..24]); 316 + 317 + // Step 9: Extract atproto_pds endpoint from services map. 318 + let atproto_pds_endpoint = signed_op 319 + .services 320 + .get("atproto_pds") 321 + .map(|s| s.endpoint.clone()); 322 + 323 + Ok(VerifiedGenesisOp { 324 + did, 325 + rotation_keys: signed_op.rotation_keys, 326 + also_known_as: signed_op.also_known_as, 327 + verification_methods: signed_op.verification_methods, 328 + atproto_pds_endpoint, 205 329 }) 206 330 } 207 331
+677
docs/implementation-plans/2026-03-13-MM-89/phase_01.md
··· 1 + # MM-89: DID Creation — did:plc via PLC Directory Proxy — Implementation Plan 2 + 3 + **Goal:** Implement `build_did_plc_genesis_op` as a pure function in the `crypto` crate that produces a signed did:plc genesis operation and derives the resulting DID. 4 + 5 + **Architecture:** Pure Functional Core in `crates/crypto/src/plc.rs`. No I/O, no HTTP, no DB. Takes key material and identity fields; returns a signed operation JSON string and the derived DID. The relay (Phase 2) will call this function and handle all I/O. 6 + 7 + **Tech Stack:** Rust stable; `p256` 0.13 (ECDSA-SHA256, RFC 6979), `ciborium` 0.2 (CBOR serialization), `data-encoding` 2 (base32-lowercase), `sha2` 0.10 (SHA-256), `base64` 0.21 (base64url), `serde`/`serde_json` 1 (struct serialization) 8 + 9 + **Scope:** Phase 1 of 2 from the original design 10 + 11 + **Codebase verified:** 2026-03-13 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-89.AC1: crypto crate produces a valid did:plc genesis operation 20 + - **MM-89.AC1.1 Success:** `build_did_plc_genesis_op` with valid inputs returns `PlcGenesisOp` with `did` matching `^did:plc:[a-z2-7]{24}$` 21 + - **MM-89.AC1.2 Success:** `signed_op_json` contains all required fields: `type`, `rotationKeys`, `verificationMethods`, `alsoKnownAs`, `services`, `prev` (null), `sig` 22 + - **MM-89.AC1.3 Success:** `rotation_key` appears as `rotationKeys[0]`; `signing_key` appears as both `rotationKeys[1]` and `verificationMethods.atproto` 23 + - **MM-89.AC1.4 Success:** Calling `build_did_plc_genesis_op` twice with identical inputs returns the same `did` (RFC 6979 determinism) 24 + - **MM-89.AC1.5 Failure:** Invalid `signing_private_key` bytes (wrong length or invalid scalar) returns `CryptoError::PlcOperation` 25 + 26 + ### MM-89.AC3: Schema migration and protocol correctness 27 + - **MM-89.AC3.2:** `sig` field in `signed_op_json` is a base64url string (no padding) decoding to exactly 64 bytes 28 + - **MM-89.AC3.3:** `alsoKnownAs` in `signed_op_json` contains `at://{handle}` (not bare handle) 29 + 30 + --- 31 + 32 + ## External Dependency Research Findings 33 + 34 + - ✓ **ciborium 0.2**: `ciborium::ser::into_writer(&value, &mut buf)` serializes serde-compatible structs. Struct fields serialized in declaration order — MUST match DAG-CBOR canonical ordering (sort by key byte length, then alphabetically). 35 + - ✓ **data-encoding 2**: No built-in lowercase base32 constant. Must build via `Specification::new()` with alphabet `"abcdefghijklmnopqrstuvwxyz234567"`. Take `[0..24]` of result for DID suffix. 36 + - ✓ **p256 0.13 (ecdsa feature)**: `SigningKey::from_bytes(&FieldBytes)` from 32-byte scalar. RFC 6979 deterministic by default. `Signer::sign(&bytes)` internally SHA-256 hashes and signs. `sig.to_bytes()` → `[u8; 64]` (r‖s, big-endian). Low-S canonical automatically applied. 37 + - ✓ **base64 0.21** (already in workspace): `URL_SAFE_NO_PAD.encode(&bytes)` for base64url without padding. 38 + - ✓ **did:plc spec**: `type` = `"plc_operation"`. `prev` must be `null` (present, not omitted). Sig absent during signing CBOR, present in final JSON. DID derived from SHA-256 of **signed** CBOR op (with sig field). POST target is `https://plc.directory/{did}`. 39 + - ⚠ **DAG-CBOR note**: did:plc spec requires DAG-CBOR (IPLD-canonical). ciborium produces regular CBOR. For determinism, struct fields must be declared in DAG-CBOR canonical order (length-then-alpha). This implementation's DID derivation will be consistent with itself but must be validated against a real plc.directory in Phase 2 integration tests. 40 + 41 + --- 42 + 43 + <!-- START_SUBCOMPONENT_A (tasks 1-3) --> 44 + 45 + <!-- START_TASK_1 --> 46 + ### Task 1: Add workspace and crate Cargo.toml dependencies 47 + 48 + **Verifies:** None (infrastructure) 49 + 50 + **Files:** 51 + - Modify: `Cargo.toml` (workspace root) 52 + - Modify: `crates/crypto/Cargo.toml` 53 + 54 + **Step 1: Add ciborium and data-encoding to workspace Cargo.toml** 55 + 56 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add these two lines after the existing `base64` entry: 57 + 58 + ```toml 59 + ciborium = "0.2" 60 + data-encoding = "2" 61 + ``` 62 + 63 + **Step 2: Add new deps to crates/crypto/Cargo.toml** 64 + 65 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/Cargo.toml`, add to the `[dependencies]` section: 66 + 67 + ```toml 68 + ciborium = { workspace = true } 69 + data-encoding = { workspace = true } 70 + serde = { workspace = true } 71 + sha2 = { workspace = true } 72 + ``` 73 + 74 + The file after edits: 75 + 76 + ```toml 77 + [package] 78 + name = "crypto" 79 + version.workspace = true 80 + edition.workspace = true 81 + publish.workspace = true 82 + 83 + # crypto: signing, Shamir secret sharing, DID operations. 84 + # Depends on rsky-crypto (added when Wave 3 DID/key work begins). 85 + 86 + [dependencies] 87 + p256 = { workspace = true } 88 + aes-gcm = { workspace = true } 89 + multibase = { workspace = true } 90 + rand_core = { workspace = true } 91 + base64 = { workspace = true } 92 + thiserror = { workspace = true } 93 + zeroize = { workspace = true } 94 + ciborium = { workspace = true } 95 + data-encoding = { workspace = true } 96 + serde = { workspace = true } 97 + sha2 = { workspace = true } 98 + ``` 99 + 100 + **Step 3: Verify build resolves** 101 + 102 + ```bash 103 + cargo check -p crypto 104 + ``` 105 + 106 + Expected: resolves without errors (plc module not yet created, but deps should resolve). 107 + 108 + **Step 4: Commit** 109 + 110 + ```bash 111 + git add Cargo.toml Cargo.lock crates/crypto/Cargo.toml 112 + git commit -m "chore(crypto): add ciborium, data-encoding, sha2, serde deps for did:plc" 113 + ``` 114 + <!-- END_TASK_1 --> 115 + 116 + <!-- START_TASK_2 --> 117 + ### Task 2: Add CryptoError::PlcOperation variant, implement plc.rs, and update lib.rs 118 + 119 + **Verifies:** MM-89.AC1.1, MM-89.AC1.2, MM-89.AC1.3, MM-89.AC1.4, MM-89.AC1.5, MM-89.AC3.2, MM-89.AC3.3 120 + 121 + **Files:** 122 + - Modify: `crates/crypto/src/error.rs` (add variant) 123 + - Create: `crates/crypto/src/plc.rs` (new file — pure Functional Core) 124 + - Modify: `crates/crypto/src/lib.rs` (add module + re-exports) 125 + 126 + --- 127 + 128 + **Step 1: Add PlcOperation variant to error.rs** 129 + 130 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/error.rs`, add the new variant to the `CryptoError` enum: 131 + 132 + ```rust 133 + #[derive(Debug, thiserror::Error)] 134 + pub enum CryptoError { 135 + #[error("key generation failed: {0}")] 136 + KeyGeneration(String), 137 + #[error("encryption failed: {0}")] 138 + Encryption(String), 139 + #[error("decryption failed: {0}")] 140 + Decryption(String), 141 + #[error("secret sharing failed: {0}")] 142 + SecretSharing(String), 143 + #[error("secret reconstruction failed: {0}")] 144 + SecretReconstruction(String), 145 + #[error("plc operation failed: {0}")] 146 + PlcOperation(String), 147 + } 148 + ``` 149 + 150 + --- 151 + 152 + **Step 2: Create crates/crypto/src/plc.rs** 153 + 154 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/plc.rs` with this content: 155 + 156 + ```rust 157 + // pattern: Functional Core 158 + // 159 + // Pure did:plc genesis operation builder. No I/O, no HTTP, no DB. 160 + // Builds a signed genesis operation from key material and identity fields, 161 + // derives the DID, and returns both for use by the relay's imperative shell. 162 + // 163 + // Algorithm: 164 + // 1. Construct UnsignedPlcOp (fields in DAG-CBOR canonical order) 165 + // 2. CBOR-encode unsigned op (ciborium) 166 + // 3. ECDSA-SHA256 sign the CBOR bytes (p256, RFC 6979 deterministic, low-S) 167 + // 4. base64url-encode the 64-byte r‖s signature 168 + // 5. Construct SignedPlcOp (same fields + sig) 169 + // 6. CBOR-encode signed op 170 + // 7. SHA-256 hash of signed CBOR 171 + // 8. base32-lowercase first 24 chars → DID suffix 172 + // 9. JSON-serialize signed op → signed_op_json 173 + // 174 + // References: 175 + // - did:plc spec v0.1: https://web.plc.directory/spec/v0.1/did-plc 176 + // - RFC 6979: deterministic ECDSA nonce generation 177 + // - DAG-CBOR: map keys sorted by byte-length then alphabetically 178 + 179 + use std::collections::BTreeMap; 180 + 181 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 182 + use ciborium::ser::into_writer; 183 + use p256::{ 184 + FieldBytes, 185 + ecdsa::{SigningKey, Signature, signature::Signer}, 186 + }; 187 + use serde::Serialize; 188 + use sha2::{Digest, Sha256}; 189 + 190 + use crate::{CryptoError, DidKeyUri}; 191 + 192 + /// The result of building a did:plc genesis operation. 193 + pub struct PlcGenesisOp { 194 + /// The derived DID, e.g. `"did:plc:abcdefghijklmnopqrstuvwx"`. 195 + /// Ready to use as the database primary key. 196 + pub did: String, 197 + /// The signed genesis operation as a JSON string. 198 + /// Ready to POST to plc.directory. 199 + pub signed_op_json: String, 200 + } 201 + 202 + // ── Internal serialization types ──────────────────────────────────────────── 203 + // 204 + // Field declaration order matches DAG-CBOR canonical ordering: 205 + // sort by UTF-8 byte length of the serialized key name, then alphabetically. 206 + // 207 + // For UnsignedPlcOp key lengths: 208 + // "prev" → 4 bytes 209 + // "type" → 4 bytes ("prev" < "type" alphabetically) 210 + // "services" → 8 bytes 211 + // "alsoKnownAs" → 11 bytes 212 + // "rotationKeys" → 12 bytes 213 + // "verificationMethods" → 19 bytes 214 + 215 + #[derive(Serialize, Clone)] 216 + struct PlcService { 217 + // "type" → 4 bytes 218 + #[serde(rename = "type")] 219 + service_type: String, 220 + // "endpoint" → 8 bytes 221 + endpoint: String, 222 + } 223 + 224 + #[derive(Serialize)] 225 + struct UnsignedPlcOp { 226 + prev: Option<String>, 227 + #[serde(rename = "type")] 228 + op_type: String, 229 + services: BTreeMap<String, PlcService>, 230 + #[serde(rename = "alsoKnownAs")] 231 + also_known_as: Vec<String>, 232 + #[serde(rename = "rotationKeys")] 233 + rotation_keys: Vec<String>, 234 + #[serde(rename = "verificationMethods")] 235 + verification_methods: BTreeMap<String, String>, 236 + } 237 + 238 + // For SignedPlcOp key lengths (includes "sig"): 239 + // "sig" → 3 bytes (shortest — comes first) 240 + // "prev" → 4 bytes 241 + // "type" → 4 bytes 242 + // "services" → 8 bytes 243 + // "alsoKnownAs" → 11 bytes 244 + // "rotationKeys" → 12 bytes 245 + // "verificationMethods" → 19 bytes 246 + 247 + #[derive(Serialize)] 248 + struct SignedPlcOp { 249 + sig: String, 250 + prev: Option<String>, 251 + #[serde(rename = "type")] 252 + op_type: String, 253 + services: BTreeMap<String, PlcService>, 254 + #[serde(rename = "alsoKnownAs")] 255 + also_known_as: Vec<String>, 256 + #[serde(rename = "rotationKeys")] 257 + rotation_keys: Vec<String>, 258 + #[serde(rename = "verificationMethods")] 259 + verification_methods: BTreeMap<String, String>, 260 + } 261 + 262 + // ── Public API ─────────────────────────────────────────────────────────────── 263 + 264 + /// Build and sign a did:plc genesis operation, returning the signed operation 265 + /// JSON and the derived DID. 266 + /// 267 + /// # Parameters 268 + /// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`. 269 + /// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`. 270 + /// - `signing_private_key`: Raw 32-byte P-256 private key scalar for `signing_key`. 271 + /// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`. 272 + /// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`. 273 + /// 274 + /// # Errors 275 + /// Returns `CryptoError::PlcOperation` if `signing_private_key` is not a valid P-256 scalar 276 + /// (e.g. all-zero bytes, or a value ≥ the curve order). 277 + pub fn build_did_plc_genesis_op( 278 + rotation_key: &DidKeyUri, 279 + signing_key: &DidKeyUri, 280 + signing_private_key: &[u8; 32], 281 + handle: &str, 282 + service_endpoint: &str, 283 + ) -> Result<PlcGenesisOp, CryptoError> { 284 + // Step 1: Construct signing key from raw scalar bytes. 285 + let field_bytes: FieldBytes = (*signing_private_key).into(); 286 + let sk = SigningKey::from_bytes(&field_bytes) 287 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?; 288 + 289 + // Step 2: Build the unsigned operation. 290 + let mut verification_methods = BTreeMap::new(); 291 + verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 292 + 293 + let mut services = BTreeMap::new(); 294 + services.insert( 295 + "atproto_pds".to_string(), 296 + PlcService { 297 + service_type: "AtprotoPersonalDataServer".to_string(), 298 + endpoint: service_endpoint.to_string(), 299 + }, 300 + ); 301 + 302 + let unsigned_op = UnsignedPlcOp { 303 + prev: None, 304 + op_type: "plc_operation".to_string(), 305 + services: services.clone(), 306 + also_known_as: vec![format!("at://{handle}")], 307 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 308 + verification_methods: verification_methods.clone(), 309 + }; 310 + 311 + // Step 3: CBOR-encode the unsigned operation. 312 + let mut unsigned_cbor = Vec::new(); 313 + into_writer(&unsigned_op, &mut unsigned_cbor) 314 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 315 + 316 + // Step 4: ECDSA-SHA256 sign (RFC 6979 deterministic, low-S canonical). 317 + // Signer::sign internally hashes with SHA-256 before signing. 318 + let sig: Signature = sk.sign(&unsigned_cbor); 319 + let sig_bytes = sig.to_bytes(); 320 + 321 + // Step 5: base64url-encode the 64-byte r‖s signature (no padding). 322 + let sig_str = URL_SAFE_NO_PAD.encode(sig_bytes.as_ref()); 323 + 324 + // Step 6: Build the signed operation (same fields + sig). 325 + let signed_op = SignedPlcOp { 326 + sig: sig_str, 327 + prev: None, 328 + op_type: "plc_operation".to_string(), 329 + services, 330 + also_known_as: vec![format!("at://{handle}")], 331 + rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()], 332 + verification_methods, 333 + }; 334 + 335 + // Step 7: CBOR-encode the signed operation. 336 + let mut signed_cbor = Vec::new(); 337 + into_writer(&signed_op, &mut signed_cbor) 338 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 339 + 340 + // Step 8: SHA-256 hash of the signed CBOR. 341 + let hash = Sha256::digest(&signed_cbor); 342 + 343 + // Step 9: base32-lowercase, take first 24 characters. 344 + let base32_encoding = { 345 + let mut spec = data_encoding::Specification::new(); 346 + spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 347 + spec.encoding() 348 + .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 349 + }; 350 + let encoded = base32_encoding.encode(hash.as_ref()); 351 + let did = format!("did:plc:{}", &encoded[..24]); 352 + 353 + // Step 10: JSON-serialize the signed operation. 354 + let signed_op_json = serde_json::to_string(&signed_op) 355 + .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?; 356 + 357 + Ok(PlcGenesisOp { did, signed_op_json }) 358 + } 359 + 360 + // ── Tests ──────────────────────────────────────────────────────────────────── 361 + 362 + #[cfg(test)] 363 + mod tests { 364 + use super::*; 365 + use crate::generate_p256_keypair; 366 + 367 + /// Generates two test keypairs and calls build_did_plc_genesis_op with them. 368 + /// Returns (rotation_key, signing_key, private_key_bytes, result). 369 + fn make_genesis_op() -> (DidKeyUri, DidKeyUri, [u8; 32], PlcGenesisOp) { 370 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 371 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 372 + let private_key_bytes = *signing_kp.private_key_bytes; 373 + let result = build_did_plc_genesis_op( 374 + &rotation_kp.key_id, 375 + &signing_kp.key_id, 376 + &private_key_bytes, 377 + "alice.example.com", 378 + "https://relay.example.com", 379 + ) 380 + .expect("genesis op should succeed"); 381 + (rotation_kp.key_id, signing_kp.key_id, private_key_bytes, result) 382 + } 383 + 384 + /// MM-89.AC1.1: did matches ^did:plc:[a-z2-7]{24}$ 385 + #[test] 386 + fn did_matches_expected_format() { 387 + let (_, _, _, op) = make_genesis_op(); 388 + assert!( 389 + op.did.starts_with("did:plc:"), 390 + "DID should start with 'did:plc:'" 391 + ); 392 + let suffix = op.did.strip_prefix("did:plc:").unwrap(); 393 + assert_eq!(suffix.len(), 24, "DID suffix should be 24 chars"); 394 + assert!( 395 + suffix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)), 396 + "DID suffix should only contain [a-z2-7], got: {suffix}" 397 + ); 398 + } 399 + 400 + /// MM-89.AC1.2: signed_op_json contains all required fields with correct values 401 + #[test] 402 + fn signed_op_json_contains_required_fields() { 403 + let (_, _, _, op) = make_genesis_op(); 404 + let v: serde_json::Value = 405 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 406 + 407 + assert_eq!(v["type"], "plc_operation", "type field"); 408 + assert!(v["rotationKeys"].is_array(), "rotationKeys is array"); 409 + assert!( 410 + v["verificationMethods"].is_object(), 411 + "verificationMethods is object" 412 + ); 413 + assert!(v["alsoKnownAs"].is_array(), "alsoKnownAs is array"); 414 + assert!(v["services"].is_object(), "services is object"); 415 + assert_eq!(v["prev"], serde_json::Value::Null, "prev is null"); 416 + assert!(v["sig"].is_string(), "sig is string"); 417 + } 418 + 419 + /// MM-89.AC1.3: rotation_key at rotationKeys[0]; signing_key at rotationKeys[1] and verificationMethods.atproto 420 + #[test] 421 + fn keys_placed_in_correct_positions() { 422 + let (rotation_key, signing_key, _, op) = make_genesis_op(); 423 + let v: serde_json::Value = 424 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 425 + assert_eq!( 426 + v["rotationKeys"][0].as_str().unwrap(), 427 + rotation_key.0, 428 + "rotationKeys[0] should be rotation_key" 429 + ); 430 + assert_eq!( 431 + v["rotationKeys"][1].as_str().unwrap(), 432 + signing_key.0, 433 + "rotationKeys[1] should be signing_key" 434 + ); 435 + assert_eq!( 436 + v["verificationMethods"]["atproto"].as_str().unwrap(), 437 + signing_key.0, 438 + "verificationMethods.atproto should be signing_key" 439 + ); 440 + } 441 + 442 + /// MM-89.AC1.4: RFC 6979 determinism — same inputs produce same DID 443 + #[test] 444 + fn same_inputs_produce_same_did() { 445 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 446 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 447 + let private_key_bytes = *signing_kp.private_key_bytes; 448 + 449 + let op1 = build_did_plc_genesis_op( 450 + &rotation_kp.key_id, 451 + &signing_kp.key_id, 452 + &private_key_bytes, 453 + "alice.example.com", 454 + "https://relay.example.com", 455 + ) 456 + .expect("first call should succeed"); 457 + 458 + let op2 = build_did_plc_genesis_op( 459 + &rotation_kp.key_id, 460 + &signing_kp.key_id, 461 + &private_key_bytes, 462 + "alice.example.com", 463 + "https://relay.example.com", 464 + ) 465 + .expect("second call should succeed"); 466 + 467 + assert_eq!(op1.did, op2.did, "DID must be identical for same inputs"); 468 + assert_eq!( 469 + op1.signed_op_json, op2.signed_op_json, 470 + "signed_op_json must be identical for same inputs" 471 + ); 472 + } 473 + 474 + /// MM-89.AC1.5: Invalid signing key (all-zero scalar) returns CryptoError::PlcOperation 475 + #[test] 476 + fn invalid_signing_key_returns_error() { 477 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 478 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 479 + let zero_bytes = [0u8; 32]; // Zero scalar is invalid for P-256 480 + 481 + let result = build_did_plc_genesis_op( 482 + &rotation_kp.key_id, 483 + &signing_kp.key_id, 484 + &zero_bytes, 485 + "alice.example.com", 486 + "https://relay.example.com", 487 + ); 488 + 489 + assert!( 490 + matches!(result, Err(CryptoError::PlcOperation(_))), 491 + "Zero scalar should return CryptoError::PlcOperation, got: {result:?}" 492 + ); 493 + } 494 + 495 + /// MM-89.AC3.2: sig field is base64url (no padding) decoding to exactly 64 bytes 496 + #[test] 497 + fn sig_field_is_base64url_no_padding_and_64_bytes() { 498 + let (_, _, _, op) = make_genesis_op(); 499 + let v: serde_json::Value = 500 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 501 + let sig_str = v["sig"].as_str().expect("sig is a string"); 502 + 503 + // No padding characters 504 + assert!( 505 + !sig_str.contains('='), 506 + "sig should not contain padding '=', got: {sig_str}" 507 + ); 508 + // Decodes to exactly 64 bytes 509 + let decoded = URL_SAFE_NO_PAD 510 + .decode(sig_str) 511 + .expect("sig should be valid base64url"); 512 + assert_eq!( 513 + decoded.len(), 514 + 64, 515 + "sig should decode to 64 bytes (r‖s), got {} bytes", 516 + decoded.len() 517 + ); 518 + } 519 + 520 + /// MM-89.AC3.3: alsoKnownAs contains at://{handle} 521 + #[test] 522 + fn also_known_as_contains_at_uri() { 523 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 524 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 525 + let private_key_bytes = *signing_kp.private_key_bytes; 526 + 527 + let op = build_did_plc_genesis_op( 528 + &rotation_kp.key_id, 529 + &signing_kp.key_id, 530 + &private_key_bytes, 531 + "alice.example.com", 532 + "https://relay.example.com", 533 + ) 534 + .expect("genesis op should succeed"); 535 + 536 + let v: serde_json::Value = 537 + serde_json::from_str(&op.signed_op_json).expect("valid JSON"); 538 + let also_known_as = v["alsoKnownAs"].as_array().expect("alsoKnownAs is array"); 539 + assert!( 540 + also_known_as 541 + .iter() 542 + .any(|e| e.as_str() == Some("at://alice.example.com")), 543 + "alsoKnownAs should contain 'at://alice.example.com', got: {also_known_as:?}" 544 + ); 545 + } 546 + } 547 + ``` 548 + 549 + --- 550 + 551 + **Step 3: Add plc module to lib.rs and re-export public types** 552 + 553 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/src/lib.rs`, add the new module declaration and re-exports: 554 + 555 + ```rust 556 + // crypto: signing, Shamir secret sharing, DID operations. 557 + 558 + pub mod error; 559 + pub mod keys; 560 + pub mod plc; 561 + pub mod shamir; 562 + 563 + pub use error::CryptoError; 564 + pub use keys::{ 565 + decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 566 + }; 567 + pub use plc::{build_did_plc_genesis_op, PlcGenesisOp}; 568 + pub use shamir::{combine_shares, split_secret, ShamirShare}; 569 + ``` 570 + 571 + --- 572 + 573 + **Step 4: Verify all tests pass** 574 + 575 + ```bash 576 + cargo test -p crypto 577 + ``` 578 + 579 + Expected output: all tests pass, including the 7 new tests in `plc::tests`. 580 + 581 + **Step 5: Verify no clippy warnings** 582 + 583 + ```bash 584 + cargo clippy -p crypto -- -D warnings 585 + ``` 586 + 587 + Expected: no warnings. 588 + 589 + **Step 6: Commit** 590 + 591 + ```bash 592 + git add crates/crypto/src/error.rs crates/crypto/src/plc.rs crates/crypto/src/lib.rs 593 + git commit -m "feat(crypto): implement build_did_plc_genesis_op for did:plc genesis ops (MM-89)" 594 + ``` 595 + <!-- END_TASK_2 --> 596 + 597 + <!-- START_TASK_3 --> 598 + ### Task 3: Update crates/crypto/CLAUDE.md 599 + 600 + **Verifies:** None (documentation) 601 + 602 + **Files:** 603 + - Modify: `crates/crypto/CLAUDE.md` 604 + 605 + **Step 1: Add new contracts to CLAUDE.md** 606 + 607 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/crypto/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add the following to the **Public API contracts** section (add after the existing contracts): 608 + 609 + ```markdown 610 + ### `build_did_plc_genesis_op` 611 + 612 + ```rust 613 + pub fn build_did_plc_genesis_op( 614 + rotation_key: &DidKeyUri, // user's root rotation key (rotationKeys[0]) 615 + signing_key: &DidKeyUri, // relay's signing key (rotationKeys[1] + verificationMethods.atproto) 616 + signing_private_key: &[u8; 32], // raw P-256 private key scalar for signing_key 617 + handle: &str, // e.g. "alice.example.com" 618 + service_endpoint: &str, // e.g. "https://relay.example.com" 619 + ) -> Result<PlcGenesisOp, CryptoError> 620 + ``` 621 + 622 + - Constructs a signed did:plc genesis operation. 623 + - Returns `PlcGenesisOp { did, signed_op_json }`. 624 + - `did` matches `^did:plc:[a-z2-7]{24}$`. 625 + - `signed_op_json` is ready to POST to `https://plc.directory/{did}`. 626 + - Deterministic: same inputs → same DID (RFC 6979 + SHA-256 + base32). 627 + - Errors: `CryptoError::PlcOperation` if `signing_private_key` is an invalid P-256 scalar. 628 + 629 + ### `PlcGenesisOp` 630 + 631 + ```rust 632 + pub struct PlcGenesisOp { 633 + pub did: String, // "did:plc:xxxx..." — 28 chars total 634 + pub signed_op_json: String, // signed operation JSON 635 + } 636 + ``` 637 + 638 + - `did`: derived from SHA-256 of CBOR-encoded signed op, base32-lowercase, first 24 chars, prefixed with `"did:plc:"`. 639 + - `signed_op_json`: JSON containing `type`, `rotationKeys`, `verificationMethods`, `alsoKnownAs`, `services`, `prev` (null), `sig`. 640 + ``` 641 + 642 + **Step 2: Also update the Dependencies section** to include the new deps: 643 + 644 + Add to the existing dependencies list: 645 + - `ciborium` — CBOR serialization for signing and DID derivation 646 + - `data-encoding` — base32-lowercase encoding 647 + - `sha2` — SHA-256 hashing 648 + - `serde` — derive macros for CBOR/JSON serialization 649 + 650 + **Step 3: Commit** 651 + 652 + ```bash 653 + git add crates/crypto/CLAUDE.md 654 + git commit -m "docs(crypto): update CLAUDE.md with build_did_plc_genesis_op contracts (MM-89)" 655 + ``` 656 + <!-- END_TASK_3 --> 657 + 658 + <!-- END_SUBCOMPONENT_A --> 659 + 660 + --- 661 + 662 + ## Phase Completion Verification 663 + 664 + After all three tasks, verify the complete phase: 665 + 666 + ```bash 667 + # All crypto tests pass 668 + cargo test -p crypto 669 + 670 + # No clippy warnings 671 + cargo clippy -p crypto -- -D warnings 672 + 673 + # No formatting issues 674 + cargo fmt -p crypto --check 675 + ``` 676 + 677 + Expected: all tests pass (existing 9 + new 7 = 16 tests), zero warnings, formatted correctly.
+1476
docs/implementation-plans/2026-03-13-MM-89/phase_02.md
··· 1 + # MM-89: DID Creation — did:plc via PLC Directory Proxy — Phase 2 2 + 3 + **Goal:** Implement `POST /v1/dids` in the relay: DB migration, auth helper, route handler with pre-store retry resilience, and integration tests with a mocked plc.directory. 4 + 5 + **Architecture:** Imperative Shell in `crates/relay/src/routes/create_did.rs`. Authenticates a `pending_session` Bearer token, calls Phase 1's pure `build_did_plc_genesis_op`, submits to plc.directory via `reqwest`, then atomically promotes the account in the DB. 6 + 7 + **Tech Stack:** Rust stable; `axum` (routing, extractors), `reqwest` 0.12 (HTTP client), `sqlx` 0.8 (transactions), `wiremock` 0.6 (mock plc.directory in tests), `crypto::build_did_plc_genesis_op` (Phase 1) 8 + 9 + **Scope:** Phase 2 of 2 from the original design. Depends on Phase 1 (`crypto::build_did_plc_genesis_op` must be on the branch). 10 + 11 + **Codebase verified:** 2026-03-13 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + This phase implements and tests: 18 + 19 + ### MM-89.AC2: POST /v1/dids completes the DID ceremony and promotes the account 20 + - **MM-89.AC2.1 Success:** Valid request with a live `pending_session` token returns `200 OK` with `{ "did": "did:plc:...", "status": "active" }` 21 + - **MM-89.AC2.2 Success:** After success, `accounts` row exists with `did` as PK, correct `email`, and `password_hash` NULL 22 + - **MM-89.AC2.3 Success:** After success, `did_documents` row exists for the DID with non-empty `document` JSON 23 + - **MM-89.AC2.4 Success:** After success, `handles` row exists linking the handle to the DID 24 + - **MM-89.AC2.5 Success:** After success, `pending_accounts` and `pending_sessions` rows for the account are deleted 25 + - **MM-89.AC2.6 Success:** When `pending_did` is already set (client retry), handler skips the plc.directory HTTP call and completes DB promotion, returning 200 26 + - **MM-89.AC2.7 Failure:** Missing `Authorization` header returns 401 `UNAUTHORIZED` 27 + - **MM-89.AC2.8 Failure:** Expired `pending_session` token returns 401 `UNAUTHORIZED` 28 + - **MM-89.AC2.9 Failure:** `signingKey` not present in `relay_signing_keys` returns 404 `NOT_FOUND` 29 + - **MM-89.AC2.10 Failure:** Account already fully promoted (`accounts` row already exists) returns 409 `DID_ALREADY_EXISTS` 30 + - **MM-89.AC2.11 Failure:** plc.directory returns non-2xx returns 502 `PLC_DIRECTORY_ERROR` 31 + 32 + ### MM-89.AC3: Schema migration and protocol correctness 33 + - **MM-89.AC3.1:** V008 migration applies cleanly on top of V007; `accounts.password_hash` accepts NULL; `pending_accounts.pending_did` column exists 34 + 35 + --- 36 + 37 + ## External Dependency Research Findings 38 + 39 + - ✓ **reqwest 0.12**: `Client::new()` returns a `Clone + Send + Sync` client safe for AppState. POST pre-serialized JSON: `.post(url).body(json_string).header("Content-Type", "application/json").send().await?`. Check success: `response.status().is_success()`. Path format for plc.directory: `POST /{did}` (e.g., `https://plc.directory/did:plc:xyz`). 40 + - ✓ **wiremock 0.6**: `MockServer::start().await` on random port; `.uri()` for base URL. `Mock::given(method("POST")).and(path_regex(r"^/did:plc:[a-z2-7]+$")).respond_with(ResponseTemplate::new(200)).expect(1).mount(&server).await`. `.expect(0)` to assert NOT called. `MockServer` auto-verifies `.expect()` counts on drop. 41 + - ✓ **Token hash pattern** (from create_mobile_account.rs): `Sha256::digest(raw_token_bytes).iter().map(|b| format!("{b:02x}")).collect::<String>()`. SHA-256 of the raw bytes, NOT the base64url string. Bearer token sent to client is `URL_SAFE_NO_PAD.encode(raw_bytes)`. 42 + 43 + --- 44 + 45 + <!-- START_SUBCOMPONENT_A (tasks 1-4) --> 46 + 47 + <!-- START_TASK_1 --> 48 + ### Task 1: Add reqwest and wiremock Cargo.toml dependencies 49 + 50 + **Verifies:** None (infrastructure) 51 + 52 + **Files:** 53 + - Modify: `Cargo.toml` (workspace root) — add reqwest 54 + - Modify: `crates/relay/Cargo.toml` — add reqwest dep + wiremock dev-dep 55 + 56 + **Step 1: Add reqwest to workspace Cargo.toml** 57 + 58 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/Cargo.toml`, in the `[workspace.dependencies]` section, add after the existing entries: 59 + 60 + ```toml 61 + reqwest = { version = "0.12", features = ["json"] } 62 + ``` 63 + 64 + **Step 2: Update crates/relay/Cargo.toml** 65 + 66 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/Cargo.toml`, add to `[dependencies]`: 67 + 68 + ```toml 69 + reqwest = { workspace = true } 70 + serde_json = { workspace = true } 71 + ``` 72 + 73 + > **Note:** `serde_json` is already in the workspace root. It must appear in `[dependencies]` (not just `[dev-dependencies]`) because `create_did.rs` uses `serde_json::json!()` inside `build_did_document`, which is production code. Without this, `cargo build --release` (which does not include dev-deps) will fail. 74 + 75 + Add to `[dev-dependencies]`: 76 + 77 + ```toml 78 + wiremock = "0.6" 79 + ``` 80 + 81 + **Step 3: Verify deps resolve** 82 + 83 + ```bash 84 + cargo check -p relay 85 + ``` 86 + 87 + Expected: resolves without errors. 88 + 89 + **Step 4: Commit** 90 + 91 + ```bash 92 + git add Cargo.toml Cargo.lock crates/relay/Cargo.toml 93 + git commit -m "chore(relay): add reqwest 0.12 and wiremock 0.6 deps for POST /v1/dids (MM-89)" 94 + ``` 95 + <!-- END_TASK_1 --> 96 + 97 + <!-- START_TASK_2 --> 98 + ### Task 2: V008 migration — nullable password_hash and pending_did column 99 + 100 + **Verifies:** MM-89.AC3.1 101 + 102 + **Files:** 103 + - Create: `crates/relay/src/db/migrations/V008__did_promotion.sql` 104 + - Modify: `crates/relay/src/db/mod.rs` (add V008 to MIGRATIONS) 105 + - Modify: `crates/relay/src/db/CLAUDE.md` (document V008) 106 + 107 + **Step 1: Create the migration file** 108 + 109 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/migrations/V008__did_promotion.sql`: 110 + 111 + ```sql 112 + -- V008: DID promotion support 113 + -- Applied in a single transaction by the migration runner. 114 + -- 115 + -- 1. Rebuilds the accounts table with nullable password_hash. 116 + -- Mobile-provisioned accounts (via POST /v1/dids) have no password; 117 + -- only accounts created via POST /v1/accounts have a password_hash. 118 + -- SQLite does not support ALTER COLUMN, so a full table rebuild is required. 119 + -- 120 + -- 2. Adds pending_did to pending_accounts for retry-safe DID pre-storage. 121 + -- Populated by POST /v1/dids before calling plc.directory (pre-store pattern). 122 + -- If the promotion transaction fails after plc.directory accepts the op, 123 + -- a client retry detects this non-NULL value and skips the directory call. 124 + 125 + -- ── Rebuild accounts with nullable password_hash ───────────────────────────── 126 + 127 + CREATE TABLE accounts_new ( 128 + did TEXT NOT NULL, 129 + email TEXT NOT NULL, 130 + password_hash TEXT, -- NULL for mobile-provisioned accounts 131 + created_at TEXT NOT NULL, 132 + updated_at TEXT NOT NULL, 133 + email_confirmed_at TEXT, 134 + deactivated_at TEXT, 135 + PRIMARY KEY (did) 136 + ); 137 + 138 + INSERT INTO accounts_new 139 + SELECT did, email, password_hash, created_at, updated_at, email_confirmed_at, deactivated_at 140 + FROM accounts; 141 + 142 + DROP TABLE accounts; 143 + 144 + ALTER TABLE accounts_new RENAME TO accounts; 145 + 146 + CREATE UNIQUE INDEX idx_accounts_email ON accounts (email); 147 + 148 + -- ── Add pending_did to pending_accounts ────────────────────────────────────── 149 + 150 + ALTER TABLE pending_accounts ADD COLUMN pending_did TEXT; 151 + ``` 152 + 153 + > **Note for executor:** The `DROP TABLE accounts` step works even with FK enforcement ON because SQLite FK checks are triggered on INSERT/UPDATE of child rows, not on DROP of a parent table. If for any reason the migration runner reports FK constraint issues, consult the db/mod.rs migration runner to see if PRAGMA foreign_keys = OFF is needed around the table rebuild section. 154 + 155 + **Step 2: Add V008 to the MIGRATIONS array in db/mod.rs** 156 + 157 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/mod.rs`, find the `MIGRATIONS` static array. Add the V008 entry after V007: 158 + 159 + ```rust 160 + Migration { version: 8, sql: include_str!("migrations/V008__did_promotion.sql") }, 161 + ``` 162 + 163 + The full array (after edit) should look like: 164 + 165 + ```rust 166 + static MIGRATIONS: &[Migration] = &[ 167 + Migration { version: 1, sql: include_str!("migrations/V001__init.sql") }, 168 + Migration { version: 2, sql: include_str!("migrations/V002__auth_identity.sql") }, 169 + Migration { version: 3, sql: include_str!("migrations/V003__relay_signing_keys.sql") }, 170 + Migration { version: 4, sql: include_str!("migrations/V004__claim_codes_invite.sql") }, 171 + Migration { version: 5, sql: include_str!("migrations/V005__pending_accounts.sql") }, 172 + Migration { version: 6, sql: include_str!("migrations/V006__devices_v2.sql") }, 173 + Migration { version: 7, sql: include_str!("migrations/V007__pending_sessions.sql") }, 174 + Migration { version: 8, sql: include_str!("migrations/V008__did_promotion.sql") }, 175 + ]; 176 + ``` 177 + 178 + **Step 3: Update crates/relay/src/db/CLAUDE.md** 179 + 180 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/db/CLAUDE.md`, update the "Last verified" date to `2026-03-13` and add to the Key Files section: 181 + 182 + ``` 183 + - `migrations/V008__did_promotion.sql` - Rebuilds accounts with nullable password_hash (mobile accounts have no password); adds pending_did column to pending_accounts for DID pre-store retry resilience 184 + ``` 185 + 186 + **Step 4: Verify migration applies cleanly** 187 + 188 + ```bash 189 + cargo test -p relay db::tests 190 + ``` 191 + 192 + Expected: all DB tests pass including migration idempotence test with V008 applied. 193 + 194 + **Step 5: Commit** 195 + 196 + ```bash 197 + git add crates/relay/src/db/migrations/V008__did_promotion.sql crates/relay/src/db/mod.rs crates/relay/src/db/CLAUDE.md 198 + git commit -m "feat(relay): V008 migration — nullable accounts.password_hash, pending_did column (MM-89)" 199 + ``` 200 + <!-- END_TASK_2 --> 201 + 202 + <!-- START_TASK_3 --> 203 + ### Task 3: Add plc_directory_url to Config and ErrorCode variants 204 + 205 + **Verifies:** None (infrastructure — verified through route tests) 206 + 207 + **Files:** 208 + - Modify: `crates/common/src/config.rs` (add plc_directory_url field) 209 + - Modify: `crates/common/src/error.rs` (add DID_ALREADY_EXISTS, PLC_DIRECTORY_ERROR) 210 + 211 + **Step 1: Add plc_directory_url to Config struct** 212 + 213 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/config.rs`: 214 + 215 + **1a. Add to `Config` struct** (after `signing_key_master_key`): 216 + 217 + ```rust 218 + pub plc_directory_url: String, 219 + ``` 220 + 221 + **1b. Add to `RawConfig` struct** (after `admin_token`, before `signing_key_master_key`): 222 + 223 + ```rust 224 + pub(crate) plc_directory_url: Option<String>, 225 + ``` 226 + 227 + > **Note:** Unlike `signing_key_master_key`, this field is NOT security-sensitive, so it does NOT use `#[serde(skip)]` or a sentinel. Operators can set it via either TOML (`plc_directory_url = "https://..."`) or the env var `EZPDS_PLC_DIRECTORY_URL`. 228 + 229 + **1c. Add env override in `apply_env_overrides`** (after the existing EZPDS_ env var handling block, following the same pattern): 230 + 231 + ```rust 232 + if let Some(v) = env.get("EZPDS_PLC_DIRECTORY_URL") { 233 + raw.plc_directory_url = Some(v.clone()); 234 + } 235 + ``` 236 + 237 + **1d. Add to `validate_and_build`** (after the other field validations, before the final Config construction): 238 + 239 + ```rust 240 + let plc_directory_url = raw 241 + .plc_directory_url 242 + .unwrap_or_else(|| "https://plc.directory".to_string()); 243 + ``` 244 + 245 + **1e. Add to the Config constructor** in `validate_and_build` (add `plc_directory_url,` to the struct literal): 246 + 247 + ```rust 248 + Ok(Config { 249 + // ... existing fields ... 250 + plc_directory_url, 251 + }) 252 + ``` 253 + 254 + **Step 2: Add ErrorCode variants** 255 + 256 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/common/src/error.rs`, add to the `ErrorCode` enum (keeping the existing variants unchanged). Match the existing pattern — bare variants with doc comments, no `#[error(...)]` attribute (the enum derives `Serialize` for wire format, not `thiserror::Error`): 257 + 258 + ```rust 259 + /// The DID has already been fully promoted to an active account. 260 + DidAlreadyExists, 261 + /// The external PLC directory returned a non-success response. 262 + PlcDirectoryError, 263 + ``` 264 + 265 + Also add the HTTP status code mappings for these new variants. Find the `status_code()` method in `impl ErrorCode` and add: 266 + 267 + ```rust 268 + ErrorCode::DidAlreadyExists => 409, 269 + ErrorCode::PlcDirectoryError => 502, 270 + ``` 271 + 272 + > **Note for executor:** `status_code()` returns a plain `u16`, not `axum::http::StatusCode`. The common crate does not depend on axum. Match the existing pattern — e.g., `ErrorCode::AccountExists => 409,`. 273 + 274 + **Step 2b: Update the `status_code_mapping` test** 275 + 276 + In the same file (`crates/common/src/error.rs`), find the `status_code_mapping` test (in `#[cfg(test)] mod tests`). Add two entries to the `cases` array: 277 + 278 + ```rust 279 + (ErrorCode::DidAlreadyExists, 409), 280 + (ErrorCode::PlcDirectoryError, 502), 281 + ``` 282 + 283 + The test is exhaustive — it will fail at compile time if new variants are not covered. Add these entries at the end of the `cases` array, just before the closing `]`. 284 + 285 + **Step 3: Verify build passes** 286 + 287 + ```bash 288 + cargo check --workspace 289 + ``` 290 + 291 + Expected: no errors. 292 + 293 + **Step 4: Commit** 294 + 295 + ```bash 296 + git add crates/common/src/config.rs crates/common/src/error.rs 297 + git commit -m "feat(common): add plc_directory_url to Config and DID error codes (MM-89)" 298 + ``` 299 + <!-- END_TASK_3 --> 300 + 301 + <!-- START_TASK_4 --> 302 + ### Task 4: Add http_client to AppState and update test_state 303 + 304 + **Verifies:** None (infrastructure — verified through route tests) 305 + 306 + **Files:** 307 + - Modify: `crates/relay/src/app.rs` 308 + 309 + **Step 1: Add http_client field to AppState** 310 + 311 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`: 312 + 313 + **1a. Add reqwest use import** (at the top of the file with other imports): 314 + 315 + ```rust 316 + use reqwest::Client; 317 + ``` 318 + 319 + **1b. Add `http_client` field to `AppState`** struct: 320 + 321 + ```rust 322 + pub struct AppState { 323 + pub config: Arc<Config>, 324 + pub db: sqlx::SqlitePool, 325 + pub http_client: Client, 326 + } 327 + ``` 328 + 329 + **1c. Update the production AppState construction** in `main.rs` (find where `AppState { config, db }` is created and add `http_client: Client::new()`): 330 + 331 + ```rust 332 + AppState { 333 + config: Arc::new(config), 334 + db, 335 + http_client: Client::new(), 336 + } 337 + ``` 338 + 339 + > **Note for executor:** Find the AppState construction in `crates/relay/src/main.rs` (or wherever the production startup code lives) and add `http_client: Client::new()`. 340 + 341 + **1d. Replace `test_state()` in app.rs and add `test_state_with_plc_url`** (in the `#[cfg(test)]` block): 342 + 343 + Replace the existing `test_state()` function entirely with these two functions: 344 + 345 + ```rust 346 + #[cfg(test)] 347 + pub(crate) async fn test_state() -> AppState { 348 + test_state_with_plc_url("https://plc.directory".to_string()).await 349 + } 350 + 351 + #[cfg(test)] 352 + pub async fn test_state_with_plc_url(plc_directory_url: String) -> AppState { 353 + use crate::db::{open_pool, run_migrations}; 354 + use common::{BlobsConfig, IrohConfig, OAuthConfig, TelemetryConfig}; 355 + use std::path::PathBuf; 356 + 357 + let db = open_pool("sqlite::memory:").await.expect("test pool"); 358 + run_migrations(&db).await.expect("test migrations"); 359 + 360 + AppState { 361 + config: Arc::new(Config { 362 + bind_address: "127.0.0.1".to_string(), 363 + port: 8080, 364 + data_dir: PathBuf::from("/tmp"), 365 + database_url: "sqlite::memory:".to_string(), 366 + public_url: "https://test.example.com".to_string(), 367 + server_did: None, 368 + available_user_domains: vec!["test.example.com".to_string()], 369 + invite_code_required: true, 370 + links: common::ServerLinksConfig::default(), 371 + contact: common::ContactConfig::default(), 372 + blobs: BlobsConfig::default(), 373 + oauth: OAuthConfig::default(), 374 + iroh: IrohConfig::default(), 375 + telemetry: TelemetryConfig::default(), 376 + admin_token: None, 377 + signing_key_master_key: None, 378 + plc_directory_url, 379 + }), 380 + db, 381 + http_client: Client::new(), 382 + } 383 + } 384 + ``` 385 + 386 + The `test_state()` function now delegates to `test_state_with_plc_url`, keeping a single source of truth for Config defaults. All existing tests that call `test_state()` continue to work unchanged. 387 + 388 + **Step 2: Verify build passes** 389 + 390 + ```bash 391 + cargo build -p relay 392 + ``` 393 + 394 + Expected: builds without errors. 395 + 396 + **Step 3: Run existing tests to ensure no regressions** 397 + 398 + ```bash 399 + cargo test -p relay 400 + ``` 401 + 402 + Expected: all existing tests pass. 403 + 404 + **Step 4: Commit** 405 + 406 + ```bash 407 + git add crates/relay/src/app.rs crates/relay/src/main.rs 408 + git commit -m "feat(relay): add reqwest::Client to AppState for outbound HTTP (MM-89)" 409 + ``` 410 + <!-- END_TASK_4 --> 411 + 412 + <!-- END_SUBCOMPONENT_A --> 413 + 414 + <!-- START_SUBCOMPONENT_B (tasks 5-6) --> 415 + 416 + <!-- START_TASK_5 --> 417 + ### Task 5: Add require_pending_session auth helper to auth.rs 418 + 419 + **Verifies:** MM-89.AC2.7, MM-89.AC2.8 420 + 421 + **Files:** 422 + - Modify: `crates/relay/src/routes/auth.rs` 423 + 424 + **Step 1: Read current auth.rs** 425 + 426 + Before editing, read `crates/relay/src/routes/auth.rs` to understand the current imports and structure. The existing `require_admin_token` function is the pattern to follow. 427 + 428 + **Step 2: Add PendingSessionInfo struct and require_pending_session function** 429 + 430 + Add the following to `crates/relay/src/routes/auth.rs`: 431 + 432 + ```rust 433 + /// Information about an authenticated pending session. 434 + pub struct PendingSessionInfo { 435 + pub account_id: String, 436 + pub device_id: String, 437 + } 438 + 439 + /// Authenticate a `pending_session` Bearer token. 440 + /// 441 + /// Extracts the Bearer token from the Authorization header, SHA-256 hashes the raw 442 + /// decoded bytes (matching the storage format from `POST /v1/accounts/mobile`), and 443 + /// queries `pending_sessions` for a matching, unexpired row. 444 + /// 445 + /// # Errors 446 + /// Returns `ApiError::Unauthorized` if: 447 + /// - The Authorization header is missing 448 + /// - The token is not valid base64url 449 + /// - No unexpired session matches the token hash 450 + pub async fn require_pending_session( 451 + headers: &axum::http::HeaderMap, 452 + db: &sqlx::SqlitePool, 453 + ) -> Result<PendingSessionInfo, ApiError> { 454 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 455 + use sha2::{Digest, Sha256}; 456 + 457 + // Extract Bearer token from Authorization header. 458 + let token = headers 459 + .get(axum::http::header::AUTHORIZATION) 460 + .and_then(|v| v.to_str().ok()) 461 + .and_then(|v| v.strip_prefix("Bearer ")) 462 + .ok_or_else(|| { 463 + ApiError::new( 464 + ErrorCode::Unauthorized, 465 + "missing or invalid Authorization header", 466 + ) 467 + })?; 468 + 469 + // Decode base64url → raw bytes, then SHA-256 hash → hex string. 470 + // Matches the storage format written by POST /v1/accounts/mobile. 471 + let token_bytes = URL_SAFE_NO_PAD.decode(token).map_err(|_| { 472 + ApiError::new( 473 + ErrorCode::Unauthorized, 474 + "invalid session token", 475 + ) 476 + })?; 477 + let token_hash: String = Sha256::digest(&token_bytes) 478 + .iter() 479 + .map(|b| format!("{b:02x}")) 480 + .collect(); 481 + 482 + // Look up the session by hash, rejecting expired sessions. 483 + let row: Option<(String, String)> = sqlx::query_as( 484 + "SELECT account_id, device_id FROM pending_sessions \ 485 + WHERE token_hash = ? AND expires_at > datetime('now')", 486 + ) 487 + .bind(&token_hash) 488 + .fetch_optional(db) 489 + .await 490 + .map_err(|e| { 491 + tracing::error!(error = %e, "failed to query pending session"); 492 + ApiError::new( 493 + ErrorCode::InternalError, 494 + "session lookup failed", 495 + ) 496 + })?; 497 + 498 + let (account_id, device_id) = row.ok_or_else(|| { 499 + ApiError::new( 500 + ErrorCode::Unauthorized, 501 + "invalid or expired session token", 502 + ) 503 + })?; 504 + 505 + Ok(PendingSessionInfo { account_id, device_id }) 506 + } 507 + ``` 508 + 509 + **Step 3: Add necessary imports to auth.rs** 510 + 511 + Ensure the top of auth.rs has the needed imports. The function uses: 512 + - `axum::http::HeaderMap` — check if already imported 513 + - `sqlx::SqlitePool` — check if already imported 514 + - `base64`, `sha2` — these crates are already in `crates/relay/Cargo.toml` 515 + 516 + **Step 4: Verify build** 517 + 518 + ```bash 519 + cargo build -p relay 520 + ``` 521 + 522 + Expected: no errors. 523 + 524 + **Step 5: Commit** 525 + 526 + ```bash 527 + git add crates/relay/src/routes/auth.rs 528 + git commit -m "feat(relay): add require_pending_session auth helper (MM-89)" 529 + ``` 530 + <!-- END_TASK_5 --> 531 + 532 + <!-- START_TASK_6 --> 533 + ### Task 6: Implement create_did.rs route, register it, and add integration tests 534 + 535 + **Verifies:** MM-89.AC2.1, MM-89.AC2.2, MM-89.AC2.3, MM-89.AC2.4, MM-89.AC2.5, MM-89.AC2.6, MM-89.AC2.7, MM-89.AC2.8, MM-89.AC2.9, MM-89.AC2.10, MM-89.AC2.11, MM-89.AC3.1 536 + 537 + **Files:** 538 + - Create: `crates/relay/src/routes/create_did.rs` (new) 539 + - Modify: `crates/relay/src/routes/mod.rs` (add module) 540 + - Modify: `crates/relay/src/app.rs` (register route) 541 + - Create: `bruno/create-did.bru` (new) 542 + 543 + > **Pre-step for executor:** Before implementing, read `crates/relay/src/routes/test_utils.rs` to understand what test helpers are already available (e.g., helpers to insert claim codes, pending accounts, devices, sessions). Use existing helpers where possible rather than duplicating SQL setup. Also read the full current `test_state()` in app.rs before implementing `test_state_with_plc_url`. 544 + 545 + --- 546 + 547 + **Step 1: Read crates/relay/src/routes/create_signing_key.rs** 548 + 549 + Read the full file to understand the exact import pattern, master key access pattern (`state.config.signing_key_master_key.as_ref().map(|s| &*s.0)`), and error handling style. 550 + 551 + --- 552 + 553 + **Step 2: Create crates/relay/src/routes/create_did.rs** 554 + 555 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/create_did.rs`: 556 + 557 + ```rust 558 + // pattern: Imperative Shell 559 + // 560 + // POST /v1/dids — DID creation and account promotion 561 + // 562 + // Inputs: 563 + // - Authorization: Bearer <pending_session_token> 564 + // - JSON body: { "signingKey": "did:key:z...", "rotationKey": "did:key:z..." } 565 + // 566 + // Processing steps: 567 + // 1. require_pending_session → PendingSessionInfo { account_id, device_id } 568 + // 2. SELECT handle, pending_did FROM pending_accounts WHERE id = account_id 569 + // 3. SELECT private_key_encrypted FROM relay_signing_keys WHERE id = signing_key 570 + // 4. decrypt_private_key(encrypted, master_key) 571 + // 5. build_did_plc_genesis_op(rotation_key, signing_key, private_key, handle, public_url) 572 + // 6. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = did (pre-store resilience) 573 + // 7. If pending_did IS NOT NULL (retry): skip step 8 574 + // 8. POST {plc_directory_url}/{did} with signed_op_json 575 + // 9. Atomic transaction: 576 + // INSERT accounts (did, email, password_hash=NULL) 577 + // INSERT did_documents (did, document) 578 + // INSERT handles (handle, did) 579 + // DELETE pending_sessions WHERE account_id = ? 580 + // DELETE pending_accounts WHERE id = ? 581 + // 10. Return { "did": "did:plc:...", "status": "active" } 582 + // 583 + // Outputs (success): 200 { "did": "did:plc:...", "status": "active" } 584 + // Outputs (error): 401 UNAUTHORIZED, 404 NOT_FOUND, 409 DID_ALREADY_EXISTS, 585 + // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 586 + 587 + use axum::{extract::State, http::HeaderMap, Json}; 588 + use serde::{Deserialize, Serialize}; 589 + 590 + use crate::app::AppState; 591 + use crate::routes::auth::require_pending_session; 592 + use common::{ApiError, ErrorCode}; 593 + 594 + #[derive(Deserialize)] 595 + #[serde(rename_all = "camelCase")] 596 + pub struct CreateDidRequest { 597 + pub signing_key: String, 598 + pub rotation_key: String, 599 + } 600 + 601 + #[derive(Serialize)] 602 + pub struct CreateDidResponse { 603 + pub did: String, 604 + pub status: &'static str, 605 + } 606 + 607 + pub async fn create_did_handler( 608 + State(state): State<AppState>, 609 + headers: HeaderMap, 610 + Json(payload): Json<CreateDidRequest>, 611 + ) -> Result<Json<CreateDidResponse>, ApiError> { 612 + // Step 1: Authenticate via pending_session Bearer token. 613 + let session = require_pending_session(&headers, &state.db).await?; 614 + 615 + // Step 2: Load pending account details. 616 + let (handle, pending_did, email): (String, Option<String>, String) = sqlx::query_as( 617 + "SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?", 618 + ) 619 + .bind(&session.account_id) 620 + .fetch_optional(&state.db) 621 + .await 622 + .map_err(|e| { 623 + tracing::error!(error = %e, "failed to query pending account"); 624 + ApiError::new(ErrorCode::InternalError, "failed to load account") 625 + })? 626 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 627 + 628 + // Step 3: Look up signing key in relay_signing_keys. 629 + let (private_key_encrypted,): (String,) = sqlx::query_as( 630 + "SELECT private_key_encrypted FROM relay_signing_keys WHERE id = ?", 631 + ) 632 + .bind(&payload.signing_key) 633 + .fetch_optional(&state.db) 634 + .await 635 + .map_err(|e| { 636 + tracing::error!(error = %e, "failed to query relay signing key"); 637 + ApiError::new(ErrorCode::InternalError, "key lookup failed") 638 + })? 639 + .ok_or_else(|| { 640 + ApiError::new(ErrorCode::NotFound, "signing key not found in relay_signing_keys") 641 + })?; 642 + 643 + // Step 4: Decrypt the private key using the master key from config. 644 + let master_key: &[u8; 32] = state 645 + .config 646 + .signing_key_master_key 647 + .as_ref() 648 + .map(|s| &*s.0) 649 + .ok_or_else(|| { 650 + ApiError::new(ErrorCode::InternalError, "signing key master key not configured") 651 + })?; 652 + 653 + let private_key_bytes = crypto::decrypt_private_key(&private_key_encrypted, master_key) 654 + .map_err(|e| { 655 + tracing::error!(error = %e, "failed to decrypt signing key"); 656 + ApiError::new(ErrorCode::InternalError, "failed to decrypt signing key") 657 + })?; 658 + 659 + // Step 5: Build the genesis operation and derive the DID. 660 + let rotation_key = crypto::DidKeyUri(payload.rotation_key.clone()); 661 + let signing_key_uri = crypto::DidKeyUri(payload.signing_key.clone()); 662 + 663 + let genesis = crypto::build_did_plc_genesis_op( 664 + &rotation_key, 665 + &signing_key_uri, 666 + &*private_key_bytes, 667 + &handle, 668 + &state.config.public_url, 669 + ) 670 + .map_err(|e| { 671 + tracing::error!(error = %e, "failed to build genesis op"); 672 + ApiError::new(ErrorCode::InternalError, "failed to build genesis operation") 673 + })?; 674 + 675 + let did = genesis.did.clone(); 676 + let signed_op_json = genesis.signed_op_json; 677 + 678 + // Step 6: Pre-store the DID for retry resilience. 679 + // If pending_did is already set, we are on a retry path — skip the plc.directory call. 680 + let skip_plc_directory = if let Some(pre_stored_did) = &pending_did { 681 + // Retry: use the pre-stored DID (should match — same deterministic inputs). 682 + tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 683 + true 684 + } else { 685 + // First attempt: write the DID before calling plc.directory. 686 + sqlx::query( 687 + "UPDATE pending_accounts SET pending_did = ? WHERE id = ?", 688 + ) 689 + .bind(&did) 690 + .bind(&session.account_id) 691 + .execute(&state.db) 692 + .await 693 + .map_err(|e| { 694 + tracing::error!(error = %e, "failed to pre-store pending_did"); 695 + ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 696 + })?; 697 + false 698 + }; 699 + 700 + // Step 7: Check if the account is already fully promoted (idempotency guard for AC2.10). 701 + let already_promoted: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)") 702 + .bind(&did) 703 + .fetch_one(&state.db) 704 + .await 705 + .map_err(|e| { 706 + tracing::error!(error = %e, "failed to check accounts existence"); 707 + ApiError::new(ErrorCode::InternalError, "database error") 708 + })?; 709 + 710 + if already_promoted { 711 + return Err(ApiError::new(ErrorCode::DidAlreadyExists, "DID is already fully promoted")); 712 + } 713 + 714 + // Step 8: POST the genesis operation to plc.directory (skipped on retry). 715 + if !skip_plc_directory { 716 + let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 717 + let response = state 718 + .http_client 719 + .post(&plc_url) 720 + .body(signed_op_json.clone()) 721 + .header("Content-Type", "application/json") 722 + .send() 723 + .await 724 + .map_err(|e| { 725 + tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 726 + ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory") 727 + })?; 728 + 729 + if !response.status().is_success() { 730 + let status = response.status(); 731 + tracing::error!(status = %status, "plc.directory rejected genesis operation"); 732 + return Err(ApiError::new( 733 + ErrorCode::PlcDirectoryError, 734 + format!("plc.directory returned {status}"), 735 + )); 736 + } 737 + } 738 + 739 + // Step 9: Build the DID document for local storage. 740 + let did_document = build_did_document(&did, &handle, &payload.signing_key, &state.config.public_url); 741 + 742 + // Step 10: Atomically promote the account. 743 + let mut tx = state 744 + .db 745 + .begin() 746 + .await 747 + .inspect_err(|e| tracing::error!(error = %e, "failed to begin promotion transaction")) 748 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to begin transaction"))?; 749 + 750 + sqlx::query( 751 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 752 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 753 + ) 754 + .bind(&did) 755 + .bind(&email) 756 + .execute(&mut *tx) 757 + .await 758 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert account")) 759 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 760 + 761 + sqlx::query( 762 + "INSERT INTO did_documents (did, document, created_at, updated_at) \ 763 + VALUES (?, ?, datetime('now'), datetime('now'))", 764 + ) 765 + .bind(&did) 766 + .bind(&did_document) 767 + .execute(&mut *tx) 768 + .await 769 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document")) 770 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to store DID document"))?; 771 + 772 + sqlx::query( 773 + "INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))", 774 + ) 775 + .bind(&handle) 776 + .bind(&did) 777 + .execute(&mut *tx) 778 + .await 779 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle")) 780 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register handle"))?; 781 + 782 + sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?") 783 + .bind(&session.account_id) 784 + .execute(&mut *tx) 785 + .await 786 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending sessions")) 787 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up sessions"))?; 788 + 789 + sqlx::query("DELETE FROM pending_accounts WHERE id = ?") 790 + .bind(&session.account_id) 791 + .execute(&mut *tx) 792 + .await 793 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending account")) 794 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up account"))?; 795 + 796 + tx.commit() 797 + .await 798 + .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 799 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 800 + 801 + Ok(Json(CreateDidResponse { did, status: "active" })) 802 + } 803 + 804 + /// Construct a minimal DID Core document from known fields. 805 + /// 806 + /// No I/O — pure construction from parameters. 807 + fn build_did_document( 808 + did: &str, 809 + handle: &str, 810 + signing_key_did: &str, 811 + service_endpoint: &str, 812 + ) -> String { 813 + // Extract the multibase-encoded public key from the did:key URI. 814 + // did:key:zAbcDef... → publicKeyMultibase = "zAbcDef..." 815 + let public_key_multibase = signing_key_did 816 + .strip_prefix("did:key:") 817 + .unwrap_or(signing_key_did); 818 + 819 + serde_json::json!({ 820 + "@context": [ 821 + "https://www.w3.org/ns/did/v1" 822 + ], 823 + "id": did, 824 + "alsoKnownAs": [format!("at://{handle}")], 825 + "verificationMethod": [{ 826 + "id": format!("{did}#atproto"), 827 + "type": "Multikey", 828 + "controller": did, 829 + "publicKeyMultibase": public_key_multibase 830 + }], 831 + "service": [{ 832 + "id": "#atproto_pds", 833 + "type": "AtprotoPersonalDataServer", 834 + "serviceEndpoint": service_endpoint 835 + }] 836 + }) 837 + .to_string() 838 + } 839 + 840 + // ── Tests ──────────────────────────────────────────────────────────────────── 841 + 842 + #[cfg(test)] 843 + mod tests { 844 + use super::*; 845 + use crate::app::test_state_with_plc_url; 846 + use axum::{ 847 + body::Body, 848 + http::{Request, StatusCode}, 849 + }; 850 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 851 + use rand_core::{OsRng, RngCore}; 852 + use sha2::{Digest, Sha256}; 853 + use tower::ServiceExt; // for `.oneshot()` 854 + use uuid::Uuid; 855 + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path_regex}}; 856 + 857 + // ── Test setup helpers ──────────────────────────────────────────────────── 858 + 859 + /// A test master key: 32 bytes of 0x01. 860 + const TEST_MASTER_KEY: [u8; 32] = [0x01u8; 32]; 861 + 862 + /// All data needed to call POST /v1/dids in a test. 863 + struct TestSetup { 864 + session_token: String, 865 + signing_key_id: String, 866 + rotation_key_id: String, 867 + account_id: String, 868 + /// The handle stored in `pending_accounts`. Needed for AC2.10 to re-create 869 + /// a second pending account that derives the same DID (same keys + same handle). 870 + handle: String, 871 + } 872 + 873 + /// Insert all prerequisite rows for a DID-creation test. 874 + /// 875 + /// Inserts: relay_signing_key, pending_account (with claim code), device, pending_session. 876 + /// 877 + /// Pre-step: Read `crates/relay/src/routes/test_utils.rs` to see if helpers already 878 + /// exist for inserting claim codes, pending accounts, or pending sessions. Use them here 879 + /// if available. If not, use the raw SQL below. 880 + async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup { 881 + use crypto::{encrypt_private_key, generate_p256_keypair}; 882 + 883 + // Generate signing and rotation keypairs. 884 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 885 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 886 + 887 + // Encrypt the signing private key with the test master key. 888 + let encrypted = 889 + encrypt_private_key(&signing_kp.private_key_bytes, &TEST_MASTER_KEY) 890 + .expect("encrypt key"); 891 + 892 + // Insert relay_signing_key. 893 + sqlx::query( 894 + "INSERT INTO relay_signing_keys \ 895 + (id, algorithm, public_key, private_key_encrypted, created_at) \ 896 + VALUES (?, 'p256', ?, ?, datetime('now'))", 897 + ) 898 + .bind(&signing_kp.key_id.0) 899 + .bind(&signing_kp.public_key) 900 + .bind(&encrypted) 901 + .execute(db) 902 + .await 903 + .expect("insert relay_signing_key"); 904 + 905 + // Insert a claim_code row (required FK for pending_accounts). 906 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 907 + sqlx::query( 908 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 909 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 910 + ) 911 + .bind(&claim_code) 912 + .execute(db) 913 + .await 914 + .expect("insert claim_code"); 915 + 916 + // Insert pending_account. 917 + let account_id = Uuid::new_v4().to_string(); 918 + let handle = format!("alice{}.example.com", &account_id[..8]); 919 + sqlx::query( 920 + "INSERT INTO pending_accounts \ 921 + (id, email, handle, tier, claim_code, created_at) \ 922 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 923 + ) 924 + .bind(&account_id) 925 + .bind(format!("alice{}@example.com", &account_id[..8])) 926 + .bind(&handle) 927 + .bind(&claim_code) 928 + .execute(db) 929 + .await 930 + .expect("insert pending_account"); 931 + 932 + // Insert a device (required FK for pending_sessions). 933 + let device_id = Uuid::new_v4().to_string(); 934 + sqlx::query( 935 + "INSERT INTO devices \ 936 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 937 + VALUES (?, ?, 'ios', 'test_pubkey', 'test_device_hash', datetime('now'), datetime('now'))", 938 + ) 939 + .bind(&device_id) 940 + .bind(&account_id) 941 + .execute(db) 942 + .await 943 + .expect("insert device"); 944 + 945 + // Generate pending session token. 946 + let mut token_bytes = [0u8; 32]; 947 + OsRng.fill_bytes(&mut token_bytes); 948 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 949 + let token_hash: String = Sha256::digest(token_bytes) 950 + .iter() 951 + .map(|b| format!("{b:02x}")) 952 + .collect(); 953 + 954 + // Insert pending_session. 955 + sqlx::query( 956 + "INSERT INTO pending_sessions \ 957 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 958 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 959 + ) 960 + .bind(Uuid::new_v4().to_string()) 961 + .bind(&account_id) 962 + .bind(&device_id) 963 + .bind(&token_hash) 964 + .execute(db) 965 + .await 966 + .expect("insert pending_session"); 967 + 968 + TestSetup { 969 + session_token, 970 + signing_key_id: signing_kp.key_id.0, 971 + rotation_key_id: rotation_kp.key_id.0, 972 + account_id, 973 + handle, 974 + } 975 + } 976 + 977 + /// Create an AppState with TEST_MASTER_KEY set and plc_directory_url pointing to the mock. 978 + async fn test_state_for_did(plc_url: String) -> AppState { 979 + use crate::db::{open_pool, run_migrations}; 980 + use common::{BlobsConfig, IrohConfig, OAuthConfig, Sensitive, TelemetryConfig}; 981 + use reqwest::Client; 982 + use std::path::PathBuf; 983 + use std::sync::Arc; 984 + use zeroize::Zeroizing; 985 + 986 + let db = open_pool("sqlite::memory:").await.expect("test pool"); 987 + run_migrations(&db).await.expect("test migrations"); 988 + 989 + AppState { 990 + config: Arc::new(Config { 991 + bind_address: "127.0.0.1".to_string(), 992 + port: 8080, 993 + data_dir: PathBuf::from("/tmp"), 994 + database_url: "sqlite::memory:".to_string(), 995 + public_url: "https://test.example.com".to_string(), 996 + server_did: None, 997 + available_user_domains: vec!["test.example.com".to_string()], 998 + invite_code_required: true, 999 + links: common::ServerLinksConfig::default(), 1000 + contact: common::ContactConfig::default(), 1001 + blobs: BlobsConfig::default(), 1002 + oauth: OAuthConfig::default(), 1003 + iroh: IrohConfig::default(), 1004 + telemetry: TelemetryConfig::default(), 1005 + admin_token: None, 1006 + signing_key_master_key: Some(Sensitive(Zeroizing::new(TEST_MASTER_KEY))), 1007 + plc_directory_url: plc_url, 1008 + }), 1009 + db, 1010 + http_client: Client::new(), 1011 + } 1012 + } 1013 + 1014 + /// Build a POST /v1/dids request with the given session token and body. 1015 + fn create_did_request( 1016 + session_token: &str, 1017 + signing_key: &str, 1018 + rotation_key: &str, 1019 + ) -> Request<Body> { 1020 + let body = serde_json::json!({ 1021 + "signingKey": signing_key, 1022 + "rotationKey": rotation_key, 1023 + }); 1024 + Request::builder() 1025 + .method("POST") 1026 + .uri("/v1/dids") 1027 + .header("Authorization", format!("Bearer {session_token}")) 1028 + .header("Content-Type", "application/json") 1029 + .body(Body::from(body.to_string())) 1030 + .unwrap() 1031 + } 1032 + 1033 + // ── AC2.1: Valid request returns 200 with { did, status: "active" } ─────── 1034 + 1035 + /// MM-89.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5: Happy path — full promotion 1036 + #[tokio::test] 1037 + async fn happy_path_promotes_account_and_returns_did() { 1038 + let mock_server = MockServer::start().await; 1039 + Mock::given(method("POST")) 1040 + .and(path_regex(r"^/did:plc:[a-z2-7]+$")) 1041 + .respond_with(ResponseTemplate::new(200)) 1042 + .expect(1) 1043 + .named("plc.directory genesis op") 1044 + .mount(&mock_server) 1045 + .await; 1046 + 1047 + let state = test_state_for_did(mock_server.uri()).await; 1048 + let db = state.db.clone(); 1049 + let setup = insert_test_data(&db).await; 1050 + 1051 + let app = crate::app::app(state); 1052 + let response = app 1053 + .oneshot(create_did_request( 1054 + &setup.session_token, 1055 + &setup.signing_key_id, 1056 + &setup.rotation_key_id, 1057 + )) 1058 + .await 1059 + .unwrap(); 1060 + 1061 + // AC2.1: 200 OK with did + status 1062 + assert_eq!(response.status(), StatusCode::OK); 1063 + let body: serde_json::Value = 1064 + serde_json::from_slice(&axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap()).unwrap(); 1065 + let did = body["did"].as_str().expect("did field"); 1066 + assert!(did.starts_with("did:plc:"), "did should start with did:plc:"); 1067 + assert_eq!(body["status"], "active"); 1068 + 1069 + // AC2.2: accounts row with null password_hash 1070 + let (stored_email, stored_hash): (String, Option<String>) = 1071 + sqlx::query_as("SELECT email, password_hash FROM accounts WHERE did = ?") 1072 + .bind(did) 1073 + .fetch_one(&db) 1074 + .await 1075 + .expect("accounts row should exist"); 1076 + assert!(stored_hash.is_none(), "password_hash should be NULL"); 1077 + assert!(stored_email.contains("alice"), "email should be set"); 1078 + 1079 + // AC2.3: did_documents row with non-empty document 1080 + let (doc,): (String,) = 1081 + sqlx::query_as("SELECT document FROM did_documents WHERE did = ?") 1082 + .bind(did) 1083 + .fetch_one(&db) 1084 + .await 1085 + .expect("did_documents row should exist"); 1086 + assert!(!doc.is_empty(), "did_document should be non-empty"); 1087 + 1088 + // AC2.4: handles row 1089 + let (handle_did,): (String,) = 1090 + sqlx::query_as("SELECT did FROM handles WHERE did = ?") 1091 + .bind(did) 1092 + .fetch_one(&db) 1093 + .await 1094 + .expect("handles row should exist"); 1095 + assert_eq!(handle_did, did); 1096 + 1097 + // AC2.5: pending_accounts and pending_sessions deleted 1098 + let pending_count: i64 = 1099 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_accounts WHERE id = ?") 1100 + .bind(&setup.account_id) 1101 + .fetch_one(&db) 1102 + .await 1103 + .unwrap(); 1104 + assert_eq!(pending_count, 0, "pending_account should be deleted"); 1105 + 1106 + let session_count: i64 = 1107 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_sessions WHERE account_id = ?") 1108 + .bind(&setup.account_id) 1109 + .fetch_one(&db) 1110 + .await 1111 + .unwrap(); 1112 + assert_eq!(session_count, 0, "pending_sessions should be deleted"); 1113 + } 1114 + 1115 + /// MM-89.AC2.6: Retry path — pending_did pre-set, plc.directory NOT called 1116 + #[tokio::test] 1117 + async fn retry_with_pending_did_skips_plc_directory() { 1118 + let mock_server = MockServer::start().await; 1119 + // Expect zero calls to plc.directory on a retry. 1120 + // MockServer auto-verifies .expect(0) on drop — if plc.directory is called, 1121 + // the mock panics and the test fails. 1122 + Mock::given(method("POST")) 1123 + .and(path_regex(r"^/did:plc:.*$")) 1124 + .respond_with(ResponseTemplate::new(200)) 1125 + .expect(0) // Must NOT be called 1126 + .named("plc.directory (should not be called on retry)") 1127 + .mount(&mock_server) 1128 + .await; 1129 + 1130 + let state = test_state_for_did(mock_server.uri()).await; 1131 + let db = state.db.clone(); 1132 + let setup = insert_test_data(&db).await; 1133 + 1134 + // Simulate a partial-failure retry: set pending_did to any non-null value. 1135 + // The handler checks `pending_did.is_some()` as a boolean flag to skip 1136 + // plc.directory. It does NOT use the stored value — it always re-derives 1137 + // the DID from the crypto function (deterministic from key + handle inputs). 1138 + // So any syntactically valid DID string works here. 1139 + let any_did = "did:plc:abcdefghijklmnopqrstuvwx"; 1140 + sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 1141 + .bind(any_did) 1142 + .bind(&setup.account_id) 1143 + .execute(&db) 1144 + .await 1145 + .expect("pre-store pending_did"); 1146 + 1147 + let app = crate::app::app(state); 1148 + let response = app 1149 + .oneshot(create_did_request( 1150 + &setup.session_token, 1151 + &setup.signing_key_id, 1152 + &setup.rotation_key_id, 1153 + )) 1154 + .await 1155 + .unwrap(); 1156 + 1157 + // The route skips plc.directory (enforced by .expect(0) above) and proceeds 1158 + // to promote the account using the crypto-derived DID. Returns 200. 1159 + assert_eq!( 1160 + response.status(), 1161 + StatusCode::OK, 1162 + "retry should succeed with 200" 1163 + ); 1164 + } 1165 + 1166 + /// MM-89.AC2.7: Missing Authorization header returns 401 1167 + #[tokio::test] 1168 + async fn missing_auth_header_returns_401() { 1169 + let state = test_state_with_plc_url("https://plc.directory".to_string()).await; 1170 + let app = crate::app::app(state); 1171 + 1172 + let request = Request::builder() 1173 + .method("POST") 1174 + .uri("/v1/dids") 1175 + .header("Content-Type", "application/json") 1176 + .body(Body::from(r#"{"signingKey":"did:key:z...","rotationKey":"did:key:z..."}"#)) 1177 + .unwrap(); 1178 + 1179 + let response = app.oneshot(request).await.unwrap(); 1180 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 1181 + } 1182 + 1183 + /// MM-89.AC2.8: Expired session token returns 401 1184 + #[tokio::test] 1185 + async fn expired_session_returns_401() { 1186 + let state = test_state_with_plc_url("https://plc.directory".to_string()).await; 1187 + let db = state.db.clone(); 1188 + let setup = insert_test_data(&db).await; 1189 + 1190 + // Manually expire the session. 1191 + sqlx::query("UPDATE pending_sessions SET expires_at = datetime('now', '-1 hour') WHERE account_id = ?") 1192 + .bind(&setup.account_id) 1193 + .execute(&db) 1194 + .await 1195 + .expect("expire session"); 1196 + 1197 + let app = crate::app::app(state); 1198 + let response = app 1199 + .oneshot(create_did_request( 1200 + &setup.session_token, 1201 + &setup.signing_key_id, 1202 + &setup.rotation_key_id, 1203 + )) 1204 + .await 1205 + .unwrap(); 1206 + 1207 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 1208 + } 1209 + 1210 + /// MM-89.AC2.9: signingKey not in relay_signing_keys returns 404 1211 + #[tokio::test] 1212 + async fn unknown_signing_key_returns_404() { 1213 + let state = test_state_for_did("https://plc.directory".to_string()).await; 1214 + let db = state.db.clone(); 1215 + let setup = insert_test_data(&db).await; 1216 + 1217 + let app = crate::app::app(state); 1218 + let response = app 1219 + .oneshot(create_did_request( 1220 + &setup.session_token, 1221 + "did:key:zNONEXISTENT", // Not in relay_signing_keys 1222 + &setup.rotation_key_id, 1223 + )) 1224 + .await 1225 + .unwrap(); 1226 + 1227 + assert_eq!(response.status(), StatusCode::NOT_FOUND); 1228 + } 1229 + 1230 + /// MM-89.AC2.10: Account already promoted returns 409 DID_ALREADY_EXISTS 1231 + /// 1232 + /// The DID is deterministic from (rotation_key, signing_key, handle, service_endpoint). 1233 + /// To reliably trigger 409, we: 1234 + /// 1. First call promotes setup's account (deletes pending_accounts + pending_sessions). 1235 + /// 2. Create a NEW pending account+session using the SAME signing key, rotation key, 1236 + /// and handle as setup. Same inputs → same crypto-derived DID. 1237 + /// 3. Second call: handler derives the same DID, finds the existing `accounts` row, 1238 + /// returns 409 DID_ALREADY_EXISTS. 1239 + #[tokio::test] 1240 + async fn already_promoted_account_returns_409() { 1241 + let mock_server = MockServer::start().await; 1242 + Mock::given(method("POST")) 1243 + .and(path_regex(r"^/did:plc:.*$")) 1244 + .respond_with(ResponseTemplate::new(200)) 1245 + .mount(&mock_server) 1246 + .await; 1247 + 1248 + let state = test_state_for_did(mock_server.uri()).await; 1249 + let db = state.db.clone(); 1250 + let setup = insert_test_data(&db).await; 1251 + 1252 + // First call: promotes setup's account (deletes pending_accounts + pending_sessions). 1253 + let app1 = crate::app::app(state.clone()); 1254 + let resp1 = app1 1255 + .oneshot(create_did_request( 1256 + &setup.session_token, 1257 + &setup.signing_key_id, 1258 + &setup.rotation_key_id, 1259 + )) 1260 + .await 1261 + .unwrap(); 1262 + assert_eq!(resp1.status(), StatusCode::OK, "first call should succeed"); 1263 + 1264 + // setup's pending_accounts row is now deleted. Create a NEW pending account 1265 + // with the SAME handle and signing key. Since pending_accounts.handle has no 1266 + // unique constraint, we can reuse setup.handle here. 1267 + let claim_code2 = format!("TEST-{}", Uuid::new_v4()); 1268 + sqlx::query( 1269 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 1270 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 1271 + ) 1272 + .bind(&claim_code2) 1273 + .execute(&db) 1274 + .await 1275 + .expect("claim_code2"); 1276 + 1277 + let account_id2 = Uuid::new_v4().to_string(); 1278 + sqlx::query( 1279 + "INSERT INTO pending_accounts \ 1280 + (id, email, handle, tier, claim_code, created_at) \ 1281 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 1282 + ) 1283 + .bind(&account_id2) 1284 + .bind(format!("retry{}@example.com", &account_id2[..8])) 1285 + .bind(&setup.handle) // same handle → same DID with same signing/rotation keys 1286 + .bind(&claim_code2) 1287 + .execute(&db) 1288 + .await 1289 + .expect("pending_account2"); 1290 + 1291 + let device_id2 = Uuid::new_v4().to_string(); 1292 + sqlx::query( 1293 + "INSERT INTO devices \ 1294 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 1295 + VALUES (?, ?, 'ios', 'retry_pubkey', 'retry_device_hash', datetime('now'), datetime('now'))", 1296 + ) 1297 + .bind(&device_id2) 1298 + .bind(&account_id2) 1299 + .execute(&db) 1300 + .await 1301 + .expect("device2"); 1302 + 1303 + let mut token_bytes2 = [0u8; 32]; 1304 + OsRng.fill_bytes(&mut token_bytes2); 1305 + let session_token2 = URL_SAFE_NO_PAD.encode(token_bytes2); 1306 + let token_hash2: String = Sha256::digest(token_bytes2) 1307 + .iter() 1308 + .map(|b| format!("{b:02x}")) 1309 + .collect(); 1310 + sqlx::query( 1311 + "INSERT INTO pending_sessions \ 1312 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 1313 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 1314 + ) 1315 + .bind(Uuid::new_v4().to_string()) 1316 + .bind(&account_id2) 1317 + .bind(&device_id2) 1318 + .bind(&token_hash2) 1319 + .execute(&db) 1320 + .await 1321 + .expect("session2"); 1322 + 1323 + // Second call: same signing_key + rotation_key + handle → same DID. 1324 + // accounts table already has this DID → handler returns 409. 1325 + let app2 = crate::app::app(state); 1326 + let resp2 = app2 1327 + .oneshot(create_did_request( 1328 + &session_token2, 1329 + &setup.signing_key_id, // same signing key 1330 + &setup.rotation_key_id, // same rotation key 1331 + )) 1332 + .await 1333 + .unwrap(); 1334 + assert_eq!(resp2.status(), StatusCode::CONFLICT, "should return 409 DID_ALREADY_EXISTS"); 1335 + } 1336 + 1337 + /// MM-89.AC2.11: plc.directory returns non-2xx → 502 PLC_DIRECTORY_ERROR 1338 + #[tokio::test] 1339 + async fn plc_directory_error_returns_502() { 1340 + let mock_server = MockServer::start().await; 1341 + Mock::given(method("POST")) 1342 + .and(path_regex(r"^/did:plc:.*$")) 1343 + .respond_with(ResponseTemplate::new(500).set_body_string("Internal Server Error")) 1344 + .expect(1) 1345 + .mount(&mock_server) 1346 + .await; 1347 + 1348 + let state = test_state_for_did(mock_server.uri()).await; 1349 + let db = state.db.clone(); 1350 + let setup = insert_test_data(&db).await; 1351 + 1352 + let app = crate::app::app(state); 1353 + let response = app 1354 + .oneshot(create_did_request( 1355 + &setup.session_token, 1356 + &setup.signing_key_id, 1357 + &setup.rotation_key_id, 1358 + )) 1359 + .await 1360 + .unwrap(); 1361 + 1362 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); 1363 + } 1364 + } 1365 + ``` 1366 + 1367 + --- 1368 + 1369 + **Step 3: Add create_did module to routes/mod.rs** 1370 + 1371 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/routes/mod.rs`, add: 1372 + 1373 + ```rust 1374 + pub mod create_did; 1375 + ``` 1376 + 1377 + Keep the existing module declarations; add this line in alphabetical order (after `claim_codes`, before `create_mobile_account`). 1378 + 1379 + --- 1380 + 1381 + **Step 4: Register POST /v1/dids in app.rs router** 1382 + 1383 + In `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/crates/relay/src/app.rs`, in the `app(state: AppState)` function: 1384 + 1385 + **4a. Add the import** at the top of the function body or via `use`: 1386 + 1387 + ```rust 1388 + use crate::routes::create_did::create_did_handler; 1389 + ``` 1390 + 1391 + **4b. Add the route** to the `Router::new()` chain (after the existing `/v1/relay/keys` route): 1392 + 1393 + ```rust 1394 + .route("/v1/dids", post(create_did_handler)) 1395 + ``` 1396 + 1397 + --- 1398 + 1399 + **Step 5: Create bruno/create-did.bru** 1400 + 1401 + Create `/Users/jacob.zweifel/workspace/malpercio-dev/ezpds/bruno/create-did.bru`: 1402 + 1403 + ``` 1404 + meta { 1405 + name: Create DID 1406 + type: http 1407 + seq: 8 1408 + } 1409 + 1410 + post { 1411 + url: {{baseUrl}}/v1/dids 1412 + body: json 1413 + auth: bearer 1414 + } 1415 + 1416 + auth:bearer { 1417 + token: {{pendingSessionToken}} 1418 + } 1419 + 1420 + body:json { 1421 + { 1422 + "signingKey": "{{signingKeyId}}", 1423 + "rotationKey": "{{rotationKeyId}}" 1424 + } 1425 + } 1426 + ``` 1427 + 1428 + --- 1429 + 1430 + **Step 6: Verify all tests pass** 1431 + 1432 + ```bash 1433 + cargo test -p relay 1434 + ``` 1435 + 1436 + Expected: all tests pass including the new `create_did` integration tests. 1437 + 1438 + **Step 7: Verify no clippy warnings** 1439 + 1440 + ```bash 1441 + cargo clippy --workspace -- -D warnings 1442 + ``` 1443 + 1444 + Expected: zero warnings. 1445 + 1446 + **Step 8: Commit** 1447 + 1448 + ```bash 1449 + git add crates/relay/src/routes/create_did.rs crates/relay/src/routes/mod.rs crates/relay/src/app.rs bruno/create-did.bru 1450 + git commit -m "feat(relay): implement POST /v1/dids with pre-store retry resilience (MM-89)" 1451 + ``` 1452 + <!-- END_TASK_6 --> 1453 + 1454 + <!-- END_SUBCOMPONENT_B --> 1455 + 1456 + --- 1457 + 1458 + ## Phase Completion Verification 1459 + 1460 + After all tasks, verify the complete phase: 1461 + 1462 + ```bash 1463 + # All relay tests pass (existing 167+ tests + new create_did tests) 1464 + cargo test -p relay 1465 + 1466 + # All crypto tests still pass 1467 + cargo test -p crypto 1468 + 1469 + # No clippy warnings across workspace 1470 + cargo clippy --workspace -- -D warnings 1471 + 1472 + # No formatting issues 1473 + cargo fmt --all --check 1474 + ``` 1475 + 1476 + Expected: all tests pass, zero warnings, formatted correctly.
+41
docs/implementation-plans/2026-03-13-MM-89/test-requirements.md
··· 1 + # Test Requirements — MM-89 2 + 3 + ## Overview 4 + MM-89 implements did:plc DID creation and account promotion for the ezpds relay. The `crypto` crate gains a pure function (`build_did_plc_genesis_op`) that constructs a signed did:plc genesis operation and derives the resulting DID from key material and identity fields. The `relay` crate gains a `POST /v1/dids` endpoint that authenticates a pending session, calls the crypto function, submits the signed operation to the external PLC Directory, and atomically promotes the pending account to an active account in the database. 5 + 6 + ## Automated Tests 7 + 8 + | Criterion | Description | Test Type | Expected Test File | Task | 9 + |-----------|-------------|-----------|-------------------|------| 10 + | MM-89.AC1.1 | `build_did_plc_genesis_op` with valid inputs returns `PlcGenesisOp` with `did` matching `^did:plc:[a-z2-7]{24}$` | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 11 + | MM-89.AC1.2 | `signed_op_json` contains all required fields: `type`, `rotationKeys`, `verificationMethods`, `alsoKnownAs`, `services`, `prev` (null), `sig` | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 12 + | MM-89.AC1.3 | `rotation_key` appears as `rotationKeys[0]`; `signing_key` appears as both `rotationKeys[1]` and `verificationMethods.atproto` | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 13 + | MM-89.AC1.4 | Calling `build_did_plc_genesis_op` twice with identical inputs returns the same `did` (RFC 6979 determinism) | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 14 + | MM-89.AC1.5 | Invalid `signing_private_key` bytes (zero scalar) returns `CryptoError::PlcOperation` | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 15 + | MM-89.AC2.1 | Valid request with a live `pending_session` token returns `200 OK` with `{ "did": "did:plc:...", "status": "active" }` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 16 + | MM-89.AC2.2 | After success, `accounts` row exists with `did` as PK, correct `email`, and `password_hash` NULL | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 17 + | MM-89.AC2.3 | After success, `did_documents` row exists for the DID with non-empty `document` JSON | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 18 + | MM-89.AC2.4 | After success, `handles` row exists linking the handle to the DID | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 19 + | MM-89.AC2.5 | After success, `pending_accounts` and `pending_sessions` rows for the account are deleted | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 20 + | MM-89.AC2.6 | When `pending_did` is already set (client retry), handler skips the plc.directory HTTP call and completes DB promotion, returning 200 | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 21 + | MM-89.AC2.7 | Missing `Authorization` header returns 401 `UNAUTHORIZED` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 22 + | MM-89.AC2.8 | Expired `pending_session` token returns 401 `UNAUTHORIZED` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 23 + | MM-89.AC2.9 | `signingKey` not present in `relay_signing_keys` returns 404 `NOT_FOUND` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 24 + | MM-89.AC2.10 | Account already fully promoted (`accounts` row already exists) returns 409 `DID_ALREADY_EXISTS` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 25 + | MM-89.AC2.11 | plc.directory returns non-2xx returns 502 `PLC_DIRECTORY_ERROR` | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 26 + | MM-89.AC3.1 | V008 migration applies cleanly on top of V007; `accounts.password_hash` accepts NULL; `pending_accounts.pending_did` column exists | integration | `crates/relay/src/routes/create_did.rs` (inline `#[cfg(test)]`) | Phase 2, Task 6 | 27 + | MM-89.AC3.2 | `sig` field in `signed_op_json` is a base64url string (no padding) decoding to exactly 64 bytes | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 28 + | MM-89.AC3.3 | `alsoKnownAs` in `signed_op_json` contains `at://{handle}` (not bare handle) | unit | `crates/crypto/src/plc.rs` (inline `#[cfg(test)]`) | Phase 1, Task 2 | 29 + 30 + ## Human Verification Required 31 + 32 + | Criterion | Description | Why Automated Testing Is Insufficient | Verification Approach | 33 + |-----------|-------------|---------------------------------------|----------------------| 34 + 35 + No criteria require human verification. All 19 acceptance criteria are covered by automated tests. The integration tests in Phase 2 use `wiremock` to simulate plc.directory responses, which is sufficient to verify the relay's behavior without requiring a live external service. The V008 migration (AC3.1) is implicitly verified by every integration test in Phase 2, since `test_state_for_did` calls `run_migrations` on an in-memory SQLite database (applying V001 through V008), and the happy-path test (AC2.1/AC2.2) inserts an `accounts` row with `password_hash = NULL` and reads back `pending_did` from `pending_accounts`, exercising both schema changes. 36 + 37 + ## Coverage Summary 38 + - Total criteria: 19 39 + - Automated: 19 40 + - Human verification: 0 41 + - Coverage: 100%
+326
docs/implementation-plans/2026-03-13-MM-90/phase_01.md
··· 1 + # MM-90 Implementation Plan — Phase 1: crypto crate verify_genesis_op 2 + 3 + **Goal:** Add `verify_genesis_op` as a pure function to the crypto crate; no I/O. 4 + 5 + **Architecture:** Functional Core only. Extends `crates/crypto/src/plc.rs` with a verification counterpart to `build_did_plc_genesis_op`. No relay changes in this phase. 6 + 7 + **Tech Stack:** Rust stable; p256 0.13 (ecdsa feature), ciborium 0.2, multibase (already in crate), base64 0.21, sha2 0.10, data-encoding 2, serde/serde_json 1. 8 + 9 + **Scope:** Phase 1 of 2 (crypto crate only) 10 + 11 + **Codebase verified:** 2026-03-13 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-90.AC1: `verify_genesis_op` in the crypto crate 18 + - **MM-90.AC1.1 Success:** Valid signed genesis op JSON with matching rotation key returns `VerifiedGenesisOp` with correct `did`, `also_known_as`, `verification_methods`, and `atproto_pds_endpoint` 19 + - **MM-90.AC1.2 Success:** DID returned by `verify_genesis_op` matches the DID returned by `build_did_plc_genesis_op` with the same inputs (round-trip consistency confirms both functions use identical CBOR encoding) 20 + - **MM-90.AC1.3 Failure:** Signed op verified against a different rotation key returns `CryptoError::PlcOperation` 21 + - **MM-90.AC1.4 Failure:** Op with a corrupted signature (one byte changed in the base64url string) returns `CryptoError::PlcOperation` 22 + - **MM-90.AC1.5 Failure:** Op JSON containing unknown/extra fields is rejected with `CryptoError::PlcOperation` 23 + 24 + --- 25 + 26 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 27 + 28 + <!-- START_TASK_1 --> 29 + ### Task 1: Add `Deserialize`, `VerifiedGenesisOp`, and `verify_genesis_op` to `plc.rs`; update `lib.rs` 30 + 31 + **Verifies:** MM-90.AC1.1, MM-90.AC1.2, MM-90.AC1.3, MM-90.AC1.4, MM-90.AC1.5 32 + 33 + **Files:** 34 + - Modify: `crates/crypto/src/plc.rs` 35 + - Modify: `crates/crypto/src/lib.rs` 36 + 37 + **Implementation:** 38 + 39 + **1. Update imports in `plc.rs` — add `Verifier`, `VerifyingKey`, `Deserialize`, and `multibase`:** 40 + 41 + ```rust 42 + // Replace: 43 + use p256::{ 44 + ecdsa::{signature::Signer, Signature, SigningKey}, 45 + FieldBytes, 46 + }; 47 + use serde::Serialize; 48 + 49 + // With: 50 + use p256::{ 51 + ecdsa::{signature::Signer, signature::Verifier, Signature, SigningKey, VerifyingKey}, 52 + FieldBytes, 53 + }; 54 + use serde::{Deserialize, Serialize}; 55 + ``` 56 + 57 + Also add `multibase` after the existing use statements (before `use crate::{...}`): 58 + ```rust 59 + use multibase; 60 + ``` 61 + 62 + **2. Add a module-level constant for the P-256 multicodec prefix (after the `use` block, before `// ── Internal serialization types`):** 63 + 64 + ```rust 65 + /// P-256 multicodec varint prefix for did:key URIs. 66 + /// 0x1200 encoded as LEB128 varint = [0x80, 0x24]. 67 + /// 68 + /// This constant is redefined here rather than promoted to `pub(crate)` in 69 + /// `keys.rs` to avoid cross-module coupling between two sibling functional 70 + /// modules. Each module owns its own copy; if the value ever needs to change, 71 + /// both sites are easy to find via the shared literal `[0x80, 0x24]`. 72 + const P256_MULTICODEC_PREFIX: &[u8] = &[0x80, 0x24]; 73 + ``` 74 + 75 + **3. Add `Deserialize` to `PlcService` and `SignedPlcOp`; add `deny_unknown_fields` to `SignedPlcOp` (AC1.5):** 76 + 77 + `PlcService` — add `Deserialize`: 78 + ```rust 79 + #[derive(Serialize, Deserialize, Clone)] 80 + struct PlcService { 81 + #[serde(rename = "type")] 82 + service_type: String, 83 + endpoint: String, 84 + } 85 + ``` 86 + 87 + `UnsignedPlcOp` — no change to derives (never deserialized from JSON, reconstructed from `SignedPlcOp` fields). 88 + 89 + `SignedPlcOp` — add `Deserialize` and `deny_unknown_fields`: 90 + ```rust 91 + #[derive(Serialize, Deserialize)] 92 + #[serde(deny_unknown_fields)] 93 + struct SignedPlcOp { 94 + sig: String, 95 + prev: Option<String>, 96 + #[serde(rename = "type")] 97 + op_type: String, 98 + services: BTreeMap<String, PlcService>, 99 + #[serde(rename = "alsoKnownAs")] 100 + also_known_as: Vec<String>, 101 + #[serde(rename = "rotationKeys")] 102 + rotation_keys: Vec<String>, 103 + #[serde(rename = "verificationMethods")] 104 + verification_methods: BTreeMap<String, String>, 105 + } 106 + ``` 107 + 108 + **4. Add `VerifiedGenesisOp` struct in the `// ── Public API` section, before `build_did_plc_genesis_op`:** 109 + 110 + ```rust 111 + /// The result of verifying a client-submitted did:plc genesis operation. 112 + /// 113 + /// Returned by [`verify_genesis_op`]. Fields are extracted directly from the 114 + /// verified signed op; the relay uses them for semantic validation and DID 115 + /// document construction. 116 + pub struct VerifiedGenesisOp { 117 + /// The derived DID, e.g. `"did:plc:abcdefghijklmnopqrstuvwx"`. 118 + pub did: String, 119 + /// Full `rotationKeys` array from the op. 120 + pub rotation_keys: Vec<String>, 121 + /// Full `alsoKnownAs` array from the op. 122 + pub also_known_as: Vec<String>, 123 + /// Full `verificationMethods` map from the op. 124 + pub verification_methods: BTreeMap<String, String>, 125 + /// Endpoint from `services["atproto_pds"]`, if present. 126 + pub atproto_pds_endpoint: Option<String>, 127 + } 128 + ``` 129 + 130 + **5. Add `verify_genesis_op` function after `build_did_plc_genesis_op` in the `// ── Public API` section:** 131 + 132 + ```rust 133 + /// Verify a client-submitted did:plc signed genesis operation. 134 + /// 135 + /// Parses `signed_op_json` into a [`SignedPlcOp`] (rejecting unknown fields), 136 + /// reconstructs the unsigned operation with the same DAG-CBOR field ordering 137 + /// as [`build_did_plc_genesis_op`], verifies the ECDSA-SHA256 signature against 138 + /// `rotation_key`, derives the DID (SHA-256 of signed CBOR → base32-lowercase 139 + /// first 24 chars), and returns the extracted operation fields. 140 + /// 141 + /// # Errors 142 + /// Returns `CryptoError::PlcOperation` for any parse, format, or cryptographic failure. 143 + pub fn verify_genesis_op( 144 + signed_op_json: &str, 145 + rotation_key: &DidKeyUri, 146 + ) -> Result<VerifiedGenesisOp, CryptoError> { 147 + // Step 1: Parse the signed op, rejecting unknown fields (AC1.5). 148 + let signed_op: SignedPlcOp = serde_json::from_str(signed_op_json) 149 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signed op JSON: {e}")))?; 150 + 151 + // Step 2: Base64url-decode the signature field. 152 + let sig_bytes = URL_SAFE_NO_PAD 153 + .decode(&signed_op.sig) 154 + .map_err(|e| CryptoError::PlcOperation(format!("invalid sig base64url: {e}")))?; 155 + 156 + // Step 3: Parse the 64-byte r‖s fixed-size ECDSA signature. 157 + let signature = Signature::try_from(sig_bytes.as_slice()) 158 + .map_err(|e| CryptoError::PlcOperation(format!("invalid ECDSA signature bytes: {e}")))?; 159 + 160 + // Step 4: Reconstruct the unsigned operation from signed op fields. 161 + // Field order must match UnsignedPlcOp's DAG-CBOR canonical ordering. 162 + let unsigned_op = UnsignedPlcOp { 163 + prev: signed_op.prev.clone(), 164 + op_type: signed_op.op_type.clone(), 165 + services: signed_op.services.clone(), 166 + also_known_as: signed_op.also_known_as.clone(), 167 + rotation_keys: signed_op.rotation_keys.clone(), 168 + verification_methods: signed_op.verification_methods.clone(), 169 + }; 170 + 171 + // Step 5: CBOR-encode the unsigned op — byte-exact match to what was signed. 172 + let mut unsigned_cbor = Vec::new(); 173 + into_writer(&unsigned_op, &mut unsigned_cbor) 174 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 175 + 176 + // Step 6: Parse rotation key URI → P-256 VerifyingKey. 177 + let key_str = rotation_key 178 + .0 179 + .strip_prefix("did:key:") 180 + .ok_or_else(|| { 181 + CryptoError::PlcOperation("rotation key missing did:key: prefix".to_string()) 182 + })?; 183 + let (_, multikey_bytes) = multibase::decode(key_str) 184 + .map_err(|e| CryptoError::PlcOperation(format!("decode rotation key multibase: {e}")))?; 185 + if multikey_bytes.get(..2) != Some(P256_MULTICODEC_PREFIX) { 186 + return Err(CryptoError::PlcOperation( 187 + "rotation key is not a P-256 key (wrong multicodec prefix)".to_string(), 188 + )); 189 + } 190 + let verifying_key = VerifyingKey::from_sec1_bytes(&multikey_bytes[2..]) 191 + .map_err(|e| CryptoError::PlcOperation(format!("invalid P-256 public key: {e}")))?; 192 + 193 + // Step 7: Verify the ECDSA-SHA256 signature (SHA-256 applied internally by p256). 194 + verifying_key 195 + .verify(&unsigned_cbor, &signature) 196 + .map_err(|e| CryptoError::PlcOperation(format!("signature verification failed: {e}")))?; 197 + 198 + // Step 8: CBOR-encode the signed op and derive the DID. 199 + let mut signed_cbor = Vec::new(); 200 + into_writer(&signed_op, &mut signed_cbor) 201 + .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 202 + 203 + let hash = Sha256::digest(&signed_cbor); 204 + let base32_encoding = { 205 + let mut spec = data_encoding::Specification::new(); 206 + spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567"); 207 + spec.encoding() 208 + .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))? 209 + }; 210 + let encoded = base32_encoding.encode(hash.as_ref()); 211 + let did = format!("did:plc:{}", &encoded[..24]); 212 + 213 + // Step 9: Extract atproto_pds endpoint from services map. 214 + let atproto_pds_endpoint = signed_op 215 + .services 216 + .get("atproto_pds") 217 + .map(|s| s.endpoint.clone()); 218 + 219 + Ok(VerifiedGenesisOp { 220 + did, 221 + rotation_keys: signed_op.rotation_keys, 222 + also_known_as: signed_op.also_known_as, 223 + verification_methods: signed_op.verification_methods, 224 + atproto_pds_endpoint, 225 + }) 226 + } 227 + ``` 228 + 229 + **6. Update `crates/crypto/src/lib.rs` — add re-exports for `verify_genesis_op` and `VerifiedGenesisOp`:** 230 + 231 + ```rust 232 + // Replace: 233 + pub use plc::{build_did_plc_genesis_op, PlcGenesisOp}; 234 + 235 + // With: 236 + pub use plc::{build_did_plc_genesis_op, verify_genesis_op, PlcGenesisOp, VerifiedGenesisOp}; 237 + ``` 238 + 239 + **Verification:** 240 + 241 + Run: `cargo build -p crypto` 242 + Expected: Compiles with zero errors 243 + 244 + Run: `cargo clippy -p crypto -- -D warnings` 245 + Expected: Zero warnings 246 + 247 + **Commit:** `feat(crypto): add verify_genesis_op and VerifiedGenesisOp (MM-90 Phase 1, step 1)` 248 + <!-- END_TASK_1 --> 249 + 250 + <!-- START_TASK_2 --> 251 + ### Task 2: Write tests for MM-90.AC1.1–AC1.5 252 + 253 + **Verifies:** MM-90.AC1.1, MM-90.AC1.2, MM-90.AC1.3, MM-90.AC1.4, MM-90.AC1.5 254 + 255 + **Files:** 256 + - Modify: `crates/crypto/src/plc.rs` — append to existing `#[cfg(test)]` `mod tests` block 257 + 258 + **Testing:** 259 + 260 + Add a helper function inside `mod tests` that produces a verifiable signed op. Note: `build_did_plc_genesis_op` signs with the `signing_key` private key — so `verify_genesis_op` must be called with `signing_kp.key_id` (not `rotation_kp.key_id`) to succeed: 261 + 262 + ```rust 263 + /// Returns (signing_key_uri, PlcGenesisOp) for MM-90 verification tests. 264 + /// build_did_plc_genesis_op signs with signing_key_bytes; verify_genesis_op 265 + /// must receive signing_kp.key_id as its rotation_key argument. 266 + fn make_op_for_verify() -> (DidKeyUri, PlcGenesisOp) { 267 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 268 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 269 + let private_key_bytes = *signing_kp.private_key_bytes; 270 + let op = build_did_plc_genesis_op( 271 + &rotation_kp.key_id, 272 + &signing_kp.key_id, 273 + &private_key_bytes, 274 + "alice.example.com", 275 + "https://relay.example.com", 276 + ) 277 + .expect("genesis op"); 278 + (signing_kp.key_id, op) 279 + } 280 + ``` 281 + 282 + Tests to add (each in its own `#[test]` function): 283 + 284 + - **`verify_valid_op_returns_correct_fields`** (MM-90.AC1.1): 285 + Call `make_op_for_verify()`, then `verify_genesis_op(&op.signed_op_json, &signing_key)`. 286 + Assert `Result::is_ok()`. On the returned `VerifiedGenesisOp`, assert: 287 + - `verified.did.starts_with("did:plc:")` and `verified.did.len() == 28` 288 + - `verified.also_known_as` contains `"at://alice.example.com"` 289 + - `verified.verification_methods.contains_key("atproto")` 290 + - `verified.atproto_pds_endpoint == Some("https://relay.example.com".to_string())` 291 + 292 + - **`verify_did_matches_build_did_plc_genesis_op`** (MM-90.AC1.2): 293 + Call `build_did_plc_genesis_op` with fixed test keypairs and inputs, then call 294 + `verify_genesis_op` with the resulting `signed_op_json` and `signing_kp.key_id`. 295 + Assert `verified_op.did == genesis_op.did`. 296 + 297 + - **`verify_wrong_rotation_key_returns_error`** (MM-90.AC1.3): 298 + Call `make_op_for_verify()`. Generate a fresh third keypair (`wrong_kp`). 299 + Call `verify_genesis_op(&op.signed_op_json, &wrong_kp.key_id)`. 300 + Assert `matches!(result, Err(CryptoError::PlcOperation(_)))`. 301 + 302 + - **`verify_corrupted_signature_returns_error`** (MM-90.AC1.4): 303 + Call `make_op_for_verify()`. Parse `op.signed_op_json` as `serde_json::Value`. 304 + Get the `sig` string, base64url-decode it, flip one byte (e.g. `sig_bytes[0] ^= 0xff`), 305 + re-encode with `URL_SAFE_NO_PAD`, set `v["sig"] = ...`, re-serialize. 306 + Call `verify_genesis_op` with the corrupted JSON and the signing key. 307 + Assert `matches!(result, Err(CryptoError::PlcOperation(_)))`. 308 + 309 + - **`verify_unknown_fields_returns_error`** (MM-90.AC1.5): 310 + Call `make_op_for_verify()`. Parse `op.signed_op_json` as `serde_json::Value`. 311 + Add `v["unknownField"] = serde_json::json!("surprise")`, re-serialize. 312 + Call `verify_genesis_op` with the modified JSON and signing key. 313 + Assert `matches!(result, Err(CryptoError::PlcOperation(_)))`. 314 + 315 + **Verification:** 316 + 317 + Run: `cargo test -p crypto` 318 + Expected: All tests pass, including 5 new `MM-90.AC1.*` tests 319 + 320 + Run: `cargo clippy --workspace -- -D warnings` 321 + Expected: Zero warnings 322 + 323 + **Commit:** `test(crypto): add MM-90.AC1.1–AC1.5 tests for verify_genesis_op` 324 + <!-- END_TASK_2 --> 325 + 326 + <!-- END_SUBCOMPONENT_A -->
+1016
docs/implementation-plans/2026-03-13-MM-90/phase_02.md
··· 1 + # MM-90 Implementation Plan — Phase 2: relay crate POST /v1/dids 2 + 3 + **Goal:** Replace the relay-signing DID handler with a device-signing handler that validates the client-submitted signed genesis op. 4 + 5 + **Architecture:** Imperative Shell only. `crates/relay/src/routes/create_did.rs` is completely rewritten. Calls `crypto::verify_genesis_op` (Phase 1) for all cryptographic work. All I/O (DB, HTTP) stays in the relay crate. 6 + 7 + **Tech Stack:** Rust/axum, sqlx (SQLite), reqwest, serde_json, wiremock (tests), crypto crate (Phase 1). 8 + 9 + **Scope:** Phase 2 of 2 (relay crate only; depends on Phase 1) 10 + 11 + **Codebase verified:** 2026-03-13 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### MM-90.AC2: `POST /v1/dids` — happy path and account promotion 18 + - **MM-90.AC2.1 Success:** Valid request with a live `pending_session` token and a correctly signed op returns `200 OK` with `{ "did": "did:plc:...", "did_document": {...}, "status": "active" }` 19 + - **MM-90.AC2.2 Success:** After success, `accounts` row exists with the correct `did` and `email`; `password_hash` is NULL 20 + - **MM-90.AC2.3 Success:** After success, `did_documents` row exists for the DID with a non-empty `document` JSON 21 + - **MM-90.AC2.4 Success:** After success, `handles` row exists linking the pending account's handle to the DID 22 + - **MM-90.AC2.5 Success:** After success, `pending_accounts` and `pending_sessions` rows for the account are deleted 23 + - **MM-90.AC2.6 Success:** When `pending_did` is already set (client retry after partial failure), plc.directory is not called and promotion completes returning 200 24 + 25 + ### MM-90.AC3: `POST /v1/dids` — failure cases 26 + - **MM-90.AC3.1 Failure:** Invalid ECDSA signature in submitted op returns 400 `INVALID_CLAIM` 27 + - **MM-90.AC3.2 Failure:** `alsoKnownAs[0]` in op does not match `at://{handle}` from `pending_accounts` returns 400 `INVALID_CLAIM` 28 + - **MM-90.AC3.3 Failure:** `services.atproto_pds.endpoint` in op does not match `config.public_url` returns 400 `INVALID_CLAIM` 29 + - **MM-90.AC3.4 Failure:** `rotationKeys[0]` in op does not match `rotationKeyPublic` from the request body returns 400 `INVALID_CLAIM` 30 + - **MM-90.AC3.5 Failure:** Account already fully promoted (`accounts` row already exists for the derived DID) returns 409 `DID_ALREADY_EXISTS` 31 + - **MM-90.AC3.6 Failure:** Missing or expired `pending_session` token returns 401 `UNAUTHORIZED` 32 + - **MM-90.AC3.7 Failure:** plc.directory returns non-2xx returns 502 `PLC_DIRECTORY_ERROR` 33 + 34 + ### MM-90.AC4: DID document correctness 35 + - **MM-90.AC4.1 Success:** `did_document` in the response contains a `verificationMethod` whose `publicKeyMultibase` is derived from the `verificationMethods.atproto` field in the submitted op 36 + - **MM-90.AC4.2 Success:** `did_document` in the response contains `alsoKnownAs` with `at://` + the account's handle from `pending_accounts` 37 + - **MM-90.AC4.3 Success:** `did_document` in the response contains a service entry with `serviceEndpoint` matching `config.public_url` 38 + 39 + --- 40 + 41 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 42 + 43 + <!-- START_TASK_1 --> 44 + ### Task 1: Replace handler, types, and `build_did_document` in `create_did.rs` 45 + 46 + **Verifies:** MM-90.AC2.1, MM-90.AC2.2, MM-90.AC2.3, MM-90.AC2.4, MM-90.AC2.5, MM-90.AC2.6, MM-90.AC3.1, MM-90.AC3.2, MM-90.AC3.3, MM-90.AC3.4, MM-90.AC3.5, MM-90.AC3.6, MM-90.AC3.7, MM-90.AC4.1, MM-90.AC4.2, MM-90.AC4.3 47 + 48 + **Files:** 49 + - Modify: `crates/relay/src/routes/create_did.rs` (full replacement of comment block, imports, structs, handler, and `build_did_document`; keep `// ── Tests ──` section intact until Task 3) 50 + 51 + **Implementation:** 52 + 53 + Replace everything from line 1 through the end of `build_did_document` (keep the test block). Preserve the `// pattern: Imperative Shell` comment convention, updated for MM-90. 54 + 55 + **1. Updated comment block and imports (replace lines 1–35):** 56 + 57 + ```rust 58 + // pattern: Imperative Shell 59 + // 60 + // POST /v1/dids — Device-signed DID ceremony and account promotion 61 + // 62 + // Inputs: 63 + // - Authorization: Bearer <pending_session_token> 64 + // - JSON body: { 65 + // "rotationKeyPublic": "did:key:z...", 66 + // "signedCreationOp": { ...genesis op fields... } 67 + // } 68 + // 69 + // Processing steps: 70 + // 1. require_pending_session → PendingSessionInfo { account_id, device_id } 71 + // 2. SELECT handle, pending_did, email FROM pending_accounts WHERE id = account_id 72 + // 3. Validate rotationKeyPublic starts with "did:key:z" → DidKeyUri 73 + // 4. serde_json::to_string(signedCreationOp) → signed_op_str 74 + // 5. crypto::verify_genesis_op(signed_op_str, rotation_key) → VerifiedGenesisOp 75 + // 6. Semantic validation: 76 + // verified.rotation_keys[0] == rotationKeyPublic 77 + // verified.also_known_as[0] == "at://{handle}" 78 + // verified.atproto_pds_endpoint == config.public_url 79 + // 7. If pending_did IS NULL: UPDATE pending_accounts SET pending_did = verified.did 80 + // If pending_did IS NOT NULL: verify match, set skip_plc_directory = true 81 + // 8. SELECT EXISTS(SELECT 1 FROM accounts WHERE did = verified.did) → 409 if true 82 + // 9. If !skip_plc_directory: POST {plc_directory_url}/{did} with signed_op_str 83 + // 10. build_did_document(&verified) → serde_json::Value 84 + // 11. Atomic transaction: 85 + // INSERT accounts (did, email, password_hash=NULL) 86 + // INSERT did_documents (did, document) 87 + // INSERT handles (handle, did) 88 + // DELETE pending_sessions WHERE account_id = ? 89 + // DELETE devices WHERE account_id = ? 90 + // DELETE pending_accounts WHERE id = ? 91 + // 12. Return { "did": "did:plc:...", "did_document": {...}, "status": "active" } 92 + // 93 + // Outputs (success): 200 { "did": "did:plc:...", "did_document": {...}, "status": "active" } 94 + // Outputs (error): 400 INVALID_CLAIM, 401 UNAUTHORIZED, 409 DID_ALREADY_EXISTS, 95 + // 502 PLC_DIRECTORY_ERROR, 500 INTERNAL_ERROR 96 + 97 + use axum::{extract::State, http::HeaderMap, Json}; 98 + use serde::{Deserialize, Serialize}; 99 + 100 + use crate::app::AppState; 101 + use crate::routes::auth::require_pending_session; 102 + use common::{ApiError, ErrorCode}; 103 + ``` 104 + 105 + **2. New request/response structs (replace lines 37–48):** 106 + 107 + ```rust 108 + #[derive(Deserialize)] 109 + #[serde(rename_all = "camelCase")] 110 + pub struct CreateDidRequest { 111 + pub rotation_key_public: String, 112 + pub signed_creation_op: serde_json::Value, 113 + } 114 + 115 + #[derive(Serialize)] 116 + pub struct CreateDidResponse { 117 + pub did: String, 118 + pub did_document: serde_json::Value, 119 + pub status: &'static str, 120 + } 121 + ``` 122 + 123 + **3. New handler body (replace lines 50–306):** 124 + 125 + ```rust 126 + pub async fn create_did_handler( 127 + State(state): State<AppState>, 128 + headers: HeaderMap, 129 + Json(payload): Json<CreateDidRequest>, 130 + ) -> Result<Json<CreateDidResponse>, ApiError> { 131 + // Step 1: Authenticate via pending_session Bearer token. 132 + let session = require_pending_session(&headers, &state.db).await?; 133 + 134 + // Step 2: Load pending account details. 135 + let (handle, pending_did, email): (String, Option<String>, String) = 136 + sqlx::query_as("SELECT handle, pending_did, email FROM pending_accounts WHERE id = ?") 137 + .bind(&session.account_id) 138 + .fetch_optional(&state.db) 139 + .await 140 + .map_err(|e| { 141 + tracing::error!(error = %e, "failed to query pending account"); 142 + ApiError::new(ErrorCode::InternalError, "failed to load account") 143 + })? 144 + .ok_or_else(|| ApiError::new(ErrorCode::Unauthorized, "account not found"))?; 145 + 146 + // Step 3: Validate rotationKeyPublic format. 147 + if !payload.rotation_key_public.starts_with("did:key:z") { 148 + return Err(ApiError::new( 149 + ErrorCode::InvalidClaim, 150 + "rotationKeyPublic must be a did:key: URI starting with 'did:key:z'", 151 + )); 152 + } 153 + let rotation_key = crypto::DidKeyUri(payload.rotation_key_public.clone()); 154 + 155 + // Step 4: Serialize the submitted signed op to a JSON string for crypto verification. 156 + let signed_op_str = serde_json::to_string(&payload.signed_creation_op).map_err(|e| { 157 + tracing::error!(error = %e, "failed to serialize signedCreationOp"); 158 + ApiError::new(ErrorCode::InternalError, "failed to process signed op") 159 + })?; 160 + 161 + // Step 5: Verify the ECDSA signature and derive the DID. 162 + let verified = 163 + crypto::verify_genesis_op(&signed_op_str, &rotation_key).map_err(|e| { 164 + tracing::warn!(error = %e, "genesis op verification failed"); 165 + ApiError::new(ErrorCode::InvalidClaim, format!("invalid signed genesis op: {e}")) 166 + })?; 167 + 168 + // Step 6: Semantic validation — ensure op fields match account and server config. 169 + if verified.rotation_keys.first().map(String::as_str) != Some(&payload.rotation_key_public) { 170 + return Err(ApiError::new( 171 + ErrorCode::InvalidClaim, 172 + "rotationKeys[0] in op does not match rotationKeyPublic", 173 + )); 174 + } 175 + if verified.also_known_as.first().map(String::as_str) != Some(&format!("at://{handle}")) { 176 + return Err(ApiError::new( 177 + ErrorCode::InvalidClaim, 178 + "alsoKnownAs[0] in op does not match account handle", 179 + )); 180 + } 181 + if verified.atproto_pds_endpoint.as_deref() != Some(&state.config.public_url) { 182 + return Err(ApiError::new( 183 + ErrorCode::InvalidClaim, 184 + "services.atproto_pds.endpoint in op does not match server public URL", 185 + )); 186 + } 187 + 188 + let did = &verified.did; 189 + 190 + // Step 7: Pre-store the DID for retry resilience. 191 + let skip_plc_directory = if let Some(pre_stored_did) = &pending_did { 192 + if did != pre_stored_did { 193 + tracing::error!( 194 + derived_did = %did, 195 + stored_did = %pre_stored_did, 196 + "retry path: derived DID does not match pre-stored DID; inputs may have changed" 197 + ); 198 + return Err(ApiError::new( 199 + ErrorCode::InternalError, 200 + "DID mismatch: derived DID does not match pre-stored value", 201 + )); 202 + } 203 + tracing::info!(did = %pre_stored_did, "retry detected: pending_did already set, skipping plc.directory"); 204 + true 205 + } else { 206 + sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 207 + .bind(did) 208 + .bind(&session.account_id) 209 + .execute(&state.db) 210 + .await 211 + .map_err(|e| { 212 + tracing::error!(error = %e, "failed to pre-store pending_did"); 213 + ApiError::new(ErrorCode::InternalError, "failed to store pending DID") 214 + })?; 215 + false 216 + }; 217 + 218 + // Step 8: Check if the account is already fully promoted (idempotency guard). 219 + let already_promoted: bool = 220 + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)") 221 + .bind(did) 222 + .fetch_one(&state.db) 223 + .await 224 + .map_err(|e| { 225 + tracing::error!(error = %e, "failed to check accounts existence"); 226 + ApiError::new(ErrorCode::InternalError, "database error") 227 + })?; 228 + 229 + if already_promoted { 230 + return Err(ApiError::new( 231 + ErrorCode::DidAlreadyExists, 232 + "DID is already fully promoted", 233 + )); 234 + } 235 + 236 + // Step 9: POST the signed genesis operation to plc.directory (skipped on retry). 237 + if !skip_plc_directory { 238 + let plc_url = format!("{}/{}", state.config.plc_directory_url, did); 239 + let response = state 240 + .http_client 241 + .post(&plc_url) 242 + .body(signed_op_str.clone()) 243 + .header("Content-Type", "application/json") 244 + .send() 245 + .await 246 + .map_err(|e| { 247 + tracing::error!(error = %e, plc_url = %plc_url, "failed to contact plc.directory"); 248 + ApiError::new(ErrorCode::PlcDirectoryError, "failed to contact plc.directory") 249 + })?; 250 + 251 + if !response.status().is_success() { 252 + let status = response.status(); 253 + let body_text = response 254 + .text() 255 + .await 256 + .unwrap_or_else(|_| "<failed to read body>".to_string()); 257 + tracing::error!( 258 + status = %status, 259 + body = %body_text, 260 + "plc.directory rejected genesis operation" 261 + ); 262 + return Err(ApiError::new( 263 + ErrorCode::PlcDirectoryError, 264 + format!("plc.directory returned {status}"), 265 + )); 266 + } 267 + } 268 + 269 + // Step 10: Build the DID document from verified op fields. 270 + let did_document = build_did_document(&verified)?; 271 + let did_document_str = serde_json::to_string(&did_document).map_err(|e| { 272 + tracing::error!(error = %e, "failed to serialize DID document"); 273 + ApiError::new(ErrorCode::InternalError, "failed to serialize DID document") 274 + })?; 275 + 276 + // Step 11: Atomically promote the account. 277 + let mut tx = state 278 + .db 279 + .begin() 280 + .await 281 + .inspect_err(|e| tracing::error!(error = %e, "failed to begin promotion transaction")) 282 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to begin transaction"))?; 283 + 284 + sqlx::query( 285 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 286 + VALUES (?, ?, NULL, datetime('now'), datetime('now'))", 287 + ) 288 + .bind(did) 289 + .bind(&email) 290 + .execute(&mut *tx) 291 + .await 292 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert account")) 293 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to create account"))?; 294 + 295 + sqlx::query( 296 + "INSERT INTO did_documents (did, document, created_at, updated_at) \ 297 + VALUES (?, ?, datetime('now'), datetime('now'))", 298 + ) 299 + .bind(did) 300 + .bind(&did_document_str) 301 + .execute(&mut *tx) 302 + .await 303 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert did_document")) 304 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to store DID document"))?; 305 + 306 + sqlx::query("INSERT INTO handles (handle, did, created_at) VALUES (?, ?, datetime('now'))") 307 + .bind(&handle) 308 + .bind(did) 309 + .execute(&mut *tx) 310 + .await 311 + .inspect_err(|e| tracing::error!(error = %e, "failed to insert handle")) 312 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to register handle"))?; 313 + 314 + sqlx::query("DELETE FROM pending_sessions WHERE account_id = ?") 315 + .bind(&session.account_id) 316 + .execute(&mut *tx) 317 + .await 318 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending sessions")) 319 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up sessions"))?; 320 + 321 + sqlx::query("DELETE FROM devices WHERE account_id = ?") 322 + .bind(&session.account_id) 323 + .execute(&mut *tx) 324 + .await 325 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete devices")) 326 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up devices"))?; 327 + 328 + sqlx::query("DELETE FROM pending_accounts WHERE id = ?") 329 + .bind(&session.account_id) 330 + .execute(&mut *tx) 331 + .await 332 + .inspect_err(|e| tracing::error!(error = %e, "failed to delete pending account")) 333 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to clean up account"))?; 334 + 335 + tx.commit() 336 + .await 337 + .inspect_err(|e| tracing::error!(error = %e, "failed to commit promotion transaction")) 338 + .map_err(|_| ApiError::new(ErrorCode::InternalError, "failed to commit transaction"))?; 339 + 340 + // Step 12: Return the result. 341 + Ok(Json(CreateDidResponse { 342 + did: did.clone(), 343 + did_document, 344 + status: "active", 345 + })) 346 + } 347 + ``` 348 + 349 + **4. Updated `build_did_document` function (replace lines 308–348):** 350 + 351 + ```rust 352 + /// Construct a minimal DID Core document from a verified genesis operation. 353 + /// 354 + /// No I/O — pure construction from [`crypto::VerifiedGenesisOp`] fields. 355 + /// 356 + /// # Errors 357 + /// Returns `InternalError` if `verificationMethods["atproto"]` is absent or is not a did:key: URI. 358 + fn build_did_document(verified: &crypto::VerifiedGenesisOp) -> Result<serde_json::Value, ApiError> { 359 + let did = &verified.did; 360 + 361 + // Extract the multibase key from did:key URI for publicKeyMultibase. 362 + // did:key:zAbcDef... → publicKeyMultibase = "zAbcDef..." 363 + let atproto_did_key = verified 364 + .verification_methods 365 + .get("atproto") 366 + .ok_or_else(|| { 367 + ApiError::new(ErrorCode::InternalError, "atproto verification method not found in op") 368 + })?; 369 + let public_key_multibase = atproto_did_key.strip_prefix("did:key:").ok_or_else(|| { 370 + ApiError::new(ErrorCode::InternalError, "atproto key is not a did:key: URI") 371 + })?; 372 + 373 + let service_endpoint = verified.atproto_pds_endpoint.as_deref().unwrap_or_default(); 374 + 375 + Ok(serde_json::json!({ 376 + "@context": [ 377 + "https://www.w3.org/ns/did/v1" 378 + ], 379 + "id": did, 380 + "alsoKnownAs": &verified.also_known_as, 381 + "verificationMethod": [{ 382 + "id": format!("{did}#atproto"), 383 + "type": "Multikey", 384 + "controller": did, 385 + "publicKeyMultibase": public_key_multibase 386 + }], 387 + "service": [{ 388 + "id": "#atproto_pds", 389 + "type": "AtprotoPersonalDataServer", 390 + "serviceEndpoint": service_endpoint 391 + }] 392 + })) 393 + } 394 + ``` 395 + 396 + **Verification:** 397 + 398 + Run: `cargo build -p relay` 399 + Expected: Compiles with zero errors 400 + 401 + Run: `cargo clippy -p relay -- -D warnings` 402 + Expected: Zero warnings 403 + 404 + **Commit:** `feat(relay): replace POST /v1/dids with device-signing handler (MM-90 Phase 2, step 1)` 405 + <!-- END_TASK_1 --> 406 + 407 + <!-- START_TASK_2 --> 408 + ### Task 2: Update `bruno/create-did.bru` for new request shape 409 + 410 + **Verifies:** None (Bruno file is developer tooling, not covered by ACs) 411 + 412 + **Files:** 413 + - Modify: `bruno/create-did.bru` (resolve merge conflict, update request body) 414 + 415 + **Implementation:** 416 + 417 + Replace the entire file contents with the following (resolves the existing merge conflict markers and updates to MM-90 request shape): 418 + 419 + ``` 420 + meta { 421 + name: Create DID 422 + type: http 423 + seq: 8 424 + } 425 + 426 + post { 427 + url: {{baseUrl}}/v1/dids 428 + body: json 429 + auth: bearer 430 + } 431 + 432 + auth:bearer { 433 + token: {{pendingSessionToken}} 434 + } 435 + 436 + body:json { 437 + { 438 + "rotationKeyPublic": "{{rotationKeyPublic}}", 439 + "signedCreationOp": {{signedCreationOp}} 440 + } 441 + } 442 + ``` 443 + 444 + **Verification:** 445 + 446 + Open Bruno desktop app or inspect the file. Confirm no merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain. Confirm request body uses `rotationKeyPublic` and `signedCreationOp`. 447 + 448 + **Commit:** `chore(bruno): update create-did.bru for MM-90 device-signing request shape` 449 + <!-- END_TASK_2 --> 450 + 451 + <!-- END_SUBCOMPONENT_A --> 452 + 453 + <!-- START_SUBCOMPONENT_B (tasks 3) --> 454 + 455 + <!-- START_TASK_3 --> 456 + ### Task 3: Replace all inline tests with MM-90 AC coverage 457 + 458 + **Verifies:** MM-90.AC2.1, MM-90.AC2.2, MM-90.AC2.3, MM-90.AC2.4, MM-90.AC2.5, MM-90.AC2.6, MM-90.AC3.1, MM-90.AC3.2, MM-90.AC3.3, MM-90.AC3.4, MM-90.AC3.5, MM-90.AC3.6, MM-90.AC3.7, MM-90.AC4.1, MM-90.AC4.2, MM-90.AC4.3 459 + 460 + **Files:** 461 + - Modify: `crates/relay/src/routes/create_did.rs` — replace entire `#[cfg(test)] mod tests` block (from `// ── Tests ──` to end of file) 462 + 463 + **Implementation:** 464 + 465 + Delete everything from the `// ── Tests ──` comment (currently line 350) to the end of the file, and replace with the following test module. 466 + 467 + **Key changes from MM-89 test module:** 468 + - Remove `TEST_MASTER_KEY` constant (no relay key to decrypt) 469 + - Remove relay signing key insertion from `insert_test_data` 470 + - Remove `signing_key_master_key` from `test_state_for_did` (simplify to wrap `test_state_with_plc_url`) 471 + - Add `make_signed_op(handle, public_url)` helper using `build_did_plc_genesis_op` 472 + - Update `create_did_request` to accept `rotation_key_public` and `signed_creation_op` 473 + - Replace all 7 MM-89 test functions with 9 MM-90 test functions 474 + 475 + **Test module to write:** 476 + 477 + ```rust 478 + // ── Tests ──────────────────────────────────────────────────────────────────── 479 + 480 + #[cfg(test)] 481 + mod tests { 482 + use super::*; 483 + use crate::app::test_state_with_plc_url; 484 + use axum::{ 485 + body::Body, 486 + http::{Request, StatusCode}, 487 + }; 488 + use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; 489 + use rand_core::{OsRng, RngCore}; 490 + use sha2::{Digest, Sha256}; 491 + use tower::ServiceExt; // for `.oneshot()` 492 + use uuid::Uuid; 493 + use wiremock::{ 494 + matchers::{method, path_regex}, 495 + Mock, MockServer, ResponseTemplate, 496 + }; 497 + 498 + // ── Test setup helpers ──────────────────────────────────────────────────── 499 + 500 + struct TestSetup { 501 + session_token: String, 502 + account_id: String, 503 + handle: String, 504 + } 505 + 506 + /// Generate a signed genesis op verifiable by the returned rotation_key_public. 507 + /// 508 + /// Uses the same keypair for both rotation and signing: kp signs the op, 509 + /// AND kp.key_id appears at rotationKeys[0]. Calling verify_genesis_op with 510 + /// kp.key_id will succeed. 511 + fn make_signed_op(handle: &str, public_url: &str) -> (String, serde_json::Value) { 512 + use crypto::{build_did_plc_genesis_op, generate_p256_keypair}; 513 + let kp = generate_p256_keypair().expect("keypair"); 514 + let private_bytes = *kp.private_key_bytes; 515 + let genesis_op = build_did_plc_genesis_op( 516 + &kp.key_id, // rotation key — placed at rotationKeys[0] 517 + &kp.key_id, // signing key (same) — kp's private key performs the signing 518 + &private_bytes, 519 + handle, 520 + public_url, 521 + ) 522 + .expect("genesis op"); 523 + let signed_op_value: serde_json::Value = 524 + serde_json::from_str(&genesis_op.signed_op_json).expect("valid JSON"); 525 + (kp.key_id.0, signed_op_value) 526 + } 527 + 528 + /// Insert prerequisite rows for a DID-creation test. 529 + /// 530 + /// Inserts: claim_code, pending_account, device, pending_session. 531 + /// No relay signing key needed for MM-90. 532 + async fn insert_test_data(db: &sqlx::SqlitePool) -> TestSetup { 533 + let claim_code = format!("TEST-{}", Uuid::new_v4()); 534 + sqlx::query( 535 + "INSERT INTO claim_codes (code, expires_at, created_at) \ 536 + VALUES (?, datetime('now', '+1 hour'), datetime('now'))", 537 + ) 538 + .bind(&claim_code) 539 + .execute(db) 540 + .await 541 + .expect("insert claim_code"); 542 + 543 + let account_id = Uuid::new_v4().to_string(); 544 + let handle = format!("alice{}.example.com", &account_id[..8]); 545 + sqlx::query( 546 + "INSERT INTO pending_accounts \ 547 + (id, email, handle, tier, claim_code, created_at) \ 548 + VALUES (?, ?, ?, 'free', ?, datetime('now'))", 549 + ) 550 + .bind(&account_id) 551 + .bind(format!("alice{}@example.com", &account_id[..8])) 552 + .bind(&handle) 553 + .bind(&claim_code) 554 + .execute(db) 555 + .await 556 + .expect("insert pending_account"); 557 + 558 + let device_id = Uuid::new_v4().to_string(); 559 + sqlx::query( 560 + "INSERT INTO devices \ 561 + (id, account_id, platform, public_key, device_token_hash, created_at, last_seen_at) \ 562 + VALUES (?, ?, 'ios', 'test_pubkey', 'test_device_hash', datetime('now'), datetime('now'))", 563 + ) 564 + .bind(&device_id) 565 + .bind(&account_id) 566 + .execute(db) 567 + .await 568 + .expect("insert device"); 569 + 570 + let mut token_bytes = [0u8; 32]; 571 + OsRng.fill_bytes(&mut token_bytes); 572 + let session_token = URL_SAFE_NO_PAD.encode(token_bytes); 573 + let token_hash: String = Sha256::digest(token_bytes) 574 + .iter() 575 + .map(|b| format!("{b:02x}")) 576 + .collect(); 577 + sqlx::query( 578 + "INSERT INTO pending_sessions \ 579 + (id, account_id, device_id, token_hash, created_at, expires_at) \ 580 + VALUES (?, ?, ?, ?, datetime('now'), datetime('now', '+1 hour'))", 581 + ) 582 + .bind(Uuid::new_v4().to_string()) 583 + .bind(&account_id) 584 + .bind(&device_id) 585 + .bind(&token_hash) 586 + .execute(db) 587 + .await 588 + .expect("insert pending_session"); 589 + 590 + TestSetup { session_token, account_id, handle } 591 + } 592 + 593 + /// Create an AppState with plc_directory_url pointing to the mock server. 594 + /// No signing_key_master_key needed for MM-90. 595 + async fn test_state_for_did(plc_url: String) -> AppState { 596 + test_state_with_plc_url(plc_url).await 597 + } 598 + 599 + /// Build a POST /v1/dids request with the MM-90 body shape. 600 + fn create_did_request( 601 + session_token: &str, 602 + rotation_key_public: &str, 603 + signed_creation_op: &serde_json::Value, 604 + ) -> Request<Body> { 605 + let body = serde_json::json!({ 606 + "rotationKeyPublic": rotation_key_public, 607 + "signedCreationOp": signed_creation_op, 608 + }); 609 + Request::builder() 610 + .method("POST") 611 + .uri("/v1/dids") 612 + .header("Authorization", format!("Bearer {session_token}")) 613 + .header("Content-Type", "application/json") 614 + .body(Body::from(body.to_string())) 615 + .unwrap() 616 + } 617 + 618 + // ── AC2.1/2.2/2.3/2.4/2.5/4.1/4.2/4.3: Happy path ─────────────────────── 619 + 620 + /// MM-90.AC2.1, AC2.2, AC2.3, AC2.4, AC2.5, AC4.1, AC4.2, AC4.3: 621 + /// Valid request promotes account and returns full DID response. 622 + #[tokio::test] 623 + async fn happy_path_promotes_account_and_returns_did() { 624 + let mock_server = MockServer::start().await; 625 + Mock::given(method("POST")) 626 + .and(path_regex(r"^/did:plc:[a-z2-7]+$")) 627 + .respond_with(ResponseTemplate::new(200)) 628 + .expect(1) 629 + .named("plc.directory genesis op") 630 + .mount(&mock_server) 631 + .await; 632 + 633 + let state = test_state_for_did(mock_server.uri()).await; 634 + let db = state.db.clone(); 635 + let setup = insert_test_data(&db).await; 636 + let (rotation_key_public, signed_op) = 637 + make_signed_op(&setup.handle, &state.config.public_url); 638 + 639 + let app = crate::app::app(state.clone()); 640 + let response = app 641 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 642 + .await 643 + .unwrap(); 644 + 645 + // AC2.1: 200 OK with { did, did_document, status: "active" } 646 + assert_eq!(response.status(), StatusCode::OK, "expected 200 OK"); 647 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 648 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 649 + assert!( 650 + body["did"].as_str().map(|d| d.starts_with("did:plc:")).unwrap_or(false), 651 + "did should start with did:plc:" 652 + ); 653 + assert_eq!(body["status"], "active", "status should be active"); 654 + assert!(body["did_document"].is_object(), "did_document should be a JSON object"); 655 + 656 + let did = body["did"].as_str().unwrap(); 657 + let doc = &body["did_document"]; 658 + 659 + // AC4.2: alsoKnownAs contains at://{handle} 660 + let also_known_as = doc["alsoKnownAs"].as_array().expect("alsoKnownAs is array"); 661 + assert!( 662 + also_known_as.iter().any(|e| e.as_str() == Some(&format!("at://{}", setup.handle))), 663 + "alsoKnownAs should contain at://{}", setup.handle 664 + ); 665 + 666 + // AC4.1: verificationMethod has publicKeyMultibase starting with "z" 667 + let vm = &doc["verificationMethod"][0]; 668 + let pkm = vm["publicKeyMultibase"].as_str().expect("publicKeyMultibase is string"); 669 + assert!(pkm.starts_with('z'), "publicKeyMultibase should start with 'z'"); 670 + 671 + // AC4.3: service entry has serviceEndpoint matching public_url 672 + let service = &doc["service"][0]; 673 + assert_eq!( 674 + service["serviceEndpoint"].as_str(), 675 + Some("https://test.example.com"), 676 + "serviceEndpoint should match config.public_url" 677 + ); 678 + 679 + // AC2.2: accounts row with correct did, email; password_hash IS NULL 680 + let row: Option<(String, Option<String>)> = 681 + sqlx::query_as("SELECT email, password_hash FROM accounts WHERE did = ?") 682 + .bind(did) 683 + .fetch_optional(&db) 684 + .await 685 + .unwrap(); 686 + let (email, password_hash) = row.expect("accounts row should exist"); 687 + assert!(email.contains("alice"), "email should match test account"); 688 + assert!(password_hash.is_none(), "password_hash should be NULL for device-provisioned account"); 689 + 690 + // AC2.3: did_documents row exists with non-empty document 691 + let doc_row: Option<(String,)> = 692 + sqlx::query_as("SELECT document FROM did_documents WHERE did = ?") 693 + .bind(did) 694 + .fetch_optional(&db) 695 + .await 696 + .unwrap(); 697 + let (document,) = doc_row.expect("did_documents row should exist"); 698 + assert!(!document.is_empty(), "document should be non-empty"); 699 + 700 + // AC2.4: handles row links handle to did 701 + let handle_row: Option<(String,)> = 702 + sqlx::query_as("SELECT did FROM handles WHERE handle = ?") 703 + .bind(&setup.handle) 704 + .fetch_optional(&db) 705 + .await 706 + .unwrap(); 707 + let (handle_did,) = handle_row.expect("handles row should exist"); 708 + assert_eq!(handle_did, did, "handles.did should match response did"); 709 + 710 + // AC2.5: pending_accounts and pending_sessions deleted 711 + let pending_count: i64 = 712 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_accounts WHERE id = ?") 713 + .bind(&setup.account_id) 714 + .fetch_one(&db) 715 + .await 716 + .unwrap(); 717 + assert_eq!(pending_count, 0, "pending_accounts row should be deleted"); 718 + 719 + let session_count: i64 = 720 + sqlx::query_scalar("SELECT COUNT(*) FROM pending_sessions WHERE account_id = ?") 721 + .bind(&setup.account_id) 722 + .fetch_one(&db) 723 + .await 724 + .unwrap(); 725 + assert_eq!(session_count, 0, "pending_sessions rows should be deleted"); 726 + } 727 + 728 + // ── AC2.6: Retry path skips plc.directory ───────────────────────────────── 729 + 730 + /// MM-90.AC2.6: When pending_did already set, plc.directory is not called. 731 + #[tokio::test] 732 + async fn retry_with_pending_did_skips_plc_directory() { 733 + let mock_server = MockServer::start().await; 734 + // plc.directory must NOT be called on retry 735 + Mock::given(method("POST")) 736 + .respond_with(ResponseTemplate::new(200)) 737 + .expect(0) 738 + .named("plc.directory should not be called") 739 + .mount(&mock_server) 740 + .await; 741 + 742 + let state = test_state_for_did(mock_server.uri()).await; 743 + let db = state.db.clone(); 744 + let setup = insert_test_data(&db).await; 745 + let (rotation_key_public, signed_op) = 746 + make_signed_op(&setup.handle, &state.config.public_url); 747 + 748 + // Derive the DID from the signed op to pre-store it. 749 + let signed_op_str = serde_json::to_string(&signed_op).unwrap(); 750 + let verified = crypto::verify_genesis_op( 751 + &signed_op_str, 752 + &crypto::DidKeyUri(rotation_key_public.clone()), 753 + ) 754 + .expect("verify should succeed"); 755 + 756 + // Pre-set pending_did to simulate a retry scenario. 757 + sqlx::query("UPDATE pending_accounts SET pending_did = ? WHERE id = ?") 758 + .bind(&verified.did) 759 + .bind(&setup.account_id) 760 + .execute(&db) 761 + .await 762 + .expect("pre-store pending_did"); 763 + 764 + let app = crate::app::app(state); 765 + let response = app 766 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 767 + .await 768 + .unwrap(); 769 + 770 + assert_eq!(response.status(), StatusCode::OK, "retry should return 200"); 771 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 772 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 773 + assert_eq!(body["did"].as_str(), Some(verified.did.as_str()), "did should match pre-computed DID"); 774 + // wiremock verifies expect(0) on mock_server drop 775 + } 776 + 777 + // ── AC3.1: Invalid signature ─────────────────────────────────────────────── 778 + 779 + /// MM-90.AC3.1: Corrupted signature returns 400 INVALID_CLAIM. 780 + #[tokio::test] 781 + async fn invalid_signature_returns_400() { 782 + let state = test_state_for_did("https://plc.directory".to_string()).await; 783 + let db = state.db.clone(); 784 + let setup = insert_test_data(&db).await; 785 + let (rotation_key_public, mut signed_op) = 786 + make_signed_op(&setup.handle, &state.config.public_url); 787 + 788 + // Corrupt the sig: decode, flip one byte, re-encode. 789 + let sig_str = signed_op["sig"].as_str().unwrap().to_string(); 790 + let mut sig_bytes = URL_SAFE_NO_PAD.decode(&sig_str).unwrap(); 791 + sig_bytes[0] ^= 0xff; 792 + signed_op["sig"] = serde_json::json!(URL_SAFE_NO_PAD.encode(&sig_bytes)); 793 + 794 + let app = crate::app::app(state); 795 + let response = app 796 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 797 + .await 798 + .unwrap(); 799 + 800 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 801 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 802 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 803 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 804 + } 805 + 806 + // ── AC3.2: Wrong handle in alsoKnownAs ──────────────────────────────────── 807 + 808 + /// MM-90.AC3.2: alsoKnownAs mismatch returns 400 INVALID_CLAIM. 809 + #[tokio::test] 810 + async fn wrong_handle_in_op_returns_400() { 811 + let state = test_state_for_did("https://plc.directory".to_string()).await; 812 + let db = state.db.clone(); 813 + let setup = insert_test_data(&db).await; 814 + // Build op with a different handle — pending_accounts has setup.handle. 815 + let (rotation_key_public, signed_op) = 816 + make_signed_op("different.handle.com", &state.config.public_url); 817 + 818 + let app = crate::app::app(state); 819 + let response = app 820 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 821 + .await 822 + .unwrap(); 823 + 824 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 825 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 826 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 827 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 828 + } 829 + 830 + // ── AC3.3: Wrong service endpoint ───────────────────────────────────────── 831 + 832 + /// MM-90.AC3.3: services.atproto_pds.endpoint mismatch returns 400 INVALID_CLAIM. 833 + #[tokio::test] 834 + async fn wrong_service_endpoint_returns_400() { 835 + let state = test_state_for_did("https://plc.directory".to_string()).await; 836 + let db = state.db.clone(); 837 + let setup = insert_test_data(&db).await; 838 + // Build op with wrong service endpoint. 839 + let (rotation_key_public, signed_op) = 840 + make_signed_op(&setup.handle, "https://wrong.example.com"); 841 + 842 + let app = crate::app::app(state); 843 + let response = app 844 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 845 + .await 846 + .unwrap(); 847 + 848 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 849 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 850 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 851 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 852 + } 853 + 854 + // ── AC3.4: rotationKeys[0] mismatch ─────────────────────────────────────── 855 + 856 + /// MM-90.AC3.4: rotationKeys[0] in op != rotationKeyPublic in request body → 400 INVALID_CLAIM. 857 + /// 858 + /// To isolate semantic validation (not crypto failure): use kp_x as the signer 859 + /// (signature verifies with kp_x), but put kp_y at rotationKeys[0]. Send kp_x 860 + /// as rotationKeyPublic — verify passes (kp_x signed), but rotation_keys[0] == kp_y ≠ kp_x. 861 + #[tokio::test] 862 + async fn wrong_rotation_key_in_op_returns_400() { 863 + use crypto::{build_did_plc_genesis_op, generate_p256_keypair}; 864 + 865 + let state = test_state_for_did("https://plc.directory".to_string()).await; 866 + let db = state.db.clone(); 867 + let setup = insert_test_data(&db).await; 868 + 869 + let kp_x = generate_p256_keypair().expect("signer keypair"); 870 + let kp_y = generate_p256_keypair().expect("rotation keypair"); 871 + let x_private = *kp_x.private_key_bytes; 872 + 873 + // Build op: rotationKeys[0] = kp_y, signing key = kp_x (signs with kp_x). 874 + let genesis_op = build_did_plc_genesis_op( 875 + &kp_y.key_id, // rotationKeys[0] = kp_y 876 + &kp_x.key_id, // signing key = kp_x, signs with kp_x's private key 877 + &x_private, 878 + &setup.handle, 879 + &state.config.public_url, 880 + ) 881 + .expect("genesis op"); 882 + let signed_op: serde_json::Value = 883 + serde_json::from_str(&genesis_op.signed_op_json).unwrap(); 884 + 885 + // Send request with rotationKeyPublic = kp_x (not kp_y). 886 + // verify_genesis_op(op, kp_x) passes (kp_x signed it), 887 + // but rotation_keys[0] == kp_y ≠ kp_x → semantic validation fails. 888 + let app = crate::app::app(state); 889 + let response = app 890 + .oneshot(create_did_request(&setup.session_token, &kp_x.key_id.0, &signed_op)) 891 + .await 892 + .unwrap(); 893 + 894 + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "expected 400"); 895 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 896 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 897 + assert_eq!(body["error"]["code"], "INVALID_CLAIM"); 898 + } 899 + 900 + // ── AC3.5: Already promoted ──────────────────────────────────────────────── 901 + 902 + /// MM-90.AC3.5: Account already promoted returns 409 DID_ALREADY_EXISTS. 903 + #[tokio::test] 904 + async fn already_promoted_account_returns_409() { 905 + let state = test_state_for_did("https://plc.directory".to_string()).await; 906 + let db = state.db.clone(); 907 + let setup = insert_test_data(&db).await; 908 + let (rotation_key_public, signed_op) = 909 + make_signed_op(&setup.handle, &state.config.public_url); 910 + 911 + // Derive the DID and pre-insert an accounts row. 912 + let signed_op_str = serde_json::to_string(&signed_op).unwrap(); 913 + let verified = crypto::verify_genesis_op( 914 + &signed_op_str, 915 + &crypto::DidKeyUri(rotation_key_public.clone()), 916 + ) 917 + .unwrap(); 918 + sqlx::query( 919 + "INSERT INTO accounts (did, email, password_hash, created_at, updated_at) \ 920 + VALUES (?, 'other@example.com', NULL, datetime('now'), datetime('now'))", 921 + ) 922 + .bind(&verified.did) 923 + .execute(&db) 924 + .await 925 + .expect("pre-insert promoted account"); 926 + 927 + let app = crate::app::app(state); 928 + let response = app 929 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 930 + .await 931 + .unwrap(); 932 + 933 + assert_eq!(response.status(), StatusCode::CONFLICT, "expected 409"); 934 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 935 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 936 + assert_eq!(body["error"]["code"], "DID_ALREADY_EXISTS"); 937 + } 938 + 939 + // ── AC3.6: Missing auth ──────────────────────────────────────────────────── 940 + 941 + /// MM-90.AC3.6: Missing Authorization header returns 401 UNAUTHORIZED. 942 + #[tokio::test] 943 + async fn missing_auth_returns_401() { 944 + let state = test_state_for_did("https://plc.directory".to_string()).await; 945 + let signed_op = serde_json::json!({}); 946 + let request = Request::builder() 947 + .method("POST") 948 + .uri("/v1/dids") 949 + .header("Content-Type", "application/json") 950 + .body(Body::from( 951 + serde_json::json!({ 952 + "rotationKeyPublic": "did:key:z123", 953 + "signedCreationOp": signed_op 954 + }) 955 + .to_string(), 956 + )) 957 + .unwrap(); 958 + 959 + let app = crate::app::app(state); 960 + let response = app.oneshot(request).await.unwrap(); 961 + 962 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "expected 401"); 963 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 964 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 965 + assert_eq!(body["error"]["code"], "UNAUTHORIZED"); 966 + } 967 + 968 + // ── AC3.7: plc.directory error ──────────────────────────────────────────── 969 + 970 + /// MM-90.AC3.7: plc.directory non-2xx returns 502 PLC_DIRECTORY_ERROR. 971 + #[tokio::test] 972 + async fn plc_directory_error_returns_502() { 973 + let mock_server = MockServer::start().await; 974 + Mock::given(method("POST")) 975 + .and(path_regex(r"^/did:plc:[a-z2-7]+$")) 976 + .respond_with(ResponseTemplate::new(500)) 977 + .expect(1) 978 + .named("plc.directory returns 500") 979 + .mount(&mock_server) 980 + .await; 981 + 982 + let state = test_state_for_did(mock_server.uri()).await; 983 + let db = state.db.clone(); 984 + let setup = insert_test_data(&db).await; 985 + let (rotation_key_public, signed_op) = 986 + make_signed_op(&setup.handle, &state.config.public_url); 987 + 988 + let app = crate::app::app(state); 989 + let response = app 990 + .oneshot(create_did_request(&setup.session_token, &rotation_key_public, &signed_op)) 991 + .await 992 + .unwrap(); 993 + 994 + assert_eq!(response.status(), StatusCode::BAD_GATEWAY, "expected 502"); 995 + let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 996 + let body: serde_json::Value = serde_json::from_slice(&body_bytes).unwrap(); 997 + assert_eq!(body["error"]["code"], "PLC_DIRECTORY_ERROR"); 998 + } 999 + } 1000 + ``` 1001 + 1002 + **Verification:** 1003 + 1004 + Run: `cargo test -p relay` 1005 + Expected: All tests pass. All 7 MM-89 test functions are removed; 9 new MM-90 test functions pass. 1006 + 1007 + Run: `cargo test --workspace` 1008 + Expected: All workspace tests pass (crypto + relay). 1009 + 1010 + Run: `cargo clippy --workspace -- -D warnings` 1011 + Expected: Zero warnings across all crates. 1012 + 1013 + **Commit:** `test(relay): replace MM-89 tests with MM-90 AC2-AC4 coverage for POST /v1/dids` 1014 + <!-- END_TASK_3 --> 1015 + 1016 + <!-- END_SUBCOMPONENT_B -->
+120
docs/implementation-plans/2026-03-13-MM-90/test-requirements.md
··· 1 + # MM-90: Test Requirements 2 + 3 + This document maps every MM-90 acceptance criterion to either an automated test or a documented human verification step. It is derived from the design plan (`docs/design-plans/2026-03-13-MM-90.md`) and the implementation phases (`phase_01.md`, `phase_02.md`). 4 + 5 + All automated tests use Rust's built-in `#[test]` / `#[tokio::test]` framework. Crypto-crate tests are pure unit tests (no I/O). Relay-crate tests are integration tests that spin up an in-memory SQLite database, a wiremock HTTP server (for plc.directory), and exercise the full axum handler via `tower::ServiceExt::oneshot`. 6 + 7 + --- 8 + 9 + ## Automated Test Map 10 + 11 + ### MM-90.AC1: `verify_genesis_op` in the crypto crate 12 + 13 + | Criterion | Description | Test Type | File Path | Test Function | 14 + |-----------|-------------|-----------|-----------|---------------| 15 + | MM-90.AC1.1 | Valid signed genesis op returns `VerifiedGenesisOp` with correct `did`, `also_known_as`, `verification_methods`, and `atproto_pds_endpoint` | Unit | `crates/crypto/src/plc.rs` (`mod tests`) | `verify_valid_op_returns_correct_fields` | 16 + | MM-90.AC1.2 | DID from `verify_genesis_op` matches DID from `build_did_plc_genesis_op` (round-trip CBOR consistency) | Unit | `crates/crypto/src/plc.rs` (`mod tests`) | `verify_did_matches_build_did_plc_genesis_op` | 17 + | MM-90.AC1.3 | Op verified against a different rotation key returns `CryptoError::PlcOperation` | Unit | `crates/crypto/src/plc.rs` (`mod tests`) | `verify_wrong_rotation_key_returns_error` | 18 + | MM-90.AC1.4 | Op with a corrupted signature (one byte flipped) returns `CryptoError::PlcOperation` | Unit | `crates/crypto/src/plc.rs` (`mod tests`) | `verify_corrupted_signature_returns_error` | 19 + | MM-90.AC1.5 | Op JSON with unknown/extra fields is rejected with `CryptoError::PlcOperation` | Unit | `crates/crypto/src/plc.rs` (`mod tests`) | `verify_unknown_fields_returns_error` | 20 + 21 + **Implementation notes:** 22 + 23 + - Tests use `make_op_for_verify()`, a helper that calls `generate_p256_keypair` and `build_did_plc_genesis_op` to produce a valid signed op and the corresponding key URI. The helper uses the same keypair for both signing and rotation so that `verify_genesis_op` can be called with `signing_kp.key_id` (the key that actually signed the op). 24 + - AC1.5 relies on `#[serde(deny_unknown_fields)]` on `SignedPlcOp`. The test injects an `"unknownField"` key into the JSON before calling `verify_genesis_op`. 25 + - AC1.2 confirms byte-level CBOR encoding consistency between the build and verify paths; both must produce the same DID from the same inputs. This is critical because any CBOR divergence would break real-world interop with plc.directory. 26 + 27 + ### MM-90.AC2: `POST /v1/dids` -- happy path and account promotion 28 + 29 + | Criterion | Description | Test Type | File Path | Test Function | 30 + |-----------|-------------|-----------|-----------|---------------| 31 + | MM-90.AC2.1 | Valid request returns `200 OK` with `{ "did": "did:plc:...", "did_document": {...}, "status": "active" }` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 32 + | MM-90.AC2.2 | After success, `accounts` row exists with correct `did` and `email`; `password_hash` is NULL | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 33 + | MM-90.AC2.3 | After success, `did_documents` row exists with non-empty `document` JSON | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 34 + | MM-90.AC2.4 | After success, `handles` row links the pending account's handle to the DID | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 35 + | MM-90.AC2.5 | After success, `pending_accounts` and `pending_sessions` rows are deleted | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 36 + | MM-90.AC2.6 | When `pending_did` is already set (retry), plc.directory is not called; promotion completes with 200 | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `retry_with_pending_did_skips_plc_directory` | 37 + 38 + **Implementation notes:** 39 + 40 + - AC2.1 through AC2.5 are all verified within the single `happy_path_promotes_account_and_returns_did` test. This is deliberate: the happy path is a single atomic flow, and splitting these into separate tests would duplicate all the setup (keypair generation, pending account insertion, wiremock plc.directory mock, request dispatch) without meaningful isolation benefit. Each AC is verified by a distinct assertion block within the test, annotated with its criterion ID in a comment. 41 + - AC2.6 uses wiremock's `expect(0)` to assert that plc.directory receives zero requests. The test pre-stores `pending_did` in the database before dispatching the request, simulating a retry after a partial failure. 42 + - The `insert_test_data` helper creates a full prerequisite chain: claim_code, pending_account, device, and pending_session. No relay signing key is needed for MM-90 (unlike MM-89). 43 + - The `make_signed_op` helper generates a fresh P-256 keypair and calls `build_did_plc_genesis_op` with the same key for both rotation and signing, producing a valid signed op that `verify_genesis_op` will accept. 44 + - `test_state_for_did` wraps `test_state_with_plc_url` (from `crates/relay/src/app.rs`) with the mock server URL. No `signing_key_master_key` parameter is needed for MM-90. 45 + 46 + ### MM-90.AC3: `POST /v1/dids` -- failure cases 47 + 48 + | Criterion | Description | Test Type | File Path | Test Function | 49 + |-----------|-------------|-----------|-----------|---------------| 50 + | MM-90.AC3.1 | Invalid ECDSA signature returns 400 `INVALID_CLAIM` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `invalid_signature_returns_400` | 51 + | MM-90.AC3.2 | `alsoKnownAs[0]` mismatch with `pending_accounts.handle` returns 400 `INVALID_CLAIM` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `wrong_handle_in_op_returns_400` | 52 + | MM-90.AC3.3 | `services.atproto_pds.endpoint` mismatch with `config.public_url` returns 400 `INVALID_CLAIM` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `wrong_service_endpoint_returns_400` | 53 + | MM-90.AC3.4 | `rotationKeys[0]` mismatch with request body `rotationKeyPublic` returns 400 `INVALID_CLAIM` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `wrong_rotation_key_in_op_returns_400` | 54 + | MM-90.AC3.5 | Already-promoted account (existing `accounts` row for DID) returns 409 `DID_ALREADY_EXISTS` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `already_promoted_account_returns_409` | 55 + | MM-90.AC3.6 | Missing or expired `pending_session` token returns 401 `UNAUTHORIZED` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `missing_auth_returns_401` | 56 + | MM-90.AC3.7 | plc.directory returns non-2xx returns 502 `PLC_DIRECTORY_ERROR` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `plc_directory_error_returns_502` | 57 + 58 + **Implementation notes:** 59 + 60 + - AC3.1 corrupts the signature by base64url-decoding, flipping one byte (`sig_bytes[0] ^= 0xff`), and re-encoding. This ensures the handler's call to `crypto::verify_genesis_op` fails with a signature verification error, which the handler maps to 400 `INVALID_CLAIM`. 61 + - AC3.2 builds a signed op with `"different.handle.com"` but the pending_account row has `setup.handle`. The op passes crypto verification (valid signature), but fails semantic validation at step 6. 62 + - AC3.3 builds a signed op with `"https://wrong.example.com"` as the PDS endpoint, while the server's `config.public_url` is `"https://test.example.com"`. 63 + - AC3.4 uses two distinct keypairs (`kp_x` for signing, `kp_y` for the `rotationKeys[0]` field in the op). The request body sends `kp_x.key_id` as `rotationKeyPublic`. Crypto verification passes (signed by `kp_x`, verified against `kp_x`), but `rotation_keys[0]` is `kp_y` which does not match the request's `rotationKeyPublic`. This isolates the semantic check from the cryptographic check. 64 + - AC3.5 pre-inserts an `accounts` row with the same DID before dispatching the request. The handler detects the existing row at step 8 and returns 409. 65 + - AC3.6 sends a request with no `Authorization` header. The `require_pending_session` auth helper returns 401 before any handler logic executes. 66 + - AC3.7 configures wiremock to return HTTP 500. The handler receives the non-2xx response at step 9 and returns 502 `PLC_DIRECTORY_ERROR`. 67 + 68 + ### MM-90.AC4: DID document correctness 69 + 70 + | Criterion | Description | Test Type | File Path | Test Function | 71 + |-----------|-------------|-----------|-----------|---------------| 72 + | MM-90.AC4.1 | `did_document` contains `verificationMethod` with `publicKeyMultibase` derived from `verificationMethods.atproto` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 73 + | MM-90.AC4.2 | `did_document` contains `alsoKnownAs` with `at://` + account handle | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 74 + | MM-90.AC4.3 | `did_document` contains service entry with `serviceEndpoint` matching `config.public_url` | Integration | `crates/relay/src/routes/create_did.rs` (`mod tests`) | `happy_path_promotes_account_and_returns_did` | 75 + 76 + **Implementation notes:** 77 + 78 + - AC4.1 through AC4.3 are verified within `happy_path_promotes_account_and_returns_did` by inspecting the `did_document` JSON in the response body. 79 + - AC4.1 asserts that `verificationMethod[0].publicKeyMultibase` starts with `"z"` (multibase-encoded compressed P-256 key). The `build_did_document` function strips the `did:key:` prefix from `verificationMethods["atproto"]` to produce the multibase value. 80 + - AC4.2 asserts that `alsoKnownAs` contains `"at://{handle}"`, matching the value from the pending account. 81 + - AC4.3 asserts that `service[0].serviceEndpoint` equals `"https://test.example.com"` (the test `config.public_url`). 82 + 83 + --- 84 + 85 + ## Human Verification Steps 86 + 87 + All 19 acceptance criteria are covered by automated tests. No criteria require manual human verification. 88 + 89 + The Bruno API collection file (`bruno/create-did.bru`) is updated as part of Phase 2 Task 2, but this is developer tooling and is not mapped to any acceptance criterion. Its correctness can be visually confirmed by opening it in the Bruno desktop app and inspecting the request body shape. 90 + 91 + --- 92 + 93 + ## Summary 94 + 95 + | Category | Count | 96 + |----------|-------| 97 + | Total acceptance criteria | 19 | 98 + | Automated tests (unit) | 5 | 99 + | Automated tests (integration) | 14 | 100 + | Human verification required | 0 | 101 + | Distinct test functions | 14 | 102 + 103 + **Breakdown by test function:** 104 + 105 + | # | Test Function | Criteria Covered | Crate | Type | 106 + |---|---------------|-----------------|-------|------| 107 + | 1 | `verify_valid_op_returns_correct_fields` | AC1.1 | crypto | Unit | 108 + | 2 | `verify_did_matches_build_did_plc_genesis_op` | AC1.2 | crypto | Unit | 109 + | 3 | `verify_wrong_rotation_key_returns_error` | AC1.3 | crypto | Unit | 110 + | 4 | `verify_corrupted_signature_returns_error` | AC1.4 | crypto | Unit | 111 + | 5 | `verify_unknown_fields_returns_error` | AC1.5 | crypto | Unit | 112 + | 6 | `happy_path_promotes_account_and_returns_did` | AC2.1, AC2.2, AC2.3, AC2.4, AC2.5, AC4.1, AC4.2, AC4.3 | relay | Integration | 113 + | 7 | `retry_with_pending_did_skips_plc_directory` | AC2.6 | relay | Integration | 114 + | 8 | `invalid_signature_returns_400` | AC3.1 | relay | Integration | 115 + | 9 | `wrong_handle_in_op_returns_400` | AC3.2 | relay | Integration | 116 + | 10 | `wrong_service_endpoint_returns_400` | AC3.3 | relay | Integration | 117 + | 11 | `wrong_rotation_key_in_op_returns_400` | AC3.4 | relay | Integration | 118 + | 12 | `already_promoted_account_returns_409` | AC3.5 | relay | Integration | 119 + | 13 | `missing_auth_returns_401` | AC3.6 | relay | Integration | 120 + | 14 | `plc_directory_error_returns_502` | AC3.7 | relay | Integration |