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 } };