···2233pub mod error;
44pub mod keys;
55+pub mod plc;
56pub mod shamir;
6778pub use error::CryptoError;
89pub use keys::{
910 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair,
1011};
1212+pub use plc::{build_did_plc_genesis_op, PlcGenesisOp};
1113pub use shamir::{combine_shares, split_secret, ShamirShare};
+391
crates/crypto/src/plc.rs
···11+// pattern: Functional Core
22+//
33+// Pure did:plc genesis operation builder. No I/O, no HTTP, no DB.
44+// Builds a signed genesis operation from key material and identity fields,
55+// derives the DID, and returns both for use by the relay's imperative shell.
66+//
77+// Algorithm:
88+// 1. Construct UnsignedPlcOp (fields in DAG-CBOR canonical order)
99+// 2. CBOR-encode unsigned op (ciborium)
1010+// 3. ECDSA-SHA256 sign the CBOR bytes (p256, RFC 6979 deterministic, low-S)
1111+// 4. base64url-encode the 64-byte r‖s signature
1212+// 5. Construct SignedPlcOp (same fields + sig)
1313+// 6. CBOR-encode signed op
1414+// 7. SHA-256 hash of signed CBOR
1515+// 8. base32-lowercase first 24 chars → DID suffix
1616+// 9. JSON-serialize signed op → signed_op_json
1717+//
1818+// References:
1919+// - did:plc spec v0.1: https://web.plc.directory/spec/v0.1/did-plc
2020+// - RFC 6979: deterministic ECDSA nonce generation
2121+// - DAG-CBOR: map keys sorted by byte-length then alphabetically
2222+2323+use std::collections::BTreeMap;
2424+2525+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
2626+use ciborium::ser::into_writer;
2727+use p256::{
2828+ FieldBytes,
2929+ ecdsa::{SigningKey, Signature, signature::Signer},
3030+};
3131+use serde::Serialize;
3232+use sha2::{Digest, Sha256};
3333+3434+use crate::{CryptoError, DidKeyUri};
3535+3636+/// The result of building a did:plc genesis operation.
3737+#[derive(Debug)]
3838+pub struct PlcGenesisOp {
3939+ /// The derived DID, e.g. `"did:plc:abcdefghijklmnopqrstuvwx"`.
4040+ /// Ready to use as the database primary key.
4141+ pub did: String,
4242+ /// The signed genesis operation as a JSON string.
4343+ /// Ready to POST to plc.directory.
4444+ pub signed_op_json: String,
4545+}
4646+4747+// ── Internal serialization types ────────────────────────────────────────────
4848+//
4949+// Field declaration order matches DAG-CBOR canonical ordering:
5050+// sort by UTF-8 byte length of the serialized key name, then alphabetically.
5151+//
5252+// For UnsignedPlcOp key lengths:
5353+// "prev" → 4 bytes
5454+// "type" → 4 bytes ("prev" < "type" alphabetically)
5555+// "services" → 8 bytes
5656+// "alsoKnownAs" → 11 bytes
5757+// "rotationKeys" → 12 bytes
5858+// "verificationMethods" → 19 bytes
5959+6060+#[derive(Serialize, Clone)]
6161+struct PlcService {
6262+ // "type" → 4 bytes
6363+ #[serde(rename = "type")]
6464+ service_type: String,
6565+ // "endpoint" → 8 bytes
6666+ endpoint: String,
6767+}
6868+6969+#[derive(Serialize)]
7070+struct UnsignedPlcOp {
7171+ prev: Option<String>,
7272+ #[serde(rename = "type")]
7373+ op_type: String,
7474+ services: BTreeMap<String, PlcService>,
7575+ #[serde(rename = "alsoKnownAs")]
7676+ also_known_as: Vec<String>,
7777+ #[serde(rename = "rotationKeys")]
7878+ rotation_keys: Vec<String>,
7979+ #[serde(rename = "verificationMethods")]
8080+ verification_methods: BTreeMap<String, String>,
8181+}
8282+8383+// For SignedPlcOp key lengths (includes "sig"):
8484+// "sig" → 3 bytes (shortest — comes first)
8585+// "prev" → 4 bytes
8686+// "type" → 4 bytes
8787+// "services" → 8 bytes
8888+// "alsoKnownAs" → 11 bytes
8989+// "rotationKeys" → 12 bytes
9090+// "verificationMethods" → 19 bytes
9191+9292+#[derive(Serialize)]
9393+struct SignedPlcOp {
9494+ sig: String,
9595+ prev: Option<String>,
9696+ #[serde(rename = "type")]
9797+ op_type: String,
9898+ services: BTreeMap<String, PlcService>,
9999+ #[serde(rename = "alsoKnownAs")]
100100+ also_known_as: Vec<String>,
101101+ #[serde(rename = "rotationKeys")]
102102+ rotation_keys: Vec<String>,
103103+ #[serde(rename = "verificationMethods")]
104104+ verification_methods: BTreeMap<String, String>,
105105+}
106106+107107+// ── Public API ───────────────────────────────────────────────────────────────
108108+109109+/// Build and sign a did:plc genesis operation, returning the signed operation
110110+/// JSON and the derived DID.
111111+///
112112+/// # Parameters
113113+/// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`.
114114+/// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`.
115115+/// - `signing_private_key`: Raw 32-byte P-256 private key scalar for `signing_key`.
116116+/// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`.
117117+/// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`.
118118+///
119119+/// # Errors
120120+/// Returns `CryptoError::PlcOperation` if `signing_private_key` is not a valid P-256 scalar
121121+/// (e.g. all-zero bytes, or a value ≥ the curve order).
122122+pub fn build_did_plc_genesis_op(
123123+ rotation_key: &DidKeyUri,
124124+ signing_key: &DidKeyUri,
125125+ signing_private_key: &[u8; 32],
126126+ handle: &str,
127127+ service_endpoint: &str,
128128+) -> Result<PlcGenesisOp, CryptoError> {
129129+ // Step 1: Construct signing key from raw scalar bytes.
130130+ let field_bytes: FieldBytes = (*signing_private_key).into();
131131+ let sk = SigningKey::from_bytes(&field_bytes)
132132+ .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?;
133133+134134+ // Step 2: Build the unsigned operation.
135135+ let mut verification_methods = BTreeMap::new();
136136+ verification_methods.insert("atproto".to_string(), signing_key.0.clone());
137137+138138+ let mut services = BTreeMap::new();
139139+ services.insert(
140140+ "atproto_pds".to_string(),
141141+ PlcService {
142142+ service_type: "AtprotoPersonalDataServer".to_string(),
143143+ endpoint: service_endpoint.to_string(),
144144+ },
145145+ );
146146+147147+ let unsigned_op = UnsignedPlcOp {
148148+ prev: None,
149149+ op_type: "plc_operation".to_string(),
150150+ services: services.clone(),
151151+ also_known_as: vec![format!("at://{handle}")],
152152+ rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()],
153153+ verification_methods: verification_methods.clone(),
154154+ };
155155+156156+ // Step 3: CBOR-encode the unsigned operation.
157157+ let mut unsigned_cbor = Vec::new();
158158+ into_writer(&unsigned_op, &mut unsigned_cbor)
159159+ .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?;
160160+161161+ // Step 4: ECDSA-SHA256 sign (RFC 6979 deterministic, low-S canonical).
162162+ // Signer::sign internally hashes with SHA-256 before signing.
163163+ let sig: Signature = sk.sign(&unsigned_cbor);
164164+ let sig_bytes = sig.to_bytes();
165165+166166+ // Step 5: base64url-encode the 64-byte r‖s signature (no padding).
167167+ let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes[..]);
168168+169169+ // Step 6: Build the signed operation (same fields + sig).
170170+ let signed_op = SignedPlcOp {
171171+ sig: sig_str,
172172+ prev: None,
173173+ op_type: "plc_operation".to_string(),
174174+ services,
175175+ also_known_as: vec![format!("at://{handle}")],
176176+ rotation_keys: vec![rotation_key.0.clone(), signing_key.0.clone()],
177177+ verification_methods,
178178+ };
179179+180180+ // Step 7: CBOR-encode the signed operation.
181181+ let mut signed_cbor = Vec::new();
182182+ into_writer(&signed_op, &mut signed_cbor)
183183+ .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?;
184184+185185+ // Step 8: SHA-256 hash of the signed CBOR.
186186+ let hash = Sha256::digest(&signed_cbor);
187187+188188+ // Step 9: base32-lowercase, take first 24 characters.
189189+ let base32_encoding = {
190190+ let mut spec = data_encoding::Specification::new();
191191+ spec.symbols.push_str("abcdefghijklmnopqrstuvwxyz234567");
192192+ spec.encoding()
193193+ .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}")))?
194194+ };
195195+ let encoded = base32_encoding.encode(hash.as_ref());
196196+ let did = format!("did:plc:{}", &encoded[..24]);
197197+198198+ // Step 10: JSON-serialize the signed operation.
199199+ let signed_op_json = serde_json::to_string(&signed_op)
200200+ .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?;
201201+202202+ Ok(PlcGenesisOp { did, signed_op_json })
203203+}
204204+205205+// ── Tests ────────────────────────────────────────────────────────────────────
206206+207207+#[cfg(test)]
208208+mod tests {
209209+ use super::*;
210210+ use crate::generate_p256_keypair;
211211+212212+ /// Generates two test keypairs and calls build_did_plc_genesis_op with them.
213213+ /// Returns (rotation_key, signing_key, private_key_bytes, result).
214214+ fn make_genesis_op() -> (DidKeyUri, DidKeyUri, [u8; 32], PlcGenesisOp) {
215215+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
216216+ let signing_kp = generate_p256_keypair().expect("signing keypair");
217217+ let private_key_bytes = *signing_kp.private_key_bytes;
218218+ let result = build_did_plc_genesis_op(
219219+ &rotation_kp.key_id,
220220+ &signing_kp.key_id,
221221+ &private_key_bytes,
222222+ "alice.example.com",
223223+ "https://relay.example.com",
224224+ )
225225+ .expect("genesis op should succeed");
226226+ (rotation_kp.key_id, signing_kp.key_id, private_key_bytes, result)
227227+ }
228228+229229+ /// MM-89.AC1.1: did matches ^did:plc:[a-z2-7]{24}$
230230+ #[test]
231231+ fn did_matches_expected_format() {
232232+ let (_, _, _, op) = make_genesis_op();
233233+ assert!(
234234+ op.did.starts_with("did:plc:"),
235235+ "DID should start with 'did:plc:'"
236236+ );
237237+ let suffix = op.did.strip_prefix("did:plc:").unwrap();
238238+ assert_eq!(suffix.len(), 24, "DID suffix should be 24 chars");
239239+ assert!(
240240+ suffix.chars().all(|c| c.is_ascii_lowercase() || ('2'..='7').contains(&c)),
241241+ "DID suffix should only contain [a-z2-7], got: {suffix}"
242242+ );
243243+ }
244244+245245+ /// MM-89.AC1.2: signed_op_json contains all required fields with correct values
246246+ #[test]
247247+ fn signed_op_json_contains_required_fields() {
248248+ let (_, _, _, op) = make_genesis_op();
249249+ let v: serde_json::Value =
250250+ serde_json::from_str(&op.signed_op_json).expect("valid JSON");
251251+252252+ assert_eq!(v["type"], "plc_operation", "type field");
253253+ assert!(v["rotationKeys"].is_array(), "rotationKeys is array");
254254+ assert!(
255255+ v["verificationMethods"].is_object(),
256256+ "verificationMethods is object"
257257+ );
258258+ assert!(v["alsoKnownAs"].is_array(), "alsoKnownAs is array");
259259+ assert!(v["services"].is_object(), "services is object");
260260+ assert_eq!(v["prev"], serde_json::Value::Null, "prev is null");
261261+ assert!(v["sig"].is_string(), "sig is string");
262262+ }
263263+264264+ /// MM-89.AC1.3: rotation_key at rotationKeys[0]; signing_key at rotationKeys[1] and verificationMethods.atproto
265265+ #[test]
266266+ fn keys_placed_in_correct_positions() {
267267+ let (rotation_key, signing_key, _, op) = make_genesis_op();
268268+ let v: serde_json::Value =
269269+ serde_json::from_str(&op.signed_op_json).expect("valid JSON");
270270+ assert_eq!(
271271+ v["rotationKeys"][0].as_str().unwrap(),
272272+ rotation_key.0,
273273+ "rotationKeys[0] should be rotation_key"
274274+ );
275275+ assert_eq!(
276276+ v["rotationKeys"][1].as_str().unwrap(),
277277+ signing_key.0,
278278+ "rotationKeys[1] should be signing_key"
279279+ );
280280+ assert_eq!(
281281+ v["verificationMethods"]["atproto"].as_str().unwrap(),
282282+ signing_key.0,
283283+ "verificationMethods.atproto should be signing_key"
284284+ );
285285+ }
286286+287287+ /// MM-89.AC1.4: RFC 6979 determinism — same inputs produce same DID
288288+ #[test]
289289+ fn same_inputs_produce_same_did() {
290290+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
291291+ let signing_kp = generate_p256_keypair().expect("signing keypair");
292292+ let private_key_bytes = *signing_kp.private_key_bytes;
293293+294294+ let op1 = build_did_plc_genesis_op(
295295+ &rotation_kp.key_id,
296296+ &signing_kp.key_id,
297297+ &private_key_bytes,
298298+ "alice.example.com",
299299+ "https://relay.example.com",
300300+ )
301301+ .expect("first call should succeed");
302302+303303+ let op2 = build_did_plc_genesis_op(
304304+ &rotation_kp.key_id,
305305+ &signing_kp.key_id,
306306+ &private_key_bytes,
307307+ "alice.example.com",
308308+ "https://relay.example.com",
309309+ )
310310+ .expect("second call should succeed");
311311+312312+ assert_eq!(op1.did, op2.did, "DID must be identical for same inputs");
313313+ assert_eq!(
314314+ op1.signed_op_json, op2.signed_op_json,
315315+ "signed_op_json must be identical for same inputs"
316316+ );
317317+ }
318318+319319+ /// MM-89.AC1.5: Invalid signing key (all-zero scalar) returns CryptoError::PlcOperation
320320+ #[test]
321321+ fn invalid_signing_key_returns_error() {
322322+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
323323+ let signing_kp = generate_p256_keypair().expect("signing keypair");
324324+ let zero_bytes = [0u8; 32]; // Zero scalar is invalid for P-256
325325+326326+ let result = build_did_plc_genesis_op(
327327+ &rotation_kp.key_id,
328328+ &signing_kp.key_id,
329329+ &zero_bytes,
330330+ "alice.example.com",
331331+ "https://relay.example.com",
332332+ );
333333+334334+ assert!(
335335+ matches!(result, Err(CryptoError::PlcOperation(_))),
336336+ "Zero scalar should return CryptoError::PlcOperation, got: {result:?}"
337337+ );
338338+ }
339339+340340+ /// MM-89.AC3.2: sig field is base64url (no padding) decoding to exactly 64 bytes
341341+ #[test]
342342+ fn sig_field_is_base64url_no_padding_and_64_bytes() {
343343+ let (_, _, _, op) = make_genesis_op();
344344+ let v: serde_json::Value =
345345+ serde_json::from_str(&op.signed_op_json).expect("valid JSON");
346346+ let sig_str = v["sig"].as_str().expect("sig is a string");
347347+348348+ // No padding characters
349349+ assert!(
350350+ !sig_str.contains('='),
351351+ "sig should not contain padding '=', got: {sig_str}"
352352+ );
353353+ // Decodes to exactly 64 bytes
354354+ let decoded = URL_SAFE_NO_PAD
355355+ .decode(sig_str)
356356+ .expect("sig should be valid base64url");
357357+ assert_eq!(
358358+ decoded.len(),
359359+ 64,
360360+ "sig should decode to 64 bytes (r‖s), got {} bytes",
361361+ decoded.len()
362362+ );
363363+ }
364364+365365+ /// MM-89.AC3.3: alsoKnownAs contains at://{handle}
366366+ #[test]
367367+ fn also_known_as_contains_at_uri() {
368368+ let rotation_kp = generate_p256_keypair().expect("rotation keypair");
369369+ let signing_kp = generate_p256_keypair().expect("signing keypair");
370370+ let private_key_bytes = *signing_kp.private_key_bytes;
371371+372372+ let op = build_did_plc_genesis_op(
373373+ &rotation_kp.key_id,
374374+ &signing_kp.key_id,
375375+ &private_key_bytes,
376376+ "alice.example.com",
377377+ "https://relay.example.com",
378378+ )
379379+ .expect("genesis op should succeed");
380380+381381+ let v: serde_json::Value =
382382+ serde_json::from_str(&op.signed_op_json).expect("valid JSON");
383383+ let also_known_as = v["alsoKnownAs"].as_array().expect("alsoKnownAs is array");
384384+ assert!(
385385+ also_known_as
386386+ .iter()
387387+ .any(|e| e.as_str() == Some("at://alice.example.com")),
388388+ "alsoKnownAs should contain 'at://alice.example.com', got: {also_known_as:?}"
389389+ );
390390+ }
391391+}