lib/reticulum/src/identity/test.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const alloc_phase = @import("alloc_phase");
2 const std = @import("std");
3 const pretty = @import("pretty");
4 const reticulum = @import("../root.zig");
5
6 const conformance = reticulum.conformance;
7 const identity = reticulum.identity;
8
9 fn failBytes(
10 vector_name: []const u8,
11 field_name: []const u8,
12 expected: []const u8,
13 actual: []const u8,
14 ) !void {
15 var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
16 defer arena.deinit();
17 var report = try pretty.diagnostic.Report.init(
18 arena.allocator(),
19 "Reticulum identity conformance mismatch",
20 );
21 defer report.deinit();
22 try report.field("vector", "{s}", .{vector_name});
23 try report.field("field", "{s}", .{field_name});
24 try report.field("expected", "{any}", .{expected});
25 try report.field("actual", "{any}", .{actual});
26 pretty.diagnostic.writeStderr(&report, .{ .width = 100 });
27 return error.ConformanceMismatch;
28 }
29
30 fn expectBytes(
31 vector_name: []const u8,
32 field_name: []const u8,
33 expected: []const u8,
34 actual: []const u8,
35 ) !void {
36 if (!std.mem.eql(u8, expected, actual)) {
37 return failBytes(vector_name, field_name, expected, actual);
38 }
39 }
40
41 test "Reticulum@1.5.0 RNS/Identity.py:730,737,908,925 identity corpus" {
42 for (conformance.identity.vectors) |vector| {
43 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
44 defer private.zero();
45 try expectBytes(vector.name, "private_key", vector.private_key, &private.toBytes());
46 const public_bytes = private.publicBytes();
47 try expectBytes(vector.name, "public_key", vector.public_key, &public_bytes);
48 try expectBytes(vector.name, "identity_hash", vector.identity_hash, &private.hash());
49 const signature = private.sign(vector.message);
50 try expectBytes(vector.name, "signature", vector.signature, &signature);
51
52 var public = identity.Public.fromBytes(vector.public_key[0..identity.key_bytes].*);
53 defer public.zero();
54 try std.testing.expectEqual(
55 vector.validate_verdict,
56 public.validate(vector.signature[0..64].*, vector.message),
57 );
58 }
59 }
60
61 test "Reticulum@1.5.0 RNS/Identity.py:396-401 rotating key corpus" {
62 const expected_id = [10]u8{
63 0x9d, 0xd7, 0x96, 0x09, 0x95, 0x11, 0xd5, 0x77, 0x3f, 0xd1,
64 };
65 for (conformance.identity.vectors) |vector| {
66 if (!vector.uses_ratchet) continue;
67 const private = vector.ratchet_private_key[0..32].*;
68 const public = identity.Rotating.publicBytes(private);
69 try expectBytes(vector.name, "rotating_public_key", vector.ratchet_public_key, &public);
70 const rotating_id = identity.Rotating.id(public);
71 try expectBytes(vector.name, "rotating_key_id", &expected_id, &rotating_id);
72 }
73 }
74
75 test "RFC 8032 section 7.1 test 1 Ed25519 known answer" {
76 var seed: [32]u8 = undefined;
77 var expected_public: [32]u8 = undefined;
78 var expected_signature: [64]u8 = undefined;
79 _ = try std.fmt.hexToBytes(&seed, "9d61b19deffd5a60ba844af492ec2cc4" ++
80 "4449c5697b326919703bac031cae7f60");
81 _ = try std.fmt.hexToBytes(&expected_public, "d75a980182b10ab7d54bfed3c964073a" ++
82 "0ee172f3daa62325af021a68f707511a");
83 _ = try std.fmt.hexToBytes(&expected_signature, "e5564300c360ac729086e2cc806e828a" ++
84 "84877f1eb8e5d974d873e06522490155" ++ "5fb8821590a33bacc61e39701cf9b46b" ++
85 "d25bf5f0595bbe24655141438e7a100b");
86 var private_bytes: identity.KeyBytes = @splat(0);
87 private_bytes[32..].* = seed;
88 var private = identity.Private.fromBytes(private_bytes);
89 defer private.zero();
90 const public = private.publicBytes();
91 try std.testing.expectEqualSlices(u8, &expected_public, public[32..]);
92 try std.testing.expectEqual(expected_signature, private.sign(""));
93 }
94
95 test "RFC 7748 section 6.1 X25519 known answer" {
96 var private: [32]u8 = undefined;
97 var expected_public: [32]u8 = undefined;
98 _ = try std.fmt.hexToBytes(&private, "77076d0a7318a57d3c16c17251b26645" ++
99 "df4c2f87ebc0992ab177fba51db92c2a");
100 _ = try std.fmt.hexToBytes(&expected_public, "8520f0098930a754748b7ddcb43ef75a" ++
101 "0dbf3a0d26381af4eba4a98eaa9b4e6a");
102 try std.testing.expectEqual(expected_public, identity.Rotating.publicBytes(private));
103 }
104
105 test "identity zero erases all 64 private bytes" {
106 const zeroed: identity.KeyBytes = @splat(0);
107 var private = identity.Private.fromBytes(@splat(0x5a));
108 private.zero();
109 try std.testing.expectEqual(zeroed, private.toBytes());
110 }
111
112 test "identity zero erases all 64 public bytes" {
113 const zeroed: identity.KeyBytes = @splat(0);
114 var public = identity.Public.fromBytes(@splat(0x5a));
115 public.zero();
116 try std.testing.expectEqual(zeroed, public.toBytes());
117 }
118
119 fn checkCipherVector(vector: conformance.identity.Vector) !void {
120 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
121 defer private.zero();
122 var public = identity.Public.fromBytes(vector.public_key[0..identity.key_bytes].*);
123 defer public.zero();
124 var ratchet_public: [32]u8 = undefined;
125 var ratchet_values: [1]identity.Ratchet = undefined;
126 const ratchet_count: usize = if (vector.uses_ratchet) 1 else 0;
127 if (vector.uses_ratchet) {
128 ratchet_public = vector.ratchet_public_key[0..32].*;
129 ratchet_values[0] = identity.Ratchet.fromBytes(vector.ratchet_private_key[0..32].*);
130 }
131 defer if (vector.uses_ratchet) ratchet_values[0].zero();
132 const selected: ?*const [32]u8 = if (vector.uses_ratchet) &ratchet_public else null;
133 var encrypted_storage: [512]u8 = undefined;
134 const encrypted = try identity.cipher.encrypt(
135 &public,
136 selected,
137 @ptrCast(vector.ephemeral_private_key.ptr),
138 vector.iv[0..16].*,
139 vector.plaintext,
140 &encrypted_storage,
141 );
142 try expectBytes(vector.name, "ciphertext", vector.ciphertext, encrypted);
143 var plaintext_storage: [512]u8 = undefined;
144 const decrypted = try identity.cipher.decrypt(
145 &private,
146 ratchet_values[0..ratchet_count],
147 false,
148 vector.ciphertext,
149 &plaintext_storage,
150 );
151 try std.testing.expect(vector.decrypt_verdict);
152 try expectBytes(vector.name, "plaintext", vector.plaintext, decrypted.plaintext);
153 const expected_id: ?[10]u8 = if (vector.uses_ratchet)
154 reticulum.hash.name(vector.ratchet_public_key)
155 else
156 null;
157 try std.testing.expectEqual(expected_id, decrypted.ratchet_id);
158 }
159
160 test "Reticulum@1.5.0 RNS/Identity.py:804-907 identity-key cipher corpus" {
161 try std.testing.expectEqualStrings("identity-key", conformance.identity.vectors[0].name);
162 try checkCipherVector(conformance.identity.vectors[0]);
163 }
164
165 test "Reticulum@1.5.0 RNS/Identity.py:804-907 ratchet cipher corpus" {
166 try std.testing.expectEqualStrings("ratchet", conformance.identity.vectors[1].name);
167 try checkCipherVector(conformance.identity.vectors[1]);
168 }
169
170 test "identity decrypt requires a matching retained key when enforcement is active" {
171 const vector = conformance.identity.vectors[0];
172 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
173 defer private.zero();
174 var output: [512]u8 = undefined;
175 try std.testing.expectError(
176 error.RatchetRequired,
177 identity.cipher.decrypt(&private, &.{}, true, vector.ciphertext, &output),
178 );
179 }
180
181 test "identity decrypt rejects a 32-byte ciphertext as truncated" {
182 const vector = conformance.identity.vectors[0];
183 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
184 defer private.zero();
185 var output: [32]u8 = undefined;
186 const ciphertext: [32]u8 = @splat(0x41);
187 try std.testing.expectError(
188 error.Truncated,
189 identity.cipher.decrypt(&private, &.{}, false, &ciphertext, &output),
190 );
191 }
192
193 test "identity decrypt rejects one flipped ciphertext byte" {
194 const vector = conformance.identity.vectors[0];
195 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
196 defer private.zero();
197 var ciphertext: [512]u8 = undefined;
198 @memcpy(ciphertext[0..vector.ciphertext.len], vector.ciphertext);
199 ciphertext[vector.ciphertext.len - 1] ^= 0x01;
200 var output: [512]u8 = undefined;
201 try std.testing.expectError(error.InvalidToken, identity.cipher.decrypt(
202 &private,
203 &.{},
204 false,
205 ciphertext[0..vector.ciphertext.len],
206 &output,
207 ));
208 }
209
210 test "identity decrypt falls back after a wrong retained key" {
211 const vector = conformance.identity.vectors[0];
212 var private = identity.Private.fromBytes(vector.private_key[0..identity.key_bytes].*);
213 defer private.zero();
214 var wrong = identity.Ratchet.fromBytes(@splat(0x44));
215 defer wrong.zero();
216 var output: [512]u8 = undefined;
217 const decrypted = try identity.cipher.decrypt(
218 &private,
219 &.{wrong},
220 false,
221 vector.ciphertext,
222 &output,
223 );
224 try std.testing.expectEqual(@as(?[10]u8, null), decrypted.ratchet_id);
225 try std.testing.expectEqualSlices(u8, vector.plaintext, decrypted.plaintext);
226 }
227
228 fn ringKey(byte: u8) identity.Ratchet {
229 return identity.Ratchet.fromBytes(@splat(byte));
230 }
231
232 test "rotating-key ring retains max and drops the oldest at max plus one" {
233 const bytes = 3 * @sizeOf(identity.Ratchet);
234 var storage: [bytes]u8 align(identity.Ring.storage_alignment) = undefined;
235 var ring = try identity.Ring.init(&storage, .{ .retained_max = 3 });
236 ring.activate();
237 defer _ = ring.deinit();
238 try std.testing.expect(ring.rotate(0, 10, ringKey(1)));
239 try std.testing.expect(!ring.rotate(10, 10, ringKey(9)));
240 try std.testing.expect(ring.rotate(11, 10, ringKey(2)));
241 try std.testing.expect(ring.rotate(22, 10, ringKey(3)));
242 try std.testing.expectEqual(@as(usize, 3), ring.all().len);
243 try std.testing.expect(ring.rotate(33, 10, ringKey(4)));
244 const retained = ring.all();
245 try std.testing.expectEqual(@as(usize, 3), retained.len);
246 try std.testing.expectEqual(@as([32]u8, @splat(4)), retained[0].toBytes());
247 try std.testing.expectEqual(@as([32]u8, @splat(3)), retained[1].toBytes());
248 try std.testing.expectEqual(@as([32]u8, @splat(2)), retained[2].toBytes());
249 }
250
251 test "rotating-key ring restores max and rejects max plus one" {
252 const bytes = 3 * @sizeOf(identity.Ratchet);
253 var storage: [bytes]u8 align(identity.Ring.storage_alignment) = undefined;
254 var ring = try identity.Ring.init(&storage, .{ .retained_max = 3 });
255 ring.activate();
256 defer _ = ring.deinit();
257 const saved = [_]identity.Ratchet{ ringKey(3), ringKey(2), ringKey(1), ringKey(0) };
258 try ring.restore(saved[0..3], 45);
259 try std.testing.expectEqual(@as([32]u8, @splat(3)), ring.latest().?.toBytes());
260 try std.testing.expectEqual(ring.latest().?.id(), ring.latestId().?);
261 try std.testing.expectError(error.TooManyRatchets, ring.restore(&saved, 50));
262 try std.testing.expectEqual(@as(usize, 3), ring.all().len);
263 }
264
265 test "rotating-key ring proves caller capacity 512" {
266 comptime {
267 @stardustClaim(
268 alloc_phase.capacity.witness(
269 @import("root.zig").Ring,
270 "reticulum_ratchet_ring_capacity",
271 ),
272 null,
273 null,
274 null,
275 null,
276 null,
277 null,
278 );
279 @stardustClaim(
280 alloc_phase.capacity.witness(
281 @import("root.zig").Ring,
282 "reticulum_ratchet_ring_transitive",
283 ),
284 null,
285 null,
286 null,
287 null,
288 null,
289 null,
290 );
291 @stardustClaim(
292 alloc_phase.capacity.witness(
293 @import("root.zig").Ring,
294 "reticulum_ratchet_ring_work",
295 ),
296 null,
297 null,
298 null,
299 null,
300 null,
301 null,
302 );
303 }
304 const bytes = identity.ratchet.reference_retained_max * @sizeOf(identity.Ratchet);
305 const limits = identity.ratchet.Limits{
306 .retained_max = identity.ratchet.reference_retained_max,
307 };
308 const derived = try identity.ratchet.Capacity.derive(limits);
309 try std.testing.expectEqual(@as(usize, bytes), derived.storage_bytes);
310 var storage: [bytes]u8 align(identity.Ring.storage_alignment) = undefined;
311 var ring = try identity.Ring.init(&storage, limits);
312 ring.activate();
313 try std.testing.expect(ring.rotate(0, 1_800, ringKey(0x5a)));
314 const returned = ring.deinit();
315 try std.testing.expect(std.mem.allEqual(u8, returned, 0));
316 }
317
318 test "rotating key zero erases all 32 private bytes" {
319 var key = ringKey(0x5a);
320 key.zero();
321 try std.testing.expectEqual(@as([32]u8, @splat(0)), key.toBytes());
322 }