Skip to main content

offline_protocol_sealed/
derive.rs

1//! The SDK's one address derivation.
2//!
3//! An address is the truncated hash of an identity key, so anything carrying
4//! both is self-certifying: a recipient re-derives and compares rather than
5//! consulting a directory. Every trust gate in the protocol is that
6//! comparison, which is why there is exactly one implementation of it and why
7//! it lives where both a phone and a leaf node can reach it.
8
9use crate::{Result, SealedError};
10use alloc::format;
11use offline_protocol_core::Address;
12
13/// Length of an Ed25519 public key, in bytes.
14pub const ED25519_PUBLIC_KEY_LEN: usize = 32;
15
16/// Derives the canonical self-certifying address of an Ed25519 identity
17/// key: `off1…`, the bech32m encoding of
18/// `0x01 ‖ SHA-256(public_key)[..20]`.
19///
20/// This is the only address derivation in the SDK. The MLS crate exposes it as
21/// `MlsManager::derive_address`, bridges and apps reach that through the
22/// `derive_address` FFI function, and a leaf node calls this directly, so that
23/// every platform agrees byte for byte.
24///
25/// # Arguments
26///
27/// * `public_key` - The Ed25519 public key bytes (exactly 32)
28///
29/// # Errors
30///
31/// Returns [`SealedError::InvalidPublicKey`] if `public_key` is not 32 bytes.
32/// The length is part of the format contract: hashing a differently-sized
33/// input would yield a different address for the same identity. The bytes
34/// are deliberately *not* checked against the curve. An address is defined
35/// over key bytes, and what proves ownership is the signature verification
36/// that accompanies it (`MlsManager::verify_signature`), so binding the
37/// address format to a signature library's parsing strictness would only risk
38/// the derivation drifting between versions.
39pub 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    /// RFC 8032 test vector 1's Ed25519 public key.
62    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    /// The address that key derives to.
69    ///
70    /// Re-derive it from the reference implementation if it ever needs to
71    /// change, never edit it to match new code output.
72    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/// The frozen conformance vectors for address derivation and the session slot.
98///
99/// The chapter these pin is `docs/spec/identity.md`. They were computed by
100/// `tools/spec-vectors/generate.py` from the rules in that chapter, not by
101/// running [`derive_address`]: a vector produced by the function under test
102/// agrees with any derivation it happens to compute, including a wrong one.
103///
104/// The public keys are the public halves of RFC 8032 section 7.1 test vectors 1
105/// to 3, so both halves of each case can be checked against a published source
106/// rather than against this repository.
107#[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    /// Asserts the file still carries what it carried, before anything iterates
126    /// it: a loop over an array a bad merge emptied passes by not running.
127    #[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    /// The session slot both parties compute without exchanging it.
159    ///
160    /// The ordering is by hash bytes, not by the rendered string. Where the two
161    /// disagree an implementation that sorts the strings names a slot the peer
162    /// never looks at, and neither side sees an error: each simply never hears
163    /// from the other.
164    #[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    /// The chapter, or `None` where the repo tree is absent: `cargo package`
195    /// carries `tests/` and the vector file but cannot carry `docs/`.
196    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}