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 }