acdp_crypto/sign.rs
1//! Producer-side signing — RFC-ACDP-0001 §5.8.
2//!
3//! Two algorithms are supported, matching the ACDP signature-algorithms
4//! registry: `ed25519` (mandatory baseline) and `ecdsa-p256` (interop).
5//!
6//! For both, the signature input MUST be the ASCII bytes of the full
7//! `content_hash` string (e.g. `sha256:5f8d…`), NOT the raw 32-byte
8//! digest. The wire form is base64-encoded:
9//! - `ed25519` — 64 raw signature bytes → 88 base64 chars.
10//! - `ecdsa-p256` — IEEE 1363 `r‖s` (NOT DER) → 64 raw bytes → 88 base64 chars.
11//!
12//! Use [`AcdpSigningKey`] when you want a single key handle that selects
13//! the algorithm at construction time; the producer builder treats both
14//! variants uniformly. The concrete [`SigningKey`] / [`P256SigningKey`]
15//! types remain available for callers that already know the algorithm.
16
17use acdp_primitives::error::AcdpError;
18use acdp_primitives::primitives::ContentHash;
19use base64::{engine::general_purpose::STANDARD, Engine};
20use ed25519_dalek::{Signer as _, SigningKey as DalekSigningKey};
21use zeroize::ZeroizeOnDrop;
22
23// ── Ed25519 ──────────────────────────────────────────────────────────────────
24
25/// An Ed25519 signing key. Private bytes are zeroed on drop.
26#[derive(ZeroizeOnDrop)]
27pub struct SigningKey(DalekSigningKey);
28
29impl SigningKey {
30 /// Construct from a 32-byte raw private key seed.
31 pub fn from_bytes(bytes: &[u8; 32]) -> Self {
32 Self(DalekSigningKey::from_bytes(bytes))
33 }
34
35 /// Try to construct from a slice. Returns an error if the length is wrong.
36 pub fn from_slice(bytes: &[u8]) -> Result<Self, AcdpError> {
37 let arr: [u8; 32] = bytes.try_into().map_err(|_| {
38 AcdpError::InvalidSignature(format!(
39 "signing key must be 32 bytes, got {}",
40 bytes.len()
41 ))
42 })?;
43 Ok(Self::from_bytes(&arr))
44 }
45
46 /// Generate a fresh Ed25519 key pair using the operating system RNG.
47 ///
48 /// Recommended for production callers; `from_bytes` is for loading
49 /// previously-stored key material. Do not persist the raw 32-byte
50 /// seed in cleartext — use a key vault or HSM.
51 pub fn generate() -> Self {
52 // `rand_core` 0.10 dropped `OsRng`; `getrandom::SysRng` is the
53 // replacement, wrapped in `UnwrapErr` to get the infallible
54 // `CryptoRng` this API requires (panics on OS RNG failure,
55 // matching the old `OsRng`'s behavior).
56 Self(DalekSigningKey::generate(&mut rand_core::UnwrapErr(
57 getrandom::SysRng,
58 )))
59 }
60
61 /// Sign the ASCII bytes of the full `content_hash` string per §5.8.
62 ///
63 /// Returns the signature as standard base64 (88 chars including
64 /// padding for Ed25519).
65 pub fn sign_content_hash(&self, hash: &ContentHash) -> String {
66 // Sign the ASCII bytes of "sha256:<64-hex>", not the raw digest.
67 let sig = self.0.sign(hash.as_str().as_bytes());
68 STANDARD.encode(sig.to_bytes())
69 }
70
71 /// Raw public key bytes (32 bytes).
72 pub fn verifying_key_bytes(&self) -> [u8; 32] {
73 self.0.verifying_key().to_bytes()
74 }
75
76 /// Return the 32-byte raw private-key seed.
77 ///
78 /// Used by language bindings that need to store the key across
79 /// FFI calls (the FFI surface holds a `[u8; 32]` and reconstructs
80 /// the `SigningKey` per call, since `SigningKey` is
81 /// [`ZeroizeOnDrop`] and not `Clone`).
82 ///
83 /// The seed is private-key material — treat it as a secret and
84 /// route persistence through a key vault or HSM. The round-trip
85 /// `SigningKey::from_bytes(&key.seed_bytes())` reconstructs an
86 /// identical signing key.
87 pub fn seed_bytes(&self) -> [u8; 32] {
88 self.0.to_bytes()
89 }
90
91 /// Sign the UTF-8 bytes of an arbitrary string. Returns the
92 /// signature as standard base64 (88 chars including padding).
93 ///
94 /// Distinct from [`Self::sign_content_hash`], which signs the
95 /// ASCII bytes of the `"sha256:<hex>"` `content_hash` envelope per
96 /// RFC-ACDP-0001 §5.8. Use this method when the protocol's signing
97 /// input is *not* a `ContentHash` value — most notably the ACDP
98 /// registry's bearer-token challenge flow, whose signing input is
99 /// the namespaced ASCII string
100 /// `"acdp-registry-auth:v1:{nonce}:{agent_id}:{authority}:{expires_at}"`.
101 /// The registry verifies with
102 /// [`crate::verify::verify_ed25519`]`(&pub_bytes, &sig, &input)`.
103 pub fn sign_string(&self, input: &str) -> String {
104 let sig = self.0.sign(input.as_bytes());
105 STANDARD.encode(sig.to_bytes())
106 }
107}
108
109impl std::fmt::Debug for SigningKey {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 f.write_str("SigningKey(…)")
112 }
113}
114
115// ── ECDSA-P256 ───────────────────────────────────────────────────────────────
116
117/// An ECDSA-P256 signing key. Private scalar is zeroed on drop.
118///
119/// Wire form: 64 raw bytes IEEE 1363 (`r‖s`), base64-encoded with padding
120/// for 88 characters — matching the verify path in
121/// [`crate::verify::verify_ecdsa_p256`]. DER-encoded signatures
122/// are NOT compatible with the ACDP registry entry for `ecdsa-p256`.
123pub struct P256SigningKey(p256::ecdsa::SigningKey);
124
125impl P256SigningKey {
126 /// Generate a fresh P-256 key pair using the OS RNG.
127 ///
128 /// Recommended for production callers; `from_bytes` is for loading
129 /// previously-stored key material.
130 pub fn generate() -> Self {
131 use p256::elliptic_curve::Generate;
132 // `SigningKey::random` is deprecated in favor of the `Generate`
133 // trait (ecdsa 0.17); `rand_core` 0.10 also dropped `OsRng` — see
134 // the sibling `SigningKey::generate` above for the `UnwrapErr`
135 // rationale.
136 Self(p256::ecdsa::SigningKey::generate_from_rng(
137 &mut rand_core::UnwrapErr(getrandom::SysRng),
138 ))
139 }
140
141 /// Construct from 32 raw scalar bytes (big-endian).
142 ///
143 /// Returns [`AcdpError::SchemaViolation`] when the scalar is invalid
144 /// (e.g. zero or ≥ curve order). The error variant matches the
145 /// shape used elsewhere for key-material parse failures
146 /// (`AgentDid::parse_web`, `validate_signature_length`).
147 pub fn from_bytes(bytes: &[u8; 32]) -> Result<Self, AcdpError> {
148 p256::ecdsa::SigningKey::from_bytes(bytes.into())
149 .map(Self)
150 .map_err(|e| AcdpError::SchemaViolation(format!("p256 key parse: {e}")))
151 }
152
153 /// Try to construct from a slice. Returns an error if the length is wrong.
154 pub fn from_slice(bytes: &[u8]) -> Result<Self, AcdpError> {
155 let arr: [u8; 32] = bytes.try_into().map_err(|_| {
156 AcdpError::SchemaViolation(format!(
157 "p256 signing key must be 32 bytes, got {}",
158 bytes.len()
159 ))
160 })?;
161 Self::from_bytes(&arr)
162 }
163
164 /// Sign the ASCII bytes of the full `content_hash` string per §5.8.
165 ///
166 /// Uses RFC 6979 deterministic ECDSA (no `rng` parameter required).
167 /// Returns the signature as standard base64 of the 64-byte IEEE 1363
168 /// `r‖s` wire form (88 chars including padding).
169 pub fn sign_content_hash(&self, hash: &ContentHash) -> String {
170 use p256::ecdsa::{signature::Signer as _, Signature};
171 let sig: Signature = self.0.sign(hash.as_str().as_bytes());
172 // `Signature::to_bytes()` returns the fixed-size 64-byte IEEE 1363
173 // form, exactly the wire shape ACDP requires.
174 STANDARD.encode(sig.to_bytes())
175 }
176
177 /// Return the 32-byte raw private scalar (big-endian).
178 ///
179 /// P-256 analogue of [`SigningKey::seed_bytes`]. Language bindings
180 /// hold this `[u8; 32]` and reconstruct the `P256SigningKey` per FFI
181 /// call (the key zeroizes its scalar on drop and is not `Clone`). The
182 /// round-trip `P256SigningKey::from_bytes(&k.seed_bytes())`
183 /// reconstructs an identical signing key.
184 ///
185 /// The scalar is private-key material — treat it as a secret and
186 /// route persistence through a key vault or HSM.
187 pub fn seed_bytes(&self) -> [u8; 32] {
188 let fb = self.0.to_bytes();
189 let mut out = [0u8; 32];
190 // `AsRef<[u8]>` rather than the deprecated `GenericArray::as_slice`.
191 out.copy_from_slice(fb.as_ref());
192 out
193 }
194
195 /// Sign the UTF-8 bytes of an arbitrary string. Returns the
196 /// signature as standard base64 of the 64-byte IEEE 1363 `r‖s`
197 /// wire form (88 chars including padding).
198 ///
199 /// P-256 analogue of [`SigningKey::sign_string`] — uses RFC 6979
200 /// deterministic ECDSA, so the output is reproducible. Use this for
201 /// the ACDP registry's bearer-token challenge flow when the
202 /// producer's key is ECDSA-P256; the registry verifies with
203 /// [`crate::verify::verify_ecdsa_p256`]`(&sec1, &sig, input)`.
204 pub fn sign_string(&self, input: &str) -> String {
205 use p256::ecdsa::{signature::Signer as _, Signature};
206 let sig: Signature = self.0.sign(input.as_bytes());
207 STANDARD.encode(sig.to_bytes())
208 }
209
210 /// SEC1-uncompressed public key (65 bytes: `0x04 || x || y`).
211 ///
212 /// Use this to populate a `did:web` verification method's
213 /// `publicKeyJwk` (after splitting into the `x` / `y` halves) or
214 /// `publicKeyMultibase` representation.
215 pub fn verifying_key_sec1(&self) -> Vec<u8> {
216 // `VerifyingKey::to_sec1_point` is delegated from the
217 // `elliptic_curve::sec1::ToEncodedPoint` trait — inherent in the
218 // crate's public surface, no extra `use` needed.
219 self.0
220 .verifying_key()
221 .to_sec1_point(false)
222 .as_bytes()
223 .to_vec()
224 }
225
226 /// Return the public key as a P-256 JWK object suitable for
227 /// embedding in a `did:web` verification method's `publicKeyJwk`
228 /// field:
229 ///
230 /// ```json
231 /// { "kty": "EC", "crv": "P-256",
232 /// "x": "<base64url-no-pad x>",
233 /// "y": "<base64url-no-pad y>" }
234 /// ```
235 ///
236 /// FEAT-03: lets producers wire a published key into a DID
237 /// document without manually splitting the SEC1 point and
238 /// base64url-encoding each half.
239 pub fn verifying_key_jwk(&self) -> serde_json::Value {
240 use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
241 let sec1 = self.verifying_key_sec1();
242 // SEC1 uncompressed = 0x04 || X(32) || Y(32) — slice off the
243 // tag, split into halves, base64url-no-pad each.
244 let x_b64 = URL_SAFE_NO_PAD.encode(&sec1[1..33]);
245 let y_b64 = URL_SAFE_NO_PAD.encode(&sec1[33..65]);
246 serde_json::json!({
247 "kty": "EC",
248 "crv": "P-256",
249 "x": x_b64,
250 "y": y_b64,
251 })
252 }
253
254 /// Compose a complete `verificationMethod` entry for a `did:web`
255 /// DID document. `method_id` is the full DID URL (e.g.
256 /// `did:web:agents.example.com:alice#key-1`); `controller` is the
257 /// containing DID (without fragment).
258 ///
259 /// Output uses the `JsonWebKey2020` type so consumers can resolve
260 /// the algorithm via
261 /// [`acdp_did::document::VerificationMethod::declared_algorithm`]
262 /// (RFC-ACDP-0008 §3.9 algorithm-downgrade rejection).
263 pub fn did_verification_method(&self, method_id: &str, controller: &str) -> serde_json::Value {
264 serde_json::json!({
265 "id": method_id,
266 "type": "JsonWebKey2020",
267 "controller": controller,
268 "publicKeyJwk": self.verifying_key_jwk(),
269 })
270 }
271}
272
273impl std::fmt::Debug for P256SigningKey {
274 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
275 f.write_str("P256SigningKey(…)")
276 }
277}
278
279// `p256::ecdsa::SigningKey` wraps a `Scalar` that implements
280// `ZeroizeOnDrop`, so the private material is wiped automatically when
281// `P256SigningKey` drops. No explicit `Drop` impl needed.
282
283// ── Unified key handle ───────────────────────────────────────────────────────
284
285/// Either-or signing key — selects the algorithm at construction time.
286///
287/// Producers normally use `acdp::producer::Producer::new_ed25519` or
288/// `acdp::producer::Producer::new_p256` rather than constructing this
289/// enum directly. The `acdp::producer::RequestBuilder` inspects the
290/// variant to emit the matching `signature.algorithm` field.
291#[derive(Debug)]
292pub enum AcdpSigningKey {
293 /// Ed25519 — mandatory baseline.
294 Ed25519(SigningKey),
295 /// ECDSA-P256 — interop variant.
296 P256(P256SigningKey),
297}
298
299impl AcdpSigningKey {
300 /// Returns `(algorithm_str, base64_signature)` for the wire envelope.
301 ///
302 /// The first element is the literal string ACDP requires in
303 /// `signature.algorithm` (`"ed25519"` or `"ecdsa-p256"`).
304 pub fn sign_content_hash(&self, hash: &ContentHash) -> (&'static str, String) {
305 match self {
306 Self::Ed25519(k) => ("ed25519", k.sign_content_hash(hash)),
307 Self::P256(k) => ("ecdsa-p256", k.sign_content_hash(hash)),
308 }
309 }
310
311 /// The ACDP algorithm string for the wrapped key, regardless of
312 /// whether a signature has been produced yet.
313 pub fn algorithm(&self) -> &'static str {
314 match self {
315 Self::Ed25519(_) => "ed25519",
316 Self::P256(_) => "ecdsa-p256",
317 }
318 }
319}
320
321impl From<SigningKey> for AcdpSigningKey {
322 fn from(k: SigningKey) -> Self {
323 Self::Ed25519(k)
324 }
325}
326
327impl From<P256SigningKey> for AcdpSigningKey {
328 fn from(k: P256SigningKey) -> Self {
329 Self::P256(k)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn ed25519_from_slice_rejects_wrong_length() {
339 let err = SigningKey::from_slice(&[0u8; 31]).unwrap_err();
340 assert!(
341 matches!(err, AcdpError::InvalidSignature(ref m) if m.contains("32 bytes")),
342 "got {err:?}"
343 );
344 // Exactly 32 bytes is accepted.
345 assert!(SigningKey::from_slice(&[0u8; 32]).is_ok());
346 }
347
348 #[test]
349 fn p256_from_slice_rejects_wrong_length() {
350 let err = P256SigningKey::from_slice(&[1u8; 33]).unwrap_err();
351 assert!(
352 matches!(err, AcdpError::SchemaViolation(ref m) if m.contains("32 bytes")),
353 "got {err:?}"
354 );
355 }
356
357 #[test]
358 fn p256_from_bytes_rejects_invalid_scalar() {
359 // An all-zero scalar is not a valid P-256 private key.
360 let err = P256SigningKey::from_bytes(&[0u8; 32]).unwrap_err();
361 assert!(
362 matches!(err, AcdpError::SchemaViolation(ref m) if m.contains("p256 key parse")),
363 "got {err:?}"
364 );
365 }
366
367 #[test]
368 fn ed25519_generate_produces_distinct_keys() {
369 // Two fresh OsRng draws MUST produce different public keys.
370 let a = SigningKey::generate();
371 let b = SigningKey::generate();
372 assert_ne!(
373 a.verifying_key_bytes(),
374 b.verifying_key_bytes(),
375 "OsRng-backed generate() must not yield identical keys"
376 );
377 }
378
379 #[test]
380 fn p256_generate_produces_distinct_keys() {
381 let a = P256SigningKey::generate();
382 let b = P256SigningKey::generate();
383 assert_ne!(
384 a.verifying_key_sec1(),
385 b.verifying_key_sec1(),
386 "OsRng-backed P256 generate() must not yield identical keys"
387 );
388 }
389
390 #[test]
391 fn p256_sign_verify_round_trip() {
392 use crate::verify::verify_ecdsa_p256;
393 let key = P256SigningKey::generate();
394 let hash = ContentHash(
395 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".into(),
396 );
397 let sig = key.sign_content_hash(&hash);
398 // 88 base64 chars (64 raw + padding).
399 assert_eq!(sig.len(), 88, "p256 wire signature MUST be 88 base64 chars");
400 let pub_sec1 = key.verifying_key_sec1();
401 verify_ecdsa_p256(&pub_sec1, &sig, hash.as_str())
402 .expect("round-trip p256 signature must verify");
403 }
404
405 /// FEAT-03: `verifying_key_jwk` produces an `EC/P-256` JWK whose
406 /// `x`/`y` coordinates round-trip back to the SEC1 public key via
407 /// `VerificationMethod::ecdsa_p256_public_key_sec1`. Pins the
408 /// publish-side helper against the resolver-side extractor so a
409 /// DID document populated via this helper verifies cleanly.
410 #[test]
411 fn p256_verifying_key_jwk_round_trips_to_sec1() {
412 use acdp_did::document::VerificationMethod;
413 let key = P256SigningKey::generate();
414 let jwk = key.verifying_key_jwk();
415 assert_eq!(jwk["kty"], "EC");
416 assert_eq!(jwk["crv"], "P-256");
417
418 // Build a VerificationMethod with this JWK and ask the extractor
419 // for the SEC1 form — MUST equal what verifying_key_sec1
420 // produced directly.
421 let vm = VerificationMethod {
422 id: "did:web:agents.example.com:test#key-1".into(),
423 method_type: "JsonWebKey2020".into(),
424 controller: "did:web:agents.example.com:test".into(),
425 public_key_jwk: Some(jwk),
426 public_key_multibase: None,
427 };
428 let sec1_via_jwk = vm.ecdsa_p256_public_key_sec1().unwrap();
429 assert_eq!(sec1_via_jwk, key.verifying_key_sec1());
430 assert_eq!(vm.declared_algorithm(), Some("ecdsa-p256"));
431 }
432
433 /// FEAT-03: `did_verification_method` assembles a complete VM
434 /// suitable for embedding in a DID document's `verificationMethod`
435 /// array. Verifies the assembled object deserializes as
436 /// `VerificationMethod` and exposes the right algorithm declaration.
437 #[test]
438 fn p256_did_verification_method_assembles() {
439 use acdp_did::document::VerificationMethod;
440 let key = P256SigningKey::generate();
441 let vm_value = key.did_verification_method(
442 "did:web:agents.example.com:alice#key-1",
443 "did:web:agents.example.com:alice",
444 );
445 assert_eq!(vm_value["type"], "JsonWebKey2020");
446 let vm: VerificationMethod = serde_json::from_value(vm_value).unwrap();
447 assert_eq!(vm.id, "did:web:agents.example.com:alice#key-1");
448 assert_eq!(vm.declared_algorithm(), Some("ecdsa-p256"));
449 // Round-trip through the resolver-side extractor.
450 let sec1 = vm.ecdsa_p256_public_key_sec1().unwrap();
451 assert_eq!(sec1, key.verifying_key_sec1());
452 }
453
454 #[test]
455 fn p256_sign_against_wrong_message_fails() {
456 use crate::verify::verify_ecdsa_p256;
457 let key = P256SigningKey::generate();
458 let hash = ContentHash("sha256:".to_owned() + &"a".repeat(64));
459 let sig = key.sign_content_hash(&hash);
460 let pub_sec1 = key.verifying_key_sec1();
461 let err =
462 verify_ecdsa_p256(&pub_sec1, &sig, "sha256:0000000000000000").expect_err("must fail");
463 assert!(matches!(err, AcdpError::InvalidSignature(_)));
464 }
465
466 #[test]
467 fn p256_der_encoded_signature_rejected() {
468 // The verifier requires IEEE 1363 r||s (64 bytes). A DER-encoded
469 // signature is variable-length and starts with 0x30 — must be
470 // rejected by length check.
471 use crate::verify::verify_ecdsa_p256;
472 let key = P256SigningKey::generate();
473 let hash = ContentHash("sha256:".to_owned() + &"f".repeat(64));
474 // Produce a DER-encoded signature using the lower-level API.
475 use p256::ecdsa::signature::Signer as _;
476 let der: p256::ecdsa::DerSignature = key.0.sign(hash.as_str().as_bytes());
477 let sig_b64 = STANDARD.encode(der.as_bytes());
478 let pub_sec1 = key.verifying_key_sec1();
479 let err = verify_ecdsa_p256(&pub_sec1, &sig_b64, hash.as_str())
480 .expect_err("DER-encoded p256 sig MUST be rejected");
481 assert!(matches!(err, AcdpError::InvalidSignature(_)), "got {err:?}");
482 }
483
484 #[test]
485 fn acdp_signing_key_emits_correct_algorithm() {
486 let ed = AcdpSigningKey::Ed25519(SigningKey::generate());
487 let p2 = AcdpSigningKey::P256(P256SigningKey::generate());
488 assert_eq!(ed.algorithm(), "ed25519");
489 assert_eq!(p2.algorithm(), "ecdsa-p256");
490 let hash = ContentHash("sha256:".to_owned() + &"a".repeat(64));
491 let (alg_ed, _) = ed.sign_content_hash(&hash);
492 let (alg_p2, _) = p2.sign_content_hash(&hash);
493 assert_eq!(alg_ed, "ed25519");
494 assert_eq!(alg_p2, "ecdsa-p256");
495 }
496
497 // ── Ed25519 golden vector regression (sig-001) ──────────────────────
498
499 const ED25519_TEST_SEED: [u8; 32] = [0u8; 32];
500 const ED25519_TEST_PUB_HEX: &str =
501 "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29";
502
503 #[test]
504 fn sign_and_verify_ed25519_golden() {
505 use crate::verify::verify_ed25519;
506 let key = SigningKey::from_bytes(&ED25519_TEST_SEED);
507 let hash = ContentHash(
508 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".into(),
509 );
510 let sig_b64 = key.sign_content_hash(&hash);
511 assert_eq!(
512 sig_b64,
513 "ErkbV+FUdn49TgF3zJ3RBe3AmyGxLVAQdMjlhabUfM96qendmWwdVodX/SV3O3aKLypbUu6gmb5Npt3O/w7nDQ=="
514 );
515 let pub_bytes: [u8; 32] = hex::decode(ED25519_TEST_PUB_HEX)
516 .unwrap()
517 .try_into()
518 .unwrap();
519 verify_ed25519(&pub_bytes, &sig_b64, hash.as_str()).unwrap();
520 }
521
522 /// `seed_bytes` returns the same 32-byte seed that `from_bytes`
523 /// consumes — used by the FFI bindings to store the key across
524 /// calls without holding the `ZeroizeOnDrop` handle.
525 #[test]
526 fn seed_bytes_round_trip() {
527 let key = SigningKey::from_bytes(&ED25519_TEST_SEED);
528 assert_eq!(key.seed_bytes(), ED25519_TEST_SEED);
529
530 // Reconstruct from the exported seed and confirm it signs
531 // identically — the signature is deterministic for Ed25519
532 // given the same key and message.
533 let rebuilt = SigningKey::from_bytes(&key.seed_bytes());
534 let hash = ContentHash(
535 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".into(),
536 );
537 assert_eq!(
538 key.sign_content_hash(&hash),
539 rebuilt.sign_content_hash(&hash),
540 "key reconstructed from seed_bytes must produce an identical signature"
541 );
542 }
543
544 /// `sign_string` produces a base64-encoded Ed25519 signature over
545 /// the UTF-8 bytes of the input and verifies via `verify_ed25519`
546 /// against the same string. Pins the registry auth-challenge
547 /// signing flow.
548 #[test]
549 fn sign_string_verifies_directly() {
550 use crate::verify::verify_ed25519;
551 let key = SigningKey::from_bytes(&ED25519_TEST_SEED);
552 // Shape of the ACDP registry challenge `signing_input`.
553 let signing_input = "acdp-registry-auth:v1:nonce-abc:\
554 did:web:agents.example.com:test-producer:\
555 registry.example.com:1748000000";
556 let sig_b64 = key.sign_string(signing_input);
557 // Ed25519 raw signature is 64 bytes → 88 base64 chars (padded).
558 assert_eq!(sig_b64.len(), 88);
559
560 let pub_bytes: [u8; 32] = hex::decode(ED25519_TEST_PUB_HEX)
561 .unwrap()
562 .try_into()
563 .unwrap();
564 verify_ed25519(&pub_bytes, &sig_b64, signing_input).unwrap();
565
566 // A different input must NOT verify against the same signature.
567 verify_ed25519(&pub_bytes, &sig_b64, "different-input")
568 .expect_err("sign_string output MUST be specific to the signed input");
569 }
570
571 // ── ECDSA-P256 binding-support + golden vector (sig-002) ─────────────
572
573 /// `P256SigningKey::seed_bytes` round-trips through `from_bytes` and
574 /// the reconstructed key signs identically (RFC 6979 deterministic).
575 /// Pins the FFI key-storage contract used by the P256 bindings.
576 #[test]
577 fn p256_seed_bytes_round_trip() {
578 // RFC 6979 P-256 example private scalar.
579 let seed: [u8; 32] =
580 hex::decode("c9afa9d845ba75166b5c215767b1d6934e50c3db36e89b127b8a622b120f6721")
581 .unwrap()
582 .try_into()
583 .unwrap();
584 let key = P256SigningKey::from_bytes(&seed).unwrap();
585 assert_eq!(key.seed_bytes(), seed);
586
587 let rebuilt = P256SigningKey::from_bytes(&key.seed_bytes()).unwrap();
588 let hash = ContentHash("sha256:".to_owned() + &"a".repeat(64));
589 assert_eq!(
590 key.sign_content_hash(&hash),
591 rebuilt.sign_content_hash(&hash),
592 "key reconstructed from seed_bytes must produce an identical signature"
593 );
594 }
595
596 /// `P256SigningKey::sign_string` produces an IEEE 1363 signature over
597 /// the UTF-8 bytes of the input that verifies via `verify_ecdsa_p256`.
598 /// Pins the P-256 registry auth-challenge signing flow.
599 #[test]
600 fn p256_sign_string_verifies_directly() {
601 use crate::verify::verify_ecdsa_p256;
602 let key = P256SigningKey::generate();
603 let signing_input = "acdp-registry-auth:v1:nonce-abc:\
604 did:web:agents.example.com:test-producer:\
605 registry.example.com:1748000000";
606 let sig_b64 = key.sign_string(signing_input);
607 // P-256 IEEE 1363 r‖s is 64 bytes → 88 base64 chars (padded).
608 assert_eq!(sig_b64.len(), 88);
609
610 let sec1 = key.verifying_key_sec1();
611 verify_ecdsa_p256(&sec1, &sig_b64, signing_input).unwrap();
612 verify_ecdsa_p256(&sec1, &sig_b64, "different-input")
613 .expect_err("sign_string output MUST be specific to the signed input");
614 }
615
616 /// Golden vector regression for `ecdsa-p256` (sig-002). The test
617 /// keypair's private scalar is 1 (public key = the P-256 generator);
618 /// RFC 6979 makes the signature value reproducible. Drift here is a
619 /// protocol break — keep in sync with
620 /// `schemas/conformance/sig-002-ecdsa-p256-golden.json`.
621 #[test]
622 fn sign_and_verify_ecdsa_p256_golden() {
623 use crate::verify::verify_ecdsa_p256;
624 let mut seed = [0u8; 32];
625 seed[31] = 1; // private scalar = 1
626 let key = P256SigningKey::from_bytes(&seed).unwrap();
627 let hash = ContentHash(
628 "sha256:f170150ddbf59d99794e7797824591b374d459782084597b644ecc57a41031b5".into(),
629 );
630 let sig_b64 = key.sign_content_hash(&hash);
631 assert_eq!(
632 sig_b64,
633 "O+b+E5OIecgwCnjDyTqsiwwy3VTdBHbVhiRR9k3FAPZHvLJ5dyYYVPPUWbl0dKDdgKMw2dWrnKWRANJVoS9vNw=="
634 );
635 // Public key MUST be the SEC1 generator point from the fixture.
636 let sec1_hex = hex::encode(key.verifying_key_sec1());
637 assert_eq!(
638 sec1_hex,
639 "046b17d1f2e12c4247f8bce6e563a440f277037d812deb33a0f4a13945d898c296\
640 4fe342e2fe1a7f9b8ee7eb4a7c0f9e162bce33576b315ececbb6406837bf51f5"
641 );
642 verify_ecdsa_p256(&key.verifying_key_sec1(), &sig_b64, hash.as_str()).unwrap();
643 }
644}