Skip to main content

offline_protocol_sealed/
identity_assertion.rs

1//! The identity assertion: the one byte layout by which a peer proves, over a
2//! link the platform established, that it holds the key its address derives
3//! from.
4//!
5//! A Bluetooth LE peripheral serves it from the Identity characteristic
6//! ([BLE framing](https://github.com/Offline-Protocol/offline-protocol-sdk/blob/main/docs/spec/ble-framing.md#the-identity-assertion)),
7//! and a stream transport sends it as the first frame in each direction. The
8//! layout is fixed:
9//!
10//! | Field | Size | Value |
11//! |-------|------|-------|
12//! | `public_key` | 32 | The peer's Ed25519 identity public key |
13//! | `signature` | 64 | Ed25519 signature by that key over `signed_data` |
14//! | `signed_data` | remainder | The bytes that signature covers, possibly empty |
15//!
16//! A reader refuses anything shorter than 96 bytes before any cryptography
17//! runs. `signed_data` carries no meaning at this layer: it is a message to
18//! sign, and nothing domain-separates it, so giving it meaning is a wire
19//! break. The chapter states both rules and the reasons.
20//!
21//! # Why the codec is here and the verifier is not
22//!
23//! This module splits and joins bytes. It does not check a signature or
24//! derive an address, because the sealed layer links no signature library
25//! (ADR 0022: SHA-256 is the only cryptography here). Verification, which is
26//! parse, verify the signature under the key, then derive the address from
27//! the key, lives where Ed25519 does, in `offline-protocol-mls`, and reaches
28//! bridges and applications as the `verify_identity_assertion` FFI function.
29//!
30//! What the codec being here buys is one layout for every implementation.
31//! Before it existed the split was written three times, in Swift, in Kotlin
32//! and in Python, and the Python copy was not a split at all but a JSON
33//! document, which no phone could verify. A leaf node that answers a
34//! peer-stream preamble needs the same layout, and it cannot link the engine.
35
36use crate::{Result, SealedError};
37use alloc::format;
38use alloc::vec::Vec;
39
40use crate::derive::ED25519_PUBLIC_KEY_LEN;
41
42/// Length of an Ed25519 signature, in bytes.
43pub const ED25519_SIGNATURE_LEN: usize = 64;
44
45/// The shortest assertion a reader accepts: a key and a signature with an
46/// empty `signed_data`.
47pub const IDENTITY_ASSERTION_MIN_LEN: usize = ED25519_PUBLIC_KEY_LEN + ED25519_SIGNATURE_LEN;
48
49/// The three fields of a parsed assertion, borrowed from the bytes they were
50/// parsed out of.
51///
52/// Holding one of these proves nothing: the fields have the right sizes and
53/// nothing more. A verifier owes the signature check and the derivation before
54/// it announces the peer.
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct IdentityAssertion<'a> {
57    /// The Ed25519 identity public key, exactly 32 bytes.
58    pub public_key: &'a [u8],
59    /// The Ed25519 signature over `signed_data`, exactly 64 bytes.
60    pub signature: &'a [u8],
61    /// The bytes the signature covers. May be empty, and means nothing here.
62    pub signed_data: &'a [u8],
63}
64
65/// Joins the three fields into the wire layout.
66///
67/// # Errors
68///
69/// Returns [`SealedError::InvalidPublicKey`] if `public_key` is not 32 bytes
70/// and [`SealedError::Serialization`] if `signature` is not 64. Both lengths
71/// are the format: a reader locates the fields by offset, so a short key would
72/// be parsed as a key whose first bytes of signature are its tail.
73pub fn encode_identity_assertion(
74    public_key: &[u8],
75    signature: &[u8],
76    signed_data: &[u8],
77) -> Result<Vec<u8>> {
78    if public_key.len() != ED25519_PUBLIC_KEY_LEN {
79        return Err(SealedError::InvalidPublicKey(format!(
80            "Ed25519 public key must be {} bytes, got {}",
81            ED25519_PUBLIC_KEY_LEN,
82            public_key.len()
83        )));
84    }
85    if signature.len() != ED25519_SIGNATURE_LEN {
86        return Err(SealedError::Serialization(format!(
87            "Identity assertion signature must be {} bytes, got {}",
88            ED25519_SIGNATURE_LEN,
89            signature.len()
90        )));
91    }
92
93    let mut out = Vec::with_capacity(IDENTITY_ASSERTION_MIN_LEN + signed_data.len());
94    out.extend_from_slice(public_key);
95    out.extend_from_slice(signature);
96    out.extend_from_slice(signed_data);
97    Ok(out)
98}
99
100/// Splits the wire layout into its three fields.
101///
102/// # Errors
103///
104/// Returns [`SealedError::Deserialization`] for anything shorter than
105/// [`IDENTITY_ASSERTION_MIN_LEN`]. That is the only refusal this layer makes:
106/// a 96-byte value whose key is not on the curve or whose signature does not
107/// verify parses fine here and fails at the verifier, which is where the
108/// chapter puts those checks.
109pub fn parse_identity_assertion(bytes: &[u8]) -> Result<IdentityAssertion<'_>> {
110    if bytes.len() < IDENTITY_ASSERTION_MIN_LEN {
111        return Err(SealedError::Deserialization(format!(
112            "Identity assertion is {} bytes, minimum is {}",
113            bytes.len(),
114            IDENTITY_ASSERTION_MIN_LEN
115        )));
116    }
117    let (public_key, rest) = bytes.split_at(ED25519_PUBLIC_KEY_LEN);
118    let (signature, signed_data) = rest.split_at(ED25519_SIGNATURE_LEN);
119    Ok(IdentityAssertion {
120        public_key,
121        signature,
122        signed_data,
123    })
124}
125
126#[cfg(all(test, feature = "std"))]
127mod tests {
128    use super::*;
129
130    #[test]
131    fn encode_then_parse_returns_the_fields() {
132        let key = [1u8; 32];
133        let sig = [2u8; 64];
134        let data = b"whatever follows";
135        let bytes = encode_identity_assertion(&key, &sig, data).expect("encodes");
136        assert_eq!(bytes.len(), 96 + data.len());
137        let parsed = parse_identity_assertion(&bytes).expect("parses");
138        assert_eq!(parsed.public_key, &key);
139        assert_eq!(parsed.signature, &sig);
140        assert_eq!(parsed.signed_data, data);
141    }
142
143    #[test]
144    fn an_empty_signed_data_is_the_floor_and_is_accepted() {
145        let bytes = encode_identity_assertion(&[0u8; 32], &[0u8; 64], &[]).expect("encodes");
146        assert_eq!(bytes.len(), IDENTITY_ASSERTION_MIN_LEN);
147        let parsed = parse_identity_assertion(&bytes).expect("96 bytes is a whole assertion");
148        assert!(parsed.signed_data.is_empty());
149    }
150
151    #[test]
152    fn encode_refuses_wrong_field_sizes() {
153        for key_len in [0usize, 31, 33] {
154            let err = encode_identity_assertion(&vec![0u8; key_len], &[0u8; 64], &[]).unwrap_err();
155            assert!(
156                matches!(err, SealedError::InvalidPublicKey(_)),
157                "{key_len}: {err}"
158            );
159        }
160        for sig_len in [0usize, 63, 65] {
161            let err = encode_identity_assertion(&[0u8; 32], &vec![0u8; sig_len], &[]).unwrap_err();
162            assert!(
163                matches!(err, SealedError::Serialization(_)),
164                "{sig_len}: {err}"
165            );
166        }
167    }
168
169    #[test]
170    fn parse_refuses_everything_under_the_floor() {
171        for len in [0usize, 1, 32, 64, 95] {
172            let err = parse_identity_assertion(&vec![0u8; len]).unwrap_err();
173            assert!(
174                matches!(err, SealedError::Deserialization(_)),
175                "{len}: {err}"
176            );
177        }
178    }
179}
180
181/// The frozen conformance vectors for the assertion layout.
182///
183/// The chapter these pin is `docs/spec/ble-framing.md`. Each assertion's key,
184/// signature and message are the published values of RFC 8032 section 7.1 test
185/// vectors 1 to 3, joined by `tools/spec-vectors/generate.py` from the layout
186/// the chapter states rather than by running this codec, so agreement is a
187/// cross-check and not a restatement.
188#[cfg(all(test, feature = "std"))]
189mod spec_vectors {
190    use super::*;
191
192    const VECTORS: &str = include_str!("../tests/data/identity-assertion-v1.vectors.json");
193
194    fn vectors() -> serde_json::Value {
195        serde_json::from_str(VECTORS).expect("the vector file is JSON")
196    }
197
198    fn unhex(s: &str) -> Vec<u8> {
199        (0..s.len())
200            .step_by(2)
201            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex digit"))
202            .collect()
203    }
204
205    /// Asserts the file still carries what it carried, before anything iterates
206    /// it: a loop over an array a bad merge emptied passes by not running.
207    #[test]
208    fn the_vector_file_is_the_size_it_was() {
209        let v = vectors();
210        assert_eq!(v["assertions"].as_array().expect("assertions").len(), 3);
211        assert_eq!(v["refusals"].as_array().expect("refusals").len(), 3);
212        assert_eq!(v["minimum_length"], IDENTITY_ASSERTION_MIN_LEN);
213        assert!(
214            v["assertions"]
215                .as_array()
216                .expect("assertions")
217                .iter()
218                .any(|a| a["signed_data_hex"] == ""),
219            "one assertion must sit exactly on the floor, or the file cannot catch \
220             a reader that refuses 96 bytes"
221        );
222    }
223
224    #[test]
225    fn every_assertion_parses_into_the_fields_it_was_built_from() {
226        for case in vectors()["assertions"].as_array().expect("assertions") {
227            let name = case["name"].as_str().expect("a name");
228            let bytes = unhex(case["assertion_hex"].as_str().expect("hex"));
229            let parsed =
230                parse_identity_assertion(&bytes).unwrap_or_else(|e| panic!("[{name}]: {e}"));
231            assert_eq!(
232                parsed.public_key,
233                unhex(case["public_key_hex"].as_str().expect("key")),
234                "[{name}] public key"
235            );
236            assert_eq!(
237                parsed.signature,
238                unhex(case["signature_hex"].as_str().expect("signature")),
239                "[{name}] signature"
240            );
241            assert_eq!(
242                parsed.signed_data,
243                unhex(case["signed_data_hex"].as_str().expect("signed data")),
244                "[{name}] signed data"
245            );
246            // The address the key derives to is in the file so a verifier in
247            // any language can pin its whole pipeline on published values.
248            assert_eq!(
249                crate::derive_address(parsed.public_key)
250                    .expect("32-byte key")
251                    .to_string(),
252                case["address"].as_str().expect("address"),
253                "[{name}] the address the chapter's derivation gives this key"
254            );
255        }
256    }
257
258    #[test]
259    fn every_assertion_re_encodes_to_the_same_bytes() {
260        for case in vectors()["assertions"].as_array().expect("assertions") {
261            let name = case["name"].as_str().expect("a name");
262            let want = unhex(case["assertion_hex"].as_str().expect("hex"));
263            let got = encode_identity_assertion(
264                &unhex(case["public_key_hex"].as_str().expect("key")),
265                &unhex(case["signature_hex"].as_str().expect("signature")),
266                &unhex(case["signed_data_hex"].as_str().expect("signed data")),
267            )
268            .unwrap_or_else(|e| panic!("[{name}]: {e}"));
269            assert_eq!(got, want, "[{name}] encoded bytes");
270        }
271    }
272
273    #[test]
274    fn every_refusal_is_refused() {
275        for case in vectors()["refusals"].as_array().expect("refusals") {
276            let name = case["name"].as_str().expect("a name");
277            let bytes = unhex(case["assertion_hex"].as_str().expect("hex"));
278            assert!(
279                parse_identity_assertion(&bytes).is_err(),
280                "[{name}] {} bytes must be refused",
281                bytes.len()
282            );
283        }
284    }
285
286    /// The chapter, or `None` where the repo tree is absent: `cargo package`
287    /// carries `tests/` and the vector file but cannot carry `docs/`.
288    fn chapter() -> Option<String> {
289        let path =
290            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/ble-framing.md");
291        std::fs::read_to_string(&path).ok().or_else(|| {
292            eprintln!("spec tree not present, skipping the BLE framing chapter drift checks");
293            None
294        })
295    }
296
297    #[test]
298    fn the_chapter_states_the_layout_the_code_performs() {
299        let Some(text) = chapter() else { return };
300
301        assert!(
302            text.contains("| `public_key` | 32 |") && text.contains("| `signature` | 64 |"),
303            "the chapter no longer states the two fixed field sizes"
304        );
305        assert!(
306            text.contains(&format!("shorter than {IDENTITY_ASSERTION_MIN_LEN} bytes")),
307            "the chapter no longer states the {IDENTITY_ASSERTION_MIN_LEN}-byte floor"
308        );
309        assert!(
310            text.contains("identity-assertion-v1.vectors.json"),
311            "the chapter no longer names the vector file that pins it"
312        );
313    }
314}