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        // `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}