Skip to main content

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}