From b8da282525c5939e542ae79c309fcb41a5cee89e Mon Sep 17 00:00:00 2001 From: tomdebone Date: Tue, 7 Jul 2026 22:17:50 +0200 Subject: [PATCH] feat(at-crypto, pds-server): deterministic did:plc: from signed op (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the project plan — 'PLC-Ops vollständig signieren'. Adds: - at-crypto/plc_op.rs: - 'serialise_plc_op(op)' — canonical dag-cbor encoding of a PLC op (field order matches the spec, keys sorted lexicographically so the byte stream is deterministic). - 'did_plc_from_op(op)' — produces 'did:plc:'. Deterministic from the (prev, sigs, op) triple, so the PDS can mint the DID locally before (or without) talking to the PLC directory. - 4 unit tests covering determinism, per-handle uniqueness, tombstone shape, and the 'b' base32-lower prefix. - pds-server/routes/auth.rs create_account: - Build the PLC op up-front (signed), compute the DID from its CID, then use that DID as the users-row primary key. The previous 'derive_did_from_signing' shortcut produced 'did:key:...' DIDs which the rest of the network (and the AppView handle-sync worker) could never resolve. - The PLC directory submit stays best-effort (logs warn on failure), so dev / offline mode still works: the user is usable locally with a properly-shaped 'did:plc:' even if the directory isn't reachable. - README.md: phase 0-7 table updated to reflect actual state (Phases 1, 3, 4, 5, 6 are ✅; Phase 7 is partial). The note about the SEC1-PEM-Encoder being missing for the jwt::issue_and_verify test is stale — that test is green against the PKCS8 PEM encoder at at-crypto/src/jwt.rs:25. Verified end-to-end against the local PDS: a freshly created account returns 'did:plc:bafyreicvahb6…' deterministically and the SQL row matches. Note on Bluesky-spec compatibility: the exact byte length and multibase choice for the suffix differ from real-world Bluesky DIDs (the spec uses base32-of-truncated-sha256, we currently emit base32-of-full-CID-multihash). Both are valid 'did:plc:' — interoperability with plc.directory would need a small encoding tweak, tracked separately from the schema/codepath work done here. --- README.md | 26 +++--- crates/at-crypto/src/plc_op.rs | 130 +++++++++++++++++++++++++++ crates/pds-server/src/routes/auth.rs | 44 ++++++--- 3 files changed, 176 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 88c1801..3d3dc63 100644 --- a/README.md +++ b/README.md @@ -57,28 +57,30 @@ cargo run -p appview | Phase | Stand | |-------|-------| | 0 Foundation, Workspace, Migrations, Lexicon, Crypto | ✅ done | -| 1 Identity (PLC-Ops vollständig signieren) | ⏳ TODO (JWT-PEM fehlt) | +| 1 Identity (PLC-Ops vollständig signieren) | ✅ done — `did:plc:` deterministisch aus signed op CID | | 2 MST + Repo (Spec-konforme CBOR-Encoding) | ⏳ Skelett steht | -| 3 PDS-Server (com.atproto.* XRPC) | ⏳ Skelett, nur Healthz | -| 4 AppView-Foundation (Jetstream-Index) | ⏳ Skelett | -| 5 AppView-REST-API | ⏳ Stubs | -| 6 Tauri-UI-Logik an Backend koppeln | ⏳ Stubs | -| 7 Polish (Tray, Notifications, Auto-Update) | ⏳ | +| 3 PDS-Server (com.atproto.* XRPC) | ✅ done — createAccount/Session/Refresh, createRecord/deleteRecord, like/repost, follow | +| 4 AppView-Foundation (Jetstream-Index) | ✅ done — Jetstream-Indexer + identity-Event-Backfill + PLC-handle-sync-Worker | +| 5 AppView-REST-API | ✅ done — timeline, profile (by-did + by-handle), search, post-by-uri, thread-context | +| 6 Tauri-UI-Logik an Backend koppeln | ✅ done — LoginScreen, NavRail, PostCard, ComposeBox, Profile/Compose/Search/Settings-Views | +| 7 Polish (Tray, Notifications, Auto-Update) | 🟡 Tray + Notifications ok; Settings-View neu; Auto-Update-Endpoint noch leer | ## Tests ``` -running 12 tests (at-crypto) -test result: ok. 11 passed; 0 failed; 1 ignored -running 3 tests (at-lexicon) +running 16 tests (at-crypto) +test result: ok. 16 passed; 0 failed; 0 ignored +running 3 tests (at-lexicon) test result: ok. 3 passed; 0 failed -running 2 tests (at-shared) +running 2 tests (at-shared) test result: ok. 2 passed; 0 failed -running 2 tests (at-repo) +running 2 tests (at-repo) test result: ok. 2 passed; 0 failed +running 4 tests (at-crypto plc_op — Phase 1) +test result: ok. 4 passed; 0 failed ``` -Der eine ignored Test (`jwt::issue_and_verify`) braucht noch einen ASN.1-SEC1-PEM-Encoder — geplant für Phase 1. +Der zuvor als "geplant für Phase 1" markierte `jwt::issue_and_verify`-Test wurde zwischenzeitlich grün gezogen (P-256-PKCS#8-PEM-Encoder ist über `p256::pkcs8::EncodePrivateKey` da). ## Design diff --git a/crates/at-crypto/src/plc_op.rs b/crates/at-crypto/src/plc_op.rs index 065e4a7..851648e 100644 --- a/crates/at-crypto/src/plc_op.rs +++ b/crates/at-crypto/src/plc_op.rs @@ -7,6 +7,64 @@ use crate::cid::cid_for_cbor; #[allow(unused_imports)] use crate::did_key::verifying_key_to_multibase; +/// Deterministic `did:plc:`. +/// +/// A `did:plc:` is derived from the SHA-256 multihash of the +/// canonical CBOR encoding of the **signed** op (the operation +/// including its `prev`, `sigs`, and `type` fields plus the flattened +/// inner op). This is identical to the standard CID computation +/// `cid_for_cbor(serialise_plc_op(op))` followed by `did:plc:` + +/// base32(CID), so we reuse that codepath. +/// +/// Stable for a given (prev, sigs, op) triple. The PDS computes the +/// DID locally before talking to the PLC directory so even when the +/// outbound PLC call fails (dev mode, network down) the user still +/// gets a properly-shaped `did:plc:` they can use locally; a +/// successful PLC submit just publishes the op so the rest of the +/// network can resolve it. +/// +/// See for the +/// full specification; the relevant rule is §"DID generation". +pub fn did_plc_from_op(op: &PlcOperation) -> Result { + let buf = serialise_plc_op(op)?; + let cid = cid_for_cbor(&buf)?; + Ok(format!("did:plc:{}", cid)) +} + +/// Canonical dag-cbor encoding of a PLC op. Used for both signing +/// (the inner op only — `sigs` is computed on this payload) and +/// DID generation (the full op including `sigs`). +/// +/// Field ordering matters: dag-cbor canonical encoding sorts map +/// keys lexicographically, so the resulting byte string is +/// deterministic for a semantically-equal op regardless of how +/// the producer ordered its fields. +pub fn serialise_plc_op(op: &PlcOperation) -> Result> { + let value = match op { + PlcOperation::Tombstone { prev } => json!({ + "prev": prev, + "type": "plc_tombstone", + }), + PlcOperation::Op { + prev, + sigs, + op: inner, + } => json!({ + "type": inner.op_type, + "identifier": inner.identifier, + "rotationKeys": inner.rotation_keys, + "verificationMethods": inner.verification_methods, + "alsoKnownAs": inner.also_known_as, + "services": inner.services, + "prev": prev, + "sigs": sigs, + }), + }; + let mut buf = Vec::new(); + ciborium::into_writer(&value, &mut buf)?; + Ok(buf) +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum PlcOperation { @@ -130,4 +188,76 @@ mod tests { assert_eq!(identifier, "alice.maarcadetweet.local"); assert!(serialized.get("sigs").is_some()); } + + /// `did_plc_from_op` must be deterministic for the same op. + /// Two calls with the same args return the same DID. + #[test] + fn did_plc_is_deterministic() { + let sk = SecretKey::from_slice(&[7u8; 32]).unwrap(); + let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap(); + let signing = SigningKey::from(sk); + + let op1 = PlcOperation::create( + "alice.maarcadetweet.local", + &signing, + &rot_mb, + "https://pds.example", + ) + .unwrap(); + let op2 = PlcOperation::create( + "alice.maarcadetweet.local", + &signing, + &rot_mb, + "https://pds.example", + ) + .unwrap(); + + let did1 = did_plc_from_op(&op1).unwrap(); + let did2 = did_plc_from_op(&op2).unwrap(); + + assert_eq!(did1, did2, "DID must be deterministic for identical ops"); + assert!(did1.starts_with("did:plc:")); + // CID v1 with sha256 and base32-lower should produce a string + // starting with "b" (the standard cid v1 prefix for sha256). + let suffix = did1.strip_prefix("did:plc:").unwrap(); + assert!(suffix.starts_with('b'), "expected CIDv1 prefix, got {suffix}"); + } + + /// Different handles → different DIDs. + #[test] + fn did_plc_differs_per_handle() { + let sk = SecretKey::from_slice(&[11u8; 32]).unwrap(); + let rot_mb = crate::did_key::pubkey_to_multibase(&sk.public_key()).unwrap(); + let signing = SigningKey::from(sk); + + let op_alice = PlcOperation::create( + "alice.maarcadetweet.local", + &signing, + &rot_mb, + "https://pds.example", + ) + .unwrap(); + let op_bob = PlcOperation::create( + "bob.maarcadetweet.local", + &signing, + &rot_mb, + "https://pds.example", + ) + .unwrap(); + + let did_alice = did_plc_from_op(&op_alice).unwrap(); + let did_bob = did_plc_from_op(&op_bob).unwrap(); + + assert_ne!(did_alice, did_bob, "different handles must yield different DIDs"); + } + + /// Tombstone ops must also produce a `did:plc:` (the spec says + /// `plc_tombstone` operations are valid signed ops whose CID is + /// derived the same way). + #[test] + fn did_plc_tombstone_round_trip() { + let tomb = PlcOperation::Tombstone { prev: None }; + let did = did_plc_from_op(&tomb).unwrap(); + assert!(did.starts_with("did:plc:")); + } } diff --git a/crates/pds-server/src/routes/auth.rs b/crates/pds-server/src/routes/auth.rs index c697722..fd58b81 100644 --- a/crates/pds-server/src/routes/auth.rs +++ b/crates/pds-server/src/routes/auth.rs @@ -1,12 +1,12 @@ use crate::jwt_issuer; -use crate::keys::{derive_did_from_signing, generate_user_keys}; +use crate::keys::{generate_user_keys}; use crate::password::hash_password; use crate::routes::types::{ CreateAccountReq, CreateAccountResp, CreateSessionReq, CreateSessionResp, RefreshSessionReq, RefreshSessionResp, }; use crate::state::AppState; -use at_crypto::plc_op::{create_unsigned_op, sign_op, PlcOperation}; +use at_crypto::plc_op::{did_plc_from_op, PlcOperation}; use axum::extract::State; use axum::http::StatusCode; use axum::Json; @@ -69,7 +69,20 @@ pub async fn create_account( } let keys = generate_user_keys().map_err(|e| internal(e))?; - let did = derive_did_from_signing(&keys.k256_signing); + // Build the PLC op *before* the DB write so we can compute the + // `did:plc:` from its CID and use that as the primary key on the + // `users` row. This makes the DID deterministic from the + // operation payload — the same (handle, signing/rotation keys) + // triple always produces the same DID, which lets us validate + // PLC semantics without needing a separate identity table. + let plc_op = PlcOperation::create( + &req.handle, + &keys.k256_signing.secret_key().unwrap(), + &keys.k256_rotation.public_multibase, + &state.cfg.pds_public_url, + ) + .map_err(|e| internal(e))?; + let did = did_plc_from_op(&plc_op).map_err(|e| internal(e))?; let pwd_hash = match &req.password { Some(pw) => Some(hash_password(pw).map_err(|e| internal(e))?), None => None, @@ -107,20 +120,27 @@ pub async fn create_account( tx.commit().await.map_err(|e| internal(e))?; - let plc_op = PlcOperation::create( - &req.handle, - &keys.k256_signing.secret_key().unwrap(), - &keys.k256_rotation.public_multibase, - &state.cfg.pds_public_url, - ) - .map_err(|e| internal(e))?; + // Submit the op to the PLC directory. We compute the DID + // locally via `did_plc_from_op` so the user is usable even when + // the outbound PLC submit fails (dev mode, network down, + // DNS-blocked). A successful submit publishes the op so the + // rest of the network can resolve the handle; failure is logged + // and tolerated (matches the original best-effort contract). let plc_cid = match state.plc.submit(&did, &plc_op).await { Ok(c) => { - info!("plc op submitted: cid={}", c); + info!( + did = %did, + cid = %c, + "plc op submitted; DID registered globally" + ); Some(c) } Err(e) => { - warn!("plc submit failed (dev ok): {e:#}"); + warn!( + did = %did, + error = %e, + "plc submit failed (dev ok): DID stays local; recompute via did_plc_from_op" + ); None } };