Skip to main content

entracte_lib/supporter/
manual.rs

1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
2use chrono::{DateTime, TimeZone, Utc};
3use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey, SIGNATURE_LENGTH};
4
5pub const TOKEN_PREFIX: &str = "ENT1-";
6const TOKEN_VERSION: u8 = 1;
7const MAX_NAME_BYTES: usize = 1024;
8
9/// Embedded public key (hex) used to verify manual licences in production
10/// builds. Replace this with the public half of the keypair you generate
11/// via `cargo run --bin issue-license -- generate`; keep the private half
12/// out of the repo.
13///
14/// The all-zero placeholder is rejected by `embedded_verifying_key()` so
15/// dev builds fail closed rather than accepting forgeries.
16const EMBEDDED_PUBLIC_KEY_HEX: &str =
17    "2ee366ddb411a7181b40c57b2469902be5f36984817adadf56aa0c4fd2dc0589";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct ManualLicense {
21    pub name: String,
22    pub issued_at: DateTime<Utc>,
23}
24
25pub fn looks_like_manual_token(s: &str) -> bool {
26    s.trim().starts_with(TOKEN_PREFIX)
27}
28
29pub fn sign(signing_key: &SigningKey, license: &ManualLicense) -> Result<String, String> {
30    let message = encode_message(license)?;
31    let signature = signing_key.sign(&message);
32    let mut wire = Vec::with_capacity(message.len() + SIGNATURE_LENGTH);
33    wire.extend_from_slice(&message);
34    wire.extend_from_slice(&signature.to_bytes());
35    Ok(format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&wire)))
36}
37
38pub fn verify(token: &str) -> Result<ManualLicense, String> {
39    verify_with(token, &embedded_verifying_key()?)
40}
41
42pub fn verify_with(token: &str, verifying_key: &VerifyingKey) -> Result<ManualLicense, String> {
43    let trimmed = token.trim();
44    let payload = trimmed
45        .strip_prefix(TOKEN_PREFIX)
46        .ok_or_else(|| "not an Entracte manual token".to_string())?;
47    let wire = URL_SAFE_NO_PAD
48        .decode(payload)
49        .map_err(|e| format!("manual token is not valid base64: {e}"))?;
50    if wire.len() < SIGNATURE_LENGTH {
51        return Err("manual token is truncated".to_string());
52    }
53    let split = wire.len() - SIGNATURE_LENGTH;
54    let (message, sig_bytes) = wire.split_at(split);
55    let signature_array: [u8; SIGNATURE_LENGTH] = sig_bytes
56        .try_into()
57        .map_err(|_| "manual token signature has wrong length".to_string())?;
58    let signature = Signature::from_bytes(&signature_array);
59    verifying_key
60        .verify(message, &signature)
61        .map_err(|_| "manual token signature does not verify".to_string())?;
62    decode_message(message)
63}
64
65pub(crate) fn encode_message(license: &ManualLicense) -> Result<Vec<u8>, String> {
66    let name_bytes = license.name.as_bytes();
67    if name_bytes.len() > MAX_NAME_BYTES {
68        return Err(format!(
69            "name is {} bytes; manual tokens cap at {MAX_NAME_BYTES}",
70            name_bytes.len()
71        ));
72    }
73    let issued = license.issued_at.timestamp();
74    let mut out = Vec::with_capacity(11 + name_bytes.len());
75    out.push(TOKEN_VERSION);
76    out.extend_from_slice(&issued.to_be_bytes());
77    out.extend_from_slice(&(name_bytes.len() as u16).to_be_bytes());
78    out.extend_from_slice(name_bytes);
79    Ok(out)
80}
81
82fn decode_message(bytes: &[u8]) -> Result<ManualLicense, String> {
83    if bytes.len() < 11 {
84        return Err("manual token payload is truncated".to_string());
85    }
86    let version = bytes[0];
87    if version != TOKEN_VERSION {
88        return Err(format!("unsupported manual token version {version}"));
89    }
90    let mut ts_buf = [0u8; 8];
91    ts_buf.copy_from_slice(&bytes[1..9]);
92    let issued = i64::from_be_bytes(ts_buf);
93    let mut len_buf = [0u8; 2];
94    len_buf.copy_from_slice(&bytes[9..11]);
95    let name_len = u16::from_be_bytes(len_buf) as usize;
96    if bytes.len() != 11 + name_len {
97        return Err("manual token payload length mismatch".to_string());
98    }
99    if name_len > MAX_NAME_BYTES {
100        return Err("manual token name exceeds maximum length".to_string());
101    }
102    let name = std::str::from_utf8(&bytes[11..])
103        .map_err(|_| "manual token name is not valid UTF-8".to_string())?
104        .to_string();
105    let issued_at = Utc
106        .timestamp_opt(issued, 0)
107        .single()
108        .ok_or_else(|| "manual token issued_at is out of range".to_string())?;
109    Ok(ManualLicense { name, issued_at })
110}
111
112fn embedded_verifying_key() -> Result<VerifyingKey, String> {
113    parse_verifying_key_hex(EMBEDDED_PUBLIC_KEY_HEX)
114}
115
116fn parse_verifying_key_hex(hex_str: &str) -> Result<VerifyingKey, String> {
117    let bytes = hex::decode(hex_str)
118        .map_err(|e| format!("embedded manual-license public key is not valid hex: {e}"))?;
119    let array: [u8; 32] = bytes
120        .as_slice()
121        .try_into()
122        .map_err(|_| "embedded manual-license public key must be 32 bytes".to_string())?;
123    if array == [0u8; 32] {
124        return Err(
125            "manual licence verification disabled: placeholder public key not replaced".to_string(),
126        );
127    }
128    VerifyingKey::from_bytes(&array)
129        .map_err(|e| format!("embedded manual-license public key is not a valid Ed25519 key: {e}"))
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn fresh_keypair() -> (SigningKey, VerifyingKey) {
137        let mut seed = [0u8; 32];
138        getrandom::getrandom(&mut seed).unwrap();
139        let signing = SigningKey::from_bytes(&seed);
140        let verifying = signing.verifying_key();
141        (signing, verifying)
142    }
143
144    #[test]
145    fn looks_like_manual_token_matches_prefix() {
146        assert!(looks_like_manual_token("ENT1-abc"));
147        assert!(looks_like_manual_token("  ENT1-abc  "));
148        assert!(!looks_like_manual_token("XXXX-1111-2222-3333"));
149        assert!(!looks_like_manual_token(""));
150    }
151
152    #[test]
153    fn sign_then_verify_round_trip() {
154        let (sk, vk) = fresh_keypair();
155        let license = ManualLicense {
156            name: "Jane Doe".to_string(),
157            issued_at: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
158        };
159        let token = sign(&sk, &license).unwrap();
160        assert!(token.starts_with(TOKEN_PREFIX));
161        let got = verify_with(&token, &vk).unwrap();
162        assert_eq!(got, license);
163    }
164
165    #[test]
166    fn verify_rejects_token_signed_with_other_key() {
167        let (sk_a, _) = fresh_keypair();
168        let (_, vk_b) = fresh_keypair();
169        let license = ManualLicense {
170            name: "Contributor".to_string(),
171            issued_at: Utc::now(),
172        };
173        let token = sign(&sk_a, &license).unwrap();
174        let err = verify_with(&token, &vk_b).unwrap_err();
175        assert!(err.contains("does not verify"), "got: {err}");
176    }
177
178    #[test]
179    fn verify_rejects_tampered_name() {
180        let (sk, vk) = fresh_keypair();
181        let license = ManualLicense {
182            name: "Alice".to_string(),
183            issued_at: Utc.timestamp_opt(1_700_000_000, 0).single().unwrap(),
184        };
185        let token = sign(&sk, &license).unwrap();
186        // Flip one bit inside the payload body (after the prefix).
187        let mut wire = URL_SAFE_NO_PAD
188            .decode(token.strip_prefix(TOKEN_PREFIX).unwrap())
189            .unwrap();
190        wire[12] ^= 0x01;
191        let tampered = format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&wire));
192        let err = verify_with(&tampered, &vk).unwrap_err();
193        assert!(err.contains("does not verify"), "got: {err}");
194    }
195
196    #[test]
197    fn verify_rejects_wrong_prefix() {
198        let (_, vk) = fresh_keypair();
199        let err = verify_with("LEMON-1234", &vk).unwrap_err();
200        assert!(err.contains("not an Entracte manual token"), "got: {err}");
201    }
202
203    #[test]
204    fn verify_rejects_truncated_token() {
205        let (_, vk) = fresh_keypair();
206        let err = verify_with("ENT1-aGVsbG8", &vk).unwrap_err();
207        assert!(err.contains("truncated"), "got: {err}");
208    }
209
210    #[test]
211    fn parse_verifying_key_rejects_all_zero_placeholder() {
212        let err = parse_verifying_key_hex(
213            "0000000000000000000000000000000000000000000000000000000000000000",
214        )
215        .unwrap_err();
216        assert!(err.contains("placeholder"), "got: {err}");
217    }
218
219    #[test]
220    fn parse_verifying_key_rejects_wrong_length() {
221        let err = parse_verifying_key_hex("deadbeef").unwrap_err();
222        assert!(err.contains("32 bytes"), "got: {err}");
223    }
224
225    #[test]
226    fn encode_message_caps_name_length() {
227        let huge = "a".repeat(MAX_NAME_BYTES + 1);
228        let err = encode_message(&ManualLicense {
229            name: huge,
230            issued_at: Utc::now(),
231        })
232        .unwrap_err();
233        assert!(err.contains("cap"), "got: {err}");
234    }
235
236    fn sign_raw_message(sk: &SigningKey, message: &[u8]) -> String {
237        let signature = sk.sign(message);
238        let mut wire = Vec::with_capacity(message.len() + SIGNATURE_LENGTH);
239        wire.extend_from_slice(message);
240        wire.extend_from_slice(&signature.to_bytes());
241        format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(&wire))
242    }
243
244    #[test]
245    fn verify_rejects_payload_shorter_than_header() {
246        let (sk, vk) = fresh_keypair();
247        let token = sign_raw_message(&sk, &[0x01, 0, 0, 0, 0]);
248        let err = verify_with(&token, &vk).unwrap_err();
249        assert!(err.contains("truncated"), "got: {err}");
250    }
251
252    #[test]
253    fn verify_rejects_unknown_version_byte() {
254        let (sk, vk) = fresh_keypair();
255        let mut message = vec![0x02_u8];
256        message.extend_from_slice(&0_i64.to_be_bytes());
257        message.extend_from_slice(&0_u16.to_be_bytes());
258        let token = sign_raw_message(&sk, &message);
259        let err = verify_with(&token, &vk).unwrap_err();
260        assert!(err.contains("unsupported"), "got: {err}");
261    }
262
263    #[test]
264    fn verify_rejects_name_length_mismatch() {
265        let (sk, vk) = fresh_keypair();
266        let mut message = vec![TOKEN_VERSION];
267        message.extend_from_slice(&0_i64.to_be_bytes());
268        message.extend_from_slice(&50_u16.to_be_bytes());
269        let token = sign_raw_message(&sk, &message);
270        let err = verify_with(&token, &vk).unwrap_err();
271        assert!(err.contains("length mismatch"), "got: {err}");
272    }
273
274    #[test]
275    fn verify_rejects_name_exceeding_max() {
276        // encode_message refuses to produce oversized tokens, so we
277        // assemble the wire bytes by hand to reach the decoder branch.
278        let (sk, vk) = fresh_keypair();
279        let oversized = (MAX_NAME_BYTES + 1) as u16;
280        let mut message = vec![TOKEN_VERSION];
281        message.extend_from_slice(&0_i64.to_be_bytes());
282        message.extend_from_slice(&oversized.to_be_bytes());
283        message.extend(std::iter::repeat_n(b'a', oversized as usize));
284        let token = sign_raw_message(&sk, &message);
285        let err = verify_with(&token, &vk).unwrap_err();
286        assert!(err.contains("maximum length"), "got: {err}");
287    }
288}