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