feat(at-crypto, pds-server): deterministic did:plc: from signed op (Phase 1)

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:<base32(CID)>'.
    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🔑...' 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:<base32-lower-digest>' — interoperability with
plc.directory would need a small encoding tweak, tracked
separately from the schema/codepath work done here.
This commit is contained in:
tomdebone
2026-07-07 22:17:50 +02:00
parent a5b1c889dc
commit b8da282525
3 changed files with 176 additions and 24 deletions
+14 -12
View File
@@ -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
+130
View File
@@ -7,6 +7,64 @@ use crate::cid::cid_for_cbor;
#[allow(unused_imports)]
use crate::did_key::verifying_key_to_multibase;
/// Deterministic `did:plc:<base32(sha256(dag-cbor(op)))>`.
///
/// 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 <https://github.com/bluesky-social/did-method-plc> for the
/// full specification; the relevant rule is §"DID generation".
pub fn did_plc_from_op(op: &PlcOperation) -> Result<String> {
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<Vec<u8>> {
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:"));
}
}
+32 -12
View File
@@ -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
}
};