h33_substrate_verifier/public_keys.rs
1//! Parser for the `GET /v1/substrate/public-keys` JSON response.
2//!
3//! The verifier does NOT currently use the public keys to verify raw
4//! signatures — that's blocked on scif-backend Tier 3.2 (permanent
5//! signature storage + nonce exposure). The parser exists today so
6//! customer verification code can hard-code the JSON shape and the
7//! base64 field contract, and the types are forward-compatible with
8//! the day a raw-verification path lands.
9//!
10//! ## JSON shape
11//!
12//! ```json
13//! {
14//! "epoch": "h33-substrate-abcdef1234567890",
15//! "is_current": true,
16//! "rotation_history": ["h33-substrate-abcdef1234567890"],
17//! "keys": {
18//! "dilithium": { "algorithm": "ML-DSA-65", "format": "raw", "key_b64": "..." },
19//! "falcon": { "algorithm": "FALCON-512", "format": "raw", "key_b64": "..." },
20//! "sphincs": { "algorithm": "SPHINCS+-SHA2-128f", "format": "raw", "key_b64": "..." }
21//! }
22//! }
23//! ```
24
25use crate::error::VerifierError;
26use alloc::{string::String, vec::Vec};
27use serde::{Deserialize, Serialize};
28
29/// Type alias for the decoded three-family public key byte triple
30/// returned by [`PublicKeysResponse::decode_all`].
31pub type DecodedKeyTriple = (Vec<u8>, Vec<u8>, Vec<u8>);
32
33/// Parsed `GET /v1/substrate/public-keys` response.
34///
35/// This is an owned-string type because it represents data fetched
36/// over the network that the caller will typically cache for 24 h
37/// (the static-resource TTL in the server's response middleware).
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct PublicKeysResponse {
40 /// Opaque identifier for this key generation epoch. Stable across
41 /// restarts of the same keypair set.
42 pub epoch: String,
43 /// Whether this response represents the currently-active epoch.
44 pub is_current: bool,
45 /// Every epoch the server has records of. Today this is always a
46 /// single entry; with Tier 3.5 key rotation this grows into a
47 /// full timeline.
48 pub rotation_history: Vec<String>,
49 /// The three public keys themselves.
50 pub keys: PublicKeyBundle,
51}
52
53/// The three public keys, one per signature family.
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct PublicKeyBundle {
56 /// Dilithium ML-DSA-65 public key.
57 pub dilithium: PublicKeyEntry,
58 /// FALCON-512 public key.
59 pub falcon: PublicKeyEntry,
60 /// SPHINCS+-SHA2-128f public key.
61 pub sphincs: PublicKeyEntry,
62}
63
64/// A single public-key entry. Owned-string form so the parser can
65/// deserialize from any input without lifetime entanglement.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct PublicKeyEntry {
68 /// SPDX-style algorithm identifier (e.g. `ML-DSA-65`).
69 pub algorithm: String,
70 /// Encoding of `key_b64`. Currently always `raw`.
71 pub format: String,
72 /// The public key itself, base64-encoded with the standard alphabet.
73 pub key_b64: String,
74}
75
76impl PublicKeyEntry {
77 /// Decode the base64-encoded key bytes.
78 ///
79 /// # Examples
80 ///
81 /// ```
82 /// use h33_substrate_verifier::PublicKeyEntry;
83 ///
84 /// let entry = PublicKeyEntry {
85 /// algorithm: "ML-DSA-65".into(),
86 /// format: "raw".into(),
87 /// key_b64: "aGVsbG8gd29ybGQ=".into(),
88 /// };
89 /// let bytes = entry.decode_bytes("dilithium").unwrap();
90 /// assert_eq!(bytes, b"hello world");
91 /// ```
92 pub fn decode_bytes(
93 &self,
94 field_name: &'static str,
95 ) -> Result<Vec<u8>, VerifierError> {
96 use base64::Engine as _;
97 base64::engine::general_purpose::STANDARD
98 .decode(self.key_b64.as_bytes())
99 .map_err(|e| VerifierError::PublicKeysBase64 {
100 field: field_name,
101 detail: alloc::format!("{e}"),
102 })
103 }
104}
105
106impl PublicKeysResponse {
107 /// Parse a JSON document into a `PublicKeysResponse`.
108 pub fn from_json(json: &str) -> Result<Self, VerifierError> {
109 serde_json::from_str(json).map_err(|e| {
110 VerifierError::PublicKeysParse(alloc::format!("{e}"))
111 })
112 }
113
114 /// Decode all three public keys at once, returning a
115 /// [`DecodedKeyTriple`] of `(dilithium, falcon, sphincs)` as owned
116 /// byte vectors. Returns the first base64 error encountered.
117 pub fn decode_all(&self) -> Result<DecodedKeyTriple, VerifierError> {
118 Ok((
119 self.keys.dilithium.decode_bytes("dilithium")?,
120 self.keys.falcon.decode_bytes("falcon")?,
121 self.keys.sphincs.decode_bytes("sphincs")?,
122 ))
123 }
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129
130 const FIXTURE_JSON: &str = r#"{
131 "epoch": "h33-substrate-abcdef1234567890",
132 "is_current": true,
133 "rotation_history": ["h33-substrate-abcdef1234567890"],
134 "keys": {
135 "dilithium": {
136 "algorithm": "ML-DSA-65",
137 "format": "raw",
138 "key_b64": "aGVsbG8gd29ybGQ="
139 },
140 "falcon": {
141 "algorithm": "FALCON-512",
142 "format": "raw",
143 "key_b64": "Zm9vYmFy"
144 },
145 "sphincs": {
146 "algorithm": "SPHINCS+-SHA2-128f",
147 "format": "raw",
148 "key_b64": "YmF6"
149 }
150 }
151 }"#;
152
153 #[test]
154 fn parses_fixture_json() {
155 let parsed = PublicKeysResponse::from_json(FIXTURE_JSON).unwrap();
156 assert_eq!(parsed.epoch, "h33-substrate-abcdef1234567890");
157 assert!(parsed.is_current);
158 assert_eq!(parsed.rotation_history.len(), 1);
159 assert_eq!(parsed.keys.dilithium.algorithm, "ML-DSA-65");
160 assert_eq!(parsed.keys.falcon.algorithm, "FALCON-512");
161 assert_eq!(parsed.keys.sphincs.algorithm, "SPHINCS+-SHA2-128f");
162 }
163
164 #[test]
165 fn decodes_base64_bytes() {
166 let parsed = PublicKeysResponse::from_json(FIXTURE_JSON).unwrap();
167 let (dil, fal, sph) = parsed.decode_all().unwrap();
168 assert_eq!(dil, b"hello world");
169 assert_eq!(fal, b"foobar");
170 assert_eq!(sph, b"baz");
171 }
172
173 #[test]
174 fn rejects_malformed_json() {
175 let bad = "{not valid json";
176 assert!(matches!(
177 PublicKeysResponse::from_json(bad),
178 Err(VerifierError::PublicKeysParse(_))
179 ));
180 }
181
182 #[test]
183 fn rejects_bad_base64_in_key() {
184 let bad_json = r#"{
185 "epoch": "e",
186 "is_current": true,
187 "rotation_history": ["e"],
188 "keys": {
189 "dilithium": { "algorithm": "ML-DSA-65", "format": "raw", "key_b64": "@@@@" },
190 "falcon": { "algorithm": "FALCON-512", "format": "raw", "key_b64": "Zm9v" },
191 "sphincs": { "algorithm": "SPHINCS+-SHA2-128f", "format": "raw", "key_b64": "YmF6" }
192 }
193 }"#;
194 let parsed = PublicKeysResponse::from_json(bad_json).unwrap();
195 let err = parsed.decode_all().unwrap_err();
196 assert!(matches!(
197 err,
198 VerifierError::PublicKeysBase64 {
199 field: "dilithium",
200 ..
201 }
202 ));
203 }
204}