lib/reticulum/src/crypto/token.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const crypto = @import("root.zig");
3
4 pub const iv_length: u8 = crypto.cbc.block_length;
5 pub const overhead: u16 = iv_length + crypto.hmac.tag_length;
6 pub const max_plaintext_length: u16 = 65_471;
7 pub const max_encrypted_length: u16 = 65_520;
8
9 const FormatError = error{
10 InvalidKeyLength,
11 PlaintextTooLong,
12 InvalidToken,
13 };
14
15 pub const TokenError = crypto.cbc.CbcError || crypto.pkcs7.PadError ||
16 crypto.pkcs7.UnpadError || FormatError;
17
18 const Mode = enum {
19 aes128,
20 aes256,
21 };
22
23 pub fn encryptedLength(plaintext_length: u17) TokenError!u16 {
24 if (plaintext_length > max_plaintext_length) return error.PlaintextTooLong;
25 const padded_length = try crypto.pkcs7.paddedLength(plaintext_length);
26 const result = @as(u32, padded_length) + overhead;
27 std.debug.assert(result <= max_encrypted_length);
28 return @intCast(result);
29 }
30
31 /// A key split in half, the first half signing and the second half encrypting
32 /// (*token key*), for a caller holding a shared key to seal or open payloads,
33 /// following Reticulum@1.5.0 RNS/Cryptography/Token.py:61-72. The value borrows
34 /// the caller's key bytes, so they have to outlive it. A 32-byte key selects
35 /// AES-128 and a 64-byte key selects AES-256, and any other length returns
36 /// `error.InvalidKeyLength`.
37 pub const Token = struct {
38 signing_key: []const u8,
39 encryption_key: []const u8,
40 mode: Mode,
41
42 pub fn init(key: []const u8) TokenError!Token {
43 return switch (key.len) {
44 32 => .{
45 .signing_key = key[0..16],
46 .encryption_key = key[16..32],
47 .mode = .aes128,
48 },
49 64 => .{
50 .signing_key = key[0..32],
51 .encryption_key = key[32..64],
52 .mode = .aes256,
53 },
54 else => error.InvalidKeyLength,
55 };
56 }
57
58 /// Writes the sealed form into `out` and returns it: the initialization
59 /// vector, the padded ciphertext, then the tag over everything before it,
60 /// for a caller to seal a payload under the shared key, following
61 /// Reticulum@1.5.0 RNS/Cryptography/Token.py:87-97. The caller supplies the
62 /// initialization vector and owns the source it drew it from. The call
63 /// returns `error.PlaintextTooLong` past 65,471 bytes,
64 /// `error.OutputTooSmall` when `out` is shorter than the sealed form, and
65 /// `error.OverlappingBuffers` when either key half overlaps the output.
66 pub fn encrypt(
67 self: Token,
68 iv: [iv_length]u8,
69 plaintext: []const u8,
70 out: []u8,
71 ) TokenError![]u8 {
72 if (plaintext.len > max_plaintext_length) return error.PlaintextTooLong;
73 const result_length: usize = try encryptedLength(@intCast(plaintext.len));
74 if (out.len < result_length) return error.OutputTooSmall;
75 const result = out[0..result_length];
76 if (overlaps(self.signing_key, result)) return error.OverlappingBuffers;
77 if (overlaps(self.encryption_key, result)) return error.OverlappingBuffers;
78 const ciphertext_length = result_length - overhead;
79 const ciphertext_out = result[iv_length..][0..ciphertext_length];
80 const padded = try crypto.pkcs7.pad(plaintext, ciphertext_out);
81 std.debug.assert(padded.len == ciphertext_length);
82 @memcpy(result[0..iv_length], &iv);
83 switch (self.mode) {
84 .aes128 => _ = try crypto.cbc.Aes128Cbc.encrypt(
85 self.encryption_key[0..16].*,
86 iv,
87 padded,
88 ciphertext_out,
89 ),
90 .aes256 => _ = try crypto.cbc.Aes256Cbc.encrypt(
91 self.encryption_key[0..32].*,
92 iv,
93 padded,
94 ciphertext_out,
95 ),
96 }
97 const signed_length = result_length - crypto.hmac.tag_length;
98 const tag = crypto.hmac.sign(self.signing_key, result[0..signed_length]);
99 @memcpy(result[signed_length..result_length], &tag);
100 return result;
101 }
102
103 /// Returns whether the trailing 32-byte authentication value (*tag*)
104 /// matches a tag over every byte before it, for a caller to check that a
105 /// sealed payload arrived as it was sent before decrypting anything,
106 /// following Reticulum@1.5.0 RNS/Cryptography/Token.py:77-85. Data of 32
107 /// bytes or fewer, and data past 65,520 bytes, give false.
108 pub fn verify(self: Token, data: []const u8) bool {
109 if (data.len <= crypto.hmac.tag_length) return false;
110 if (data.len > max_encrypted_length) return false;
111 const signed_length = data.len - crypto.hmac.tag_length;
112 const tag = data[signed_length..][0..crypto.hmac.tag_length].*;
113 return crypto.hmac.verify(self.signing_key, data[0..signed_length], tag);
114 }
115
116 /// Checks the 32-byte authentication value (*tag*), decrypts, cuts the
117 /// padding back off, and returns the plaintext in `out` for a caller to
118 /// open a sealed payload under the shared key, following Reticulum@1.5.0
119 /// RNS/Cryptography/Token.py:100-114. The call returns `error.InvalidToken`
120 /// when the tag fails or the sealed form is under 64 bytes,
121 /// `error.OutputTooSmall` when `out` is shorter than the plaintext, and
122 /// `error.OverlappingBuffers` when either key half overlaps the output. The
123 /// tag is checked before anything is decrypted.
124 pub fn decrypt(self: Token, data: []const u8, out: []u8) TokenError![]u8 {
125 if (!self.verify(data)) return error.InvalidToken;
126 const minimum_length = overhead + crypto.cbc.block_length;
127 if (data.len < minimum_length) return error.InvalidToken;
128 const ciphertext_length = data.len - overhead;
129 if (out.len < ciphertext_length) return error.OutputTooSmall;
130 const plaintext_out = out[0..ciphertext_length];
131 if (overlaps(self.signing_key, plaintext_out)) return error.OverlappingBuffers;
132 if (overlaps(self.encryption_key, plaintext_out)) return error.OverlappingBuffers;
133 const iv = data[0..iv_length].*;
134 const ciphertext = data[iv_length .. data.len - crypto.hmac.tag_length];
135 const padded = switch (self.mode) {
136 .aes128 => try crypto.cbc.Aes128Cbc.decrypt(
137 self.encryption_key[0..16].*,
138 iv,
139 ciphertext,
140 plaintext_out,
141 ),
142 .aes256 => try crypto.cbc.Aes256Cbc.decrypt(
143 self.encryption_key[0..32].*,
144 iv,
145 ciphertext,
146 plaintext_out,
147 ),
148 };
149 const plaintext = try crypto.pkcs7.unpad(padded);
150 return plaintext_out[0..plaintext.len];
151 }
152 };
153
154 fn overlaps(input: []const u8, output: []u8) bool {
155 if (input.len == 0) return false;
156 const input_start = @intFromPtr(input.ptr);
157 const output_start = @intFromPtr(output.ptr);
158 const input_end = input_start + input.len;
159 const output_end = output_start + output.len;
160 return input_start < output_end and output_start < input_end;
161 }