An easy-to-host PDS on the ATProtocol, iPhone and MacOS. Maintain control of your keys and data, always.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(crypto): add build_did_plc_genesis_op_with_external_signer for SE-backed signing

authored by

Malpercio and committed by
Tangled
e16dc8e9 4197999e

+117 -22
+4 -1
crates/crypto/src/lib.rs
··· 9 9 pub use keys::{ 10 10 decrypt_private_key, encrypt_private_key, generate_p256_keypair, DidKeyUri, P256Keypair, 11 11 }; 12 - pub use plc::{build_did_plc_genesis_op, verify_genesis_op, PlcGenesisOp, VerifiedGenesisOp}; 12 + pub use plc::{ 13 + build_did_plc_genesis_op, build_did_plc_genesis_op_with_external_signer, verify_genesis_op, 14 + PlcGenesisOp, VerifiedGenesisOp, 15 + }; 13 16 pub use shamir::{combine_shares, split_secret, ShamirShare};
+113 -21
crates/crypto/src/plc.rs
··· 158 158 .map_err(|e| CryptoError::PlcOperation(format!("build base32 encoding: {e}"))) 159 159 } 160 160 161 - pub fn build_did_plc_genesis_op( 161 + /// Build and sign a did:plc genesis operation using an external signing callback. 162 + /// 163 + /// This variant accepts a signing callback instead of raw private key bytes, enabling 164 + /// use with non-extractable keys such as Apple Secure Enclave keys. 165 + /// 166 + /// # Parameters 167 + /// - `rotation_key`: The user's device key (highest-priority rotation key). Placed at `rotationKeys[0]`. 168 + /// - `signing_key`: The relay's signing key. Placed at `rotationKeys[1]` and `verificationMethods.atproto`. 169 + /// - `handle`: The account handle, e.g. `"alice.example.com"`. Stored as `"at://alice.example.com"` in `alsoKnownAs`. 170 + /// - `service_endpoint`: The relay's public URL, e.g. `"https://relay.example.com"`. 171 + /// - `sign`: A callback that receives the CBOR-encoded unsigned op bytes and must return the 172 + /// raw 64-byte r‖s P-256 ECDSA signature bytes (big-endian, low-S canonical). 173 + /// 174 + /// # Errors 175 + /// Returns `CryptoError::PlcOperation` if `sign` returns `Err`, or if any serialization step fails. 176 + pub fn build_did_plc_genesis_op_with_external_signer<F>( 162 177 rotation_key: &DidKeyUri, 163 178 signing_key: &DidKeyUri, 164 - signing_private_key: &[u8; 32], 165 179 handle: &str, 166 180 service_endpoint: &str, 167 - ) -> Result<PlcGenesisOp, CryptoError> { 168 - // Step 1: Construct signing key from raw scalar bytes. 169 - let field_bytes: FieldBytes = (*signing_private_key).into(); 170 - let sk = SigningKey::from_bytes(&field_bytes) 171 - .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?; 172 - 173 - // Step 2: Build the unsigned operation. 181 + sign: F, 182 + ) -> Result<PlcGenesisOp, CryptoError> 183 + where 184 + F: FnOnce(&[u8]) -> Result<Vec<u8>, CryptoError>, 185 + { 186 + // Step 1: Build the unsigned operation. 174 187 let mut verification_methods = BTreeMap::new(); 175 188 verification_methods.insert("atproto".to_string(), signing_key.0.clone()); 176 189 ··· 192 205 verification_methods: verification_methods.clone(), 193 206 }; 194 207 195 - // Step 3: CBOR-encode the unsigned operation. 208 + // Step 2: CBOR-encode the unsigned operation. 196 209 let mut unsigned_cbor = Vec::new(); 197 210 into_writer(&unsigned_op, &mut unsigned_cbor) 198 211 .map_err(|e| CryptoError::PlcOperation(format!("cbor encode unsigned op: {e}")))?; 199 212 200 - // Step 4: ECDSA-SHA256 sign (RFC 6979 deterministic, low-S canonical). 201 - // Signer::sign internally hashes with SHA-256 before signing. 202 - let sig: Signature = sk.sign(&unsigned_cbor); 203 - let sig_bytes = sig.to_bytes(); 213 + // Step 3: Call external signer with the CBOR bytes. 214 + // The callback must return raw 64-byte r‖s P-256 ECDSA signature bytes. 215 + let sig_bytes = sign(&unsigned_cbor)?; 204 216 205 - // Step 5: base64url-encode the 64-byte r‖s signature (no padding). 206 - let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes[..]); 217 + // Step 4: base64url-encode the signature (no padding). 218 + let sig_str = URL_SAFE_NO_PAD.encode(&sig_bytes); 207 219 208 - // Step 6: Build the signed operation (same fields + sig). 220 + // Step 5: Build the signed operation (same fields + sig). 209 221 let signed_op = SignedPlcOp { 210 222 sig: sig_str, 211 223 prev: None, ··· 216 228 verification_methods, 217 229 }; 218 230 219 - // Step 7: CBOR-encode the signed operation. 231 + // Step 6: CBOR-encode the signed operation. 220 232 let mut signed_cbor = Vec::new(); 221 233 into_writer(&signed_op, &mut signed_cbor) 222 234 .map_err(|e| CryptoError::PlcOperation(format!("cbor encode signed op: {e}")))?; 223 235 224 - // Step 8: SHA-256 hash of the signed CBOR. 236 + // Step 7: SHA-256 hash of the signed CBOR. 225 237 let hash = Sha256::digest(&signed_cbor); 226 238 227 - // Step 9: base32-lowercase, take first 24 characters. 239 + // Step 8: base32-lowercase, take first 24 characters. 228 240 let encoded = base32_lowercase()?.encode(hash.as_ref()); 229 241 let did = format!("did:plc:{}", &encoded[..24]); 230 242 231 - // Step 10: JSON-serialize the signed operation. 243 + // Step 9: JSON-serialize the signed operation. 232 244 let signed_op_json = serde_json::to_string(&signed_op) 233 245 .map_err(|e| CryptoError::PlcOperation(format!("json serialize signed op: {e}")))?; 234 246 ··· 236 248 did, 237 249 signed_op_json, 238 250 }) 251 + } 252 + 253 + pub fn build_did_plc_genesis_op( 254 + rotation_key: &DidKeyUri, 255 + signing_key: &DidKeyUri, 256 + signing_private_key: &[u8; 32], 257 + handle: &str, 258 + service_endpoint: &str, 259 + ) -> Result<PlcGenesisOp, CryptoError> { 260 + let field_bytes: FieldBytes = (*signing_private_key).into(); 261 + let sk = SigningKey::from_bytes(&field_bytes) 262 + .map_err(|e| CryptoError::PlcOperation(format!("invalid signing key: {e}")))?; 263 + build_did_plc_genesis_op_with_external_signer( 264 + rotation_key, 265 + signing_key, 266 + handle, 267 + service_endpoint, 268 + |data| { 269 + let sig: Signature = Signer::sign(&sk, data); 270 + Ok(sig.to_bytes().to_vec()) 271 + }, 272 + ) 239 273 } 240 274 241 275 /// Verify a client-submitted did:plc signed genesis operation. ··· 737 771 verified.did, op.did, 738 772 "DID should match the original op's DID" 739 773 ); 774 + } 775 + 776 + // MM-146.AC2.1: Callback receives CBOR bytes; returned PlcGenesisOp passes verify_genesis_op. 777 + #[test] 778 + fn external_signer_callback_produces_valid_genesis_op() { 779 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 780 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 781 + let private_key_bytes: [u8; 32] = *signing_kp.private_key_bytes; 782 + 783 + // Simulate SE: the key is available for signing but bytes are not "exposed" to the caller. 784 + let field_bytes: FieldBytes = private_key_bytes.into(); 785 + let sk = SigningKey::from_bytes(&field_bytes).expect("valid signing key"); 786 + 787 + let result = build_did_plc_genesis_op_with_external_signer( 788 + &rotation_kp.key_id, 789 + &signing_kp.key_id, 790 + "alice.example.com", 791 + "https://relay.example.com", 792 + |data| { 793 + let sig: Signature = Signer::sign(&sk, data); 794 + Ok(sig.to_bytes().to_vec()) 795 + }, 796 + ) 797 + .expect("external signer should succeed"); 798 + 799 + // The resulting op must pass verify_genesis_op with the signing key (which made the signature). 800 + let verified = verify_genesis_op(&result.signed_op_json, &signing_kp.key_id) 801 + .expect("signed op must be verifiable with signing key"); 802 + assert_eq!( 803 + verified.did, result.did, 804 + "verified DID must match the DID returned by the builder" 805 + ); 806 + } 807 + 808 + // MM-146.AC2.2: Callback returning Err propagates as CryptoError::PlcOperation. 809 + #[test] 810 + fn external_signer_callback_error_propagates_as_plc_operation() { 811 + let rotation_kp = generate_p256_keypair().expect("rotation keypair"); 812 + let signing_kp = generate_p256_keypair().expect("signing keypair"); 813 + 814 + let result = build_did_plc_genesis_op_with_external_signer( 815 + &rotation_kp.key_id, 816 + &signing_kp.key_id, 817 + "alice.example.com", 818 + "https://relay.example.com", 819 + |_data| Err(CryptoError::PlcOperation("SE signing failed".to_string())), 820 + ); 821 + 822 + assert!(result.is_err(), "must return error when callback fails"); 823 + match result.unwrap_err() { 824 + CryptoError::PlcOperation(msg) => { 825 + assert!( 826 + msg.contains("SE signing failed"), 827 + "error message must propagate from callback, got: {msg}" 828 + ); 829 + } 830 + other => panic!("expected CryptoError::PlcOperation, got: {other:?}"), 831 + } 740 832 } 741 833 }