lib/reticulum/src/identity/cipher.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const reticulum = @import("../root.zig");
3
4 const crypto = reticulum.crypto;
5 const identity = reticulum.identity;
6 const X25519 = std.crypto.dh.X25519;
7
8 pub const ephemeral_bytes: u8 = X25519.public_length;
9 pub const derived_bytes: u8 = 64;
10
11 pub const EncryptError = crypto.token.TokenError || error{InvalidKey};
12
13 pub const DecryptError = error{
14 Truncated,
15 RatchetRequired,
16 InvalidToken,
17 OutputTooSmall,
18 };
19
20 pub const Decrypted = struct {
21 plaintext: []u8,
22 ratchet_id: ?[reticulum.hash.name_bytes]u8,
23 };
24
25 pub fn encryptedLength(plaintext_length: usize) EncryptError!usize {
26 if (plaintext_length > crypto.token.max_plaintext_length) {
27 return error.PlaintextTooLong;
28 }
29 const token_bytes = try crypto.token.encryptedLength(@intCast(plaintext_length));
30 return @as(usize, ephemeral_bytes) + token_bytes;
31 }
32
33 /// Seals a payload for a recipient identity and writes the result into `out`,
34 /// returning the bytes written, so a sender uses a key that only the holder of
35 /// that identity can rebuild. What goes on the wire is the sender's 32-byte
36 /// ephemeral public key followed by a token, following Reticulum@1.5.0
37 /// RNS/Identity.py:804-836. The key both ends arrive at comes from the X25519
38 /// product of the caller's ephemeral secret and either the recipient's rotating
39 /// public key, when the caller passes one, or the recipient's identity key. The
40 /// recipient's identity hash salts that product. The call then expands that
41 /// product into the 64 token bytes through HKDF. The shared secret and the
42 /// derived key are erased before the call returns. The call returns
43 /// `error.PlaintextTooLong` past 65,471 bytes, `error.OutputTooSmall` when
44 /// `out` is shorter than the result, and `error.InvalidKey` when the ephemeral
45 /// secret or the target key forms no valid X25519 point.
46 pub fn encrypt(
47 recipient: *const identity.Public,
48 ratchet_public: ?*const [X25519.public_length]u8,
49 ephemeral_private: *const [X25519.secret_length]u8,
50 iv: [crypto.token.iv_length]u8,
51 plaintext: []const u8,
52 out: []u8,
53 ) EncryptError![]u8 {
54 const result_length = try encryptedLength(plaintext.len);
55 if (out.len < result_length) return error.OutputTooSmall;
56 const ephemeral_secret = ephemeral_private.*;
57 const ephemeral_public = X25519.recoverPublicKey(ephemeral_secret) catch
58 return error.InvalidKey;
59 const recipient_bytes = recipient.toBytes();
60 const target = if (ratchet_public) |public|
61 public.*
62 else
63 recipient_bytes[0..X25519.public_length].*;
64 var shared = X25519.scalarmult(ephemeral_secret, target) catch
65 return error.InvalidKey;
66 defer std.crypto.secureZero(u8, &shared);
67 var derived: [derived_bytes]u8 = undefined;
68 defer std.crypto.secureZero(u8, &derived);
69 const salt = recipient.hash();
70 _ = crypto.hkdf.derive(derived_bytes, &shared, &salt, null, &derived) catch
71 unreachable;
72 const token = crypto.token.Token.init(&derived) catch unreachable;
73 const body = try token.encrypt(iv, plaintext, out[ephemeral_bytes..result_length]);
74 std.debug.assert(body.len + ephemeral_bytes == result_length);
75 out[0..ephemeral_bytes].* = ephemeral_public;
76 return out[0..result_length];
77 }
78
79 fn tokenOutputLength(token: []const u8) ?usize {
80 const minimum = crypto.token.overhead + crypto.cbc.block_length;
81 if (token.len < minimum) return null;
82 if (token.len > crypto.token.max_encrypted_length) return null;
83 const padded_length = token.len - crypto.token.overhead;
84 if (padded_length % crypto.cbc.block_length != 0) return null;
85 return padded_length;
86 }
87
88 fn decryptWithKey(
89 key: *const [X25519.secret_length]u8,
90 peer_public: [X25519.public_length]u8,
91 salt: [reticulum.hash.truncated_bytes]u8,
92 token_bytes: []const u8,
93 out: []u8,
94 ) ?[]u8 {
95 var shared = X25519.scalarmult(key.*, peer_public) catch return null;
96 defer std.crypto.secureZero(u8, &shared);
97 var derived: [derived_bytes]u8 = undefined;
98 defer std.crypto.secureZero(u8, &derived);
99 _ = crypto.hkdf.derive(derived_bytes, &shared, &salt, null, &derived) catch return null;
100 const token = crypto.token.Token.init(&derived) catch return null;
101 return token.decrypt(token_bytes, out) catch null;
102 }
103
104 fn decryptWithRatchet(
105 ratchet: *const identity.Ratchet,
106 peer_public: [X25519.public_length]u8,
107 salt: [reticulum.hash.truncated_bytes]u8,
108 token_bytes: []const u8,
109 out: []u8,
110 ) ?[]u8 {
111 var private_bytes = ratchet.toBytes();
112 defer std.crypto.secureZero(u8, &private_bytes);
113 return decryptWithKey(&private_bytes, peer_public, salt, token_bytes, out);
114 }
115
116 /// Opens a ciphertext into `out` and reports which retained key it took, so a
117 /// receiver opens what arrived without being told which of its keys the sender
118 /// chose, following Reticulum@1.5.0 RNS/Identity.py:849-907. The call tries
119 /// each retained key in the order the caller gave and then falls back to the
120 /// identity key. Enforcing rotating keys turns that fallback off, so a
121 /// ciphertext no retained key opened returns `error.RatchetRequired`. The call
122 /// returns `error.Truncated` when the ciphertext is as short as the ephemeral
123 /// key or shorter, `error.OutputTooSmall` when `out` is shorter than the
124 /// plaintext, and `error.InvalidToken` when no key opened it. Every retained
125 /// private key the call copies and every key it derives is erased before it
126 /// returns.
127 pub fn decrypt(
128 private: *const identity.Private,
129 ratchets: []const identity.Ratchet,
130 enforce_ratchets: bool,
131 ciphertext: []const u8,
132 out: []u8,
133 ) DecryptError!Decrypted {
134 if (ciphertext.len <= ephemeral_bytes) return error.Truncated;
135 const peer_public = ciphertext[0..ephemeral_bytes].*;
136 const token_bytes = ciphertext[ephemeral_bytes..];
137 if (tokenOutputLength(token_bytes)) |needed| {
138 if (out.len < needed) return error.OutputTooSmall;
139 }
140 const salt = private.hash();
141 for (ratchets) |*ratchet| {
142 if (decryptWithRatchet(ratchet, peer_public, salt, token_bytes, out)) |plaintext| {
143 return .{ .plaintext = plaintext, .ratchet_id = ratchet.id() };
144 }
145 }
146 if (enforce_ratchets) return error.RatchetRequired;
147 var private_bytes = private.toBytes();
148 defer std.crypto.secureZero(u8, &private_bytes);
149 const key = private_bytes[0..X25519.secret_length];
150 const plaintext = decryptWithKey(key, peer_public, salt, token_bytes, out) orelse
151 return error.InvalidToken;
152 return .{ .plaintext = plaintext, .ratchet_id = null };
153 }