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 }