auths_keri/keys.rs
1//! KERI CESR public key parsing for Ed25519 and P-256.
2//!
3//! Decodes KERI-encoded public keys from their CESR-qualified string form.
4//! Ed25519: 'D' prefix (transferable) / 'B' (non-transferable) + base64url(32 bytes).
5//! P-256: '1AAJ' prefix (transferable) / '1AAI' (non-transferable) + base64url(33 bytes).
6//!
7//! Both curves carry the transferability recorded from their CESR code: rotating
8//! identity keys are transferable (`D` / `1AAJ`), while keys pinned to one
9//! incepting event — most notably KERI witnesses — are non-transferable
10//! (`B` / `1AAI`). The raw bytes and signature algorithm are identical across the
11//! pair; only the code (and thus the rotation semantics) differ.
12//!
13//! Per the CESR master code table (cesride / keripy `MatterCodex`):
14//! `1AAJ` = `ECDSA_256r1` = transferable secp256r1 verification key;
15//! `1AAI` = `ECDSA_256r1N` = the non-transferable variant. This mirrors the
16//! Ed25519 `D`/`B` pair. Auths identities rotate, so they encode verkeys with
17//! the transferable `1AAJ` code.
18
19/// Errors from decoding a KERI-encoded public key.
20#[derive(Debug, Clone, thiserror::Error, PartialEq, Eq)]
21#[non_exhaustive]
22pub enum KeriDecodeError {
23 /// The KERI derivation code prefix was not recognized.
24 #[error("Unsupported KERI key type: prefix '{0}'")]
25 UnsupportedKeyType(String),
26 /// Input string was empty; no derivation code could be read.
27 #[error("Missing KERI prefix: empty string")]
28 EmptyInput,
29 /// Base64url decoding of the key payload failed.
30 #[error("Base64url decode failed: {0}")]
31 DecodeError(String),
32 /// Decoded bytes were not the expected length for the key type.
33 #[error("Invalid key length: expected {expected} bytes, got {actual}")]
34 InvalidLength {
35 /// Expected byte count.
36 expected: usize,
37 /// Actual byte count.
38 actual: usize,
39 },
40}
41
42impl auths_crypto::AuthsErrorInfo for KeriDecodeError {
43 fn error_code(&self) -> &'static str {
44 match self {
45 Self::UnsupportedKeyType(_) => "AUTHS-E1201",
46 Self::EmptyInput => "AUTHS-E1202",
47 Self::DecodeError(_) => "AUTHS-E1203",
48 Self::InvalidLength { .. } => "AUTHS-E1204",
49 }
50 }
51
52 fn suggestion(&self) -> Option<&'static str> {
53 match self {
54 Self::UnsupportedKeyType(_) => Some(
55 "Supported verkey prefixes: 'D'/'B' (Ed25519 transferable/non-transferable), '1AAJ'/'1AAI' (P-256 transferable/non-transferable).",
56 ),
57 Self::EmptyInput => Some("Provide a non-empty KERI-encoded key string"),
58 _ => None,
59 }
60 }
61}
62
63/// A validated KERI public key supporting Ed25519 and P-256.
64///
65/// Parsed from a CESR-qualified string. The derivation code prefix
66/// determines the curve, key size, and transferability.
67///
68/// Usage:
69/// ```
70/// use auths_keri::KeriPublicKey;
71///
72/// // Ed25519 (D prefix, 32 bytes)
73/// let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
74/// assert_eq!(key.as_bytes().len(), 32);
75///
76/// // P-256 transferable uses the "1AAJ" prefix (33 bytes compressed SEC1).
77/// ```
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum KeriPublicKey {
80 /// Ed25519 public key (32 bytes).
81 ///
82 /// `transferable` records which CESR code qualified it: `D` (true) is the
83 /// rotating verkey code; `B` (false) is the non-transferable one used by
84 /// KERI witnesses. Both decode to the same 32-byte key and verify with the
85 /// same Ed25519 algorithm.
86 Ed25519 {
87 /// Raw Ed25519 public key (32 bytes).
88 key: [u8; 32],
89 /// Whether the key was qualified with the transferable code (`D`).
90 transferable: bool,
91 },
92 /// P-256 compressed public key (33 bytes, SEC1: 0x02/0x03 + x-coordinate).
93 ///
94 /// `transferable` records which CESR code qualified it: `1AAJ` (true) is
95 /// the rotating verkey code; `1AAI` (false) is the non-transferable one.
96 P256 {
97 /// Compressed SEC1 point (33 bytes).
98 key: [u8; 33],
99 /// Whether the key was qualified with the transferable code (`1AAJ`).
100 transferable: bool,
101 },
102}
103
104impl KeriPublicKey {
105 /// Parse a CESR-qualified key string, dispatching on the derivation code prefix.
106 ///
107 /// - `D` prefix → Ed25519 transferable (32 bytes)
108 /// - `B` prefix → Ed25519 non-transferable (32 bytes) — the KERI witness code
109 /// - `1AAJ` prefix → P-256 transferable (33 bytes compressed)
110 /// - `1AAI` prefix → P-256 non-transferable (33 bytes compressed)
111 ///
112 /// Per the CESR master code table, `D`/`B` and `1AAJ`/`1AAI` are the
113 /// transferable / non-transferable verkey codes for each curve. Both members
114 /// of a pair decode to the same raw key; only the recorded transferability
115 /// (and thus the rotation semantics) differ. Any other matter code returns
116 /// `Err(UnsupportedKeyType)`; malformed CESR returns `Err(DecodeError)`.
117 pub fn parse(encoded: &str) -> Result<Self, KeriDecodeError> {
118 if encoded.is_empty() {
119 return Err(KeriDecodeError::EmptyInput);
120 }
121
122 // cesride's qb64 decoder derives a primitive's length from its derivation
123 // code and slices the input by that length, so a non-ASCII, truncated, or
124 // unknown code makes it slice off a char boundary or past the end and panic.
125 // Validate the input is exactly one well-formed CESR primitive (with no
126 // trailing bytes) before the decoder runs; the curve is then selected by the
127 // in-band code below, never by raw byte length.
128 let primitive = crate::cesr_encode::take_matter_qb64(encoded)?;
129 if primitive.len() != encoded.len() {
130 return Err(KeriDecodeError::DecodeError(
131 "trailing bytes after verkey primitive".into(),
132 ));
133 }
134
135 let (bytes, code) = crate::cesr_encode::decode_verkey(encoded)?;
136
137 use cesride::matter::Codex;
138 if code.as_str() == Codex::Ed25519 || code.as_str() == Codex::Ed25519N {
139 let arr: [u8; 32] =
140 bytes
141 .as_slice()
142 .try_into()
143 .map_err(|_| KeriDecodeError::InvalidLength {
144 expected: 32,
145 actual: bytes.len(),
146 })?;
147 Ok(KeriPublicKey::Ed25519 {
148 key: arr,
149 transferable: code.as_str() == Codex::Ed25519,
150 })
151 } else if code.as_str() == Codex::ECDSA_256r1 || code.as_str() == Codex::ECDSA_256r1N {
152 let arr: [u8; 33] =
153 bytes
154 .as_slice()
155 .try_into()
156 .map_err(|_| KeriDecodeError::InvalidLength {
157 expected: 33,
158 actual: bytes.len(),
159 })?;
160 Ok(KeriPublicKey::P256 {
161 key: arr,
162 transferable: code.as_str() == Codex::ECDSA_256r1,
163 })
164 } else {
165 Err(KeriDecodeError::UnsupportedKeyType(code))
166 }
167 }
168
169 /// Returns the raw public key bytes (32 for Ed25519, 33 for P-256).
170 pub fn as_bytes(&self) -> &[u8] {
171 match self {
172 KeriPublicKey::Ed25519 { key, .. } => key,
173 KeriPublicKey::P256 { key, .. } => key,
174 }
175 }
176
177 /// Consume self and return the raw bytes as a Vec.
178 pub fn into_bytes(self) -> Vec<u8> {
179 match self {
180 KeriPublicKey::Ed25519 { key, .. } => key.to_vec(),
181 KeriPublicKey::P256 { key, .. } => key.to_vec(),
182 }
183 }
184
185 /// Returns the curve type.
186 pub fn curve(&self) -> auths_crypto::CurveType {
187 match self {
188 KeriPublicKey::Ed25519 { .. } => auths_crypto::CurveType::Ed25519,
189 KeriPublicKey::P256 { .. } => auths_crypto::CurveType::P256,
190 }
191 }
192
193 /// Whether this key is transferable (rotating).
194 ///
195 /// Each variant carries the transferability recorded from its CESR code:
196 /// Ed25519 from `D`/`B`, P-256 from `1AAJ`/`1AAI`.
197 pub fn is_transferable(&self) -> bool {
198 match self {
199 KeriPublicKey::Ed25519 { transferable, .. }
200 | KeriPublicKey::P256 { transferable, .. } => *transferable,
201 }
202 }
203
204 /// Returns the CESR derivation code prefix for this key type.
205 ///
206 /// `D`/`B` for transferable / non-transferable Ed25519; `1AAJ`/`1AAI` for
207 /// transferable / non-transferable P-256 (per the CESR master code table).
208 pub fn cesr_prefix(&self) -> &'static str {
209 match self {
210 KeriPublicKey::Ed25519 {
211 transferable: true, ..
212 } => "D",
213 KeriPublicKey::Ed25519 {
214 transferable: false,
215 ..
216 } => "B",
217 KeriPublicKey::P256 {
218 transferable: true, ..
219 } => "1AAJ",
220 KeriPublicKey::P256 {
221 transferable: false,
222 ..
223 } => "1AAI",
224 }
225 }
226
227 /// Encode this key as a CESR-qualified qb64 string, byte-identical to keripy.
228 ///
229 /// Ed25519 → `D…`; transferable P-256 → `1AAJ…`; non-transferable P-256 → `1AAI…`.
230 /// This is the CESR-correct encoding (proper lead-byte alignment), not the legacy
231 /// naive `D` + base64url(raw) form.
232 ///
233 /// Usage:
234 /// ```ignore
235 /// let qb64 = key.to_qb64()?;
236 /// ```
237 pub fn to_qb64(&self) -> Result<String, KeriDecodeError> {
238 let code = crate::cesr_encode::verkey_code(self.curve(), self.is_transferable());
239 crate::cesr_encode::encode_verkey(self.as_bytes(), code)
240 }
241
242 /// Construct a transferable Ed25519 verkey from a 32-byte slice.
243 ///
244 /// Ergonomic bridge for raw-byte sources (e.g. a `ring` public key) into the
245 /// typed key. Returns `Err(InvalidLength)` if the slice is not 32 bytes.
246 ///
247 /// Usage:
248 /// ```
249 /// use auths_keri::KeriPublicKey;
250 /// let key = KeriPublicKey::ed25519(&[0u8; 32]).unwrap();
251 /// assert!(matches!(key, KeriPublicKey::Ed25519 { .. }));
252 /// ```
253 pub fn ed25519(bytes: &[u8]) -> Result<Self, KeriDecodeError> {
254 let arr: [u8; 32] = bytes
255 .try_into()
256 .map_err(|_| KeriDecodeError::InvalidLength {
257 expected: 32,
258 actual: bytes.len(),
259 })?;
260 Ok(KeriPublicKey::Ed25519 {
261 key: arr,
262 transferable: true,
263 })
264 }
265
266 /// Construct a transferable verkey from raw bytes plus an explicit curve.
267 ///
268 /// The complement of [`Self::as_bytes`] + [`Self::curve`]: rebuilds the typed key
269 /// when you hold curve-tagged bytes (a `CurveType` carried alongside a `Vec<u8>`),
270 /// instead of re-guessing the curve from byte length. Encodes as transferable
271 /// (`D` / `1AAJ`). Returns `Err(InvalidLength)` if the length doesn't match the curve.
272 ///
273 /// Usage:
274 /// ```
275 /// use auths_keri::KeriPublicKey;
276 /// use auths_crypto::CurveType;
277 /// let key = KeriPublicKey::from_verkey_bytes(&[0u8; 32], CurveType::Ed25519).unwrap();
278 /// assert_eq!(key.curve(), CurveType::Ed25519);
279 /// ```
280 pub fn from_verkey_bytes(
281 bytes: &[u8],
282 curve: auths_crypto::CurveType,
283 ) -> Result<Self, KeriDecodeError> {
284 match curve {
285 auths_crypto::CurveType::Ed25519 => Self::ed25519(bytes),
286 auths_crypto::CurveType::P256 => {
287 let arr: [u8; 33] =
288 bytes
289 .try_into()
290 .map_err(|_| KeriDecodeError::InvalidLength {
291 expected: 33,
292 actual: bytes.len(),
293 })?;
294 Ok(KeriPublicKey::P256 {
295 key: arr,
296 transferable: true,
297 })
298 }
299 }
300 }
301
302 /// Verify a signature against this public key.
303 ///
304 /// Dispatches to the correct algorithm based on the key's curve:
305 /// - Ed25519 → `ring::signature::ED25519`
306 /// - P-256 → `p256::ecdsa` (handles compressed SEC1 keys natively)
307 ///
308 /// This method keeps the curve dispatch in one place so validation code
309 /// doesn't need to know about specific algorithms.
310 pub fn verify_signature(&self, message: &[u8], signature: &[u8]) -> Result<(), String> {
311 match self {
312 KeriPublicKey::Ed25519 { key: pk, .. } => {
313 use ring::signature::UnparsedPublicKey;
314 let verifier = UnparsedPublicKey::new(&ring::signature::ED25519, pk);
315 verifier
316 .verify(message, signature)
317 .map_err(|_| "Ed25519 signature verification failed".to_string())
318 }
319 KeriPublicKey::P256 { key: pk, .. } => {
320 use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
321 // p256 crate handles compressed SEC1 (33 bytes) natively
322 let vk = VerifyingKey::from_sec1_bytes(pk)
323 .map_err(|e| format!("P-256 key parse failed: {e}"))?;
324 let sig = Signature::from_slice(signature)
325 .map_err(|e| format!("P-256 signature parse failed: {e}"))?;
326 vk.verify(message, &sig)
327 .map_err(|e| format!("P-256 signature verification failed: {e}"))
328 }
329 }
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
337
338 #[test]
339 fn parse_ed25519_all_zeros() {
340 let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
341 assert_eq!(key.as_bytes(), &[0u8; 32]);
342 assert!(matches!(key, KeriPublicKey::Ed25519 { .. }));
343 assert!(key.is_transferable());
344 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
345 assert_eq!(key.cesr_prefix(), "D");
346 }
347
348 #[test]
349 fn parse_p256_key() {
350 // `1AAJ` is the transferable P-256 verkey code (the rotating default).
351 let zeros_33 = [0u8; 33];
352 let encoded = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(zeros_33));
353 let key = KeriPublicKey::parse(&encoded).unwrap();
354 assert_eq!(key.as_bytes().len(), 33);
355 assert!(matches!(key, KeriPublicKey::P256 { .. }));
356 assert_eq!(key.curve(), auths_crypto::CurveType::P256);
357 assert!(key.is_transferable());
358 assert_eq!(key.cesr_prefix(), "1AAJ");
359 }
360
361 #[test]
362 fn parses_both_1aai_and_1aaj() {
363 // Both P-256 codes decode to the same 33-byte point; transferability
364 // and the round-tripped prefix differ.
365 let zeros_33 = [0u8; 33];
366 let transferable =
367 KeriPublicKey::parse(&format!("1AAJ{}", URL_SAFE_NO_PAD.encode(zeros_33))).unwrap();
368 let non_transferable =
369 KeriPublicKey::parse(&format!("1AAI{}", URL_SAFE_NO_PAD.encode(zeros_33))).unwrap();
370 assert_eq!(transferable.as_bytes(), non_transferable.as_bytes());
371 assert!(transferable.is_transferable());
372 assert!(!non_transferable.is_transferable());
373 assert_eq!(transferable.cesr_prefix(), "1AAJ");
374 assert_eq!(non_transferable.cesr_prefix(), "1AAI");
375 }
376
377 #[test]
378 fn parse_rejects_malformed_qb64_without_panicking() {
379 // A derivation code whose declared primitive size exceeds the actual
380 // input length must fail closed with a typed error, never an
381 // out-of-bounds slice panic inside the CESR decoder.
382 for malformed in ["6AAA1IAA", "1AAJ", "D", "1AAI"] {
383 assert!(
384 KeriPublicKey::parse(malformed).is_err(),
385 "malformed CESR verkey {malformed:?} must be rejected, not panic"
386 );
387 }
388 }
389
390 #[test]
391 fn parse_p256_non_transferable() {
392 let zeros_33 = [0u8; 33];
393 let encoded = format!("1AAI{}", URL_SAFE_NO_PAD.encode(zeros_33));
394 let key = KeriPublicKey::parse(&encoded).unwrap();
395 assert!(matches!(
396 key,
397 KeriPublicKey::P256 {
398 transferable: false,
399 ..
400 }
401 ));
402 }
403
404 #[test]
405 fn rejects_empty_input() {
406 let err = KeriPublicKey::parse("").unwrap_err();
407 assert_eq!(err, KeriDecodeError::EmptyInput);
408 }
409
410 #[test]
411 fn rejects_unknown_prefix() {
412 // Not valid CESR for any verkey code: rejected either as undecodable or
413 // (if it parses to some other matter code) as an unsupported key type.
414 let err = KeriPublicKey::parse("Xsomething").unwrap_err();
415 assert!(
416 matches!(
417 err,
418 KeriDecodeError::DecodeError(_) | KeriDecodeError::UnsupportedKeyType(_)
419 ),
420 "unexpected error: {err:?}"
421 );
422 }
423
424 #[test]
425 fn parses_non_transferable_ed25519_code() {
426 // `B` (Ed25519N) is the standard KERI witness key code. It decodes to the
427 // same 32-byte key as `D`; only transferability and the prefix differ.
428 let b_code =
429 crate::cesr_encode::encode_verkey(&[0u8; 32], cesride::matter::Codex::Ed25519N)
430 .unwrap();
431 let key = KeriPublicKey::parse(&b_code).unwrap();
432 assert!(matches!(
433 key,
434 KeriPublicKey::Ed25519 {
435 transferable: false,
436 ..
437 }
438 ));
439 assert_eq!(key.as_bytes(), &[0u8; 32]);
440 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
441 assert!(!key.is_transferable());
442 assert_eq!(key.cesr_prefix(), "B");
443 }
444
445 #[test]
446 fn parses_both_d_and_b_ed25519() {
447 // Both Ed25519 codes decode to the same 32-byte key; transferability and
448 // the round-tripped prefix differ — mirroring the P-256 1AAJ/1AAI pair.
449 let d =
450 crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519).unwrap();
451 let b = crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519N)
452 .unwrap();
453 let transferable = KeriPublicKey::parse(&d).unwrap();
454 let non_transferable = KeriPublicKey::parse(&b).unwrap();
455 assert_eq!(transferable.as_bytes(), non_transferable.as_bytes());
456 assert!(transferable.is_transferable());
457 assert!(!non_transferable.is_transferable());
458 assert_eq!(transferable.cesr_prefix(), "D");
459 assert_eq!(non_transferable.cesr_prefix(), "B");
460 }
461
462 #[test]
463 fn rejects_invalid_base64() {
464 let err = KeriPublicKey::parse("D!!!invalid!!!").unwrap_err();
465 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
466 }
467
468 #[test]
469 fn rejects_wrong_length_ed25519() {
470 // A naive `D` + base64(31 bytes) has the wrong qb64 length for the
471 // Ed25519 code, so cesride rejects it as malformed CESR.
472 let short = [0u8; 31];
473 let encoded = format!("D{}", URL_SAFE_NO_PAD.encode(short));
474 let err = KeriPublicKey::parse(&encoded).unwrap_err();
475 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
476 }
477
478 #[test]
479 fn rejects_wrong_length_p256() {
480 // A naive `1AAJ` + base64(32 bytes) has the wrong qb64 length for the
481 // ECDSA_256r1 code, so cesride rejects it as malformed CESR.
482 let short = [0u8; 32];
483 let encoded = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(short));
484 let err = KeriPublicKey::parse(&encoded).unwrap_err();
485 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
486 }
487
488 // `as_bytes()` returns `&[u8]`; Ed25519 keys must still convert cleanly to a
489 // 32-byte array for callers that need the fixed-width form.
490 #[test]
491 fn ed25519_as_bytes_is_32() {
492 let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
493 let bytes = key.as_bytes();
494 assert_eq!(bytes.len(), 32);
495 // Can still convert to [u8; 32] for backward compat
496 let arr: [u8; 32] = bytes.try_into().unwrap();
497 assert_eq!(arr, [0u8; 32]);
498 }
499
500 /// keripy 1.3.4 reference: `Verfer(bytes(0..32), Ed25519).qb64`. `parse` must
501 /// decode it to the exact 32 raw bytes via CESR alignment (lead-byte aware),
502 /// NOT naive base64-after-`D` (which would recover shifted, wrong bytes).
503 #[test]
504 fn parse_matches_keripy_ed25519_vector() {
505 let raw: Vec<u8> = (0u8..32).collect();
506 let key = KeriPublicKey::parse("DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f").unwrap();
507 assert_eq!(key.as_bytes(), raw.as_slice());
508 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
509 }
510
511 /// `parse` must invert `to_qb64` (cesride) for a non-zero Ed25519 key.
512 #[test]
513 fn parse_inverts_to_qb64_ed25519() {
514 let raw: Vec<u8> = (0u8..32).collect();
515 let key = KeriPublicKey::ed25519(&raw).unwrap();
516 let qb64 = key.to_qb64().unwrap();
517 let parsed = KeriPublicKey::parse(&qb64).unwrap();
518 assert_eq!(parsed, key, "parse must invert to_qb64 (CESR round-trip)");
519 }
520
521 /// `parse` must invert `to_qb64` for a non-zero transferable P-256 key.
522 #[test]
523 fn parse_inverts_to_qb64_p256() {
524 let mut point = [0u8; 33];
525 point[0] = 0x02;
526 for (i, b) in point.iter_mut().enumerate().skip(1) {
527 *b = i as u8;
528 }
529 let key = KeriPublicKey::from_verkey_bytes(&point, auths_crypto::CurveType::P256).unwrap();
530 let qb64 = key.to_qb64().unwrap();
531 let parsed = KeriPublicKey::parse(&qb64).unwrap();
532 assert_eq!(parsed, key, "P-256 parse must invert to_qb64");
533 assert!(parsed.is_transferable());
534 }
535}