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 }