1// https://github.com/P-H-C/phc-string-format
2
3const std = @import("std");
4const fmt = std.fmt;
5const mem = std.mem;
6const meta = std.meta;
7const Writer = std.Io.Writer;
8
9const fields_delimiter = "$";
10const fields_delimiter_scalar = '$';
11const version_param_name = "v";
12const params_delimiter = ",";
13const params_delimiter_scalar = ',';
14const kv_delimiter = "=";
15const kv_delimiter_scalar = '=';
16
17pub const Error = std.crypto.errors.EncodingError || error{NoSpaceLeft};
18
19const B64Decoder = std.base64.standard_no_pad.Decoder;
20const B64Encoder = std.base64.standard_no_pad.Encoder;
21
22/// A wrapped binary value whose maximum size is `max_len`.
23///
24/// This type must be used whenever a binary value is encoded in a PHC-formatted string.
25/// This includes `salt`, `hash`, and any other binary parameters such as keys.
26///
27/// Once initialized, the actual value can be read with the `constSlice()` function.
28pub fn BinValue(comptime max_len: usize) type {
29 return struct {
30 const Self = @This();
31 const capacity = max_len;
32 const max_encoded_length = B64Encoder.calcSize(max_len);
33
34 buf: [max_len]u8 = undefined,
35 len: usize = 0,
36
37 /// Wrap an existing byte slice
38 pub fn fromSlice(slice: []const u8) Error!Self {
39 if (slice.len > capacity) return Error.NoSpaceLeft;
40 var bin_value: Self = undefined;
41 @memcpy(bin_value.buf[0..slice.len], slice);
42 bin_value.len = slice.len;
43 return bin_value;
44 }
45
46 /// Return the slice containing the actual value.
47 pub fn constSlice(self: *const Self) []const u8 {
48 return self.buf[0..self.len];
49 }
50
51 fn fromB64(self: *Self, str: []const u8) !void {
52 const len = B64Decoder.calcSizeForSlice(str) catch return Error.InvalidEncoding;
53 if (len > self.buf.len) return Error.NoSpaceLeft;
54 B64Decoder.decode(&self.buf, str) catch return Error.InvalidEncoding;
55 self.len = len;
56 }
57
58 fn toB64(self: *const Self, buf: []u8) ![]const u8 {
59 const value = self.constSlice();
60 const len = B64Encoder.calcSize(value.len);
61 if (len > buf.len) return Error.NoSpaceLeft;
62 return B64Encoder.encode(buf, value);
63 }
64 };
65}
66
67/// Deserialize a PHC-formatted string into a structure `HashResult`.
68///
69/// Required field in the `HashResult` structure:
70/// - `alg_id`: algorithm identifier
71/// Optional, special fields:
72/// - `alg_version`: algorithm version (unsigned integer)
73/// - `salt`: salt
74/// - `hash`: output of the hash function
75///
76/// Other fields will also be deserialized from the function parameters section.
77pub fn deserialize(comptime HashResult: type, str: []const u8) Error!HashResult {
78 if (@hasField(HashResult, version_param_name)) {
79 @compileError("Field name '" ++ version_param_name ++ "'' is reserved for the algorithm version");
80 }
81
82 var out = mem.zeroes(HashResult);
83 var it = mem.splitScalar(u8, str, fields_delimiter_scalar);
84 var set_fields: usize = 0;
85
86 while (true) {
87 // Read the algorithm identifier
88 if ((it.next() orelse return Error.InvalidEncoding).len != 0) return Error.InvalidEncoding;
89 out.alg_id = it.next() orelse return Error.InvalidEncoding;
90 set_fields += 1;
91
92 // Read the optional version number
93 var field = it.next() orelse break;
94 if (kvSplit(field)) |opt_version| {
95 if (mem.eql(u8, opt_version.key, version_param_name)) {
96 if (@hasField(HashResult, "alg_version")) {
97 const ValueType = switch (@typeInfo(@TypeOf(out.alg_version))) {
98 .optional => |opt| opt.child,
99 else => @TypeOf(out.alg_version),
100 };
101 out.alg_version = fmt.parseUnsigned(
102 ValueType,
103 opt_version.value,
104 10,
105 ) catch return Error.InvalidEncoding;
106 set_fields += 1;
107 }
108 field = it.next() orelse break;
109 }
110 } else |_| {}
111
112 // Read optional parameters
113 var has_params = false;
114 var it_params = mem.splitScalar(u8, field, params_delimiter_scalar);
115 while (it_params.next()) |params| {
116 const param = kvSplit(params) catch break;
117 var found = false;
118 const info = @typeInfo(HashResult).@"struct";
119 inline for (info.field_names, info.field_types) |p_name, p_type| {
120 if (mem.eql(u8, p_name, param.key)) {
121 switch (@typeInfo(p_type)) {
122 .int => @field(out, p_name) = fmt.parseUnsigned(
123 p_type,
124 param.value,
125 10,
126 ) catch return Error.InvalidEncoding,
127 .pointer => |ptr| {
128 if (!ptr.attrs.@"const") @compileError("Value slice must be constant");
129 @field(out, p_name) = param.value;
130 },
131 .@"struct" => try @field(out, p_name).fromB64(param.value),
132 else => std.debug.panic(
133 "Value for [{s}] must be an integer, a constant slice or a BinValue",
134 .{p_name},
135 ),
136 }
137 set_fields += 1;
138 found = true;
139 break;
140 }
141 }
142 if (!found) return Error.InvalidEncoding; // An unexpected parameter was found in the string
143 has_params = true;
144 }
145
146 // No separator between an empty parameters set and the salt
147 if (has_params) field = it.next() orelse break;
148
149 // Read an optional salt
150 if (@hasField(HashResult, "salt")) {
151 try out.salt.fromB64(field);
152 set_fields += 1;
153 } else {
154 return Error.InvalidEncoding;
155 }
156
157 // Read an optional hash
158 field = it.next() orelse break;
159 if (@hasField(HashResult, "hash")) {
160 try out.hash.fromB64(field);
161 set_fields += 1;
162 } else {
163 return Error.InvalidEncoding;
164 }
165 break;
166 }
167
168 // Check that all the required fields have been set, excluding optional values and parameters
169 // with default values
170 var expected_fields: usize = 0;
171 const info = @typeInfo(HashResult).@"struct";
172 inline for (info.field_types, info.field_attrs) |p_type, p_attrs| {
173 if (@typeInfo(p_type) != .optional and p_attrs.default_value_ptr == null) {
174 expected_fields += 1;
175 }
176 }
177 if (set_fields < expected_fields) return Error.InvalidEncoding;
178
179 return out;
180}
181
182/// Serialize parameters into a PHC string.
183///
184/// Required field for `params`:
185/// - `alg_id`: algorithm identifier
186/// Optional, special fields:
187/// - `alg_version`: algorithm version (unsigned integer)
188/// - `salt`: salt
189/// - `hash`: output of the hash function
190///
191/// `params` can also include any additional parameters.
192pub fn serialize(params: anytype, str: []u8) Error![]const u8 {
193 var w: Writer = .fixed(str);
194 serializeTo(params, &w) catch return error.NoSpaceLeft;
195 return w.buffered();
196}
197
198/// Compute the number of bytes required to serialize `params`
199pub fn calcSize(params: anytype) usize {
200 var trash: [128]u8 = undefined;
201 var d: Writer.Discarding = .init(&trash);
202 serializeTo(params, &d.writer) catch unreachable;
203 return @intCast(d.fullCount());
204}
205
206fn serializeTo(params: anytype, out: *std.Io.Writer) !void {
207 const HashResult = @TypeOf(params);
208
209 if (@hasField(HashResult, version_param_name)) {
210 @compileError("Field name '" ++ version_param_name ++ "'' is reserved for the algorithm version");
211 }
212
213 try out.writeAll(fields_delimiter);
214 try out.writeAll(params.alg_id);
215
216 if (@hasField(HashResult, "alg_version")) {
217 if (@typeInfo(@TypeOf(params.alg_version)) == .optional) {
218 if (params.alg_version) |alg_version| {
219 try out.print(
220 "{s}{s}{s}{}",
221 .{ fields_delimiter, version_param_name, kv_delimiter, alg_version },
222 );
223 }
224 } else {
225 try out.print(
226 "{s}{s}{s}{}",
227 .{ fields_delimiter, version_param_name, kv_delimiter, params.alg_version },
228 );
229 }
230 }
231
232 var has_params = false;
233 const info = @typeInfo(HashResult).@"struct";
234 inline for (info.field_names, info.field_types) |p_name, p_type| {
235 if (comptime !(mem.eql(u8, p_name, "alg_id") or
236 mem.eql(u8, p_name, "alg_version") or
237 mem.eql(u8, p_name, "hash") or
238 mem.eql(u8, p_name, "salt")))
239 {
240 const value = @field(params, p_name);
241 try out.writeAll(if (has_params) params_delimiter else fields_delimiter);
242 if (@typeInfo(p_type) == .@"struct") {
243 var buf: [@TypeOf(value).max_encoded_length]u8 = undefined;
244 try out.print("{s}{s}{s}", .{ p_name, kv_delimiter, try value.toB64(&buf) });
245 } else {
246 try out.print(
247 if (@typeInfo(@TypeOf(value)) == .pointer) "{s}{s}{s}" else "{s}{s}{}",
248 .{ p_name, kv_delimiter, value },
249 );
250 }
251 has_params = true;
252 }
253 }
254
255 var has_salt = false;
256 if (@hasField(HashResult, "salt")) {
257 var buf: [@TypeOf(params.salt).max_encoded_length]u8 = undefined;
258 try out.print("{s}{s}", .{ fields_delimiter, try params.salt.toB64(&buf) });
259 has_salt = true;
260 }
261
262 if (@hasField(HashResult, "hash")) {
263 var buf: [@TypeOf(params.hash).max_encoded_length]u8 = undefined;
264 if (!has_salt) try out.writeAll(fields_delimiter);
265 try out.print("{s}{s}", .{ fields_delimiter, try params.hash.toB64(&buf) });
266 }
267}
268
269// Split a `key=value` string into `key` and `value`
270fn kvSplit(str: []const u8) !struct { key: []const u8, value: []const u8 } {
271 var it = mem.splitScalar(u8, str, kv_delimiter_scalar);
272 const key = it.first();
273 const value = it.next() orelse return Error.InvalidEncoding;
274 return .{ .key = key, .value = value };
275}
276
277test "phc format - encoding/decoding" {
278 const Input = struct {
279 str: []const u8,
280 HashResult: type,
281 };
282 const inputs = [_]Input{
283 .{
284 .str = "$argon2id$v=19$key=a2V5,m=4096,t=0,p=1$X1NhbHQAAAAAAAAAAAAAAA$bWh++MKN1OiFHKgIWTLvIi1iHicmHH7+Fv3K88ifFfI",
285 .HashResult = struct {
286 alg_id: []const u8,
287 alg_version: u16,
288 key: BinValue(16),
289 m: usize,
290 t: u64,
291 p: u32,
292 salt: BinValue(16),
293 hash: BinValue(32),
294 },
295 },
296 .{
297 .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M",
298 .HashResult = struct {
299 alg_id: []const u8,
300 alg_version: ?u30,
301 ln: u6,
302 r: u30,
303 p: u30,
304 salt: BinValue(16),
305 hash: BinValue(16),
306 },
307 },
308 .{
309 .str = "$scrypt",
310 .HashResult = struct { alg_id: []const u8 },
311 },
312 .{ .str = "$scrypt$v=1", .HashResult = struct { alg_id: []const u8, alg_version: u16 } },
313 .{
314 .str = "$scrypt$ln=15,r=8,p=1",
315 .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
316 },
317 .{
318 .str = "$scrypt$c2FsdHNhbHQ",
319 .HashResult = struct { alg_id: []const u8, salt: BinValue(16) },
320 },
321 .{
322 .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ",
323 .HashResult = struct {
324 alg_id: []const u8,
325 alg_version: u16,
326 ln: u6,
327 r: u30,
328 p: u30,
329 salt: BinValue(16),
330 },
331 },
332 .{
333 .str = "$scrypt$v=1$ln=15,r=8,p=1",
334 .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
335 },
336 .{
337 .str = "$scrypt$v=1$c2FsdHNhbHQ$dGVzdHBhc3M",
338 .HashResult = struct {
339 alg_id: []const u8,
340 alg_version: u16,
341 salt: BinValue(16),
342 hash: BinValue(16),
343 },
344 },
345 .{
346 .str = "$scrypt$v=1$c2FsdHNhbHQ",
347 .HashResult = struct { alg_id: []const u8, alg_version: u16, salt: BinValue(16) },
348 },
349 .{
350 .str = "$scrypt$c2FsdHNhbHQ$dGVzdHBhc3M",
351 .HashResult = struct { alg_id: []const u8, salt: BinValue(16), hash: BinValue(16) },
352 },
353 };
354 inline for (inputs) |input| {
355 const v = try deserialize(input.HashResult, input.str);
356 var buf: [input.str.len]u8 = undefined;
357 const s1 = try serialize(v, &buf);
358 try std.testing.expectEqualSlices(u8, input.str, s1);
359 }
360}
361
362test "phc format - empty input string" {
363 const s = "";
364 const v = deserialize(struct { alg_id: []const u8 }, s);
365 try std.testing.expectError(Error.InvalidEncoding, v);
366}
367
368test "phc format - hash without salt" {
369 const s = "$scrypt";
370 const v = deserialize(struct { alg_id: []const u8, hash: BinValue(16) }, s);
371 try std.testing.expectError(Error.InvalidEncoding, v);
372}
373
374test "phc format - calcSize" {
375 const s = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M";
376 const v = try deserialize(struct {
377 alg_id: []const u8,
378 alg_version: u16,
379 ln: u6,
380 r: u30,
381 p: u30,
382 salt: BinValue(8),
383 hash: BinValue(8),
384 }, s);
385 try std.testing.expectEqual(calcSize(v), s.len);
386}