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

docs: add MM-90 DID ceremony design plan

Device-signing model replaces relay-signing. Key decisions:
- verify_genesis_op() returns typed VerifiedGenesisOp (signature + DID derivation in one pure fn)
- Strict semantic validation: rotationKeys[0], alsoKnownAs, services endpoint
- In-house CBOR (ciborium) for byte-level consistency with MM-89 signing
- Relay key absent from genesis op; added later via key rotation
- 2 implementation phases: crypto crate, then relay route replacement

authored by malpercio.dev and committed by

Tangled faa9bb76 224d34b1

+190
+190
docs/design-plans/2026-03-13-MM-90.md
··· 1 + # MM-90: DID Ceremony — Device-Signed Genesis Op 2 + 3 + ## Summary 4 + 5 + MM-90 shifts DID creation from a relay-signing model to a device-signing model. In the previous design (MM-89), the relay generated the signed genesis operation itself using a server-held private key. In this design, the client generates and signs the genesis operation on-device — the relay's job is to verify that signature, validate the operation's semantic fields against the account's pre-registered handle and the server's own public URL, and then submit the already-signed operation to plc.directory. Because the relay never touches a private key during this flow, the resulting DID is anchored entirely to a key the user holds, not a key the server holds. 6 + 7 + The implementation spans two crates following the established Functional Core / Imperative Shell pattern. A new pure function, `verify_genesis_op`, is added to the `crypto` crate: it parses the client-submitted JSON, reconstructs the unsigned operation, CBOR-encodes it using the same byte-exact algorithm used during signing, and verifies the ECDSA-SHA256 signature against the supplied rotation key — with no I/O. The `relay` crate's `POST /v1/dids` handler is then replaced: it authenticates via the pending session Bearer token, calls `verify_genesis_op`, performs semantic validation, writes the derived DID to `pending_accounts` before touching plc.directory (enabling safe retries), submits the signed operation, and atomically promotes the pending account into a full account in a single database transaction. 8 + 9 + ## Definition of Done 10 + 11 + MM-90 is complete when: 12 + 13 + 1. The `crypto` crate gains a pure function `verify_genesis_op(signed_op_json, rotation_key) -> Result<VerifiedGenesisOp, CryptoError>` that parses a client-provided signed genesis op, verifies its ECDSA signature, derives the DID, and returns a typed struct of op fields — no I/O. 14 + 2. `POST /v1/dids` is replaced with a device-signing handler that: authenticates via `pending_session` Bearer token; validates the client's submitted signed genesis op (signature, rotationKeys[0], alsoKnownAs, services endpoint); submits the op to plc.directory; and atomically promotes the pending account to a full account. 15 + 3. The response includes `{ "did": "did:plc:...", "did_document": {...}, "status": "active" }`. 16 + 4. Tests cover all acceptance criteria: valid op results in promotion and 200, invalid signature returns 400, semantic validation failures return 400, account already having a DID returns 409, and DID storage + account promotion happen atomically. 17 + 18 + ## Acceptance Criteria 19 + 20 + ### MM-90.AC1: `verify_genesis_op` in the crypto crate 21 + - **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` 22 + - **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) 23 + - **MM-90.AC1.3 Failure:** Signed op verified against a different rotation key returns `CryptoError::PlcOperation` 24 + - **MM-90.AC1.4 Failure:** Op with a corrupted signature (one byte changed in the base64url string) returns `CryptoError::PlcOperation` 25 + - **MM-90.AC1.5 Failure:** Op JSON containing unknown/extra fields is rejected with `CryptoError::PlcOperation` 26 + 27 + ### MM-90.AC2: `POST /v1/dids` — happy path and account promotion 28 + - **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" }` 29 + - **MM-90.AC2.2 Success:** After success, `accounts` row exists with the correct `did` and `email`; `password_hash` is NULL 30 + - **MM-90.AC2.3 Success:** After success, `did_documents` row exists for the DID with a non-empty `document` JSON 31 + - **MM-90.AC2.4 Success:** After success, `handles` row exists linking the pending account's handle to the DID 32 + - **MM-90.AC2.5 Success:** After success, `pending_accounts` and `pending_sessions` rows for the account are deleted 33 + - **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 34 + 35 + ### MM-90.AC3: `POST /v1/dids` — failure cases 36 + - **MM-90.AC3.1 Failure:** Invalid ECDSA signature in submitted op returns 400 `INVALID_CLAIM` 37 + - **MM-90.AC3.2 Failure:** `alsoKnownAs[0]` in op does not match `at://{handle}` from `pending_accounts` returns 400 `INVALID_CLAIM` 38 + - **MM-90.AC3.3 Failure:** `services.atproto_pds.endpoint` in op does not match `config.public_url` returns 400 `INVALID_CLAIM` 39 + - **MM-90.AC3.4 Failure:** `rotationKeys[0]` in op does not match `rotationKeyPublic` from the request body returns 400 `INVALID_CLAIM` 40 + - **MM-90.AC3.5 Failure:** Account already fully promoted (`accounts` row already exists for the derived DID) returns 409 `DID_ALREADY_EXISTS` 41 + - **MM-90.AC3.6 Failure:** Missing or expired `pending_session` token returns 401 `UNAUTHORIZED` 42 + - **MM-90.AC3.7 Failure:** plc.directory returns non-2xx returns 502 `PLC_DIRECTORY_ERROR` 43 + 44 + ### MM-90.AC4: DID document correctness 45 + - **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 46 + - **MM-90.AC4.2 Success:** `did_document` in the response contains `alsoKnownAs` with `at://` + the account's handle from `pending_accounts` 47 + - **MM-90.AC4.3 Success:** `did_document` in the response contains a service entry with `serviceEndpoint` matching `config.public_url` 48 + 49 + ## Glossary 50 + 51 + - **ATProto**: AT Protocol — the open social networking protocol developed by Bluesky. Defines the data model, DID-based identity, and repo structure that this server implements. 52 + - **did:plc**: A DID method maintained by plc.directory. A DID (Decentralized Identifier) is a self-sovereign, globally unique identifier. The `did:plc` method derives the identifier from the SHA-256 hash of the account's first (genesis) operation, encoded in base32. 53 + - **did:key**: A DID method that encodes a public key directly in the identifier string. Used here for rotation keys and verification method keys; the `z`-prefixed suffix is a multibase-encoded multicodec representation of the compressed P-256 public key. 54 + - **Genesis op**: The first signed operation submitted to plc.directory when a `did:plc` is created. It establishes the account's initial rotation keys, verification methods, ATProto handle (`alsoKnownAs`), and PDS service endpoint. The DID itself is derived from this operation's hash. 55 + - **plc.directory**: The external service that stores and resolves `did:plc` operations. The relay submits the client's signed genesis op to `https://plc.directory/{did}` via HTTP POST. 56 + - **Rotation key**: A key whose holder can update or rotate the DID's key material. In this design the user's device key is the sole rotation key at genesis, giving the user exclusive early control over their identity. 57 + - **Verification method**: A public key entry in a DID document, associated with a purpose (here: `atproto`). Clients use the key at `verificationMethods.atproto` to verify ATProto record signatures made by the account. 58 + - **ECDSA-SHA256**: The signing algorithm used throughout. The message is SHA-256 hashed, then signed with a P-256 private key. Verification takes the public key and the same hashed message and confirms the signature matches. 59 + - **CBOR (ciborium)**: Concise Binary Object Representation — a compact binary serialization format. The did:plc spec requires CBOR encoding of the operation before signing and hashing. `ciborium` is the Rust library used here. 60 + - **DAG-CBOR canonical ordering**: The specific field ordering required by the IPLD DAG-CBOR spec and the did:plc spec. Map keys are sorted first by UTF-8 byte length, then alphabetically within the same length. Incorrect ordering produces a different byte sequence and a different DID. 61 + - **base64url**: URL-safe Base64 using `-` and `_` instead of `+` and `/`, with no `=` padding. Used to encode the ECDSA signature in the signed genesis op JSON. 62 + - **Functional Core / Imperative Shell**: An architectural pattern separating pure, side-effect-free logic from code that performs I/O. In this codebase, the `crypto` crate is the functional core; the `relay` crate is the imperative shell. 63 + - **Pending session / pending account**: A pre-activation state in the relay's database. A `pending_account` holds registration data (handle, email) before a DID is assigned. A `pending_session` is the Bearer token that authenticates the device during the ceremony. Both are deleted atomically on promotion. 64 + - **Account promotion**: The atomic database transaction that converts a `pending_account` into a fully active `account` row, creating associated `did_documents` and `handles` rows and deleting all pending-state rows in a single commit. 65 + - **`pending_did` pre-store**: Before calling plc.directory, the handler writes the derived DID into `pending_accounts.pending_did`. On retry, the handler detects the pre-stored DID, skips the plc.directory call, and proceeds directly to promotion. 66 + - **V008 migration**: The database schema migration (`V008__did_promotion.sql`) that added `pending_accounts.pending_did` and nullable `accounts.password_hash`. Both are prerequisites for MM-90; no new migrations are needed. 67 + - **RFC 6979**: A standard for deterministic ECDSA nonce generation — the same private key and message always produce the same signature, meaning the same inputs always produce the same DID. 68 + - **`VerifyingKey` (p256 crate)**: The Rust type representing a P-256 public key capable of verifying ECDSA signatures. Derived from the `did:key` URI of the rotation key supplied in the request. 69 + 70 + ## Architecture 71 + 72 + `POST /v1/dids` spans two crates following the Functional Core / Imperative Shell pattern established in this workspace. 73 + 74 + **`crates/crypto/src/plc.rs`** (extended — pure functional core) 75 + 76 + Adds one public function and one public struct: 77 + 78 + ```rust 79 + pub fn verify_genesis_op( 80 + signed_op_json: &str, 81 + rotation_key: &DidKeyUri, 82 + ) -> Result<VerifiedGenesisOp, CryptoError> 83 + 84 + pub struct VerifiedGenesisOp { 85 + pub did: String, 86 + pub rotation_keys: Vec<String>, 87 + pub also_known_as: Vec<String>, 88 + pub verification_methods: BTreeMap<String, String>, 89 + pub atproto_pds_endpoint: Option<String>, 90 + } 91 + ``` 92 + 93 + Internally: parse `signed_op_json` into `SignedPlcOp` struct (gains `Deserialize`) → extract and base64url-decode `sig` → reconstruct `UnsignedPlcOp` (all fields except `sig`) → CBOR-encode unsigned op with `ciborium` → verify ECDSA-SHA256 signature using `p256::ecdsa::VerifyingKey` derived from `rotation_key` → CBOR-encode signed op → SHA-256 → base32-lowercase first 24 chars → `"did:plc:" + …` → return `VerifiedGenesisOp`. No I/O. 94 + 95 + `UnsignedPlcOp`, `SignedPlcOp`, and `PlcService` gain `#[derive(Deserialize)]` so they can parse incoming JSON. These structs stay private to the module. The same `ciborium` CBOR encoding used for signing (MM-89) is used for verification — ensuring byte-level consistency without mixing CBOR libraries. 96 + 97 + **`crates/relay/src/routes/create_did.rs`** (replaced — imperative shell) 98 + 99 + Route: `POST /v1/dids` 100 + Auth: `Authorization: Bearer <sessionToken>` from `pending_sessions` 101 + 102 + Request body: 103 + ```json 104 + { "rotationKeyPublic": "did:key:z...", "signedCreationOp": { ...genesis op fields... } } 105 + ``` 106 + 107 + Response `200 OK`: 108 + ```json 109 + { "did": "did:plc:...", "did_document": { "@context": [...], "id": "...", ... }, "status": "active" } 110 + ``` 111 + 112 + Handler flow: 113 + 1. `require_pending_session(headers, db)` → `PendingSessionInfo { account_id, device_id }` 114 + 2. `SELECT handle, pending_did, email FROM pending_accounts WHERE id = account_id` 115 + 3. Validate `rotationKeyPublic` starts with `"did:key:z"` → `DidKeyUri` 116 + 4. `serde_json::to_string(&payload.signed_creation_op)` → signed op JSON string 117 + 5. `crypto::verify_genesis_op(&signed_op_str, &rotation_key)` → `VerifiedGenesisOp` (400 on any error) 118 + 6. Semantic validation (400 on failure): 119 + - `verified.rotation_keys.first()` == `Some(&rotation_key_public)` 120 + - `verified.also_known_as.first()` == `Some("at://{handle}")` 121 + - `verified.atproto_pds_endpoint` == `Some(&config.public_url)` 122 + 7. Pre-store DID for retry resilience: if `pending_did` is null → `UPDATE pending_accounts SET pending_did = verified.did`; if set → verify it matches `verified.did` (mismatch → 500) 123 + 8. `SELECT EXISTS(SELECT 1 FROM accounts WHERE did = ?)` → 409 if already promoted 124 + 9. If not on retry path: POST `signed_op_str` to `{plc_directory_url}/{verified.did}` 125 + 10. Build DID document from `VerifiedGenesisOp` fields and `config.public_url` 126 + 11. Atomic transaction: `INSERT accounts`, `INSERT did_documents`, `INSERT handles`, `DELETE pending_sessions WHERE account_id = ?`, `DELETE devices WHERE account_id = ?`, `DELETE pending_accounts WHERE id = ?` 127 + 12. Return `{ did, did_document, status: "active" }` 128 + 129 + The DID document is constructed locally from known fields. The `verificationMethod` public key comes from `verified.verification_methods["atproto"]`. The relay's own signing key is **not** included in the genesis op; it will be added via a future key rotation operation (tracked separately). 130 + 131 + Error codes: 132 + 133 + | Status | Code | Condition | 134 + |--------|------|-----------| 135 + | 400 | `INVALID_CLAIM` | Crypto verification failure, `rotationKeyPublic` format, semantic validation failures | 136 + | 401 | `UNAUTHORIZED` | Missing, invalid, or expired session token | 137 + | 409 | `DID_ALREADY_EXISTS` | `accounts` row already exists for the derived DID | 138 + | 502 | `PLC_DIRECTORY_ERROR` | plc.directory returned non-2xx | 139 + | 500 | `INTERNAL_ERROR` | DB failure, decrypt failure, DID mismatch on retry | 140 + 141 + ## Existing Patterns 142 + 143 + This design follows established patterns throughout: 144 + 145 + - **Functional Core / Imperative Shell** — `verify_genesis_op` and `VerifiedGenesisOp` follow the `build_did_plc_genesis_op` / `PlcGenesisOp` pattern in `crates/crypto/src/plc.rs`. Pure function, named-field return struct, `CryptoError` result. 146 + - **Imperative Shell route comment** — `create_did.rs` opens with the `// pattern: Imperative Shell` block documenting inputs, processing steps, and outputs, matching every other route file. 147 + - **Inline auth helper** — `require_pending_session` in `crates/relay/src/routes/auth.rs` (established in MM-89): extract Bearer header, hash, lookup, return typed result or `ApiError`. 148 + - **Atomic provisioning transaction** — `db.begin()` → multiple statements → `tx.commit()` with `inspect_err` tracing on each step, matching `create_mobile_account.rs`. 149 + - **Pre-store resilience** — `pending_accounts.pending_did` column (added in V008 for MM-89): write derived DID before calling plc.directory so retries can skip the directory call. 150 + - **ApiError / ErrorCode** — all error paths use `ApiError::new(ErrorCode::..., message)` from the `common` crate. 151 + 152 + The relay's signing key is absent from this genesis op. MM-89 included it at `rotationKeys[1]` and `verificationMethods.atproto`. MM-90 is a device-signing design: the user's rotation key is the sole entry in `rotationKeys`, and `verificationMethods.atproto` holds whatever key the client specifies. This is an intentional divergence; the relay key will be added later via a key rotation flow. 153 + 154 + ## Implementation Phases 155 + 156 + <!-- START_PHASE_1 --> 157 + ### Phase 1: `crypto` crate — `verify_genesis_op` 158 + 159 + **Goal:** Implement genesis op verification as a pure function, tested in isolation before any relay changes. 160 + 161 + **Components:** 162 + - `crates/crypto/src/plc.rs` — add `#[derive(Deserialize)]` to `UnsignedPlcOp`, `SignedPlcOp`, `PlcService`; implement `verify_genesis_op`; add `VerifiedGenesisOp` struct 163 + - `crates/crypto/src/lib.rs` — re-export `verify_genesis_op` and `VerifiedGenesisOp` 164 + 165 + **Dependencies:** None (first phase) 166 + 167 + **Done when:** `cargo test -p crypto` passes covering MM-90.AC1.1–AC1.5; `cargo clippy --workspace -- -D warnings` clean 168 + <!-- END_PHASE_1 --> 169 + 170 + <!-- START_PHASE_2 --> 171 + ### Phase 2: `relay` crate — replace `POST /v1/dids` 172 + 173 + **Goal:** Replace the relay-signing handler from MM-89 with the device-signing handler that validates the client's submitted op. 174 + 175 + **Components:** 176 + - `crates/relay/src/routes/create_did.rs` — replace `CreateDidRequest` (remove `signingKey`/`rotationKey`, add `rotationKeyPublic` + `signedCreationOp: serde_json::Value`); replace handler body with the new 12-step flow; update `build_did_document` to use `VerifiedGenesisOp` fields; replace all inline tests 177 + - `bruno/create-did.bru` — update request shape to `rotationKeyPublic` + `signedCreationOp` 178 + 179 + **Dependencies:** Phase 1 (`crypto::verify_genesis_op` must be available) 180 + 181 + **Done when:** `cargo test -p relay` passes covering MM-90.AC2.1–AC2.6, AC3.1–AC3.7, AC4.1–AC4.3; `cargo clippy --workspace -- -D warnings` clean 182 + <!-- END_PHASE_2 --> 183 + 184 + ## Additional Considerations 185 + 186 + **`atproto-plc` crate considered and deferred:** A purpose-built Rust crate (`atproto-plc` v0.2.0) exists for did:plc operations. It was intentionally not adopted for MM-90 because it uses `serde_ipld_dagcbor` for CBOR encoding while MM-89's signing implementation uses `ciborium`. Mixing libraries would introduce an untested assumption about byte-level CBOR compatibility. If the project wants to standardize on `atproto-plc` (replacing both MM-89 signing and MM-90 verification), that is a clean future refactor. 187 + 188 + **Relay key absent from genesis op:** The relay's signing key does not appear in the genesis op in this design. The device-signing model gives the user's rotation key sole authority from creation. The relay key will be added in a future key rotation flow, giving the relay operational authority over ATProto record signing without embedding it at DID genesis time. 189 + 190 + **No new migrations:** V008 (`crates/relay/src/db/migrations/V008__did_promotion.sql`) already provides the `pending_accounts.pending_did` column for retry resilience and the nullable `accounts.password_hash` for mobile-provisioned accounts. No schema changes are required.