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 /// The raw public-key bytes (32 for Ed25519, 33 compressed SEC1 for P-256) —
194 /// paired with [`Self::curve`] so consumers dispatch on the typed curve and
195 /// never re-match variants.
196 pub fn raw_bytes(&self) -> &[u8] {
197 match self {
198 KeriPublicKey::Ed25519 { key, .. } => key,
199 KeriPublicKey::P256 { key, .. } => key,
200 }
201 }
202
203 /// Whether this key is transferable (rotating).
204 ///
205 /// Each variant carries the transferability recorded from its CESR code:
206 /// Ed25519 from `D`/`B`, P-256 from `1AAJ`/`1AAI`.
207 pub fn is_transferable(&self) -> bool {
208 match self {
209 KeriPublicKey::Ed25519 { transferable, .. }
210 | KeriPublicKey::P256 { transferable, .. } => *transferable,
211 }
212 }
213
214 /// Returns the CESR derivation code prefix for this key type.
215 ///
216 /// `D`/`B` for transferable / non-transferable Ed25519; `1AAJ`/`1AAI` for
217 /// transferable / non-transferable P-256 (per the CESR master code table).
218 pub fn cesr_prefix(&self) -> &'static str {
219 match self {
220 KeriPublicKey::Ed25519 {
221 transferable: true, ..
222 } => "D",
223 KeriPublicKey::Ed25519 {
224 transferable: false,
225 ..
226 } => "B",
227 KeriPublicKey::P256 {
228 transferable: true, ..
229 } => "1AAJ",
230 KeriPublicKey::P256 {
231 transferable: false,
232 ..
233 } => "1AAI",
234 }
235 }
236
237 /// Encode this key as a CESR-qualified qb64 string, byte-identical to keripy.
238 ///
239 /// Ed25519 → `D…`; transferable P-256 → `1AAJ…`; non-transferable P-256 → `1AAI…`.
240 /// This is the CESR-correct encoding (proper lead-byte alignment), not the legacy
241 /// naive `D` + base64url(raw) form.
242 ///
243 /// Usage:
244 /// ```ignore
245 /// let qb64 = key.to_qb64()?;
246 /// ```
247 pub fn to_qb64(&self) -> Result<String, KeriDecodeError> {
248 let code = crate::cesr_encode::verkey_code(self.curve(), self.is_transferable());
249 crate::cesr_encode::encode_verkey(self.as_bytes(), code)
250 }
251
252 /// Construct a transferable Ed25519 verkey from a 32-byte slice.
253 ///
254 /// Ergonomic bridge for raw-byte sources (e.g. a `ring` public key) into the
255 /// typed key. Returns `Err(InvalidLength)` if the slice is not 32 bytes.
256 ///
257 /// Usage:
258 /// ```
259 /// use auths_keri::KeriPublicKey;
260 /// let key = KeriPublicKey::ed25519(&[0u8; 32]).unwrap();
261 /// assert!(matches!(key, KeriPublicKey::Ed25519 { .. }));
262 /// ```
263 pub fn ed25519(bytes: &[u8]) -> Result<Self, KeriDecodeError> {
264 let arr: [u8; 32] = bytes
265 .try_into()
266 .map_err(|_| KeriDecodeError::InvalidLength {
267 expected: 32,
268 actual: bytes.len(),
269 })?;
270 Ok(KeriPublicKey::Ed25519 {
271 key: arr,
272 transferable: true,
273 })
274 }
275
276 /// Construct a transferable verkey from raw bytes plus an explicit curve.
277 ///
278 /// The complement of [`Self::as_bytes`] + [`Self::curve`]: rebuilds the typed key
279 /// when you hold curve-tagged bytes (a `CurveType` carried alongside a `Vec<u8>`),
280 /// instead of re-guessing the curve from byte length. Encodes as transferable
281 /// (`D` / `1AAJ`). Returns `Err(InvalidLength)` if the length doesn't match the curve.
282 ///
283 /// Usage:
284 /// ```
285 /// use auths_keri::KeriPublicKey;
286 /// use auths_crypto::CurveType;
287 /// let key = KeriPublicKey::from_verkey_bytes(&[0u8; 32], CurveType::Ed25519).unwrap();
288 /// assert_eq!(key.curve(), CurveType::Ed25519);
289 /// ```
290 pub fn from_verkey_bytes(
291 bytes: &[u8],
292 curve: auths_crypto::CurveType,
293 ) -> Result<Self, KeriDecodeError> {
294 match curve {
295 auths_crypto::CurveType::Ed25519 => Self::ed25519(bytes),
296 auths_crypto::CurveType::P256 => {
297 let arr: [u8; 33] =
298 bytes
299 .try_into()
300 .map_err(|_| KeriDecodeError::InvalidLength {
301 expected: 33,
302 actual: bytes.len(),
303 })?;
304 Ok(KeriPublicKey::P256 {
305 key: arr,
306 transferable: true,
307 })
308 }
309 }
310 }
311
312 /// Verify a signature against this public key.
313 ///
314 /// Dispatches to the correct algorithm based on the key's curve:
315 /// - Ed25519 → `ring::signature::ED25519`
316 /// - P-256 → `p256::ecdsa` (handles compressed SEC1 keys natively)
317 ///
318 /// This method keeps the curve dispatch in one place so validation code
319 /// doesn't need to know about specific algorithms.
320 pub fn verify_signature(&self, message: &[u8], signature: &[u8]) -> Result<(), String> {
321 match self {
322 KeriPublicKey::Ed25519 { key: pk, .. } => {
323 use ring::signature::UnparsedPublicKey;
324 let verifier = UnparsedPublicKey::new(&ring::signature::ED25519, pk);
325 verifier
326 .verify(message, signature)
327 .map_err(|_| "Ed25519 signature verification failed".to_string())
328 }
329 KeriPublicKey::P256 { key: pk, .. } => {
330 use p256::ecdsa::{Signature, VerifyingKey, signature::Verifier};
331 // p256 crate handles compressed SEC1 (33 bytes) natively
332 let vk = VerifyingKey::from_sec1_bytes(pk)
333 .map_err(|e| format!("P-256 key parse failed: {e}"))?;
334 let sig = Signature::from_slice(signature)
335 .map_err(|e| format!("P-256 signature parse failed: {e}"))?;
336 vk.verify(message, &sig)
337 .map_err(|e| format!("P-256 signature verification failed: {e}"))
338 }
339 }
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346 use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
347
348 #[test]
349 fn parse_ed25519_all_zeros() {
350 let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
351 assert_eq!(key.as_bytes(), &[0u8; 32]);
352 assert!(matches!(key, KeriPublicKey::Ed25519 { .. }));
353 assert!(key.is_transferable());
354 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
355 assert_eq!(key.cesr_prefix(), "D");
356 }
357
358 #[test]
359 fn parse_p256_key() {
360 // `1AAJ` is the transferable P-256 verkey code (the rotating default).
361 let zeros_33 = [0u8; 33];
362 let encoded = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(zeros_33));
363 let key = KeriPublicKey::parse(&encoded).unwrap();
364 assert_eq!(key.as_bytes().len(), 33);
365 assert!(matches!(key, KeriPublicKey::P256 { .. }));
366 assert_eq!(key.curve(), auths_crypto::CurveType::P256);
367 assert!(key.is_transferable());
368 assert_eq!(key.cesr_prefix(), "1AAJ");
369 }
370
371 #[test]
372 fn parses_both_1aai_and_1aaj() {
373 // Both P-256 codes decode to the same 33-byte point; transferability
374 // and the round-tripped prefix differ.
375 let zeros_33 = [0u8; 33];
376 let transferable =
377 KeriPublicKey::parse(&format!("1AAJ{}", URL_SAFE_NO_PAD.encode(zeros_33))).unwrap();
378 let non_transferable =
379 KeriPublicKey::parse(&format!("1AAI{}", URL_SAFE_NO_PAD.encode(zeros_33))).unwrap();
380 assert_eq!(transferable.as_bytes(), non_transferable.as_bytes());
381 assert!(transferable.is_transferable());
382 assert!(!non_transferable.is_transferable());
383 assert_eq!(transferable.cesr_prefix(), "1AAJ");
384 assert_eq!(non_transferable.cesr_prefix(), "1AAI");
385 }
386
387 #[test]
388 fn parse_rejects_malformed_qb64_without_panicking() {
389 // A derivation code whose declared primitive size exceeds the actual
390 // input length must fail closed with a typed error, never an
391 // out-of-bounds slice panic inside the CESR decoder.
392 for malformed in ["6AAA1IAA", "1AAJ", "D", "1AAI"] {
393 assert!(
394 KeriPublicKey::parse(malformed).is_err(),
395 "malformed CESR verkey {malformed:?} must be rejected, not panic"
396 );
397 }
398 }
399
400 #[test]
401 fn parse_p256_non_transferable() {
402 let zeros_33 = [0u8; 33];
403 let encoded = format!("1AAI{}", URL_SAFE_NO_PAD.encode(zeros_33));
404 let key = KeriPublicKey::parse(&encoded).unwrap();
405 assert!(matches!(
406 key,
407 KeriPublicKey::P256 {
408 transferable: false,
409 ..
410 }
411 ));
412 }
413
414 #[test]
415 fn rejects_empty_input() {
416 let err = KeriPublicKey::parse("").unwrap_err();
417 assert_eq!(err, KeriDecodeError::EmptyInput);
418 }
419
420 #[test]
421 fn rejects_unknown_prefix() {
422 // Not valid CESR for any verkey code: rejected either as undecodable or
423 // (if it parses to some other matter code) as an unsupported key type.
424 let err = KeriPublicKey::parse("Xsomething").unwrap_err();
425 assert!(
426 matches!(
427 err,
428 KeriDecodeError::DecodeError(_) | KeriDecodeError::UnsupportedKeyType(_)
429 ),
430 "unexpected error: {err:?}"
431 );
432 }
433
434 #[test]
435 fn parses_non_transferable_ed25519_code() {
436 // `B` (Ed25519N) is the standard KERI witness key code. It decodes to the
437 // same 32-byte key as `D`; only transferability and the prefix differ.
438 let b_code =
439 crate::cesr_encode::encode_verkey(&[0u8; 32], cesride::matter::Codex::Ed25519N)
440 .unwrap();
441 let key = KeriPublicKey::parse(&b_code).unwrap();
442 assert!(matches!(
443 key,
444 KeriPublicKey::Ed25519 {
445 transferable: false,
446 ..
447 }
448 ));
449 assert_eq!(key.as_bytes(), &[0u8; 32]);
450 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
451 assert!(!key.is_transferable());
452 assert_eq!(key.cesr_prefix(), "B");
453 }
454
455 #[test]
456 fn parses_both_d_and_b_ed25519() {
457 // Both Ed25519 codes decode to the same 32-byte key; transferability and
458 // the round-tripped prefix differ — mirroring the P-256 1AAJ/1AAI pair.
459 let d =
460 crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519).unwrap();
461 let b = crate::cesr_encode::encode_verkey(&[7u8; 32], cesride::matter::Codex::Ed25519N)
462 .unwrap();
463 let transferable = KeriPublicKey::parse(&d).unwrap();
464 let non_transferable = KeriPublicKey::parse(&b).unwrap();
465 assert_eq!(transferable.as_bytes(), non_transferable.as_bytes());
466 assert!(transferable.is_transferable());
467 assert!(!non_transferable.is_transferable());
468 assert_eq!(transferable.cesr_prefix(), "D");
469 assert_eq!(non_transferable.cesr_prefix(), "B");
470 }
471
472 #[test]
473 fn rejects_invalid_base64() {
474 let err = KeriPublicKey::parse("D!!!invalid!!!").unwrap_err();
475 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
476 }
477
478 #[test]
479 fn rejects_wrong_length_ed25519() {
480 // A naive `D` + base64(31 bytes) has the wrong qb64 length for the
481 // Ed25519 code, so cesride rejects it as malformed CESR.
482 let short = [0u8; 31];
483 let encoded = format!("D{}", URL_SAFE_NO_PAD.encode(short));
484 let err = KeriPublicKey::parse(&encoded).unwrap_err();
485 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
486 }
487
488 #[test]
489 fn rejects_wrong_length_p256() {
490 // A naive `1AAJ` + base64(32 bytes) has the wrong qb64 length for the
491 // ECDSA_256r1 code, so cesride rejects it as malformed CESR.
492 let short = [0u8; 32];
493 let encoded = format!("1AAJ{}", URL_SAFE_NO_PAD.encode(short));
494 let err = KeriPublicKey::parse(&encoded).unwrap_err();
495 assert!(matches!(err, KeriDecodeError::DecodeError(_)));
496 }
497
498 // `as_bytes()` returns `&[u8]`; Ed25519 keys must still convert cleanly to a
499 // 32-byte array for callers that need the fixed-width form.
500 #[test]
501 fn ed25519_as_bytes_is_32() {
502 let key = KeriPublicKey::parse("DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA").unwrap();
503 let bytes = key.as_bytes();
504 assert_eq!(bytes.len(), 32);
505 // Can still convert to [u8; 32] for backward compat
506 let arr: [u8; 32] = bytes.try_into().unwrap();
507 assert_eq!(arr, [0u8; 32]);
508 }
509
510 /// keripy 1.3.4 reference: `Verfer(bytes(0..32), Ed25519).qb64`. `parse` must
511 /// decode it to the exact 32 raw bytes via CESR alignment (lead-byte aware),
512 /// NOT naive base64-after-`D` (which would recover shifted, wrong bytes).
513 #[test]
514 fn parse_matches_keripy_ed25519_vector() {
515 let raw: Vec<u8> = (0u8..32).collect();
516 let key = KeriPublicKey::parse("DAABAgMEBQYHCAkKCwwNDg8QERITFBUWFxgZGhscHR4f").unwrap();
517 assert_eq!(key.as_bytes(), raw.as_slice());
518 assert_eq!(key.curve(), auths_crypto::CurveType::Ed25519);
519 }
520
521 /// `parse` must invert `to_qb64` (cesride) for a non-zero Ed25519 key.
522 #[test]
523 fn parse_inverts_to_qb64_ed25519() {
524 let raw: Vec<u8> = (0u8..32).collect();
525 let key = KeriPublicKey::ed25519(&raw).unwrap();
526 let qb64 = key.to_qb64().unwrap();
527 let parsed = KeriPublicKey::parse(&qb64).unwrap();
528 assert_eq!(parsed, key, "parse must invert to_qb64 (CESR round-trip)");
529 }
530
531 /// `parse` must invert `to_qb64` for a non-zero transferable P-256 key.
532 #[test]
533 fn parse_inverts_to_qb64_p256() {
534 let mut point = [0u8; 33];
535 point[0] = 0x02;
536 for (i, b) in point.iter_mut().enumerate().skip(1) {
537 *b = i as u8;
538 }
539 let key = KeriPublicKey::from_verkey_bytes(&point, auths_crypto::CurveType::P256).unwrap();
540 let qb64 = key.to_qb64().unwrap();
541 let parsed = KeriPublicKey::parse(&qb64).unwrap();
542 assert_eq!(parsed, key, "P-256 parse must invert to_qb64");
543 assert!(parsed.is_transferable());
544 }
545}