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}