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 }