crypto_vote/encoding.rs
1//! Human-friendly "prefixed" encoding for the public data types.
2//!
3//! This module sits *on top of* the canonical byte / hex encoding in
4//! [`crate::types`]. It does not change a single bit of what is hashed,
5//! signed or verified — it is a pure presentation layer whose only job is
6//! to make a copy-pasted value:
7//!
8//! - **self-describing** — a short tag (`pk`, `sk`, `ki`, `blsag`) up
9//! front says what kind of value it is, so a public key pasted where a
10//! key image was expected is caught immediately;
11//! - **typo-resistant** — a trailing checksum detects the overwhelming
12//! majority of single-character mistakes, transpositions and truncated
13//! pastes before the bytes ever reach the cryptographic core.
14//!
15//! ## Wire shape
16//!
17//! ```text
18//! pk_3f8a…e1c0_d4e9a1b7
19//! │ │ │
20//! │ │ └ checksum: 4 bytes, hex (8 chars)
21//! │ └ body: the canonical hex encoding, exactly as `to_hex()` emits it
22//! └ tag: pk | sk | ki | blsag
23//! ```
24//!
25//! Three `_`-separated parts. None of the parts can itself contain a `_`
26//! (the tags are fixed, the other two are hexadecimal), so splitting on
27//! `_` is unambiguous.
28//!
29//! ## Checksum
30//!
31//! `checksum = BLAKE3(DOMAIN || tag || 0x00 || payload)[..4]`.
32//!
33//! The tag is folded into the checksum pre-image (with a `0x00`
34//! separator so no tag can be confused with a prefix of another), which
35//! is what makes relabelling detectable: take a valid `pk_…` string,
36//! rewrite the tag to `ki_`, and the checksum no longer matches. So one
37//! check covers both "is the prefix coherent?" and "was the value
38//! mistyped?".
39//!
40//! This checksum is **not** a security primitive. An attacker can
41//! trivially compute a valid checksum for any bytes they like; its only
42//! purpose is to catch honest mistakes. Authenticity comes from the
43//! BLSAG proof, never from this.
44
45use crate::error::{Error, Result};
46use zeroize::Zeroizing;
47
48/// Length of the checksum in bytes (hex-encoded to twice this many chars).
49const CHECKSUM_LEN: usize = 4;
50
51/// Domain-separation string mixed into every checksum. Bumping the
52/// trailing version would invalidate previously-issued strings, so it is
53/// part of the format's compatibility contract.
54const DOMAIN: &[u8] = b"crypto_vote/prefixed-checksum/v1";
55
56/// Which kind of value a prefixed string carries. The string form is the
57/// human-readable prefix; it is also folded into the checksum.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum Tag {
60 /// A [`crate::PublicKey`] — prefix `pk`.
61 PublicKey,
62 /// A [`crate::SecretKey`] — prefix `sk`.
63 SecretKey,
64 /// A [`crate::KeyImage`] — prefix `ki`.
65 KeyImage,
66 /// A [`crate::Signature`] — prefix `blsag`.
67 Signature,
68 /// An [`crate::OwnershipProof`] — prefix `own`.
69 Ownership,
70 /// A [`crate::Nonce`] — prefix `nonce`.
71 Nonce,
72}
73
74impl Tag {
75 /// The human-readable prefix for this tag (no trailing `_`).
76 pub const fn as_str(self) -> &'static str {
77 match self {
78 Tag::PublicKey => "pk",
79 Tag::SecretKey => "sk",
80 Tag::KeyImage => "ki",
81 Tag::Signature => "blsag",
82 Tag::Ownership => "own",
83 Tag::Nonce => "nonce",
84 }
85 }
86}
87
88/// Compute the 4-byte checksum for `payload` under `tag`.
89fn checksum(tag: Tag, payload: &[u8]) -> [u8; CHECKSUM_LEN] {
90 let mut hasher = blake3::Hasher::new();
91 hasher.update(DOMAIN);
92 hasher.update(tag.as_str().as_bytes());
93 // A separator the tag can never contain, so `pk` || payload can never
94 // collide with some other (tag, payload) pairing.
95 hasher.update(&[0u8]);
96 hasher.update(payload);
97 let hash = hasher.finalize();
98 let mut out = [0u8; CHECKSUM_LEN];
99 out.copy_from_slice(&hash.as_bytes()[..CHECKSUM_LEN]);
100 out
101}
102
103/// Encode `payload` as `tag_<hexbody>_<hexchecksum>`.
104///
105/// `payload` is the canonical byte encoding of the value (what
106/// `to_bytes()` returns). The body is its lowercase hex, identical to
107/// what `to_hex()` would emit.
108pub fn encode_prefixed(tag: Tag, payload: &[u8]) -> String {
109 let cs = checksum(tag, payload);
110 format!(
111 "{}_{}_{}",
112 tag.as_str(),
113 hex::encode(payload),
114 hex::encode(cs)
115 )
116}
117
118/// Decode a `tag_<hexbody>_<hexchecksum>` string, verifying both the tag
119/// and the checksum, and return the raw payload bytes.
120///
121/// The returned bytes are wrapped in [`Zeroizing`] so that decoding a
122/// secret key does not leave a readable copy on the heap once the caller
123/// is done with it.
124///
125/// Errors:
126/// - [`Error::InvalidPrefix`] if the string is not in three-part shape,
127/// or its tag is not the one expected for this type;
128/// - [`Error::InvalidHex`] if the body or checksum part is not hex;
129/// - [`Error::InvalidChecksum`] if the checksum is the wrong length or
130/// does not match the recomputed value.
131pub fn decode_prefixed(expected: Tag, s: &str) -> Result<Zeroizing<Vec<u8>>> {
132 let parts: Vec<&str> = s.split('_').collect();
133 // Not the `tag_body_checksum` shape at all — e.g. a bare hex string,
134 // or one with too many separators. Deliberately do *not* echo the
135 // input back: it could be a secret key, and an error `Display` should
136 // never leak one.
137 let [tag_str, body_hex, cs_hex] = parts.as_slice() else {
138 return Err(Error::InvalidPrefix {
139 expected: expected.as_str(),
140 got: String::new(),
141 });
142 };
143
144 if *tag_str != expected.as_str() {
145 // Safe to echo: a real tag is short and never carries the body.
146 return Err(Error::InvalidPrefix {
147 expected: expected.as_str(),
148 got: (*tag_str).to_owned(),
149 });
150 }
151
152 let payload = Zeroizing::new(hex::decode(body_hex).map_err(|_| Error::InvalidHex)?);
153 let provided = hex::decode(cs_hex).map_err(|_| Error::InvalidHex)?;
154 if provided.len() != CHECKSUM_LEN {
155 return Err(Error::InvalidChecksum);
156 }
157 if provided[..] != checksum(expected, &payload)[..] {
158 return Err(Error::InvalidChecksum);
159 }
160 Ok(payload)
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 #[test]
168 fn round_trips_every_tag() {
169 let payload = [7u8; 32];
170 for tag in [
171 Tag::PublicKey,
172 Tag::SecretKey,
173 Tag::KeyImage,
174 Tag::Signature,
175 Tag::Ownership,
176 Tag::Nonce,
177 ] {
178 let s = encode_prefixed(tag, &payload);
179 assert!(s.starts_with(tag.as_str()));
180 let back = decode_prefixed(tag, &s).unwrap();
181 assert_eq!(&back[..], &payload[..]);
182 }
183 }
184
185 #[test]
186 fn shape_is_tag_body_checksum() {
187 let s = encode_prefixed(Tag::PublicKey, &[0xab; 32]);
188 let parts: Vec<&str> = s.split('_').collect();
189 assert_eq!(parts.len(), 3);
190 assert_eq!(parts[0], "pk");
191 assert_eq!(parts[1].len(), 64); // 32 bytes hex
192 assert_eq!(parts[2].len(), 8); // 4 bytes hex
193 }
194
195 #[test]
196 fn rejects_wrong_tag() {
197 // A value encoded as a public key must not decode as a key image,
198 // even though both are 32-byte payloads.
199 let s = encode_prefixed(Tag::PublicKey, &[1u8; 32]);
200 let err = decode_prefixed(Tag::KeyImage, &s).unwrap_err();
201 assert_eq!(
202 err,
203 Error::InvalidPrefix {
204 expected: "ki",
205 got: "pk".to_owned()
206 }
207 );
208 }
209
210 #[test]
211 fn rejects_relabelled_value() {
212 // Take a valid pk string and rewrite the tag to ki_; the checksum
213 // (which is bound to the tag) must now fail.
214 let s = encode_prefixed(Tag::PublicKey, &[2u8; 32]);
215 let relabelled = format!("ki{}", &s["pk".len()..]);
216 assert_eq!(
217 decode_prefixed(Tag::KeyImage, &relabelled).unwrap_err(),
218 Error::InvalidChecksum
219 );
220 }
221
222 #[test]
223 fn rejects_corrupted_checksum() {
224 let s = encode_prefixed(Tag::Signature, &[3u8; 96]);
225 // Flip the last hex digit of the checksum.
226 let mut bytes = s.into_bytes();
227 let last = bytes.last_mut().unwrap();
228 *last = if *last == b'0' { b'1' } else { b'0' };
229 let corrupted = String::from_utf8(bytes).unwrap();
230 assert_eq!(
231 decode_prefixed(Tag::Signature, &corrupted).unwrap_err(),
232 Error::InvalidChecksum
233 );
234 }
235
236 #[test]
237 fn rejects_corrupted_body() {
238 let s = encode_prefixed(Tag::PublicKey, &[4u8; 32]);
239 // Flip a digit in the body (part index 1).
240 let mut parts: Vec<String> = s.split('_').map(|p| p.to_owned()).collect();
241 let body = &mut parts[1];
242 let first = body.remove(0);
243 body.insert(0, if first == 'a' { 'b' } else { 'a' });
244 let corrupted = parts.join("_");
245 assert_eq!(
246 decode_prefixed(Tag::PublicKey, &corrupted).unwrap_err(),
247 Error::InvalidChecksum
248 );
249 }
250
251 #[test]
252 fn rejects_bare_hex_without_leaking_it() {
253 // A bare hex string (no prefix) is rejected, and the error must
254 // not contain the input — it might be a secret key.
255 let secret_like = "ab".repeat(32);
256 let err = decode_prefixed(Tag::SecretKey, &secret_like).unwrap_err();
257 match err {
258 Error::InvalidPrefix { got, .. } => assert!(got.is_empty()),
259 other => panic!("expected InvalidPrefix, got {other:?}"),
260 }
261 }
262
263 #[test]
264 fn rejects_non_hex_parts() {
265 assert_eq!(
266 decode_prefixed(Tag::PublicKey, "pk_zzzz_d4e9a1b7").unwrap_err(),
267 Error::InvalidHex
268 );
269 }
270}