Skip to main content

auths_verifier/
core.rs

1//! Core attestation types and canonical serialization.
2
3use crate::error::AttestationError;
4use crate::types::{CanonicalDid, IdentityDID};
5use chrono::{DateTime, Utc};
6use hex;
7use json_canon;
8use log::debug;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11use std::fmt;
12use std::ops::Deref;
13use std::str::FromStr;
14
15/// Maximum allowed size for a single attestation JSON input (64 KiB).
16pub const MAX_ATTESTATION_JSON_SIZE: usize = 64 * 1024;
17
18/// Maximum allowed size for JSON array inputs — chains, receipts, witness keys (1 MiB).
19pub const MAX_JSON_BATCH_SIZE: usize = 1024 * 1024;
20
21/// Maximum hex string length for Ed25519 public key (32 bytes × 2).
22pub const MAX_PUBLIC_KEY_HEX_LEN: usize = 64;
23/// Maximum hex string length for Ed25519 signature (64 bytes × 2).
24pub const MAX_SIGNATURE_HEX_LEN: usize = 128;
25/// Maximum hex string length for SHA-256 file hash (32 bytes × 2).
26pub const MAX_FILE_HASH_HEX_LEN: usize = 64;
27
28pub use auths_keri::capability::{
29    Capability, CapabilityError, MANAGE_MEMBERS, ROTATE_KEYS, SIGN_COMMIT, SIGN_RELEASE,
30};
31
32// =============================================================================
33// ResourceId newtype
34// =============================================================================
35
36/// A validated resource identifier linking an attestation to its storage ref.
37///
38/// Wraps a `String` with `#[serde(transparent)]` so JSON output is identical to bare `String`.
39/// Prevents accidental substitution of a DID, Git ref, or other string where a
40/// resource ID is expected.
41#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[serde(transparent)]
44pub struct ResourceId(String);
45
46impl ResourceId {
47    /// Creates a new ResourceId.
48    pub fn new(s: impl Into<String>) -> Self {
49        Self(s.into())
50    }
51
52    /// Returns the inner string slice.
53    pub fn as_str(&self) -> &str {
54        &self.0
55    }
56}
57
58impl Deref for ResourceId {
59    type Target = str;
60    fn deref(&self) -> &str {
61        &self.0
62    }
63}
64
65impl fmt::Display for ResourceId {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(&self.0)
68    }
69}
70
71impl From<String> for ResourceId {
72    fn from(s: String) -> Self {
73        Self(s)
74    }
75}
76
77impl From<&str> for ResourceId {
78    fn from(s: &str) -> Self {
79        Self(s.to_string())
80    }
81}
82
83impl PartialEq<str> for ResourceId {
84    fn eq(&self, other: &str) -> bool {
85        self.0 == other
86    }
87}
88
89impl PartialEq<&str> for ResourceId {
90    fn eq(&self, other: &&str) -> bool {
91        self.0 == *other
92    }
93}
94
95impl PartialEq<String> for ResourceId {
96    fn eq(&self, other: &String) -> bool {
97        self.0 == *other
98    }
99}
100
101// =============================================================================
102// Role enum
103// =============================================================================
104
105/// Role classification for organization members.
106///
107/// Governs the default capability set assigned at member authorization time.
108/// Serializes as lowercase strings: `"admin"`, `"member"`, `"readonly"`.
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111#[serde(rename_all = "lowercase")]
112pub enum Role {
113    /// Full admin access with all capabilities.
114    Admin,
115    /// Standard member with signing capabilities.
116    Member,
117    /// Read-only access; no signing capabilities.
118    Readonly,
119}
120
121impl Role {
122    /// Returns the canonical string representation.
123    pub fn as_str(&self) -> &str {
124        match self {
125            Role::Admin => "admin",
126            Role::Member => "member",
127            Role::Readonly => "readonly",
128        }
129    }
130
131    /// Return the default capability set for this role.
132    pub fn default_capabilities(&self) -> Vec<Capability> {
133        match self {
134            Role::Admin => vec![
135                Capability::sign_commit(),
136                Capability::sign_release(),
137                Capability::manage_members(),
138                Capability::rotate_keys(),
139            ],
140            Role::Member => vec![Capability::sign_commit(), Capability::sign_release()],
141            Role::Readonly => vec![],
142        }
143    }
144}
145
146impl fmt::Display for Role {
147    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
148        f.write_str(self.as_str())
149    }
150}
151
152impl FromStr for Role {
153    type Err = RoleParseError;
154
155    fn from_str(s: &str) -> Result<Self, Self::Err> {
156        match s.trim().to_lowercase().as_str() {
157            "admin" => Ok(Role::Admin),
158            "member" => Ok(Role::Member),
159            "readonly" => Ok(Role::Readonly),
160            other => Err(RoleParseError(other.to_string())),
161        }
162    }
163}
164
165/// Error returned when parsing an invalid role string.
166#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
167#[error("unknown role: '{0}' (expected admin, member, or readonly)")]
168pub struct RoleParseError(String);
169
170// =============================================================================
171// Ed25519PublicKey newtype
172// =============================================================================
173
174/// A 32-byte Ed25519 public key.
175///
176/// Serializes as a hex string for JSON compatibility. Enforces exactly 32 bytes
177/// at construction time.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
179pub struct Ed25519PublicKey([u8; 32]);
180
181impl Ed25519PublicKey {
182    /// Creates a new Ed25519PublicKey from a 32-byte array.
183    pub fn from_bytes(bytes: [u8; 32]) -> Self {
184        Self(bytes)
185    }
186
187    /// Creates a new Ed25519PublicKey from a byte slice.
188    ///
189    /// Args:
190    /// * `slice`: Byte slice that must be exactly 32 bytes.
191    ///
192    /// Usage:
193    /// ```ignore
194    /// let pk = Ed25519PublicKey::try_from_slice(&bytes)?;
195    /// ```
196    pub fn try_from_slice(slice: &[u8]) -> Result<Self, Ed25519KeyError> {
197        let arr: [u8; 32] = slice
198            .try_into()
199            .map_err(|_| Ed25519KeyError::InvalidLength(slice.len()))?;
200        Ok(Self(arr))
201    }
202
203    /// Returns the inner 32-byte array.
204    pub fn as_bytes(&self) -> &[u8; 32] {
205        &self.0
206    }
207
208    /// Returns `true` if all 32 bytes are zero (used for unsigned org-member attestations).
209    pub fn is_zero(&self) -> bool {
210        self.0 == [0u8; 32]
211    }
212}
213
214impl Serialize for Ed25519PublicKey {
215    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
216        s.serialize_str(&hex::encode(self.0))
217    }
218}
219
220impl<'de> Deserialize<'de> for Ed25519PublicKey {
221    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
222        let s = String::deserialize(d)?;
223        let bytes =
224            hex::decode(&s).map_err(|e| serde::de::Error::custom(format!("invalid hex: {e}")))?;
225        let arr: [u8; 32] = bytes.try_into().map_err(|v: Vec<u8>| {
226            serde::de::Error::custom(format!("expected 32 bytes, got {}", v.len()))
227        })?;
228        Ok(Self(arr))
229    }
230}
231
232impl fmt::Display for Ed25519PublicKey {
233    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234        f.write_str(&hex::encode(self.0))
235    }
236}
237
238impl AsRef<[u8]> for Ed25519PublicKey {
239    fn as_ref(&self) -> &[u8] {
240        &self.0
241    }
242}
243
244#[cfg(feature = "schema")]
245impl schemars::JsonSchema for Ed25519PublicKey {
246    fn schema_name() -> String {
247        "Ed25519PublicKey".to_owned()
248    }
249
250    fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
251        schemars::schema::SchemaObject {
252            instance_type: Some(schemars::schema::InstanceType::String.into()),
253            format: Some("hex".to_owned()),
254            metadata: Some(Box::new(schemars::schema::Metadata {
255                description: Some("Ed25519 public key (32 bytes, hex-encoded)".to_owned()),
256                ..Default::default()
257            })),
258            ..Default::default()
259        }
260        .into()
261    }
262}
263
264/// Error type for Ed25519 public key construction.
265#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
266pub enum Ed25519KeyError {
267    /// The byte slice is not exactly 32 bytes.
268    #[error("expected 32 bytes, got {0}")]
269    InvalidLength(usize),
270    /// The hex string is not valid.
271    #[error("invalid hex: {0}")]
272    InvalidHex(String),
273}
274
275// =============================================================================
276// TypedSignature newtype (fn-114: was Ed25519Signature)
277// =============================================================================
278
279/// Attestation schema version.
280///
281/// Bumped to 2 in fn-114.15 as part of the curve-agnostic refactor (hard break,
282/// pre-launch posture). Attestations serialized under older versions are not
283/// readable — fn-114 has no v1 tolerant reader by design.
284pub const ATTESTATION_VERSION: u32 = 2;
285
286/// A validated 64-byte signature. Curve is determined by the companion
287/// [`DevicePublicKey`] — both Ed25519 and ECDSA P-256 r||s are 64 bytes.
288///
289/// Previously named `Ed25519Signature`. The new name reflects that the byte
290/// container is curve-agnostic; the receiver dispatches verify by looking at
291/// the associated key's curve. If/when a curve with a different signature
292/// length joins the workspace (e.g. ML-DSA-44 at 2420 bytes), this type
293/// graduates to an enum variant.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct TypedSignature([u8; 64]);
296
297/// Transitional alias for pre-fn-114 callers. Removed in fn-114.40.
298pub type Ed25519Signature = TypedSignature;
299
300impl TypedSignature {
301    /// Creates a signature from a 64-byte array.
302    pub fn from_bytes(bytes: [u8; 64]) -> Self {
303        Self(bytes)
304    }
305
306    /// Attempts to create a signature from a byte slice, returning an error if the length is not 64.
307    pub fn try_from_slice(slice: &[u8]) -> Result<Self, SignatureLengthError> {
308        let arr: [u8; 64] = slice
309            .try_into()
310            .map_err(|_| SignatureLengthError(slice.len()))?;
311        Ok(Self(arr))
312    }
313
314    /// Creates an all-zero signature, used as a placeholder.
315    pub fn empty() -> Self {
316        Self([0u8; 64])
317    }
318
319    /// Returns `true` if the signature is all zeros (placeholder).
320    pub fn is_empty(&self) -> bool {
321        self.0 == [0u8; 64]
322    }
323
324    /// Returns a reference to the underlying 64-byte array.
325    pub fn as_bytes(&self) -> &[u8; 64] {
326        &self.0
327    }
328}
329
330impl Default for TypedSignature {
331    fn default() -> Self {
332        Self::empty()
333    }
334}
335
336impl std::fmt::Display for TypedSignature {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        write!(f, "{}", hex::encode(self.0))
339    }
340}
341
342impl AsRef<[u8]> for TypedSignature {
343    fn as_ref(&self) -> &[u8] {
344        &self.0
345    }
346}
347
348#[cfg(feature = "schema")]
349impl schemars::JsonSchema for TypedSignature {
350    fn schema_name() -> String {
351        "TypedSignature".to_owned()
352    }
353
354    fn json_schema(_gen: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
355        schemars::schema::SchemaObject {
356            instance_type: Some(schemars::schema::InstanceType::String.into()),
357            format: Some("hex".to_owned()),
358            metadata: Some(Box::new(schemars::schema::Metadata {
359                description: Some(
360                    "Curve-agnostic 64-byte signature (Ed25519 or P-256 r||s, hex-encoded). \
361                     Curve is determined by the companion DevicePublicKey."
362                        .to_owned(),
363                ),
364                ..Default::default()
365            })),
366            ..Default::default()
367        }
368        .into()
369    }
370}
371
372impl serde::Serialize for TypedSignature {
373    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
374        serializer.serialize_str(&hex::encode(self.0))
375    }
376}
377
378impl<'de> serde::Deserialize<'de> for TypedSignature {
379    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
380        let s = String::deserialize(deserializer)?;
381        if s.is_empty() {
382            return Ok(Self::empty());
383        }
384        let bytes = hex::decode(&s).map_err(serde::de::Error::custom)?;
385        Self::try_from_slice(&bytes).map_err(serde::de::Error::custom)
386    }
387}
388
389/// Error when constructing an Ed25519Signature from a byte slice of wrong length.
390#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
391#[error("expected 64 bytes, got {0}")]
392pub struct SignatureLengthError(pub usize);
393
394/// A device public key carrying its curve type explicitly.
395///
396/// Curve is stored alongside the raw key bytes so dispatch never relies on
397/// key length — adding a new curve that shares a byte length (e.g. secp256k1,
398/// also 33 bytes compressed) won't break existing match arms.
399///
400/// Accepted byte lengths per curve:
401/// - Ed25519: 32
402/// - P-256: 33 (compressed SEC1) or 65 (uncompressed SEC1)
403#[derive(Debug, Clone)]
404#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
405pub struct DevicePublicKey {
406    #[cfg_attr(feature = "schema", schemars(skip))]
407    curve: auths_crypto::CurveType,
408    #[cfg_attr(feature = "schema", schemars(with = "String"))]
409    bytes: Vec<u8>,
410}
411
412impl DevicePublicKey {
413    /// Canonical SEC1 bytes for equality + hashing. P-256 is normalized to its 33-byte
414    /// compressed form, so two encodings of the *same point* compare equal — the SSH
415    /// wire format is 65-byte uncompressed while the KEL/CESR form is 33-byte compressed.
416    /// Pure byte math: a compressed point is `[0x02|0x03 by Y-parity] || X`, and the
417    /// Y-parity is the uncompressed point's final-byte LSB. Ed25519 is unchanged.
418    fn canonical_sec1(&self) -> std::borrow::Cow<'_, [u8]> {
419        if self.curve == auths_crypto::CurveType::P256
420            && self.bytes.len() == 65
421            && self.bytes[0] == 0x04
422        {
423            let mut compressed = vec![0u8; 33];
424            compressed[0] = if self.bytes[64] & 1 == 0 { 0x02 } else { 0x03 };
425            compressed[1..].copy_from_slice(&self.bytes[1..33]);
426            std::borrow::Cow::Owned(compressed)
427        } else {
428            std::borrow::Cow::Borrowed(&self.bytes)
429        }
430    }
431}
432
433impl PartialEq for DevicePublicKey {
434    fn eq(&self, other: &Self) -> bool {
435        self.curve == other.curve && self.canonical_sec1() == other.canonical_sec1()
436    }
437}
438
439impl Eq for DevicePublicKey {}
440
441impl std::hash::Hash for DevicePublicKey {
442    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
443        self.curve.hash(state);
444        self.canonical_sec1().hash(state);
445    }
446}
447
448/// Error returned when constructing a `DevicePublicKey` with invalid key material.
449#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
450pub enum InvalidKeyError {
451    /// The byte length is wrong for the specified curve.
452    #[error("invalid key length for {curve}: expected {expected}, got {actual}")]
453    InvalidLength {
454        /// The curve that was specified.
455        curve: auths_crypto::CurveType,
456        /// The expected length(s) as a human-readable string.
457        expected: &'static str,
458        /// The actual byte count.
459        actual: usize,
460    },
461}
462
463impl DevicePublicKey {
464    /// Create from a curve type and raw bytes, validating length per curve.
465    ///
466    /// Args:
467    /// * `curve`: Which elliptic curve this key belongs to.
468    /// * `bytes`: Raw public key bytes.
469    ///
470    /// Usage:
471    /// ```ignore
472    /// let pk = DevicePublicKey::try_new(CurveType::Ed25519, &key_bytes)?;
473    /// ```
474    pub fn try_new(curve: auths_crypto::CurveType, bytes: &[u8]) -> Result<Self, InvalidKeyError> {
475        let valid = match curve {
476            auths_crypto::CurveType::Ed25519 => bytes.len() == 32,
477            auths_crypto::CurveType::P256 => bytes.len() == 33 || bytes.len() == 65,
478        };
479        if !valid {
480            return Err(InvalidKeyError::InvalidLength {
481                curve,
482                expected: match curve {
483                    auths_crypto::CurveType::Ed25519 => "32",
484                    auths_crypto::CurveType::P256 => "33 or 65",
485                },
486                actual: bytes.len(),
487            });
488        }
489        Ok(Self {
490            curve,
491            bytes: bytes.to_vec(),
492        })
493    }
494
495    /// Create an Ed25519 device key from raw 32-byte key.
496    pub fn ed25519(bytes: &[u8; 32]) -> Self {
497        Self {
498            curve: auths_crypto::CurveType::Ed25519,
499            bytes: bytes.to_vec(),
500        }
501    }
502
503    /// Create a P-256 device key from raw SEC1 bytes (33 compressed or 65 uncompressed).
504    ///
505    /// Returns `Err` if `bytes` is not 33 or 65 bytes.
506    pub fn p256(bytes: &[u8]) -> Result<Self, InvalidKeyError> {
507        Self::try_new(auths_crypto::CurveType::P256, bytes)
508    }
509
510    /// Returns the curve type.
511    pub fn curve(&self) -> auths_crypto::CurveType {
512        self.curve
513    }
514
515    /// Returns the raw key bytes.
516    pub fn as_bytes(&self) -> &[u8] {
517        &self.bytes
518    }
519
520    /// Returns true if all bytes are zero.
521    pub fn is_zero(&self) -> bool {
522        self.bytes.iter().all(|&b| b == 0)
523    }
524
525    /// Returns the byte length.
526    pub fn len(&self) -> usize {
527        self.bytes.len()
528    }
529
530    /// Returns true if empty.
531    pub fn is_empty(&self) -> bool {
532        self.bytes.is_empty()
533    }
534
535    /// Verify a signature against this key, dispatching on curve.
536    ///
537    /// This is the single canonical verify method — every caller that holds
538    /// a `DevicePublicKey` should call this rather than branching on curve
539    /// themselves. Adding a new curve means updating this one impl; the
540    /// compiler then flags every call site still assuming only Ed25519.
541    ///
542    /// Args:
543    /// * `message`: Payload bytes that were signed.
544    /// * `signature`: Raw signature bytes (64 for Ed25519 / P-256).
545    /// * `provider`: Pluggable crypto provider (`RingCryptoProvider` native,
546    ///   `WebCryptoProvider` wasm) — used for Ed25519. P-256 routes through
547    ///   `RingCryptoProvider::p256_verify` on native.
548    ///
549    /// Usage:
550    /// ```ignore
551    /// issuer_pk.verify(&payload, &signature, provider).await?;
552    /// ```
553    pub async fn verify(
554        &self,
555        message: &[u8],
556        signature: &[u8],
557        provider: &dyn auths_crypto::CryptoProvider,
558    ) -> Result<(), SignatureVerifyError> {
559        let result = match self.curve {
560            auths_crypto::CurveType::Ed25519 => {
561                provider
562                    .verify_ed25519(&self.bytes, message, signature)
563                    .await
564            }
565            auths_crypto::CurveType::P256 => {
566                provider.verify_p256(&self.bytes, message, signature).await
567            }
568        };
569        result.map_err(|e| SignatureVerifyError::VerificationFailed(e.to_string()))
570    }
571}
572
573/// Error returned by the typed ingestion helpers
574/// [`decode_public_key_hex`] / [`decode_public_key_bytes`].
575#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
576pub enum PublicKeyDecodeError {
577    /// The input hex string failed to decode.
578    #[error("invalid hex: {0}")]
579    InvalidHex(String),
580
581    /// The decoded byte length does not match any supported curve (32 Ed25519,
582    /// 33 or 65 P-256).
583    #[error("invalid public key length {len} — expected 32 (Ed25519) or 33/65 (P-256)")]
584    InvalidLength {
585        /// Number of bytes supplied.
586        len: usize,
587    },
588
589    /// `DevicePublicKey::try_new` rejected the bytes even after curve
590    /// inference succeeded.
591    #[error("DevicePublicKey validation failed: {0}")]
592    Validation(String),
593}
594
595/// Decode a hex-encoded public key string into a typed, curve-tagged
596/// `DevicePublicKey`.
597///
598/// Intended for external ingestion boundaries ONLY (FFI hex strings, WASM
599/// entry points, `--signer-key` CLI flags). Internal code threads
600/// `DevicePublicKey` end-to-end.
601///
602/// Args:
603/// * `hex_str`: Hex-encoded public key (32, 33, or 65 bytes after decode).
604/// * `curve`: The curve type for the key.
605///
606/// Usage:
607/// ```ignore
608/// let pk = decode_public_key_hex(user_supplied_hex, CurveType::P256)?;
609/// issuer_pk.verify(msg, sig, provider).await?;
610/// ```
611pub fn decode_public_key_hex(
612    hex_str: &str,
613    curve: auths_crypto::CurveType,
614) -> Result<DevicePublicKey, PublicKeyDecodeError> {
615    let bytes =
616        hex::decode(hex_str.trim()).map_err(|e| PublicKeyDecodeError::InvalidHex(e.to_string()))?;
617    decode_public_key_bytes(&bytes, curve)
618}
619
620/// Decode raw public key bytes into a typed, curve-tagged `DevicePublicKey`
621/// using an explicit curve tag.
622///
623/// Args:
624/// * `bytes`: Raw public key bytes.
625/// * `curve`: The curve type for the key.
626///
627/// Usage:
628/// ```ignore
629/// let pk = decode_public_key_bytes(&ffi_bytes[..len], CurveType::P256)?;
630/// ```
631pub fn decode_public_key_bytes(
632    bytes: &[u8],
633    curve: auths_crypto::CurveType,
634) -> Result<DevicePublicKey, PublicKeyDecodeError> {
635    DevicePublicKey::try_new(curve, bytes)
636        .map_err(|e| PublicKeyDecodeError::Validation(e.to_string()))
637}
638
639/// Error returned by [`DevicePublicKey::verify`].
640#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
641pub enum SignatureVerifyError {
642    /// Underlying signature check failed. Argument contains provider-specific detail.
643    #[error("signature verification failed: {0}")]
644    VerificationFailed(String),
645
646    /// The target platform does not support the requested curve (e.g. WASM
647    /// without the `native` feature bundled).
648    #[error("{0}")]
649    UnsupportedOnTarget(String),
650}
651
652impl From<Ed25519PublicKey> for DevicePublicKey {
653    fn from(pk: Ed25519PublicKey) -> Self {
654        Self {
655            curve: auths_crypto::CurveType::Ed25519,
656            bytes: pk.as_bytes().to_vec(),
657        }
658    }
659}
660
661impl Serialize for DevicePublicKey {
662    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
663        use serde::ser::SerializeStruct;
664        let mut st = s.serialize_struct("DevicePublicKey", 2)?;
665        st.serialize_field("curve", &self.curve.to_string())?;
666        st.serialize_field("key", &hex::encode(&self.bytes))?;
667        st.end()
668    }
669}
670
671impl<'de> Deserialize<'de> for DevicePublicKey {
672    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
673        // Accept both formats:
674        // - New: {"curve": "p256", "key": "hex..."}
675        // - Legacy: "hex..." (bare string, infer curve from length)
676        let value = serde_json::Value::deserialize(d)?;
677
678        if let Some(s) = value.as_str() {
679            // Legacy format: bare hex string
680            if s.is_empty() {
681                return Ok(Self::default());
682            }
683            let bytes = hex::decode(s)
684                .map_err(|e| serde::de::Error::custom(format!("invalid hex: {e}")))?;
685            let curve = match bytes.len() {
686                32 => auths_crypto::CurveType::Ed25519,
687                33 | 65 => auths_crypto::CurveType::P256,
688                n => {
689                    return Err(serde::de::Error::custom(format!(
690                        "invalid device public key length: {n}"
691                    )));
692                }
693            };
694            return Self::try_new(curve, &bytes)
695                .map_err(|e| serde::de::Error::custom(e.to_string()));
696        }
697
698        // New format: {"curve": "...", "key": "..."}
699        let curve_str = value["curve"]
700            .as_str()
701            .ok_or_else(|| serde::de::Error::custom("missing 'curve' field"))?;
702        let key_hex = value["key"]
703            .as_str()
704            .ok_or_else(|| serde::de::Error::custom("missing 'key' field"))?;
705
706        let curve = match curve_str {
707            "ed25519" => auths_crypto::CurveType::Ed25519,
708            "p256" => auths_crypto::CurveType::P256,
709            other => {
710                return Err(serde::de::Error::custom(format!("unknown curve: {other}")));
711            }
712        };
713        if key_hex.is_empty() {
714            return Err(serde::de::Error::custom("empty key"));
715        }
716        let bytes = hex::decode(key_hex)
717            .map_err(|e| serde::de::Error::custom(format!("invalid hex: {e}")))?;
718        Self::try_new(curve, &bytes).map_err(|e| serde::de::Error::custom(e.to_string()))
719    }
720}
721
722impl Default for DevicePublicKey {
723    fn default() -> Self {
724        Self {
725            curve: auths_crypto::CurveType::Ed25519,
726            bytes: vec![0u8; 32],
727        }
728    }
729}
730
731impl fmt::Display for DevicePublicKey {
732    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
733        write!(f, "{}:{}", self.curve, hex::encode(&self.bytes))
734    }
735}
736
737// =============================================================================
738// Signature algorithm enum (for configurable checkpoint verification)
739// =============================================================================
740
741/// Signature algorithm used by a transparency log for checkpoint signing.
742///
743/// Each log in a `TrustConfig` specifies which algorithm its checkpoints use.
744/// The verifier dispatches on this when checking checkpoint signatures.
745///
746/// Usage:
747/// ```ignore
748/// match trust_root.signature_algorithm {
749///     SignatureAlgorithm::Ed25519 => verify_ed25519(..),
750///     SignatureAlgorithm::EcdsaP256 => verify_ecdsa_p256(..),
751/// }
752/// ```
753#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
754#[serde(rename_all = "snake_case")]
755pub enum SignatureAlgorithm {
756    /// Ed25519 (RFC 8032). Default for auths-native logs.
757    #[default]
758    Ed25519,
759    /// ECDSA with NIST P-256 and SHA-256. Used by Rekor production shard.
760    EcdsaP256,
761}
762
763impl fmt::Display for SignatureAlgorithm {
764    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
765        match self {
766            Self::Ed25519 => f.write_str("ed25519"),
767            Self::EcdsaP256 => f.write_str("ecdsa_p256"),
768        }
769    }
770}
771
772// =============================================================================
773// ECDSA P-256 types (for Rekor checkpoint verification)
774// =============================================================================
775
776/// A DER-encoded ECDSA P-256 public key (PKIX SubjectPublicKeyInfo).
777///
778/// Stores the full DER encoding so `ring::signature::UnparsedPublicKey`
779/// can consume it directly.
780///
781/// Usage:
782/// ```ignore
783/// let pk = EcdsaP256PublicKey::from_der(&der_bytes)?;
784/// ```
785#[derive(Debug, Clone, PartialEq, Eq)]
786pub struct EcdsaP256PublicKey(Vec<u8>);
787
788impl EcdsaP256PublicKey {
789    /// Creates from DER-encoded PKIX SubjectPublicKeyInfo bytes.
790    ///
791    /// Performs minimal validation: checks the ASN.1 OID prefix for P-256
792    /// (`06 08 2a 86 48 ce 3d 03 01 07`).
793    pub fn from_der(der: &[u8]) -> Result<Self, EcdsaP256Error> {
794        // PKIX P-256 key is typically 91 bytes (SEQUENCE { AlgorithmIdentifier, BIT STRING })
795        // The AlgorithmIdentifier contains OID 1.2.840.10045.3.1.7 (P-256)
796        const P256_OID: &[u8] = &[0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07];
797        if der.len() < 26 {
798            return Err(EcdsaP256Error::InvalidKey(
799                "DER too short for P-256 PKIX key".into(),
800            ));
801        }
802        if !der.windows(P256_OID.len()).any(|w| w == P256_OID) {
803            return Err(EcdsaP256Error::InvalidKey(
804                "missing P-256 OID in PKIX key".into(),
805            ));
806        }
807        Ok(Self(der.to_vec()))
808    }
809
810    /// Returns the raw DER bytes.
811    pub fn as_der(&self) -> &[u8] {
812        &self.0
813    }
814
815    /// Returns the public key as a raw uncompressed SEC1 point
816    /// (`0x04 || X || Y`, 65 bytes).
817    ///
818    /// This is the form `ring`'s `ECDSA_P256_SHA256_ASN1` verifier consumes — it
819    /// does NOT accept the DER SPKI returned by [`Self::as_der`]. For a
820    /// well-formed P-256 SPKI the point is the trailing 65 bytes (the BIT STRING
821    /// value, sans its `0x00` unused-bits prefix). Returns `None` for a
822    /// compressed or malformed key.
823    ///
824    /// Usage:
825    /// ```ignore
826    /// let point = key.as_sec1_uncompressed().ok_or(/* malformed */)?;
827    /// let verifier = UnparsedPublicKey::new(&ECDSA_P256_SHA256_ASN1, point);
828    /// ```
829    pub fn as_sec1_uncompressed(&self) -> Option<&[u8]> {
830        let start = self.0.len().checked_sub(65)?;
831        let point = &self.0[start..];
832        (point[0] == 0x04).then_some(point)
833    }
834}
835
836impl Serialize for EcdsaP256PublicKey {
837    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
838        use base64::Engine;
839        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0))
840    }
841}
842
843impl<'de> Deserialize<'de> for EcdsaP256PublicKey {
844    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
845        use base64::Engine;
846        let s = String::deserialize(d)?;
847        let bytes = base64::engine::general_purpose::STANDARD
848            .decode(&s)
849            .map_err(|e| serde::de::Error::custom(format!("invalid base64: {e}")))?;
850        Self::from_der(&bytes).map_err(serde::de::Error::custom)
851    }
852}
853
854/// A DER-encoded ECDSA P-256 signature.
855///
856/// ECDSA signatures are variable-length ASN.1 DER (typically 70-72 bytes).
857///
858/// Usage:
859/// ```ignore
860/// let sig = EcdsaP256Signature::from_der(&der_bytes)?;
861/// ```
862#[derive(Debug, Clone, PartialEq, Eq)]
863pub struct EcdsaP256Signature(Vec<u8>);
864
865impl EcdsaP256Signature {
866    /// Creates from DER-encoded signature bytes.
867    pub fn from_der(der: &[u8]) -> Result<Self, EcdsaP256Error> {
868        // Minimal check: ASN.1 SEQUENCE tag (0x30)
869        if der.is_empty() || der[0] != 0x30 {
870            return Err(EcdsaP256Error::InvalidSignature(
871                "not an ASN.1 SEQUENCE".into(),
872            ));
873        }
874        Ok(Self(der.to_vec()))
875    }
876
877    /// Returns the raw DER bytes.
878    pub fn as_der(&self) -> &[u8] {
879        &self.0
880    }
881}
882
883impl Serialize for EcdsaP256Signature {
884    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
885        use base64::Engine;
886        s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0))
887    }
888}
889
890impl<'de> Deserialize<'de> for EcdsaP256Signature {
891    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
892        use base64::Engine;
893        let s = String::deserialize(d)?;
894        let bytes = base64::engine::general_purpose::STANDARD
895            .decode(&s)
896            .map_err(|e| serde::de::Error::custom(format!("invalid base64: {e}")))?;
897        Self::from_der(&bytes).map_err(serde::de::Error::custom)
898    }
899}
900
901/// Errors from ECDSA P-256 operations.
902#[derive(Debug, Clone, thiserror::Error)]
903pub enum EcdsaP256Error {
904    /// Invalid DER-encoded public key.
905    #[error("invalid ECDSA P-256 key: {0}")]
906    InvalidKey(String),
907    /// Invalid DER-encoded signature.
908    #[error("invalid ECDSA P-256 signature: {0}")]
909    InvalidSignature(String),
910}
911
912/// An identity bundle for stateless verification in CI/CD environments.
913///
914/// Contains all the information needed to verify commit signatures without
915/// requiring access to the identity repository or daemon.
916#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
917#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
918pub struct IdentityBundle {
919    /// The DID of the identity (e.g., `"did:keri:..."`)
920    pub identity_did: IdentityDID,
921    /// The public key in hex format for signature verification.
922    pub public_key_hex: PublicKeyHex,
923    /// Curve of `public_key_hex`. Carried in-band so verifiers never infer
924    /// curve from byte length. Defaults to P-256 when absent (older bundles
925    /// shipped before this field existed).
926    #[serde(default)]
927    #[cfg_attr(feature = "schema", schemars(with = "String"))]
928    pub curve: auths_crypto::CurveType,
929    /// Chain of attestations linking the signing key to the identity
930    pub attestation_chain: Vec<Attestation>,
931    /// The identity's raw KEL events (JSON, oldest first). Carried so
932    /// KEL-native commit verification stays stateless on CI runners with no
933    /// identity store. Empty in bundles exported before this field existed.
934    #[serde(default, skip_serializing_if = "Vec::is_empty")]
935    pub kel: Vec<serde_json::Value>,
936    /// UTC timestamp when this bundle was created
937    pub bundle_timestamp: DateTime<Utc>,
938    /// Maximum age in seconds before this bundle is considered stale
939    pub max_valid_for_secs: u64,
940}
941
942impl IdentityBundle {
943    /// Check that this bundle is still within its TTL.
944    ///
945    /// Args:
946    /// * `now`: The current time, injected for deterministic verification.
947    ///
948    /// Usage:
949    /// ```ignore
950    /// bundle.check_freshness(Utc::now())?;
951    /// ```
952    pub fn check_freshness(&self, now: DateTime<Utc>) -> Result<(), AttestationError> {
953        let age = (now - self.bundle_timestamp).num_seconds().max(0) as u64;
954        if age > self.max_valid_for_secs {
955            return Err(AttestationError::BundleExpired {
956                age_secs: age,
957                max_secs: self.max_valid_for_secs,
958            });
959        }
960        Ok(())
961    }
962}
963
964/// Represents a 2-way key attestation between a primary identity and a device key.
965#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
966#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
967pub struct Attestation {
968    /// Schema version.
969    pub version: u32,
970    /// Record identifier linking this attestation to its storage ref.
971    pub rid: ResourceId,
972    /// DID of the issuing identity (can be `did:keri:` or `did:key:`).
973    pub issuer: CanonicalDid,
974    /// DID of the attested subject (device `did:key:` or identity `did:keri:`).
975    pub subject: CanonicalDid,
976    /// Device public key (32 bytes Ed25519 or 33 bytes P-256 compressed, hex-encoded in JSON).
977    pub device_public_key: DevicePublicKey,
978    /// Issuer's Ed25519 signature over the canonical attestation data (hex-encoded in JSON).
979    #[serde(default, skip_serializing_if = "Ed25519Signature::is_empty")]
980    pub identity_signature: Ed25519Signature,
981    /// Device's Ed25519 signature over the canonical attestation data (hex-encoded in JSON).
982    pub device_signature: Ed25519Signature,
983    /// Timestamp when the attestation was revoked, if applicable.
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub revoked_at: Option<DateTime<Utc>>,
986    /// Expiration timestamp, if set.
987    #[serde(skip_serializing_if = "Option::is_none")]
988    pub expires_at: Option<DateTime<Utc>>,
989    /// Creation timestamp.
990    pub timestamp: Option<DateTime<Utc>>,
991    /// Optional human-readable note.
992    #[serde(skip_serializing_if = "Option::is_none")]
993    pub note: Option<String>,
994    /// Optional arbitrary JSON payload.
995    #[serde(skip_serializing_if = "Option::is_none")]
996    pub payload: Option<Value>,
997
998    /// Git commit SHA (for commit signing attestations).
999    #[serde(skip_serializing_if = "Option::is_none")]
1000    pub commit_sha: Option<String>,
1001
1002    /// Git commit message (for commit signing attestations).
1003    #[serde(skip_serializing_if = "Option::is_none")]
1004    pub commit_message: Option<String>,
1005
1006    /// Git commit author (for commit signing attestations).
1007    #[serde(skip_serializing_if = "Option::is_none")]
1008    pub author: Option<String>,
1009
1010    /// OIDC binding information (issuer, subject, audience, expiration).
1011    #[serde(skip_serializing_if = "Option::is_none")]
1012    pub oidc_binding: Option<OidcBinding>,
1013
1014    /// DID of the attestation that delegated authority (for chain tracking).
1015    #[serde(default, skip_serializing_if = "Option::is_none")]
1016    pub delegated_by: Option<CanonicalDid>,
1017
1018    /// The type of entity that produced this signature (human, agent, workload).
1019    /// Included in the canonical JSON before signing — the signature covers this field.
1020    #[serde(default, skip_serializing_if = "Option::is_none")]
1021    pub signer_type: Option<SignerType>,
1022
1023    /// Unsigned environment claim for gateway-level verification via `auths-env`.
1024    /// Excluded from `CanonicalAttestationData` — does not affect signatures.
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub environment_claim: Option<Value>,
1027}
1028
1029/// OIDC token binding information for machine identity attestations.
1030///
1031/// Proves that the attestation was created by a CI/CD workload with a specific
1032/// OIDC token. Contains the issuer, subject, audience, and expiration so verifiers
1033/// can reconstruct the identity without needing the ephemeral private key.
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1035#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1036pub struct OidcBinding {
1037    /// OIDC token issuer (e.g., "https://token.actions.githubusercontent.com").
1038    pub issuer: String,
1039    /// Token subject (unique workload identifier).
1040    pub subject: String,
1041    /// Expected audience.
1042    pub audience: String,
1043    /// Token expiration timestamp (Unix timestamp).
1044    pub token_exp: i64,
1045    /// CI/CD platform (e.g., "github", "gitlab", "circleci").
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub platform: Option<String>,
1048    /// JTI for replay detection (if available).
1049    #[serde(default, skip_serializing_if = "Option::is_none")]
1050    pub jti: Option<String>,
1051    /// Platform-normalized claims (e.g., repo, actor, run_id for GitHub).
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub normalized_claims: Option<serde_json::Map<String, serde_json::Value>>,
1054}
1055
1056/// The type of entity that produced a signature.
1057///
1058/// Duplicated here (also in `auths-policy`) because `auths-verifier` is a
1059/// standalone minimal-dependency crate that cannot depend on `auths-policy`.
1060#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1061#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1062#[non_exhaustive]
1063pub enum SignerType {
1064    /// A human user.
1065    Human,
1066    /// An autonomous AI agent.
1067    Agent,
1068    /// A CI/CD workload or service identity.
1069    Workload,
1070}
1071
1072/// An attestation that has passed signature verification.
1073///
1074/// This type enforces at compile time that an attestation's signatures were verified
1075/// before it can be stored. It can only be constructed by:
1076/// - Verification functions (`verify_with_keys`, `verify_chain`)
1077/// - The `dangerous_from_unchecked` escape hatch (for self-signed attestations)
1078///
1079/// Does NOT implement `Deserialize` to prevent bypassing verification by
1080/// deserializing directly.
1081#[derive(Debug, Clone, Serialize)]
1082pub struct VerifiedAttestation(Attestation);
1083
1084impl VerifiedAttestation {
1085    /// Access the inner attestation.
1086    pub fn inner(&self) -> &Attestation {
1087        &self.0
1088    }
1089
1090    /// Consume and return the inner attestation.
1091    pub fn into_inner(self) -> Attestation {
1092        self.0
1093    }
1094
1095    /// Construct a `VerifiedAttestation` without running verification.
1096    ///
1097    /// # Safety (logical)
1098    /// Only use this when you are the signer (e.g., you just created and signed
1099    /// the attestation) or in test code. Misuse defeats the purpose of this type.
1100    #[doc(hidden)]
1101    pub fn dangerous_from_unchecked(attestation: Attestation) -> Self {
1102        Self(attestation)
1103    }
1104
1105    pub(crate) fn from_verified(attestation: Attestation) -> Self {
1106        Self(attestation)
1107    }
1108}
1109
1110impl std::ops::Deref for VerifiedAttestation {
1111    type Target = Attestation;
1112
1113    fn deref(&self) -> &Attestation {
1114        &self.0
1115    }
1116}
1117
1118/// Data structure for canonicalizing standard attestations (link, extend).
1119#[derive(Serialize, Debug)]
1120pub struct CanonicalAttestationData<'a> {
1121    /// Schema version.
1122    pub version: u32,
1123    /// Record identifier.
1124    pub rid: &'a str,
1125    /// DID of the issuing identity.
1126    pub issuer: &'a CanonicalDid,
1127    /// DID of the attested subject.
1128    pub subject: &'a CanonicalDid,
1129    /// Raw Ed25519 public key of the device.
1130    #[serde(with = "hex::serde")]
1131    pub device_public_key: &'a [u8],
1132    /// Optional arbitrary JSON payload.
1133    pub payload: &'a Option<Value>,
1134    /// Creation timestamp.
1135    pub timestamp: &'a Option<DateTime<Utc>>,
1136    /// Expiration timestamp.
1137    pub expires_at: &'a Option<DateTime<Utc>>,
1138    /// Revocation timestamp.
1139    pub revoked_at: &'a Option<DateTime<Utc>>,
1140    /// Optional human-readable note.
1141    pub note: &'a Option<String>,
1142
1143    /// DID of the delegating attestation (included in signed envelope).
1144    #[serde(skip_serializing_if = "Option::is_none")]
1145    pub delegated_by: Option<&'a CanonicalDid>,
1146    /// Type of signer (included in signed envelope).
1147    #[serde(skip_serializing_if = "Option::is_none")]
1148    pub signer_type: Option<&'a SignerType>,
1149    /// Git commit SHA for provenance binding (included in signed envelope).
1150    #[serde(skip_serializing_if = "Option::is_none")]
1151    pub commit_sha: Option<&'a str>,
1152}
1153
1154/// Produce the canonical JSON bytes over which signatures are computed.
1155///
1156/// Args:
1157/// * `data`: The attestation data to canonicalize.
1158pub fn canonicalize_attestation_data(
1159    data: &CanonicalAttestationData,
1160) -> Result<Vec<u8>, AttestationError> {
1161    let canonical_json_string = json_canon::to_string(data).map_err(|e| {
1162        AttestationError::SerializationError(format!("Failed to create canonical JSON: {}", e))
1163    })?;
1164    debug!(
1165        "Generated canonical data (standard): {}",
1166        canonical_json_string
1167    );
1168    Ok(canonical_json_string.into_bytes())
1169}
1170
1171impl Attestation {
1172    /// Returns `true` if this attestation has been revoked.
1173    pub fn is_revoked(&self) -> bool {
1174        self.revoked_at.is_some()
1175    }
1176
1177    /// Deserializes an Attestation from JSON bytes.
1178    ///
1179    /// Returns an error if the input exceeds [`MAX_ATTESTATION_JSON_SIZE`] (64 KiB).
1180    pub fn from_json(json_bytes: &[u8]) -> Result<Self, AttestationError> {
1181        if json_bytes.len() > MAX_ATTESTATION_JSON_SIZE {
1182            return Err(AttestationError::InputTooLarge(format!(
1183                "attestation JSON is {} bytes, max {}",
1184                json_bytes.len(),
1185                MAX_ATTESTATION_JSON_SIZE
1186            )));
1187        }
1188        serde_json::from_slice(json_bytes)
1189            .map_err(|e| AttestationError::SerializationError(e.to_string()))
1190    }
1191
1192    /// Returns the canonical subset of fields that signatures are computed over.
1193    ///
1194    /// Args:
1195    /// * `&self`: The attestation to extract canonical data from.
1196    ///
1197    /// Usage:
1198    /// ```ignore
1199    /// let canonical = attestation.canonical_data();
1200    /// let bytes = canonicalize_attestation_data(&canonical)?;
1201    /// ```
1202    pub fn canonical_data(&self) -> CanonicalAttestationData<'_> {
1203        CanonicalAttestationData {
1204            version: self.version,
1205            rid: &self.rid,
1206            issuer: &self.issuer,
1207            subject: &self.subject,
1208            device_public_key: self.device_public_key.as_bytes(),
1209            payload: &self.payload,
1210            timestamp: &self.timestamp,
1211            expires_at: &self.expires_at,
1212            revoked_at: &self.revoked_at,
1213            note: &self.note,
1214            delegated_by: self.delegated_by.as_ref(),
1215            signer_type: self.signer_type.as_ref(),
1216            commit_sha: self.commit_sha.as_deref(),
1217        }
1218    }
1219
1220    /// Formats the attestation contents for debug or inspection purposes.
1221    pub fn to_debug_string(&self) -> String {
1222        format!(
1223            "RID: {}\nIssuer DID: {}\nSubject DID: {}\nDevice PK: {}\nIdentity Sig: {}\nDevice Sig: {}\nRevoked At: {:?}\nExpires: {:?}\nNote: {:?}",
1224            self.rid,
1225            self.issuer,
1226            self.subject, // CanonicalDid implements Display
1227            hex::encode(self.device_public_key.as_bytes()),
1228            hex::encode(self.identity_signature.as_bytes()),
1229            hex::encode(self.device_signature.as_bytes()),
1230            self.revoked_at,
1231            self.expires_at,
1232            self.note
1233        )
1234    }
1235}
1236
1237// =============================================================================
1238// Threshold Signatures (FROST) - Future Implementation
1239// =============================================================================
1240
1241/// Policy for threshold signature operations (M-of-N).
1242///
1243/// This struct defines the parameters for FROST (Flexible Round-Optimized
1244/// Schnorr Threshold) signature operations. FROST enables M-of-N threshold
1245/// signing where at least M participants must cooperate to produce a valid
1246/// signature, but no single participant can sign alone.
1247///
1248/// # Protocol Choice: FROST
1249///
1250/// FROST was chosen over alternatives for several reasons:
1251/// - **Ed25519 native**: Works with existing Ed25519 key infrastructure
1252/// - **Round-optimized**: Only 2 rounds for signing (vs 3+ for alternatives)
1253/// - **Rust ecosystem**: `frost-ed25519` crate from ZcashFoundation is mature
1254/// - **Security**: Proven secure under discrete log assumption
1255///
1256/// # Key Generation Approaches
1257///
1258/// Two approaches exist for generating threshold key shares:
1259///
1260/// 1. **Trusted Dealer**: One party generates the key and distributes shares
1261///    - Simpler to implement
1262///    - Single point of failure during key generation
1263///    - Appropriate for org-controlled scenarios
1264///
1265/// 2. **Distributed Key Generation (DKG)**: Participants jointly generate key
1266///    - No single party ever sees the full key
1267///    - More complex, requires additional round-trips
1268///    - Better for trustless scenarios
1269///
1270/// # Integration with Auths
1271///
1272/// Threshold policies can be attached to high-value operations like:
1273/// - `sign-release`: Release signing requires M-of-N approvers
1274/// - `rotate-keys`: Key rotation requires multi-party approval
1275/// - `manage-members`: Adding admins requires quorum
1276///
1277/// # Example
1278///
1279/// ```ignore
1280/// let policy = ThresholdPolicy {
1281///     threshold: 2,
1282///     signers: vec![
1283///         "did:key:alice".to_string(),
1284///         "did:key:bob".to_string(),
1285///         "did:key:carol".to_string(),
1286///     ],
1287///     policy_id: "release-signing-v1".to_string(),
1288///     scope: Some(Capability::sign_release()),
1289///     ceremony_endpoint: Some("wss://auths.example/ceremony".to_string()),
1290/// };
1291/// // 2-of-3: Any 2 of Alice, Bob, Carol can sign releases
1292/// ```
1293///
1294/// # Storage
1295///
1296/// Key shares are NOT stored in Git refs (they are secrets). Options:
1297/// - Platform keychain (macOS Keychain, Windows Credential Manager)
1298/// - Hardware security modules (HSMs)
1299/// - Secret managers (Vault, AWS Secrets Manager)
1300///
1301/// The policy itself (public info) is stored in Git at:
1302/// `refs/auths/policies/threshold/<policy_id>`
1303#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1304#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1305pub struct ThresholdPolicy {
1306    /// Minimum signers required (M in M-of-N)
1307    pub threshold: u8,
1308
1309    /// Total authorized signers (N in M-of-N) - DIDs of participants
1310    pub signers: Vec<String>,
1311
1312    /// Unique identifier for this policy
1313    pub policy_id: PolicyId,
1314
1315    /// Scope of operations this policy covers (optional)
1316    #[serde(default, skip_serializing_if = "Option::is_none")]
1317    pub scope: Option<Capability>,
1318
1319    /// Ceremony coordination endpoint (e.g., WebSocket URL for signing rounds)
1320    #[serde(default, skip_serializing_if = "Option::is_none")]
1321    pub ceremony_endpoint: Option<String>,
1322}
1323
1324impl ThresholdPolicy {
1325    /// Create a new threshold policy
1326    pub fn new(threshold: u8, signers: Vec<String>, policy_id: impl Into<PolicyId>) -> Self {
1327        Self {
1328            threshold,
1329            signers,
1330            policy_id: policy_id.into(),
1331            scope: None,
1332            ceremony_endpoint: None,
1333        }
1334    }
1335
1336    /// Check if the policy parameters are valid
1337    pub fn is_valid(&self) -> bool {
1338        // Threshold must be at least 1
1339        if self.threshold < 1 {
1340            return false;
1341        }
1342        // Threshold cannot exceed number of signers
1343        if self.threshold as usize > self.signers.len() {
1344            return false;
1345        }
1346        // Must have at least one signer
1347        if self.signers.is_empty() {
1348            return false;
1349        }
1350        // Policy ID must not be empty
1351        if self.policy_id.is_empty() {
1352            return false;
1353        }
1354        true
1355    }
1356
1357    /// Returns M (threshold) and N (total signers)
1358    pub fn m_of_n(&self) -> (u8, usize) {
1359        (self.threshold, self.signers.len())
1360    }
1361}
1362
1363// =============================================================================
1364// CommitOid newtype (validated)
1365// =============================================================================
1366
1367/// Error type for `CommitOid` construction.
1368#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1369pub enum CommitOidError {
1370    /// The string is empty.
1371    #[error("commit OID is empty")]
1372    Empty,
1373    /// The string length is not 40 (SHA-1) or 64 (SHA-256).
1374    #[error("expected 40 or 64 hex chars, got {0}")]
1375    InvalidLength(usize),
1376    /// The string contains non-hex characters.
1377    #[error("invalid hex character in commit OID")]
1378    InvalidHex,
1379}
1380
1381/// A validated Git commit object identifier (SHA-1 or SHA-256 hex string).
1382///
1383/// Accepts exactly 40 lowercase hex characters (SHA-1) or 64 (SHA-256).
1384#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
1385#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1386#[repr(transparent)]
1387#[serde(try_from = "String")]
1388pub struct CommitOid(String);
1389
1390impl CommitOid {
1391    /// Parses and validates a commit OID string.
1392    ///
1393    /// Args:
1394    /// * `raw`: A hex string that must be exactly 40 or 64 lowercase hex characters.
1395    ///
1396    /// Usage:
1397    /// ```ignore
1398    /// let oid = CommitOid::parse("a".repeat(40))?;
1399    /// ```
1400    pub fn parse(raw: &str) -> Result<Self, CommitOidError> {
1401        let s = raw.trim().to_lowercase();
1402        if s.is_empty() {
1403            return Err(CommitOidError::Empty);
1404        }
1405        if s.len() != 40 && s.len() != 64 {
1406            return Err(CommitOidError::InvalidLength(s.len()));
1407        }
1408        if !s.chars().all(|c| c.is_ascii_hexdigit()) {
1409            return Err(CommitOidError::InvalidHex);
1410        }
1411        Ok(Self(s))
1412    }
1413
1414    /// Creates a `CommitOid` without validation.
1415    ///
1416    /// Only use at deserialization boundaries where the value was previously validated.
1417    pub fn new_unchecked(s: impl Into<String>) -> Self {
1418        Self(s.into())
1419    }
1420
1421    /// Returns the inner string slice.
1422    pub fn as_str(&self) -> &str {
1423        &self.0
1424    }
1425
1426    /// Consumes self and returns the inner `String`.
1427    pub fn into_inner(self) -> String {
1428        self.0
1429    }
1430}
1431
1432impl fmt::Display for CommitOid {
1433    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1434        f.write_str(&self.0)
1435    }
1436}
1437
1438impl AsRef<str> for CommitOid {
1439    fn as_ref(&self) -> &str {
1440        &self.0
1441    }
1442}
1443
1444impl TryFrom<String> for CommitOid {
1445    type Error = CommitOidError;
1446    fn try_from(s: String) -> Result<Self, Self::Error> {
1447        Self::parse(&s)
1448    }
1449}
1450
1451impl TryFrom<&str> for CommitOid {
1452    type Error = CommitOidError;
1453    fn try_from(s: &str) -> Result<Self, Self::Error> {
1454        Self::parse(s)
1455    }
1456}
1457
1458impl FromStr for CommitOid {
1459    type Err = CommitOidError;
1460    fn from_str(s: &str) -> Result<Self, Self::Err> {
1461        Self::parse(s)
1462    }
1463}
1464
1465impl From<CommitOid> for String {
1466    fn from(oid: CommitOid) -> Self {
1467        oid.0
1468    }
1469}
1470
1471impl<'de> Deserialize<'de> for CommitOid {
1472    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1473        let s = String::deserialize(d)?;
1474        Self::parse(&s).map_err(serde::de::Error::custom)
1475    }
1476}
1477
1478// =============================================================================
1479// PublicKeyHex newtype (validated)
1480// =============================================================================
1481
1482/// Error type for `PublicKeyHex` construction.
1483#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1484pub enum PublicKeyHexError {
1485    /// The hex string has the wrong length (not 64 or 66 chars).
1486    #[error("expected 64 (Ed25519) or 66 (P-256) hex chars, got {0} chars")]
1487    InvalidLength(usize),
1488    /// The string contains non-hex characters.
1489    #[error("invalid hex: {0}")]
1490    InvalidHex(String),
1491}
1492
1493/// A validated hex-encoded public key (64 hex chars for Ed25519, 66 for P-256 compressed).
1494#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
1495#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1496#[repr(transparent)]
1497#[serde(try_from = "String")]
1498pub struct PublicKeyHex(String);
1499
1500impl PublicKeyHex {
1501    /// Parses and validates a hex-encoded public key string.
1502    ///
1503    /// Args:
1504    /// * `raw`: A 64-character hex string encoding 32 bytes.
1505    ///
1506    /// Usage:
1507    /// ```ignore
1508    /// let pk = PublicKeyHex::parse("ab".repeat(32))?;
1509    /// ```
1510    pub fn parse(raw: &str) -> Result<Self, PublicKeyHexError> {
1511        let s = raw.trim().to_lowercase();
1512        let bytes = hex::decode(&s).map_err(|e| PublicKeyHexError::InvalidHex(e.to_string()))?;
1513        if bytes.len() != 32 && bytes.len() != 33 {
1514            return Err(PublicKeyHexError::InvalidLength(s.len()));
1515        }
1516        Ok(Self(s))
1517    }
1518
1519    /// Creates a `PublicKeyHex` without validation.
1520    ///
1521    /// Only use at deserialization boundaries where the value was previously validated.
1522    pub fn new_unchecked(s: impl Into<String>) -> Self {
1523        Self(s.into())
1524    }
1525
1526    /// Returns the inner string slice.
1527    pub fn as_str(&self) -> &str {
1528        &self.0
1529    }
1530
1531    /// Consumes self and returns the inner `String`.
1532    pub fn into_inner(self) -> String {
1533        self.0
1534    }
1535}
1536
1537impl fmt::Display for PublicKeyHex {
1538    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1539        f.write_str(&self.0)
1540    }
1541}
1542
1543impl AsRef<str> for PublicKeyHex {
1544    fn as_ref(&self) -> &str {
1545        &self.0
1546    }
1547}
1548
1549impl TryFrom<String> for PublicKeyHex {
1550    type Error = PublicKeyHexError;
1551    fn try_from(s: String) -> Result<Self, Self::Error> {
1552        Self::parse(&s)
1553    }
1554}
1555
1556impl TryFrom<&str> for PublicKeyHex {
1557    type Error = PublicKeyHexError;
1558    fn try_from(s: &str) -> Result<Self, Self::Error> {
1559        Self::parse(s)
1560    }
1561}
1562
1563impl FromStr for PublicKeyHex {
1564    type Err = PublicKeyHexError;
1565    fn from_str(s: &str) -> Result<Self, Self::Err> {
1566        Self::parse(s)
1567    }
1568}
1569
1570impl From<PublicKeyHex> for String {
1571    fn from(pk: PublicKeyHex) -> Self {
1572        pk.0
1573    }
1574}
1575
1576impl<'de> Deserialize<'de> for PublicKeyHex {
1577    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
1578        let s = String::deserialize(d)?;
1579        Self::parse(&s).map_err(serde::de::Error::custom)
1580    }
1581}
1582
1583// =============================================================================
1584// PolicyId newtype (unvalidated)
1585// =============================================================================
1586
1587/// An opaque policy identifier.
1588///
1589/// No validation — wraps any `String`. Use where policy IDs are passed around
1590/// without needing to inspect their content.
1591#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
1592#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1593#[serde(transparent)]
1594pub struct PolicyId(String);
1595
1596impl PolicyId {
1597    /// Creates a new PolicyId.
1598    pub fn new(s: impl Into<String>) -> Self {
1599        Self(s.into())
1600    }
1601
1602    /// Returns the inner string slice.
1603    pub fn as_str(&self) -> &str {
1604        &self.0
1605    }
1606}
1607
1608impl Deref for PolicyId {
1609    type Target = str;
1610    fn deref(&self) -> &str {
1611        &self.0
1612    }
1613}
1614
1615impl fmt::Display for PolicyId {
1616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1617        f.write_str(&self.0)
1618    }
1619}
1620
1621impl From<String> for PolicyId {
1622    fn from(s: String) -> Self {
1623        Self(s)
1624    }
1625}
1626
1627impl From<&str> for PolicyId {
1628    fn from(s: &str) -> Self {
1629        Self(s.to_string())
1630    }
1631}
1632
1633impl PartialEq<str> for PolicyId {
1634    fn eq(&self, other: &str) -> bool {
1635        self.0 == other
1636    }
1637}
1638
1639impl PartialEq<&str> for PolicyId {
1640    fn eq(&self, other: &&str) -> bool {
1641        self.0 == *other
1642    }
1643}
1644
1645#[cfg(test)]
1646#[allow(clippy::disallowed_methods)]
1647mod tests {
1648    use super::*;
1649    use crate::AttestationBuilder;
1650
1651    // Tests for Attestation delegation field
1652
1653    #[test]
1654    fn attestation_old_json_without_delegated_by_deserializes() {
1655        // Simulates an attestation JSON without the delegated_by field.
1656        let old_json = r#"{
1657            "version": 1,
1658            "rid": "test-rid",
1659            "issuer": "did:keri:Eissuer",
1660            "subject": "did:key:zSubject",
1661            "device_public_key": "0102030405060708091011121314151617181920212223242526272829303132",
1662            "identity_signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
1663            "device_signature": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
1664            "revoked_at": null,
1665            "timestamp": null
1666        }"#;
1667
1668        let att: Attestation = serde_json::from_str(old_json).unwrap();
1669
1670        assert_eq!(att.delegated_by, None);
1671    }
1672
1673    #[test]
1674    fn attestation_delegated_by_serializes_correctly() {
1675        let att = AttestationBuilder::default()
1676            .rid("test-rid")
1677            .issuer("did:keri:Eissuer")
1678            .subject("did:key:zSubject")
1679            .delegated_by(Some(CanonicalDid::new_unchecked("did:keri:Edelegator")))
1680            .build();
1681
1682        let json = serde_json::to_string(&att).unwrap();
1683        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1684
1685        assert_eq!(parsed["delegated_by"], "did:keri:Edelegator");
1686    }
1687
1688    #[test]
1689    fn attestation_omits_delegated_by_when_absent() {
1690        let att = AttestationBuilder::default()
1691            .rid("test-rid")
1692            .issuer("did:keri:Eissuer")
1693            .subject("did:key:zSubject")
1694            .build();
1695
1696        let json = serde_json::to_string(&att).unwrap();
1697        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1698
1699        assert!(parsed.get("delegated_by").is_none());
1700    }
1701
1702    #[test]
1703    fn attestation_delegated_by_roundtrips() {
1704        let original = AttestationBuilder::default()
1705            .rid("test-rid")
1706            .issuer("did:keri:Eissuer")
1707            .subject("did:key:zSubject")
1708            .delegated_by(Some(CanonicalDid::new_unchecked("did:keri:Eadmin")))
1709            .build();
1710
1711        let json = serde_json::to_string(&original).unwrap();
1712        let deserialized: Attestation = serde_json::from_str(&json).unwrap();
1713
1714        assert_eq!(original.delegated_by, deserialized.delegated_by);
1715    }
1716
1717    // Tests for ThresholdPolicy (fn-6.11)
1718
1719    #[test]
1720    fn threshold_policy_new_creates_valid_policy() {
1721        let policy = ThresholdPolicy::new(
1722            2,
1723            vec![
1724                "did:key:alice".to_string(),
1725                "did:key:bob".to_string(),
1726                "did:key:carol".to_string(),
1727            ],
1728            "test-policy".to_string(),
1729        );
1730
1731        assert_eq!(policy.threshold, 2);
1732        assert_eq!(policy.signers.len(), 3);
1733        assert_eq!(policy.policy_id, "test-policy");
1734        assert!(policy.scope.is_none());
1735        assert!(policy.ceremony_endpoint.is_none());
1736    }
1737
1738    #[test]
1739    fn threshold_policy_is_valid_checks_constraints() {
1740        // Valid 2-of-3
1741        let valid = ThresholdPolicy::new(
1742            2,
1743            vec!["a".to_string(), "b".to_string(), "c".to_string()],
1744            "policy".to_string(),
1745        );
1746        assert!(valid.is_valid());
1747
1748        // Invalid: threshold 0
1749        let zero_threshold = ThresholdPolicy::new(0, vec!["a".to_string()], "policy".to_string());
1750        assert!(!zero_threshold.is_valid());
1751
1752        // Invalid: threshold > signers
1753        let too_high = ThresholdPolicy::new(
1754            3,
1755            vec!["a".to_string(), "b".to_string()],
1756            "policy".to_string(),
1757        );
1758        assert!(!too_high.is_valid());
1759
1760        // Invalid: empty signers
1761        let no_signers = ThresholdPolicy::new(1, vec![], "policy".to_string());
1762        assert!(!no_signers.is_valid());
1763
1764        // Invalid: empty policy_id
1765        let no_id = ThresholdPolicy::new(1, vec!["a".to_string()], "".to_string());
1766        assert!(!no_id.is_valid());
1767    }
1768
1769    #[test]
1770    fn threshold_policy_m_of_n_returns_correct_values() {
1771        let policy = ThresholdPolicy::new(
1772            2,
1773            vec!["a".to_string(), "b".to_string(), "c".to_string()],
1774            "policy".to_string(),
1775        );
1776        let (m, n) = policy.m_of_n();
1777        assert_eq!(m, 2);
1778        assert_eq!(n, 3);
1779    }
1780
1781    #[test]
1782    fn threshold_policy_serializes_correctly() {
1783        let mut policy = ThresholdPolicy::new(
1784            2,
1785            vec!["did:key:alice".to_string(), "did:key:bob".to_string()],
1786            "release-policy".to_string(),
1787        );
1788        policy.scope = Some(Capability::sign_release());
1789        policy.ceremony_endpoint = Some("wss://example.com/ceremony".to_string());
1790
1791        let json = serde_json::to_string(&policy).unwrap();
1792        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1793
1794        assert_eq!(parsed["threshold"], 2);
1795        assert_eq!(parsed["signers"][0], "did:key:alice");
1796        assert_eq!(parsed["policy_id"], "release-policy");
1797        assert_eq!(parsed["scope"], "sign_release");
1798        assert_eq!(parsed["ceremony_endpoint"], "wss://example.com/ceremony");
1799    }
1800
1801    #[test]
1802    fn threshold_policy_without_optional_fields_omits_them() {
1803        let policy =
1804            ThresholdPolicy::new(1, vec!["did:key:alice".to_string()], "policy".to_string());
1805
1806        let json = serde_json::to_string(&policy).unwrap();
1807        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1808
1809        assert!(parsed.get("scope").is_none());
1810        assert!(parsed.get("ceremony_endpoint").is_none());
1811    }
1812
1813    #[test]
1814    fn threshold_policy_roundtrips() {
1815        let mut original = ThresholdPolicy::new(
1816            3,
1817            vec![
1818                "a".to_string(),
1819                "b".to_string(),
1820                "c".to_string(),
1821                "d".to_string(),
1822            ],
1823            "important-policy".to_string(),
1824        );
1825        original.scope = Some(Capability::rotate_keys());
1826
1827        let json = serde_json::to_string(&original).unwrap();
1828        let deserialized: ThresholdPolicy = serde_json::from_str(&json).unwrap();
1829
1830        assert_eq!(original, deserialized);
1831    }
1832
1833    // Tests for IdentityBundle (CI/CD stateless verification)
1834
1835    #[test]
1836    fn identity_bundle_serializes_correctly() {
1837        let bundle = IdentityBundle {
1838            identity_did: IdentityDID::new_unchecked("did:keri:test123"),
1839            public_key_hex: PublicKeyHex::new_unchecked("aabbccdd"),
1840            curve: Default::default(),
1841            attestation_chain: vec![],
1842            kel: vec![],
1843            bundle_timestamp: DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
1844                .unwrap()
1845                .with_timezone(&Utc),
1846            max_valid_for_secs: 86400,
1847        };
1848
1849        let json = serde_json::to_string(&bundle).unwrap();
1850        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1851
1852        assert_eq!(parsed["identity_did"], "did:keri:test123");
1853        assert_eq!(parsed["public_key_hex"], "aabbccdd");
1854        assert!(parsed["attestation_chain"].as_array().unwrap().is_empty());
1855    }
1856
1857    #[test]
1858    fn identity_bundle_deserializes_correctly() {
1859        let json = r#"{
1860            "identity_did": "did:keri:abc123",
1861            "public_key_hex": "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
1862            "attestation_chain": [],
1863            "bundle_timestamp": "2099-01-01T00:00:00Z",
1864            "max_valid_for_secs": 86400
1865        }"#;
1866
1867        let bundle: IdentityBundle = serde_json::from_str(json).unwrap();
1868
1869        assert_eq!(bundle.identity_did.as_str(), "did:keri:abc123");
1870        assert_eq!(
1871            bundle.public_key_hex.as_str(),
1872            "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
1873        );
1874        assert!(bundle.attestation_chain.is_empty());
1875    }
1876
1877    #[test]
1878    fn identity_bundle_roundtrips() {
1879        let attestation = AttestationBuilder::default()
1880            .rid("test-rid")
1881            .issuer("did:keri:Eissuer")
1882            .subject("did:key:zSubject")
1883            .build();
1884
1885        let original = IdentityBundle {
1886            identity_did: IdentityDID::new_unchecked("did:keri:Eexample"),
1887            public_key_hex: PublicKeyHex::new_unchecked(
1888                "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
1889            ),
1890            curve: Default::default(),
1891            attestation_chain: vec![attestation],
1892            kel: vec![],
1893            bundle_timestamp: DateTime::parse_from_rfc3339("2099-01-01T00:00:00Z")
1894                .unwrap()
1895                .with_timezone(&Utc),
1896            max_valid_for_secs: 86400,
1897        };
1898
1899        let json = serde_json::to_string(&original).unwrap();
1900        let deserialized: IdentityBundle = serde_json::from_str(&json).unwrap();
1901
1902        assert_eq!(original.identity_did, deserialized.identity_did);
1903        assert_eq!(original.public_key_hex, deserialized.public_key_hex);
1904        assert_eq!(
1905            original.attestation_chain.len(),
1906            deserialized.attestation_chain.len()
1907        );
1908    }
1909}
1910
1911#[cfg(test)]
1912mod decode_public_key_tests {
1913    use super::*;
1914
1915    #[test]
1916    fn hex_ed25519_32_bytes() {
1917        let hex = "00".repeat(32);
1918        let pk = decode_public_key_hex(&hex, auths_crypto::CurveType::Ed25519).unwrap();
1919        assert_eq!(pk.curve(), auths_crypto::CurveType::Ed25519);
1920        assert_eq!(pk.len(), 32);
1921    }
1922
1923    #[test]
1924    fn hex_p256_33_bytes_compressed() {
1925        // Need a valid-ish compressed SEC1 for try_new to accept: starts with 0x02 or 0x03
1926        let mut bytes = [0u8; 33];
1927        bytes[0] = 0x02;
1928        let hex = hex::encode(bytes);
1929        let pk = decode_public_key_hex(&hex, auths_crypto::CurveType::P256).unwrap();
1930        assert_eq!(pk.curve(), auths_crypto::CurveType::P256);
1931        assert_eq!(pk.len(), 33);
1932    }
1933
1934    #[test]
1935    fn bytes_p256_65_uncompressed() {
1936        let mut bytes = [0u8; 65];
1937        bytes[0] = 0x04;
1938        let pk = decode_public_key_bytes(&bytes, auths_crypto::CurveType::P256).unwrap();
1939        assert_eq!(pk.curve(), auths_crypto::CurveType::P256);
1940        assert_eq!(pk.len(), 65);
1941    }
1942
1943    #[test]
1944    fn rejects_validation_error() {
1945        let err = decode_public_key_bytes(&[0u8; 50], auths_crypto::CurveType::P256).unwrap_err();
1946        assert!(matches!(err, PublicKeyDecodeError::Validation(_)));
1947    }
1948
1949    #[test]
1950    fn rejects_malformed_hex() {
1951        let err = decode_public_key_hex("zz", auths_crypto::CurveType::P256).unwrap_err();
1952        assert!(matches!(err, PublicKeyDecodeError::InvalidHex(_)));
1953    }
1954}