lib/reticulum/src/identity/ratchet.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const alloc_phase = @import("alloc_phase");
2 const std = @import("std");
3 const reticulum = @import("../root.zig");
4
5 const capacity = alloc_phase.capacity;
6 const X25519 = std.crypto.dh.X25519;
7
8 pub const Seconds = u64;
9 /// 512, the number of retained keys the reference gives a destination by
10 /// default, following Reticulum@1.5.0 RNS/Destination.py:85.
11 pub const reference_retained_max: u16 = 512;
12 /// 1,800 seconds, the rotation interval the reference gives a destination by
13 /// default, following Reticulum@1.5.0 RNS/Destination.py:90.
14 pub const reference_interval: Seconds = 30 * 60;
15
16 /// One retained X25519 private key. A destination publishes the public half in
17 /// its announcements and holds the key afterward, so a message a sender
18 /// addressed to a key it has since retired still opens.
19 pub const Ratchet = struct {
20 bytes: [X25519.secret_length]u8,
21
22 /// Takes 32 bytes of the caller's entropy as a retained private key,
23 /// following Reticulum@1.5.0 RNS/Identity.py:404-408.
24 pub fn fromBytes(bytes: [X25519.secret_length]u8) Ratchet {
25 return .{ .bytes = bytes };
26 }
27
28 pub fn generate(entropy: [X25519.secret_length]u8) Ratchet {
29 return fromBytes(entropy);
30 }
31
32 pub fn toBytes(self: *const Ratchet) [X25519.secret_length]u8 {
33 return self.bytes;
34 }
35
36 /// Returns the X25519 public bytes a destination publishes for this
37 /// retained key, following Reticulum@1.5.0 RNS/Identity.py:400-401.
38 pub fn publicBytes(self: *const Ratchet) [X25519.public_length]u8 {
39 return X25519.recoverPublicKey(self.bytes) catch unreachable;
40 }
41
42 /// Returns the 10-byte name hash of this key's public bytes, which names
43 /// the key a message was addressed to, following Reticulum@1.5.0
44 /// RNS/Identity.py:396-397.
45 pub fn id(self: *const Ratchet) [reticulum.hash.name_bytes]u8 {
46 const public = self.publicBytes();
47 return reticulum.hash.name(&public);
48 }
49
50 pub fn zero(self: *Ratchet) void {
51 std.crypto.secureZero(u8, &self.bytes);
52 }
53 };
54
55 pub const Limits = struct {
56 retained_max: u16,
57 };
58
59 pub const Capacity = struct {
60 retained_max: u16,
61 storage_bytes: usize,
62
63 pub const DeriveError = error{
64 InvalidLimit,
65 CapacityOverflow,
66 };
67
68 pub fn derive(limits: Limits) DeriveError!Capacity {
69 if (limits.retained_max == 0) return error.InvalidLimit;
70 const retained: usize = limits.retained_max;
71 const storage_bytes = capacity.mul(usize, retained, @sizeOf(Ratchet)) catch
72 return error.CapacityOverflow;
73 return .{
74 .retained_max = limits.retained_max,
75 .storage_bytes = storage_bytes,
76 };
77 }
78 };
79
80 const RingLimits = Limits;
81 const RingCapacity = Capacity;
82
83 /// The caller's storage for a destination's retained keys, held newest first.
84 /// The caller fixes how many keys the ring holds, and the storage it hands over
85 /// has to reach the byte count derived from that number. Taking the storage
86 /// zeroes it, and handing it back zeroes it again. Restoring more keys than the
87 /// ring holds returns `error.TooManyRatchets`.
88 pub const Ring = struct {
89 phase: capacity.Phase,
90 capacity: RingCapacity,
91 storage: Storage,
92 count: u16,
93 latest_time: ?Seconds,
94
95 pub const storage_alignment: usize = @alignOf(Ratchet);
96 pub const Storage = []align(storage_alignment) u8;
97 pub const Limits: type = RingLimits;
98 pub const Capacity: type = RingCapacity;
99 pub const InitError = RingCapacity.DeriveError || error{StorageTooShort};
100 pub const RestoreError = error{TooManyRatchets};
101 pub const work_limits: capacity.WorkLimits = .{
102 .transition_steps_max = 1,
103 .cleanup_steps_per_call_max = 0,
104 .cleanup_calls_at_capacity_max = 0,
105 };
106
107 pub const claim: capacity.Declaration = .{
108 .source = .{
109 .id = "reticulum.ratchet_ring",
110 .kind = .phase_static,
111 .limit_source = .caller,
112 .storage = .{
113 .covered = &.{.{
114 .id = "retained_private_keys",
115 .lifetime = .transferred,
116 .detail = "caller storage for the bounded newest-first private key ring",
117 }},
118 .excluded = &.{
119 "ring count and latest wall-clock seconds",
120 "caller-owned restore slices and returned storage",
121 },
122 },
123 .capacity = .{
124 .inputs = &.{capacity.bindInput(RingLimits, "retained_max", "retained_max")},
125 .type_selectors = &.{capacity.bindType(Ratchet, "ratchet")},
126 .nodes = &.{
127 .{ .input = 0 },
128 .{ .scale = .{
129 .node = 0,
130 .coefficient = .{ .size_of_concrete_type = 0 },
131 } },
132 },
133 .assertions = &.{.{
134 .scope = .closure_total,
135 .measure = .retained,
136 .relation = .exact,
137 .expression = 1,
138 }},
139 },
140 .overload = .{
141 .kind = .not_applicable,
142 .detail = "rotation replaces the oldest slot inside the exact window",
143 },
144 .risks = .{
145 .transitive = .{
146 .status = .witnessed,
147 .detail = "the corpus checks fixed-value X25519 derivation and key identifiers",
148 },
149 .foreign = .{
150 .status = .excluded,
151 .detail = "ring operations cross no foreign or operating-system boundary",
152 },
153 },
154 .work = .{ .equation = "rotation copy steps <= retained_max" },
155 .obligations = &.{
156 .{ .key = "reticulum_ratchet_ring_capacity", .role = .capacity_model },
157 .{ .key = "reticulum_ratchet_ring_transitive", .role = .transitive_risk },
158 .{ .key = "reticulum_ratchet_ring_work", .role = .work_bound },
159 },
160 },
161 .bindings = .{
162 .owner = @This(),
163 .seal = .{
164 .family = capacity.selector(@This().activate),
165 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
166 },
167 .teardown = .{
168 .family = capacity.selector(@This().deinit),
169 .premise = .{ .class = .checked_semantic_fact, .authority = .checker },
170 },
171 },
172 };
173
174 pub fn init(storage: Storage, limits: RingLimits) InitError!Ring {
175 const derived = try RingCapacity.derive(limits);
176 if (storage.len < derived.storage_bytes) return error.StorageTooShort;
177 const owned = storage[0..derived.storage_bytes];
178 std.crypto.secureZero(u8, owned);
179 return .{
180 .phase = .initialization,
181 .capacity = derived,
182 .storage = owned,
183 .count = 0,
184 .latest_time = null,
185 };
186 }
187
188 pub fn activate(self: *Ring) void {
189 std.debug.assert(self.phase == .initialization);
190 std.debug.assert(self.storage.len == self.capacity.storage_bytes);
191 self.phase = .steady;
192 }
193
194 pub fn deinit(self: *Ring) Storage {
195 std.debug.assert(self.phase == .steady);
196 std.crypto.secureZero(u8, self.storage);
197 self.phase = .teardown;
198 const owned = self.storage;
199 self.* = undefined;
200 return owned;
201 }
202
203 fn slots(self: *const Ring) []const Ratchet {
204 std.debug.assert(self.phase == .steady);
205 return std.mem.bytesAsSlice(Ratchet, self.storage);
206 }
207
208 fn slotsMut(self: *Ring) []Ratchet {
209 std.debug.assert(self.phase == .steady);
210 return std.mem.bytesAsSlice(Ratchet, self.storage);
211 }
212
213 fn provisionedSlotsMut(self: *Ring) []Ratchet {
214 std.debug.assert(self.phase != .teardown);
215 return std.mem.bytesAsSlice(Ratchet, self.storage);
216 }
217
218 pub fn latest(self: *const Ring) ?*const Ratchet {
219 if (self.count == 0) return null;
220 return &self.slots()[0];
221 }
222
223 pub fn latestId(self: *const Ring) ?[reticulum.hash.name_bytes]u8 {
224 const value = self.latest() orelse return null;
225 return value.id();
226 }
227
228 pub fn all(self: *const Ring) []const Ratchet {
229 const count: usize = self.count;
230 return self.slots()[0..count];
231 }
232
233 pub fn restore(
234 self: *Ring,
235 ratchets: []const Ratchet,
236 latest_time: Seconds,
237 ) RestoreError!void {
238 const retained: usize = self.capacity.retained_max;
239 if (ratchets.len > retained) return error.TooManyRatchets;
240 const owned = self.provisionedSlotsMut();
241 @memmove(owned[0..ratchets.len], ratchets);
242 std.crypto.secureZero(u8, std.mem.sliceAsBytes(owned[ratchets.len..]));
243 self.count = @intCast(ratchets.len);
244 self.latest_time = if (ratchets.len == 0) null else latest_time;
245 }
246
247 /// Puts a fresh key at the front, pushes the rest back one slot, and
248 /// returns whether it rotated, following Reticulum@1.5.0
249 /// RNS/Destination.py:206-209,228-243. A ring holding no key yet rotates,
250 /// and so does one whose newest key is older than the interval the caller
251 /// passed. Once the ring is full the oldest key falls off the back.
252 pub fn rotate(self: *Ring, now: Seconds, interval: Seconds, fresh: Ratchet) bool {
253 const should_rotate = if (self.count == 0)
254 true
255 else if (self.latest_time) |latest_time|
256 now > (std.math.add(Seconds, latest_time, interval) catch
257 std.math.maxInt(Seconds))
258 else
259 true;
260 if (!should_rotate) return false;
261 const owned = self.slotsMut();
262 const retained: usize = self.capacity.retained_max;
263 var index = @min(@as(usize, self.count), retained - 1);
264 while (index > 0) : (index -= 1) owned[index] = owned[index - 1];
265 owned[0] = fresh;
266 if (self.count < self.capacity.retained_max) self.count += 1;
267 self.latest_time = now;
268 return true;
269 }
270 };
271
272 comptime {
273 alloc_phase.capacity.requireProvisionedExactOwnerShape(Ring);
274 }