Skip to main content

freenet_git_encoding/
signed.rs

1//! Length-prefixed signed-payload encoding.
2//!
3//! Every signed message in freenet-git is constructed by concatenating
4//! length-prefixed fields, with the domain prefix as the *first* field. This
5//! makes the encoding self-describing in the trivial sense that no field can
6//! be confused with any other field, and makes a domain-version bump
7//! syntactically distinguishable from a same-domain message that happened to
8//! start with the same bytes.
9//!
10//! ```text
11//! payload = field(domain)
12//!         || field(repo_key)
13//!         || field(...)
14//!         || ...
15//!
16//! field(x) = u32_le(len(x)) || raw(x)
17//! ```
18//!
19//! Primitive encodings:
20//!
21//! | Type        | Bytes (inside the length prefix)                                        |
22//! |-------------|--------------------------------------------------------------------------|
23//! | `bool`      | `0x00` (false) or `0x01` (true)                                          |
24//! | `u32`       | 4 bytes, little-endian                                                   |
25//! | `u64`       | 8 bytes, little-endian                                                   |
26//! | `[u8; N]`   | the N raw bytes                                                          |
27//! | `&[u8]` /`String`/`&str` | the raw bytes                                              |
28//! | `Option<T>` | `0x00` for `None`; `0x01` followed by the encoded payload of `T`         |
29//!
30//! Each one of these primitives is then wrapped in the standard
31//! length-prefix envelope when it appears as a field of a payload.
32//!
33//! There are no nested structures in any v1 signed payload. If a future
34//! version adds nesting, it recursively follows the same length-prefix-
35//! everything rule.
36
37use crate::WIRE_VERSION;
38
39/// A buffer for accumulating a signed payload.
40///
41/// The buffer always begins with a domain field. Construct with
42/// [`Builder::new`].
43#[derive(Debug, Clone)]
44pub struct Builder {
45    buf: Vec<u8>,
46}
47
48impl Builder {
49    /// Start a new payload for the given domain suffix (e.g. `"ref-update"`,
50    /// `"object-bundle"`, `"name"`). The full domain string written is
51    /// `"freenet-git/v1/<suffix>"`.
52    pub fn new(domain_suffix: &str) -> Self {
53        let mut me = Self {
54            buf: Vec::with_capacity(64),
55        };
56        let domain = format!("freenet-git/{}/{}", WIRE_VERSION, domain_suffix);
57        me.field_bytes(domain.as_bytes());
58        me
59    }
60
61    /// Append a field consisting of the given raw bytes.
62    pub fn field_bytes(&mut self, bytes: &[u8]) -> &mut Self {
63        let len: u32 = bytes
64            .len()
65            .try_into()
66            .expect("freenet-git signed payloads do not contain >4GiB fields");
67        self.buf.extend_from_slice(&len.to_le_bytes());
68        self.buf.extend_from_slice(bytes);
69        self
70    }
71
72    /// Append a string field.
73    pub fn field_str(&mut self, s: &str) -> &mut Self {
74        self.field_bytes(s.as_bytes())
75    }
76
77    /// Append a `u32` field (4 bytes little-endian inside the length prefix).
78    pub fn field_u32(&mut self, x: u32) -> &mut Self {
79        self.field_bytes(&x.to_le_bytes())
80    }
81
82    /// Append a `u64` field (8 bytes little-endian inside the length prefix).
83    pub fn field_u64(&mut self, x: u64) -> &mut Self {
84        self.field_bytes(&x.to_le_bytes())
85    }
86
87    /// Append a boolean field (1 byte).
88    pub fn field_bool(&mut self, b: bool) -> &mut Self {
89        self.field_bytes(&[u8::from(b)])
90    }
91
92    /// Append an `Option<&[u8]>` field.
93    ///
94    /// Encoded as `[0x00]` for `None` or `[0x01, ...payload...]` for `Some`,
95    /// where `payload` is the raw bytes (still inside the outer length prefix
96    /// of the field).
97    pub fn field_option_bytes(&mut self, value: Option<&[u8]>) -> &mut Self {
98        match value {
99            None => self.field_bytes(&[0x00]),
100            Some(b) => {
101                let mut tagged = Vec::with_capacity(1 + b.len());
102                tagged.push(0x01);
103                tagged.extend_from_slice(b);
104                self.field_bytes(&tagged)
105            }
106        }
107    }
108
109    /// Finish the builder and return the assembled byte string.
110    pub fn finish(self) -> Vec<u8> {
111        self.buf
112    }
113}
114
115/// Convenience: build a payload by chaining mutations on a `Builder`.
116///
117/// ```ignore
118/// let bytes = build("ref-update", |b| {
119///     b.field_bytes(&repo_key);
120///     b.field_str("refs/heads/main");
121///     b.field_bytes(&commit_hash);
122///     b.field_u64(update_seq);
123///     b.field_u64(auth_epoch);
124/// });
125/// ```
126pub fn build<F>(domain_suffix: &str, f: F) -> Vec<u8>
127where
128    F: FnOnce(&mut Builder),
129{
130    let mut b = Builder::new(domain_suffix);
131    f(&mut b);
132    b.finish()
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// Worked example: domain alone.
140    ///
141    /// The domain string `"freenet-git/v1/example"` is 22 bytes. The encoded
142    /// payload is therefore the little-endian u32 `22` (`16 00 00 00`)
143    /// followed by the raw bytes of the domain.
144    #[test]
145    fn domain_only_payload_is_length_prefixed() {
146        let bytes = Builder::new("example").finish();
147        assert_eq!(&bytes[..4], &22u32.to_le_bytes());
148        assert_eq!(&bytes[4..], b"freenet-git/v1/example");
149    }
150
151    /// Worked example: every primitive type.
152    ///
153    /// Pinned hex output. If this test fails because the expected bytes
154    /// changed, that is a wire-format break and the domain must bump from
155    /// `v1` to `v2` together with a contract WASM change.
156    #[test]
157    fn every_primitive_round_trip() {
158        let payload = build("worked-example", |b| {
159            b.field_bytes(&[0xAA, 0xBB, 0xCC]);
160            b.field_str("hi");
161            b.field_u32(0x01020304);
162            b.field_u64(0x0807060504030201);
163            b.field_bool(true);
164            b.field_bool(false);
165            b.field_option_bytes(None);
166            b.field_option_bytes(Some(&[0xDE, 0xAD]));
167        });
168
169        // Domain: "freenet-git/v1/worked-example" = 29 bytes
170        // (1d 00 00 00) || domain
171        // Field bytes [AA BB CC] => (03 00 00 00) || AA BB CC
172        // Field str "hi"         => (02 00 00 00) || 68 69
173        // Field u32 0x01020304   => (04 00 00 00) || 04 03 02 01
174        // Field u64 ...          => (08 00 00 00) || 01 02 03 04 05 06 07 08
175        // Field bool true        => (01 00 00 00) || 01
176        // Field bool false       => (01 00 00 00) || 00
177        // Option None            => (01 00 00 00) || 00
178        // Option Some(DE AD)     => (03 00 00 00) || 01 DE AD
179        let expected = hex::decode(concat!(
180            "1d000000",
181            "667265656e65742d6769742f76312f776f726b65642d6578616d706c65",
182            "03000000",
183            "aabbcc",
184            "02000000",
185            "6869",
186            "04000000",
187            "04030201",
188            "08000000",
189            "0102030405060708",
190            "01000000",
191            "01",
192            "01000000",
193            "00",
194            "01000000",
195            "00",
196            "03000000",
197            "01dead",
198        ))
199        .unwrap();
200        assert_eq!(payload, expected);
201    }
202
203    /// Worked example: a real ref-update signed payload, signed with a fixed
204    /// test ed25519 key. Verifies the resulting signature against the same
205    /// public key.
206    ///
207    /// ed25519 signatures are deterministic given the key and payload, so an
208    /// independent implementation building the same payload and signing with
209    /// the same key MUST produce identical signature bytes. We pin those
210    /// bytes in [`signed_fixtures`] in the integration tests once the values
211    /// are computed.
212    #[test]
213    fn ref_update_signs_and_verifies() {
214        use ed25519_dalek::{Signer, SigningKey, Verifier};
215
216        // Fixed test key (NOT for production use). 32 bytes of 0x00..0x1f.
217        let mut secret_bytes = [0u8; 32];
218        for (i, b) in secret_bytes.iter_mut().enumerate() {
219            *b = i as u8;
220        }
221        let signing_key = SigningKey::from_bytes(&secret_bytes);
222        let verifying_key = signing_key.verifying_key();
223
224        let repo_key = [0xAAu8; 32];
225        let commit_hash = [0xBBu8; 20];
226
227        let payload = build("ref-update", |b| {
228            b.field_bytes(&repo_key);
229            b.field_str("refs/heads/main");
230            b.field_bytes(&commit_hash);
231            b.field_u64(1);
232            b.field_u64(0);
233        });
234
235        let sig = signing_key.sign(&payload);
236        assert!(verifying_key.verify(&payload, &sig).is_ok());
237
238        // Cross-check that domain confusion is impossible: a different
239        // domain prefix produces a different payload, so the same signature
240        // does not verify.
241        let other_payload = build("object-bundle", |b| {
242            b.field_bytes(&repo_key);
243            b.field_str("refs/heads/main");
244            b.field_bytes(&commit_hash);
245            b.field_u64(1);
246            b.field_u64(0);
247        });
248        assert!(verifying_key.verify(&other_payload, &sig).is_err());
249    }
250}