auths_crypto/provider.rs
1//! Curve-agnostic cryptographic abstraction supporting Ed25519 and ECDSA P-256.
2//!
3//! Defines the [`CryptoProvider`] trait for signature verification, signing, and
4//! key generation — enabling `ring`/`p256` on native targets and `WebCrypto` on WASM.
5//! P-256 is the workspace default curve; Ed25519 is supported as a peer alternative
6//! (SSH/Radicle/legacy KERI compat). See `docs/architecture/cryptography.md`.
7
8use async_trait::async_trait;
9use zeroize::{Zeroize, ZeroizeOnDrop};
10
11/// Error type for cryptographic operations.
12///
13/// Usage:
14/// ```ignore
15/// match result {
16/// Err(CryptoError::InvalidSignature) => { /* signature did not verify */ }
17/// Err(CryptoError::UnsupportedTarget) => { /* not available on this platform */ }
18/// Err(CryptoError::OperationFailed(msg)) => { /* backend error */ }
19/// Ok(()) => { /* success */ }
20/// }
21/// ```
22#[derive(Debug, Clone, thiserror::Error)]
23#[non_exhaustive]
24pub enum CryptoError {
25 #[error("Invalid signature")]
26 InvalidSignature,
27
28 #[error("Invalid public key length: expected {expected}, got {actual}")]
29 InvalidKeyLength { expected: usize, actual: usize },
30
31 #[error("Invalid private key: {0}")]
32 InvalidPrivateKey(String),
33
34 #[error("Crypto operation failed: {0}")]
35 OperationFailed(String),
36
37 #[error("Operation not supported on current compilation target")]
38 UnsupportedTarget,
39}
40
41impl crate::AuthsErrorInfo for CryptoError {
42 fn error_code(&self) -> &'static str {
43 match self {
44 Self::InvalidSignature => "AUTHS-E1001",
45 Self::InvalidKeyLength { .. } => "AUTHS-E1002",
46 Self::InvalidPrivateKey(_) => "AUTHS-E1003",
47 Self::OperationFailed(_) => "AUTHS-E1004",
48 Self::UnsupportedTarget => "AUTHS-E1005",
49 }
50 }
51
52 fn suggestion(&self) -> Option<&'static str> {
53 match self {
54 Self::InvalidSignature => Some("The signature does not match the data or public key"),
55 Self::InvalidKeyLength { .. } => Some(
56 "Ensure the key length matches the declared curve (32 bytes Ed25519, 33 bytes P-256 compressed SEC1)",
57 ),
58 Self::UnsupportedTarget => {
59 Some("This operation is not available on the current platform")
60 }
61 _ => None,
62 }
63 }
64}
65
66/// Zeroize-on-drop wrapper for a raw 32-byte signing seed.
67///
68/// Curve-untyped — both Ed25519 and P-256 use 32-byte scalars, so the curve
69/// must be carried separately (e.g. via [`crate::TypedSeed`]). For curve-aware
70/// flows prefer `TypedSeed`; `SecureSeed` exists for the curve-agnostic
71/// trait surface on [`CryptoProvider`].
72///
73/// This is the portable key representation that crosses the [`CryptoProvider`]
74/// boundary. No ring types leak through the trait — only this raw seed.
75/// The provider materializes the internal keypair from the seed on each call.
76///
77/// Usage:
78/// ```ignore
79/// let (seed, pubkey) = provider.generate_ed25519_keypair().await?;
80/// let sig = provider.sign_ed25519(&seed, b"hello").await?;
81/// // seed is securely zeroed when dropped
82/// ```
83#[derive(Clone, Zeroize, ZeroizeOnDrop)]
84pub struct SecureSeed([u8; 32]);
85
86impl SecureSeed {
87 pub fn new(bytes: [u8; 32]) -> Self {
88 Self(bytes)
89 }
90
91 pub fn as_bytes(&self) -> &[u8; 32] {
92 &self.0
93 }
94}
95
96impl std::fmt::Debug for SecureSeed {
97 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98 f.write_str("SecureSeed([REDACTED])")
99 }
100}
101
102/// Curve-agnostic abstraction for cryptographic operations across target architectures.
103///
104/// All method signatures use primitive Rust types or [`SecureSeed`] — no
105/// ring-specific types. This ensures domain crates (`auths-core`, `auths-sdk`)
106/// compile without any ring dependency.
107///
108/// Usage:
109/// ```ignore
110/// use auths_crypto::CryptoProvider;
111///
112/// async fn roundtrip(provider: &dyn CryptoProvider) {
113/// let (seed, pk) = provider.generate_ed25519_keypair().await.unwrap();
114/// let sig = provider.sign_ed25519(&seed, b"msg").await.unwrap();
115/// provider.verify_ed25519(&pk, b"msg", &sig).await.unwrap();
116/// }
117/// ```
118#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
119#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
120pub trait CryptoProvider: Send + Sync {
121 /// Verify an Ed25519 signature against a public key and message.
122 async fn verify_ed25519(
123 &self,
124 pubkey: &[u8],
125 message: &[u8],
126 signature: &[u8],
127 ) -> Result<(), CryptoError>;
128
129 /// Verify an ECDSA P-256 signature (r||s, 64 bytes) against a public key
130 /// (33-byte compressed or 65-byte uncompressed SEC1) and message.
131 ///
132 /// Default impl returns `UnsupportedTarget`; override in providers that
133 /// support P-256 (`RingCryptoProvider` via `p256` crate on native,
134 /// `WebCryptoProvider` via `SubtleCrypto.verify("ECDSA", …)` on WASM).
135 async fn verify_p256(
136 &self,
137 _pubkey: &[u8],
138 _message: &[u8],
139 _signature: &[u8],
140 ) -> Result<(), CryptoError> {
141 Err(CryptoError::UnsupportedTarget)
142 }
143
144 /// Sign a message using a raw 32-byte Ed25519 seed.
145 ///
146 /// The provider materializes the internal keypair from the seed on each
147 /// call. This trades minor CPU overhead for a pure, ring-free domain layer.
148 ///
149 /// Args:
150 /// * `seed`: Raw 32-byte Ed25519 private key seed.
151 /// * `message`: The data to sign.
152 ///
153 /// Usage:
154 /// ```ignore
155 /// let sig = provider.sign_ed25519(&seed, b"hello").await?;
156 /// assert_eq!(sig.len(), 64);
157 /// ```
158 async fn sign_ed25519(&self, seed: &SecureSeed, message: &[u8])
159 -> Result<Vec<u8>, CryptoError>;
160
161 /// Generate a fresh Ed25519 keypair.
162 ///
163 /// Returns the raw 32-byte seed and 32-byte public key.
164 ///
165 /// Usage:
166 /// ```ignore
167 /// let (seed, pubkey) = provider.generate_ed25519_keypair().await?;
168 /// assert_eq!(pubkey.len(), 32);
169 /// ```
170 async fn generate_ed25519_keypair(&self) -> Result<(SecureSeed, [u8; 32]), CryptoError>;
171
172 /// Derive the 32-byte public key from a raw seed.
173 ///
174 /// Args:
175 /// * `seed`: Raw 32-byte Ed25519 private key seed.
176 ///
177 /// Usage:
178 /// ```ignore
179 /// let pk = provider.ed25519_public_key_from_seed(&seed).await?;
180 /// assert_eq!(pk.len(), 32);
181 /// ```
182 async fn ed25519_public_key_from_seed(
183 &self,
184 seed: &SecureSeed,
185 ) -> Result<[u8; 32], CryptoError>;
186
187 /// Sign a message using a raw 32-byte P-256 scalar seed. Deterministic
188 /// ECDSA per RFC 6979. Returns a 64-byte compact r||s signature.
189 ///
190 /// Args:
191 /// * `seed`: Raw 32-byte P-256 private scalar.
192 /// * `message`: The data to sign.
193 ///
194 /// Usage:
195 /// ```ignore
196 /// let sig = provider.sign_p256(&seed, b"hello").await?;
197 /// assert_eq!(sig.len(), P256_SIGNATURE_LEN);
198 /// ```
199 async fn sign_p256(&self, _seed: &SecureSeed, _message: &[u8]) -> Result<Vec<u8>, CryptoError> {
200 Err(CryptoError::UnsupportedTarget)
201 }
202
203 /// Generate a fresh P-256 keypair. Returns the raw 32-byte scalar seed
204 /// and the 33-byte SEC1 compressed public key.
205 ///
206 /// Usage:
207 /// ```ignore
208 /// let (seed, pubkey) = provider.generate_p256_keypair().await?;
209 /// assert_eq!(pubkey.len(), P256_PUBLIC_KEY_LEN);
210 /// ```
211 async fn generate_p256_keypair(&self) -> Result<(SecureSeed, Vec<u8>), CryptoError> {
212 Err(CryptoError::UnsupportedTarget)
213 }
214
215 /// Derive the 33-byte SEC1 compressed P-256 public key from a raw seed.
216 ///
217 /// Args:
218 /// * `seed`: Raw 32-byte P-256 private scalar.
219 ///
220 /// Usage:
221 /// ```ignore
222 /// let pk = provider.p256_public_key_from_seed(&seed).await?;
223 /// assert_eq!(pk.len(), P256_PUBLIC_KEY_LEN);
224 /// ```
225 async fn p256_public_key_from_seed(&self, _seed: &SecureSeed) -> Result<Vec<u8>, CryptoError> {
226 Err(CryptoError::UnsupportedTarget)
227 }
228
229 /// Encrypt `plaintext` under a 256-bit symmetric `key` with a 96-bit
230 /// `nonce` and Additional Authenticated Data (AAD). Output is
231 /// `ciphertext || tag` (tag is 16 bytes for both ChaCha20-Poly1305 and
232 /// AES-256-GCM).
233 ///
234 /// Algorithm selection is compile-time via Cargo feature:
235 /// - default build: ChaCha20-Poly1305
236 /// - `cnsa` feature (fn-128.T4): AES-256-GCM
237 ///
238 /// Args:
239 /// * `key`: 32-byte symmetric key.
240 /// * `nonce`: 12-byte per-message nonce; MUST NOT repeat under the same key.
241 /// * `aad`: Additional authenticated data (authenticated but not encrypted).
242 /// * `plaintext`: Bytes to encrypt.
243 ///
244 /// Usage:
245 /// ```ignore
246 /// let ct = provider.aead_encrypt(&key, &nonce, b"session:1", b"secret").await?;
247 /// ```
248 async fn aead_encrypt(
249 &self,
250 _key: &[u8; 32],
251 _nonce: &[u8; 12],
252 _aad: &[u8],
253 _plaintext: &[u8],
254 ) -> Result<Vec<u8>, CryptoError> {
255 Err(CryptoError::UnsupportedTarget)
256 }
257
258 /// Decrypt `ciphertext` (`ct || tag`) under a 256-bit symmetric `key`,
259 /// 96-bit `nonce`, and matching AAD. Returns the plaintext on success;
260 /// `InvalidSignature` on tag mismatch.
261 ///
262 /// Args:
263 /// * `key`: 32-byte symmetric key.
264 /// * `nonce`: 12-byte nonce (must match the one used at encryption).
265 /// * `aad`: Additional authenticated data (must match encryption AAD byte-for-byte).
266 /// * `ciphertext`: `encrypt` output (`ct || tag`).
267 ///
268 /// Usage:
269 /// ```ignore
270 /// let pt = provider.aead_decrypt(&key, &nonce, b"session:1", &ct).await?;
271 /// ```
272 async fn aead_decrypt(
273 &self,
274 _key: &[u8; 32],
275 _nonce: &[u8; 12],
276 _aad: &[u8],
277 _ciphertext: &[u8],
278 ) -> Result<Vec<u8>, CryptoError> {
279 Err(CryptoError::UnsupportedTarget)
280 }
281
282 /// One-shot HKDF-SHA256 extract-then-expand (RFC 5869).
283 ///
284 /// Args:
285 /// * `ikm`: Input keying material (secret).
286 /// * `salt`: Non-secret salt (empty slice is acceptable).
287 /// * `info`: Domain-separating context tag.
288 /// * `out_len`: Desired output length in bytes (max 255 × 32 = 8160).
289 ///
290 /// Usage:
291 /// ```ignore
292 /// let okm = provider.hkdf_sha256_expand(&ikm, &salt, b"my-proto-v1", 32).await?;
293 /// ```
294 async fn hkdf_sha256_expand(
295 &self,
296 _ikm: &[u8],
297 _salt: &[u8],
298 _info: &[u8],
299 _out_len: usize,
300 ) -> Result<Vec<u8>, CryptoError> {
301 Err(CryptoError::UnsupportedTarget)
302 }
303
304 /// One-shot HKDF-SHA384 extract-then-expand. Same shape as
305 /// [`hkdf_sha256_expand`] but with SHA-384 as the underlying hash. CNSA 2.0
306 /// requires SHA-384 for NSS workloads; default builds may return
307 /// [`CryptoError::UnsupportedTarget`] if SHA-384 is not available.
308 ///
309 /// Max output length is 255 × 48 = 12240 bytes.
310 async fn hkdf_sha384_expand(
311 &self,
312 _ikm: &[u8],
313 _salt: &[u8],
314 _info: &[u8],
315 _out_len: usize,
316 ) -> Result<Vec<u8>, CryptoError> {
317 Err(CryptoError::UnsupportedTarget)
318 }
319
320 /// Compute HMAC-SHA256 over `msg` under `key`. Returns a 32-byte tag.
321 ///
322 /// Args:
323 /// * `key`: MAC key (any length; HMAC hashes over-long keys internally).
324 /// * `msg`: Bytes to authenticate.
325 ///
326 /// Usage:
327 /// ```ignore
328 /// let tag = provider.hmac_sha256_compute(&key, b"GET\n/path\n...").await?;
329 /// ```
330 async fn hmac_sha256_compute(&self, _key: &[u8], _msg: &[u8]) -> Result<[u8; 32], CryptoError> {
331 Err(CryptoError::UnsupportedTarget)
332 }
333
334 /// Verify an HMAC-SHA256 `tag` over `msg` under `key`, constant-time.
335 ///
336 /// Returns `Ok(())` on match, `Err(CryptoError::InvalidSignature)` on
337 /// mismatch. Implementations MUST use a constant-time comparator
338 /// (`subtle::ct_eq`, `ring::constant_time`, or equivalent).
339 ///
340 /// Args:
341 /// * `key`: MAC key used at compute time.
342 /// * `msg`: Bytes whose authenticity is being checked.
343 /// * `tag`: Claimed 32-byte tag.
344 ///
345 /// Usage:
346 /// ```ignore
347 /// provider.hmac_sha256_verify(&key, &msg, &claimed_tag).await?;
348 /// ```
349 async fn hmac_sha256_verify(
350 &self,
351 _key: &[u8],
352 _msg: &[u8],
353 _tag: &[u8],
354 ) -> Result<(), CryptoError> {
355 Err(CryptoError::UnsupportedTarget)
356 }
357
358 /// Compute HMAC-SHA384 over `msg` under `key`. Returns a 48-byte tag.
359 /// CNSA counterpart of [`hmac_sha256_compute`].
360 async fn hmac_sha384_compute(&self, _key: &[u8], _msg: &[u8]) -> Result<[u8; 48], CryptoError> {
361 Err(CryptoError::UnsupportedTarget)
362 }
363
364 /// Verify an HMAC-SHA384 `tag` over `msg` under `key`, constant-time.
365 /// CNSA counterpart of [`hmac_sha256_verify`].
366 async fn hmac_sha384_verify(
367 &self,
368 _key: &[u8],
369 _msg: &[u8],
370 _tag: &[u8],
371 ) -> Result<(), CryptoError> {
372 Err(CryptoError::UnsupportedTarget)
373 }
374
375 // -------------------------------------------------------------------------
376 // Curve-agnostic entry points (default-impl'd; domain code SHOULD use these)
377 //
378 // These route through a single `match` on the caller-supplied curve, then
379 // dispatch to the primitive methods above. Domain code never needs to
380 // `match CurveType` again — that's fn-121's ethos at the provider boundary.
381 // -------------------------------------------------------------------------
382
383 /// Sign a message using a curve-carrying seed. Dispatches internally based
384 /// on `seed.curve()`; the caller never sees a curve-specific method.
385 ///
386 /// Args:
387 /// * `seed`: Typed seed carrying its curve tag.
388 /// * `message`: Bytes to sign.
389 ///
390 /// Usage:
391 /// ```ignore
392 /// let sig = provider.sign_typed(&typed_seed, message).await?;
393 /// ```
394 async fn sign_typed(
395 &self,
396 seed: &crate::key_ops::TypedSeed,
397 message: &[u8],
398 ) -> Result<Vec<u8>, CryptoError> {
399 let secure = SecureSeed::new(*seed.as_bytes());
400 match seed.curve() {
401 CurveType::Ed25519 => self.sign_ed25519(&secure, message).await,
402 CurveType::P256 => self.sign_p256(&secure, message).await,
403 }
404 }
405
406 /// Verify a signature using a curve and raw public-key bytes. Dispatches
407 /// internally; callers never see a curve-specific method.
408 ///
409 /// Args:
410 /// * `curve`: The curve the public key belongs to.
411 /// * `pubkey`: Raw public-key bytes (length validated by the curve-specific primitive).
412 /// * `message`: Bytes the signature is over.
413 /// * `signature`: Signature bytes to verify.
414 ///
415 /// Usage:
416 /// ```ignore
417 /// provider.verify_typed(curve, &pk_bytes, msg, &sig).await?;
418 /// ```
419 async fn verify_typed(
420 &self,
421 curve: CurveType,
422 pubkey: &[u8],
423 message: &[u8],
424 signature: &[u8],
425 ) -> Result<(), CryptoError> {
426 match curve {
427 CurveType::Ed25519 => self.verify_ed25519(pubkey, message, signature).await,
428 CurveType::P256 => self.verify_p256(pubkey, message, signature).await,
429 }
430 }
431
432 /// Generate a keypair for the requested curve. Returns a curve-tagged
433 /// [`crate::key_ops::TypedSeed`] plus raw public-key bytes.
434 ///
435 /// Args:
436 /// * `curve`: Which curve to generate for.
437 ///
438 /// Usage:
439 /// ```ignore
440 /// let (typed_seed, pubkey_bytes) = provider.generate_typed_keypair(curve).await?;
441 /// ```
442 async fn generate_typed_keypair(
443 &self,
444 curve: CurveType,
445 ) -> Result<(crate::key_ops::TypedSeed, Vec<u8>), CryptoError> {
446 match curve {
447 CurveType::Ed25519 => {
448 let (seed, pk) = self.generate_ed25519_keypair().await?;
449 Ok((
450 crate::key_ops::TypedSeed::Ed25519(*seed.as_bytes()),
451 pk.to_vec(),
452 ))
453 }
454 CurveType::P256 => {
455 let (seed, pk) = self.generate_p256_keypair().await?;
456 Ok((crate::key_ops::TypedSeed::P256(*seed.as_bytes()), pk))
457 }
458 }
459 }
460
461 /// Derive the public key from a curve-tagged seed.
462 ///
463 /// Args:
464 /// * `seed`: Typed seed.
465 ///
466 /// Usage:
467 /// ```ignore
468 /// let pk_bytes = provider.typed_public_key_from_seed(&typed_seed).await?;
469 /// ```
470 async fn typed_public_key_from_seed(
471 &self,
472 seed: &crate::key_ops::TypedSeed,
473 ) -> Result<Vec<u8>, CryptoError> {
474 let secure = SecureSeed::new(*seed.as_bytes());
475 match seed.curve() {
476 CurveType::Ed25519 => {
477 let pk = self.ed25519_public_key_from_seed(&secure).await?;
478 Ok(pk.to_vec())
479 }
480 CurveType::P256 => self.p256_public_key_from_seed(&secure).await,
481 }
482 }
483}
484
485// ---------------------------------------------------------------------------
486// Feature-combination guards (compile-time enforcement of provider invariants)
487// ---------------------------------------------------------------------------
488
489// `fips` and `cnsa` both replace the default provider with incompatible
490// primitive sets (FIPS uses aws-lc-rs's ChaCha20-Poly1305; CNSA forbids
491// ChaCha and mandates AES-256-GCM + SHA-384 + P-384). When a build enables
492// both — commonly `cargo {build,check,clippy} --all-features` — the
493// downstream `cfg(all(feature = "cnsa", not(feature = "fips"), …))` gates
494// on the CNSA provider silently win, so FIPS takes precedence and the
495// CNSA-specific provider is excluded. Consumers who want CNSA MUST NOT
496// enable `fips` in their dependency edge.
497//
498// Enabling both is a silent crypto-policy downgrade, so we fail the build
499// closed. The `cfg(all(feature = "cnsa", not(feature = "fips"), …))` gate
500// below loses to FIPS, so a build that asked for CNSA would instead link the
501// FIPS provider — which accepts P-256 and ChaCha20-Poly1305, the exact
502// primitives CNSA-2.0 forbids — with no error. No in-tree lint catches this
503// (there is no `check-feature-sanity` xtask), so the only honest guard is a
504// hard compile error.
505//
506// Consequence: `--all-features` (which turns on both) no longer compiles for
507// this crate. That is intentional — a profile must select at most one of
508// `fips`/`cnsa`. Any build/CI invocation that relied on `--all-features` for
509// auths-crypto must choose an explicit feature set instead.
510#[cfg(all(feature = "fips", feature = "cnsa"))]
511compile_error!(
512 "auths-crypto features `fips` and `cnsa` are mutually exclusive: enabling \
513 both silently links the FIPS provider (which accepts P-256 and \
514 ChaCha20-Poly1305) in place of the CNSA provider — a silent crypto-policy \
515 downgrade. Enable at most one of `fips`/`cnsa`."
516);
517
518/// `fips` requires CMake + Go + C toolchain and targets native platforms only.
519/// aws-lc-rs has no supported WASM target.
520#[cfg(all(feature = "fips", target_arch = "wasm32"))]
521compile_error!(
522 "auths-crypto feature `fips` is incompatible with `target_arch = wasm32`. \
523 Build the verifier with default features (RustCrypto + ring)."
524);
525
526/// Returns the workspace-configured default crypto provider.
527///
528/// Selection is compile-time:
529/// - default build → [`crate::ring_provider::RingCryptoProvider`]
530/// - `--features fips` → [`crate::aws_lc_provider::AwsLcProvider`] (AWS-LC-FIPS)
531/// - `--features cnsa` → [`crate::cnsa_provider::CnsaProvider`] (P-384 / AES-256-GCM / SHA-384; rejects P-256/ChaCha/SHA-256)
532///
533/// Domain code SHOULD route cryptographic operations through this function
534/// rather than constructing `p256::ecdsa::SigningKey` (or equivalent) directly —
535/// that way the provider swap is mechanical.
536///
537/// Usage:
538/// ```ignore
539/// use auths_crypto::{default_provider, CryptoProvider};
540///
541/// let provider = default_provider();
542/// let sig = provider.sign_p256(&seed, msg).await?;
543/// ```
544#[cfg(all(feature = "fips", not(target_arch = "wasm32")))]
545pub fn default_provider() -> &'static dyn CryptoProvider {
546 &crate::aws_lc_provider::AwsLcProvider
547}
548
549#[cfg(all(feature = "cnsa", not(feature = "fips"), not(target_arch = "wasm32")))]
550pub fn default_provider() -> &'static dyn CryptoProvider {
551 &crate::cnsa_provider::CnsaProvider
552}
553
554#[cfg(all(
555 feature = "native",
556 not(feature = "fips"),
557 not(feature = "cnsa"),
558 not(target_arch = "wasm32")
559))]
560pub fn default_provider() -> &'static dyn CryptoProvider {
561 &crate::ring_provider::RingCryptoProvider
562}
563
564/// Errors from hex seed decoding.
565///
566/// Usage:
567/// ```ignore
568/// match decode_seed_hex("bad") {
569/// Err(SeedDecodeError::InvalidHex(_)) => { /* not valid hex */ }
570/// Err(SeedDecodeError::WrongLength { .. }) => { /* not 32 bytes */ }
571/// Ok(seed) => { /* use seed */ }
572/// }
573/// ```
574#[derive(Debug, thiserror::Error)]
575pub enum SeedDecodeError {
576 /// The input string is not valid hexadecimal.
577 #[error("invalid hex encoding: {0}")]
578 InvalidHex(hex::FromHexError),
579
580 /// The decoded bytes are not exactly 32 bytes.
581 #[error("expected {expected} bytes, got {got}")]
582 WrongLength {
583 /// Expected byte count (always 32).
584 expected: usize,
585 /// Actual byte count after decoding.
586 got: usize,
587 },
588}
589
590/// Decodes a hex-encoded Ed25519 seed (64 hex chars = 32 bytes) into a [`SecureSeed`].
591///
592/// Args:
593/// * `hex_str`: Hex-encoded seed string (must be exactly 64 characters).
594///
595/// Usage:
596/// ```ignore
597/// let seed = decode_seed_hex("abcdef01...")?;
598/// ```
599pub fn decode_seed_hex(hex_str: &str) -> Result<SecureSeed, SeedDecodeError> {
600 let bytes = hex::decode(hex_str).map_err(SeedDecodeError::InvalidHex)?;
601 let arr: [u8; 32] = bytes
602 .try_into()
603 .map_err(|v: Vec<u8>| SeedDecodeError::WrongLength {
604 expected: 32,
605 got: v.len(),
606 })?;
607 Ok(SecureSeed::new(arr))
608}
609
610/// Ed25519 public key length in bytes.
611pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
612
613/// Ed25519 signature length in bytes.
614pub const ED25519_SIGNATURE_LEN: usize = 64;
615
616/// ECDSA P-256 compressed public key length in bytes (SEC1: 0x02/0x03 + 32-byte x).
617pub const P256_PUBLIC_KEY_LEN: usize = 33;
618
619/// ECDSA P-256 raw r||s signature length in bytes (32 + 32).
620pub const P256_SIGNATURE_LEN: usize = 64;
621
622/// Supported elliptic curve types for identity and signing operations.
623///
624/// P-256 is the default for all operations (identity keys, ephemeral CI keys).
625/// Ed25519 is available for compatibility with existing KERI deployments.
626///
627/// Usage:
628/// ```ignore
629/// let curve = CurveType::P256; // default
630/// let (seed, pubkey) = provider.generate_keypair(curve).await?;
631/// ```
632#[derive(
633 Debug, Clone, Copy, PartialEq, Eq, Hash, Default, serde::Serialize, serde::Deserialize,
634)]
635#[serde(rename_all = "lowercase")]
636pub enum CurveType {
637 /// Ed25519 (RFC 8032). 32-byte keys, 64-byte signatures.
638 Ed25519,
639 /// ECDSA P-256 / secp256r1. 33-byte compressed keys, 64-byte r||s signatures.
640 /// Default for all operations.
641 #[default]
642 P256,
643}
644
645impl std::fmt::Display for CurveType {
646 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
647 match self {
648 Self::Ed25519 => f.write_str("ed25519"),
649 Self::P256 => f.write_str("p256"),
650 }
651 }
652}
653
654/// The error returned when a curve name cannot be parsed into a [`CurveType`].
655#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
656#[error("unknown curve {got:?}: expected 'p256' or 'ed25519'")]
657pub struct ParseCurveError {
658 /// The unrecognized input.
659 pub got: String,
660}
661
662impl std::str::FromStr for CurveType {
663 type Err = ParseCurveError;
664
665 /// Parses a curve name, case-insensitively, accepting the common spellings.
666 /// This is the single source of truth for turning a `--curve` argument into
667 /// a typed [`CurveType`] — round-trips with [`Display`](std::fmt::Display).
668 fn from_str(s: &str) -> Result<Self, Self::Err> {
669 match s.to_ascii_lowercase().as_str() {
670 "ed25519" => Ok(Self::Ed25519),
671 "p256" | "p-256" => Ok(Self::P256),
672 _ => Err(ParseCurveError { got: s.to_string() }),
673 }
674 }
675}
676
677impl CurveType {
678 /// Returns the expected public key length for this curve.
679 pub fn public_key_len(&self) -> usize {
680 match self {
681 Self::Ed25519 => ED25519_PUBLIC_KEY_LEN,
682 Self::P256 => P256_PUBLIC_KEY_LEN,
683 }
684 }
685
686 /// Returns the expected signature length for this curve.
687 pub fn signature_len(&self) -> usize {
688 match self {
689 Self::Ed25519 => ED25519_SIGNATURE_LEN,
690 Self::P256 => P256_SIGNATURE_LEN,
691 }
692 }
693}
694
695#[cfg(test)]
696mod curve_type_tests {
697 use super::CurveType;
698 use std::str::FromStr;
699
700 #[test]
701 fn parses_canonical_and_aliased_spellings() {
702 assert_eq!(CurveType::from_str("ed25519").unwrap(), CurveType::Ed25519);
703 assert_eq!(CurveType::from_str("Ed25519").unwrap(), CurveType::Ed25519);
704 assert_eq!(CurveType::from_str("p256").unwrap(), CurveType::P256);
705 assert_eq!(CurveType::from_str("P-256").unwrap(), CurveType::P256);
706 }
707
708 #[test]
709 fn display_round_trips_through_from_str() {
710 for c in [CurveType::Ed25519, CurveType::P256] {
711 assert_eq!(CurveType::from_str(&c.to_string()).unwrap(), c);
712 }
713 }
714
715 #[test]
716 fn rejects_unknown_curve() {
717 let err = CurveType::from_str("secp256k1").unwrap_err();
718 assert_eq!(err.got, "secp256k1");
719 }
720}