Skip to main content

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}