Line data Source code
1 : //! Shared cryptographic utilities for `TinyCongress`
2 : //!
3 : //! This crate provides cryptographic functions used by both the Rust backend
4 : //! (as a native library) and the TypeScript frontend (compiled to WASM).
5 :
6 : use base64::engine::general_purpose::URL_SAFE_NO_PAD;
7 : use base64::Engine;
8 : use sha2::{Digest, Sha256};
9 : use wasm_bindgen::prelude::*;
10 :
11 : /// Error type for base64url decoding failures
12 : #[derive(Debug, thiserror::Error)]
13 : #[error("invalid base64url encoding: {0}")]
14 : pub struct DecodeError(#[from] base64::DecodeError);
15 :
16 : /// Derive a key identifier (KID) from a public key.
17 : ///
18 : /// The KID is computed as: `base64url(SHA-256(pubkey)[0:16])`
19 : ///
20 : /// This produces a ~22 character string that uniquely identifies a public key
21 : /// while being shorter than the full hash.
22 : ///
23 : /// # Arguments
24 : /// * `public_key` - The public key bytes (typically 32 bytes for Ed25519)
25 : ///
26 : /// # Returns
27 : /// A base64url-encoded string (without padding) of the first 16 bytes of the SHA-256 hash
28 : #[wasm_bindgen]
29 : #[must_use]
30 23 : pub fn derive_kid(public_key: &[u8]) -> String {
31 23 : let hash = Sha256::digest(public_key);
32 : // Truncate to first 16 bytes for shorter KIDs
33 23 : URL_SAFE_NO_PAD.encode(&hash[..16])
34 23 : }
35 :
36 : /// Encode bytes as base64url (RFC 4648) without padding.
37 : ///
38 : /// # Arguments
39 : /// * `bytes` - The bytes to encode
40 : ///
41 : /// # Returns
42 : /// A base64url-encoded string without padding characters
43 : #[wasm_bindgen]
44 : #[must_use]
45 16 : pub fn encode_base64url(bytes: &[u8]) -> String {
46 16 : URL_SAFE_NO_PAD.encode(bytes)
47 16 : }
48 :
49 : /// Decode a base64url-encoded string (RFC 4648) to bytes.
50 : ///
51 : /// Accepts input with or without padding.
52 : ///
53 : /// # Arguments
54 : /// * `encoded` - The base64url-encoded string
55 : ///
56 : /// # Returns
57 : /// The decoded bytes
58 : ///
59 : /// # Errors
60 : /// Returns `JsError` if the input is not valid base64url
61 : #[wasm_bindgen]
62 0 : pub fn decode_base64url(encoded: &str) -> Result<Vec<u8>, JsError> {
63 0 : URL_SAFE_NO_PAD
64 0 : .decode(encoded)
65 0 : .map_err(|e| JsError::new(&e.to_string()))
66 0 : }
67 :
68 : /// Native Rust API for decoding base64url (returns proper Rust error type)
69 : ///
70 : /// # Errors
71 : /// Returns `DecodeError` if the input is not valid base64url
72 11 : pub fn decode_base64url_native(encoded: &str) -> Result<Vec<u8>, DecodeError> {
73 11 : URL_SAFE_NO_PAD.decode(encoded).map_err(DecodeError::from)
74 11 : }
75 :
76 : #[cfg(test)]
77 : mod tests {
78 : use super::*;
79 :
80 : #[test]
81 : fn test_derive_kid_deterministic() {
82 : let pubkey = [1u8; 32];
83 : let kid1 = derive_kid(&pubkey);
84 : let kid2 = derive_kid(&pubkey);
85 : assert_eq!(kid1, kid2);
86 : }
87 :
88 : #[test]
89 : fn test_derive_kid_length() {
90 : let pubkey = [0u8; 32];
91 : let kid = derive_kid(&pubkey);
92 : // 16 bytes -> ~22 base64 chars (without padding)
93 : assert!(kid.len() >= 21 && kid.len() <= 22);
94 : }
95 :
96 : #[test]
97 : fn test_derive_kid_known_vector() {
98 : // Test vector: all-ones pubkey should produce consistent KID
99 : let pubkey = [1u8; 32];
100 : let kid = derive_kid(&pubkey);
101 : // This is the expected output - if this changes, the algorithm changed
102 : assert_eq!(kid, "cs1uhCLEB_ttCYaQ8RMLfQ");
103 : }
104 :
105 : #[test]
106 : fn test_encode_base64url() {
107 : let bytes = b"Hello";
108 : let encoded = encode_base64url(bytes);
109 : assert_eq!(encoded, "SGVsbG8");
110 : }
111 :
112 : #[test]
113 : fn test_decode_base64url_native() {
114 : let encoded = "SGVsbG8";
115 : let decoded = decode_base64url_native(encoded).expect("decode should succeed");
116 : assert_eq!(decoded, b"Hello");
117 : }
118 :
119 : #[test]
120 : fn test_roundtrip() {
121 : let original = b"test data for roundtrip";
122 : let encoded = encode_base64url(original);
123 : let decoded = decode_base64url_native(&encoded).expect("decode should succeed");
124 : assert_eq!(decoded, original);
125 : }
126 :
127 : #[test]
128 : fn test_decode_invalid_base64url() {
129 : let invalid = "not valid base64!!!";
130 : let result = decode_base64url_native(invalid);
131 : assert!(result.is_err());
132 : }
133 : }
134 :
135 : #[cfg(test)]
136 : mod proptests {
137 : use super::*;
138 : use proptest::prelude::*;
139 :
140 : proptest! {
141 : /// Any byte sequence can be encoded and decoded back to the original
142 : #[test]
143 : fn roundtrip_encode_decode(bytes: Vec<u8>) {
144 : let encoded = encode_base64url(&bytes);
145 : let decoded = decode_base64url_native(&encoded).unwrap();
146 : prop_assert_eq!(decoded, bytes);
147 : }
148 :
149 : /// KID derivation is deterministic - same input always produces same output
150 : #[test]
151 : fn derive_kid_deterministic(pubkey: Vec<u8>) {
152 : let kid1 = derive_kid(&pubkey);
153 : let kid2 = derive_kid(&pubkey);
154 : prop_assert_eq!(kid1, kid2);
155 : }
156 :
157 : /// KID output length is always 21-22 chars (16 bytes base64url encoded)
158 : #[test]
159 : fn derive_kid_length_invariant(pubkey: Vec<u8>) {
160 : let kid = derive_kid(&pubkey);
161 : prop_assert!(kid.len() >= 21 && kid.len() <= 22,
162 : "KID length {} not in expected range 21-22", kid.len());
163 : }
164 :
165 : /// Encoded output contains only valid base64url characters
166 : #[test]
167 : fn encode_produces_valid_base64url_chars(bytes: Vec<u8>) {
168 : let encoded = encode_base64url(&bytes);
169 : prop_assert!(encoded.chars().all(|c|
170 : c.is_ascii_alphanumeric() || c == '-' || c == '_'
171 : ));
172 : }
173 : }
174 : }
|