offline_protocol_sealed/
derive.rs1use crate::{Result, SealedError};
10use alloc::format;
11use offline_protocol_core::Address;
12
13pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
15
16pub fn derive_address(public_key: &[u8]) -> Result<Address> {
40 use sha2::{Digest, Sha256};
41
42 if public_key.len() != ED25519_PUBLIC_KEY_LEN {
43 return Err(SealedError::InvalidPublicKey(format!(
44 "Ed25519 public key must be {} bytes, got {}",
45 ED25519_PUBLIC_KEY_LEN,
46 public_key.len()
47 )));
48 }
49
50 let hash = Sha256::digest(public_key);
51 let mut truncated = [0u8; Address::HASH_LEN];
52 truncated.copy_from_slice(&hash[..Address::HASH_LEN]);
53 Ok(Address::from_hash_bytes(truncated))
54}
55
56#[cfg(all(test, feature = "std"))]
57mod tests {
58 use super::*;
59 use alloc::string::ToString;
60
61 const RFC8032_TV1_PK: [u8; 32] = [
63 0xd7, 0x5a, 0x98, 0x01, 0x82, 0xb1, 0x0a, 0xb7, 0xd5, 0x4b, 0xfe, 0xd3, 0xc9, 0x64, 0x07,
64 0x3a, 0x0e, 0xe1, 0x72, 0xf3, 0xda, 0xa6, 0x23, 0x25, 0xaf, 0x02, 0x1a, 0x68, 0xf7, 0x07,
65 0x51, 0x1a,
66 ];
67
68 const RFC8032_TV1_ADDRESS: &str = "off1qysluvwl5922yctzd0u9gpr06gn3k7ldfvgtwgvn";
73
74 #[test]
75 fn derive_address_matches_the_pinned_vector() {
76 let address = derive_address(&RFC8032_TV1_PK).expect("32-byte key derives");
77 assert_eq!(address.to_string(), RFC8032_TV1_ADDRESS);
78 assert_eq!(
79 RFC8032_TV1_ADDRESS.parse::<Address>().expect("parses"),
80 address,
81 "the pinned string must round-trip back to the same address"
82 );
83 }
84
85 #[test]
86 fn derive_address_rejects_wrong_key_lengths() {
87 for len in [0usize, 31, 33, 64] {
88 let err = derive_address(&vec![0u8; len]).unwrap_err();
89 assert!(
90 matches!(err, SealedError::InvalidPublicKey(_)),
91 "{len}-byte key must be refused, got: {err}"
92 );
93 }
94 }
95}
96
97#[cfg(all(test, feature = "std"))]
108mod spec_vectors {
109 use super::*;
110 use crate::envelope::GroupId;
111
112 const VECTORS: &str = include_str!("../tests/data/derive-address-v1.vectors.json");
113
114 fn vectors() -> serde_json::Value {
115 serde_json::from_str(VECTORS).expect("the vector file is JSON")
116 }
117
118 fn unhex(s: &str) -> Vec<u8> {
119 (0..s.len())
120 .step_by(2)
121 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
122 .collect()
123 }
124
125 #[test]
128 fn the_vector_file_is_the_size_it_was() {
129 let v = vectors();
130 assert_eq!(v["derive"].as_array().expect("derive").len(), 5);
131 assert_eq!(v["sessions"].as_array().expect("sessions").len(), 3);
132 assert!(
133 v["sessions"]
134 .as_array()
135 .expect("sessions")
136 .iter()
137 .any(|s| s["orders_disagree"] == true),
138 "at least one session pair must be one where hash order and string \
139 order disagree, or the file cannot catch the ordering bug it exists \
140 to catch"
141 );
142 }
143
144 #[test]
145 fn every_public_key_derives_its_address() {
146 for case in vectors()["derive"].as_array().expect("derive") {
147 let name = case["name"].as_str().expect("a name");
148 let pk = unhex(case["public_key_hex"].as_str().expect("a public key"));
149 let got = derive_address(&pk).unwrap_or_else(|e| panic!("[{name}]: {e}"));
150 assert_eq!(
151 got.to_string(),
152 case["address"].as_str().expect("an address"),
153 "[{name}] derived a different address than the chapter specifies"
154 );
155 }
156 }
157
158 #[test]
165 fn every_pair_derives_its_session_slot() {
166 for case in vectors()["sessions"].as_array().expect("sessions") {
167 let a = case["a"].as_str().expect("a");
168 let b = case["b"].as_str().expect("b");
169 let want = case["session_id"].as_str().expect("a slot");
170
171 let got = GroupId::for_session(a, b).expect("a slot");
172 assert_eq!(got.as_str(), want, "session slot for {a} and {b}");
173
174 let swapped = GroupId::for_session(b, a).expect("a slot");
175 assert_eq!(
176 swapped.as_str(),
177 want,
178 "the slot is not symmetric, so the two parties would name \
179 different sessions"
180 );
181
182 if case["orders_disagree"] == true {
183 assert_ne!(
184 want,
185 case["session_id_if_ordered_by_string"]
186 .as_str()
187 .expect("the string-ordered slot"),
188 "this pair no longer distinguishes the two orderings"
189 );
190 }
191 }
192 }
193
194 fn chapter() -> Option<String> {
197 let path =
198 std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/identity.md");
199 std::fs::read_to_string(&path).ok().or_else(|| {
200 eprintln!("spec tree not present, skipping the identity chapter drift checks");
201 None
202 })
203 }
204
205 #[test]
206 fn the_chapter_states_the_derivation_the_code_performs() {
207 let Some(text) = chapter() else { return };
208
209 assert!(
210 text.contains("SHA-256(ed25519_public_key)[0..20]"),
211 "the chapter no longer states the derivation this function computes"
212 );
213 assert!(
214 text.contains("session:\" || lower || \":\" || higher"),
215 "the chapter no longer states the session slot construction"
216 );
217 assert!(
218 text.contains(&format!("{ED25519_PUBLIC_KEY_LEN}")),
219 "the chapter does not state the {ED25519_PUBLIC_KEY_LEN}-byte key length"
220 );
221 }
222}