lys_core/checkpoint/verifier_key.rs
1//! [`NoteVerifierKey`] — the signed-note verifier-key text form, and the
2//! key-name validity rules shared with checkpoint origins.
3//!
4//! # Invariants
5//!
6//! - A key name (and therefore a checkpoint origin) is non-empty, contains
7//! no Unicode whitespace, and contains no `'+'` — exactly the Go
8//! `isValidName` rules (UTF-8 validity is given by `&str`; Go's
9//! `unicode.IsSpace` and Rust's `char::is_whitespace` both test the
10//! Unicode `White_Space` property).
11//! - The text form is `<name>+<8 lowercase hex chars of key ID>+<standard
12//! base64 with padding of (0x01 ‖ 32-byte pubkey)>`. Parsing accepts
13//! upper- or lowercase hex (like Go's `ParseUint`); emission is lowercase
14//! (like Go's `%08x`).
15//! - A parsed key is internally consistent: the declared key ID always
16//! equals the key ID recomputed from `(name, pubkey)` (Go `NewVerifier`
17//! validates identically).
18//! - Only public material is held; `Debug` is safe to derive.
19
20use base64::Engine;
21use base64::engine::general_purpose::STANDARD;
22
23use crate::error::{TrustError, TrustResult};
24
25/// Validates a signed-note key name / checkpoint origin.
26///
27/// Rules (Go `isValidName`): non-empty, no Unicode whitespace, no `'+'`.
28/// (UTF-8 validity is given by `&str`.)
29pub(crate) fn validate_note_name(name: &str) -> TrustResult<()> {
30 if name.is_empty() {
31 return Err(TrustError::VerifierKey {
32 reason: "key name must not be empty".to_string(),
33 });
34 }
35 if name.chars().any(char::is_whitespace) {
36 return Err(TrustError::VerifierKey {
37 reason: "key name must not contain whitespace".to_string(),
38 });
39 }
40 if name.contains('+') {
41 return Err(TrustError::VerifierKey {
42 reason: "key name must not contain '+'".to_string(),
43 });
44 }
45 Ok(())
46}
47
48/// A parsed note verifier key: `<name>+<hex keyid>+<base64(0x01 ‖ pubkey)>`.
49///
50/// Holds only public material. Constructed either from its parts via
51/// [`Self::new`] (which computes the key ID) or from the text form via
52/// [`Self::from_spec`] (which validates the declared key ID against the
53/// recomputed one).
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct NoteVerifierKey {
56 name: String,
57 key_id: [u8; 4],
58 public_key: [u8; 32],
59}
60
61impl NoteVerifierKey {
62 /// Builds a verifier key from a name and public key, computing the
63 /// key ID per the signed-note rule
64 /// `SHA-256(name ‖ 0x0A ‖ 0x01 ‖ pubkey)[..4]`.
65 ///
66 /// # Errors
67 ///
68 /// Returns [`TrustError::VerifierKey`] if `name` violates the key-name
69 /// rules.
70 pub fn new(name: &str, public_key: [u8; 32]) -> TrustResult<Self> {
71 let key_id = super::note::key_id(name, &public_key)?;
72 Ok(Self {
73 name: name.to_string(),
74 key_id,
75 public_key,
76 })
77 }
78
79 /// Parses the text form `<name>+<hex keyid>+<base64(0x01 ‖ pubkey)>`.
80 ///
81 /// Splits at the first two `'+'` characters (the base64 part may itself
82 /// contain `'+'`), then validates: name rules, exactly 8 hex chars of
83 /// key ID (upper- or lowercase, like Go), base64 decoding to exactly
84 /// 33 bytes, first byte `0x01` (the Ed25519 algorithm byte), and the
85 /// declared key ID equal to the one recomputed from `(name, pubkey)` —
86 /// Go `NewVerifier` validates identically.
87 ///
88 /// # Errors
89 ///
90 /// Returns [`TrustError::VerifierKey`] with a specific reason on any
91 /// violation — this is TRUSTED operator input, diagnostics are safe.
92 pub fn from_spec(spec: &str) -> TrustResult<Self> {
93 let mut parts = spec.splitn(3, '+');
94 let (Some(name), Some(hex_id), Some(key_b64)) = (parts.next(), parts.next(), parts.next())
95 else {
96 return Err(TrustError::VerifierKey {
97 reason: "expected <name>+<hex keyid>+<base64 key>".to_string(),
98 });
99 };
100 validate_note_name(name)?;
101 let declared_id = parse_key_id_hex(hex_id)?;
102 let key_bytes = STANDARD
103 .decode(key_b64)
104 .map_err(|e| TrustError::VerifierKey {
105 reason: format!("key part is not canonical standard base64: {e}"),
106 })?;
107 let (Some((&alg, public_key_slice)), 33) = (key_bytes.split_first(), key_bytes.len())
108 else {
109 return Err(TrustError::VerifierKey {
110 reason: format!(
111 "key part must decode to 33 bytes (algorithm byte + 32-byte public key), \
112 got {}",
113 key_bytes.len()
114 ),
115 });
116 };
117 if alg != 0x01 {
118 return Err(TrustError::VerifierKey {
119 reason: format!("unsupported algorithm byte {alg:#04x}, expected 0x01 (Ed25519)"),
120 });
121 }
122 let mut public_key = [0u8; 32];
123 public_key.copy_from_slice(public_key_slice);
124 let computed_id = super::note::key_id(name, &public_key)?;
125 if declared_id != computed_id {
126 return Err(TrustError::VerifierKey {
127 reason: "declared key ID does not match the key ID computed from \
128 the name and public key"
129 .to_string(),
130 });
131 }
132 Ok(Self {
133 name: name.to_string(),
134 key_id: computed_id,
135 public_key,
136 })
137 }
138
139 /// Emits the text form with a lowercase-hex key ID (Go emits `%08x`).
140 #[must_use]
141 pub fn to_spec(&self) -> String {
142 let mut key_bytes = Vec::with_capacity(33);
143 key_bytes.push(0x01);
144 key_bytes.extend_from_slice(&self.public_key);
145 format!(
146 "{}+{}+{}",
147 self.name,
148 crate::hex_lower(&self.key_id),
149 STANDARD.encode(&key_bytes)
150 )
151 }
152
153 /// The key name (equal to the checkpoint origin this key verifies).
154 #[must_use]
155 pub fn name(&self) -> &str {
156 &self.name
157 }
158
159 /// The 4-byte key ID.
160 #[must_use]
161 pub fn key_id(&self) -> [u8; 4] {
162 self.key_id
163 }
164
165 /// The 32-byte Ed25519 public key.
166 #[must_use]
167 pub fn public_key(&self) -> [u8; 32] {
168 self.public_key
169 }
170}
171
172/// Parses exactly 8 hex characters (either case) into a big-endian 4-byte
173/// key ID, mirroring Go's `strconv.ParseUint(…, 16, 32)` acceptance.
174fn parse_key_id_hex(hex_id: &str) -> TrustResult<[u8; 4]> {
175 if hex_id.len() != 8 || !hex_id.bytes().all(|b| b.is_ascii_hexdigit()) {
176 return Err(TrustError::VerifierKey {
177 reason: "key ID must be exactly 8 hex characters".to_string(),
178 });
179 }
180 let value = u32::from_str_radix(hex_id, 16).map_err(|e| TrustError::VerifierKey {
181 reason: format!("key ID is not valid hex: {e}"),
182 })?;
183 Ok(value.to_be_bytes())
184}
185
186#[cfg(test)]
187#[path = "verifier_key_tests.rs"]
188mod tests;