Skip to main content

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}