master
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 inline for (comptime meta.fields(HashResult)) |p| {
119 if (mem.eql(u8, p.name, param.key)) {
120 switch (@typeInfo(p.type)) {
121 .int => @field(out, p.name) = fmt.parseUnsigned(
122 p.type,
123 param.value,
124 10,
125 ) catch return Error.InvalidEncoding,
126 .pointer => |ptr| {
127 if (!ptr.is_const) @compileError("Value slice must be constant");
128 @field(out, p.name) = param.value;
129 },
130 .@"struct" => try @field(out, p.name).fromB64(param.value),
131 else => std.debug.panic(
132 "Value for [{s}] must be an integer, a constant slice or a BinValue",
133 .{p.name},
134 ),
135 }
136 set_fields += 1;
137 found = true;
138 break;
139 }
140 }
141 if (!found) return Error.InvalidEncoding; // An unexpected parameter was found in the string
142 has_params = true;
143 }
144
145 // No separator between an empty parameters set and the salt
146 if (has_params) field = it.next() orelse break;
147
148 // Read an optional salt
149 if (@hasField(HashResult, "salt")) {
150 try out.salt.fromB64(field);
151 set_fields += 1;
152 } else {
153 return Error.InvalidEncoding;
154 }
155
156 // Read an optional hash
157 field = it.next() orelse break;
158 if (@hasField(HashResult, "hash")) {
159 try out.hash.fromB64(field);
160 set_fields += 1;
161 } else {
162 return Error.InvalidEncoding;
163 }
164 break;
165 }
166
167 // Check that all the required fields have been set, excluding optional values and parameters
168 // with default values
169 var expected_fields: usize = 0;
170 inline for (comptime meta.fields(HashResult)) |p| {
171 if (@typeInfo(p.type) != .optional and p.default_value_ptr == null) {
172 expected_fields += 1;
173 }
174 }
175 if (set_fields < expected_fields) return Error.InvalidEncoding;
176
177 return out;
178}
179
180/// Serialize parameters into a PHC string.
181///
182/// Required field for `params`:
183/// - `alg_id`: algorithm identifier
184/// Optional, special fields:
185/// - `alg_version`: algorithm version (unsigned integer)
186/// - `salt`: salt
187/// - `hash`: output of the hash function
188///
189/// `params` can also include any additional parameters.
190pub fn serialize(params: anytype, str: []u8) Error![]const u8 {
191 var w: Writer = .fixed(str);
192 serializeTo(params, &w) catch return error.NoSpaceLeft;
193 return w.buffered();
194}
195
196/// Compute the number of bytes required to serialize `params`
197pub fn calcSize(params: anytype) usize {
198 var trash: [128]u8 = undefined;
199 var d: Writer.Discarding = .init(&trash);
200 serializeTo(params, &d.writer) catch unreachable;
201 return @intCast(d.fullCount());
202}
203
204fn serializeTo(params: anytype, out: *std.Io.Writer) !void {
205 const HashResult = @TypeOf(params);
206
207 if (@hasField(HashResult, version_param_name)) {
208 @compileError("Field name '" ++ version_param_name ++ "'' is reserved for the algorithm version");
209 }
210
211 try out.writeAll(fields_delimiter);
212 try out.writeAll(params.alg_id);
213
214 if (@hasField(HashResult, "alg_version")) {
215 if (@typeInfo(@TypeOf(params.alg_version)) == .optional) {
216 if (params.alg_version) |alg_version| {
217 try out.print(
218 "{s}{s}{s}{}",
219 .{ fields_delimiter, version_param_name, kv_delimiter, alg_version },
220 );
221 }
222 } else {
223 try out.print(
224 "{s}{s}{s}{}",
225 .{ fields_delimiter, version_param_name, kv_delimiter, params.alg_version },
226 );
227 }
228 }
229
230 var has_params = false;
231 inline for (comptime meta.fields(HashResult)) |p| {
232 if (comptime !(mem.eql(u8, p.name, "alg_id") or
233 mem.eql(u8, p.name, "alg_version") or
234 mem.eql(u8, p.name, "hash") or
235 mem.eql(u8, p.name, "salt")))
236 {
237 const value = @field(params, p.name);
238 try out.writeAll(if (has_params) params_delimiter else fields_delimiter);
239 if (@typeInfo(p.type) == .@"struct") {
240 var buf: [@TypeOf(value).max_encoded_length]u8 = undefined;
241 try out.print("{s}{s}{s}", .{ p.name, kv_delimiter, try value.toB64(&buf) });
242 } else {
243 try out.print(
244 if (@typeInfo(@TypeOf(value)) == .pointer) "{s}{s}{s}" else "{s}{s}{}",
245 .{ p.name, kv_delimiter, value },
246 );
247 }
248 has_params = true;
249 }
250 }
251
252 var has_salt = false;
253 if (@hasField(HashResult, "salt")) {
254 var buf: [@TypeOf(params.salt).max_encoded_length]u8 = undefined;
255 try out.print("{s}{s}", .{ fields_delimiter, try params.salt.toB64(&buf) });
256 has_salt = true;
257 }
258
259 if (@hasField(HashResult, "hash")) {
260 var buf: [@TypeOf(params.hash).max_encoded_length]u8 = undefined;
261 if (!has_salt) try out.writeAll(fields_delimiter);
262 try out.print("{s}{s}", .{ fields_delimiter, try params.hash.toB64(&buf) });
263 }
264}
265
266// Split a `key=value` string into `key` and `value`
267fn kvSplit(str: []const u8) !struct { key: []const u8, value: []const u8 } {
268 var it = mem.splitScalar(u8, str, kv_delimiter_scalar);
269 const key = it.first();
270 const value = it.next() orelse return Error.InvalidEncoding;
271 return .{ .key = key, .value = value };
272}
273
274test "phc format - encoding/decoding" {
275 const Input = struct {
276 str: []const u8,
277 HashResult: type,
278 };
279 const inputs = [_]Input{
280 .{
281 .str = "$argon2id$v=19$key=a2V5,m=4096,t=0,p=1$X1NhbHQAAAAAAAAAAAAAAA$bWh++MKN1OiFHKgIWTLvIi1iHicmHH7+Fv3K88ifFfI",
282 .HashResult = struct {
283 alg_id: []const u8,
284 alg_version: u16,
285 key: BinValue(16),
286 m: usize,
287 t: u64,
288 p: u32,
289 salt: BinValue(16),
290 hash: BinValue(32),
291 },
292 },
293 .{
294 .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M",
295 .HashResult = struct {
296 alg_id: []const u8,
297 alg_version: ?u30,
298 ln: u6,
299 r: u30,
300 p: u30,
301 salt: BinValue(16),
302 hash: BinValue(16),
303 },
304 },
305 .{
306 .str = "$scrypt",
307 .HashResult = struct { alg_id: []const u8 },
308 },
309 .{ .str = "$scrypt$v=1", .HashResult = struct { alg_id: []const u8, alg_version: u16 } },
310 .{
311 .str = "$scrypt$ln=15,r=8,p=1",
312 .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
313 },
314 .{
315 .str = "$scrypt$c2FsdHNhbHQ",
316 .HashResult = struct { alg_id: []const u8, salt: BinValue(16) },
317 },
318 .{
319 .str = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ",
320 .HashResult = struct {
321 alg_id: []const u8,
322 alg_version: u16,
323 ln: u6,
324 r: u30,
325 p: u30,
326 salt: BinValue(16),
327 },
328 },
329 .{
330 .str = "$scrypt$v=1$ln=15,r=8,p=1",
331 .HashResult = struct { alg_id: []const u8, alg_version: ?u30, ln: u6, r: u30, p: u30 },
332 },
333 .{
334 .str = "$scrypt$v=1$c2FsdHNhbHQ$dGVzdHBhc3M",
335 .HashResult = struct {
336 alg_id: []const u8,
337 alg_version: u16,
338 salt: BinValue(16),
339 hash: BinValue(16),
340 },
341 },
342 .{
343 .str = "$scrypt$v=1$c2FsdHNhbHQ",
344 .HashResult = struct { alg_id: []const u8, alg_version: u16, salt: BinValue(16) },
345 },
346 .{
347 .str = "$scrypt$c2FsdHNhbHQ$dGVzdHBhc3M",
348 .HashResult = struct { alg_id: []const u8, salt: BinValue(16), hash: BinValue(16) },
349 },
350 };
351 inline for (inputs) |input| {
352 const v = try deserialize(input.HashResult, input.str);
353 var buf: [input.str.len]u8 = undefined;
354 const s1 = try serialize(v, &buf);
355 try std.testing.expectEqualSlices(u8, input.str, s1);
356 }
357}
358
359test "phc format - empty input string" {
360 const s = "";
361 const v = deserialize(struct { alg_id: []const u8 }, s);
362 try std.testing.expectError(Error.InvalidEncoding, v);
363}
364
365test "phc format - hash without salt" {
366 const s = "$scrypt";
367 const v = deserialize(struct { alg_id: []const u8, hash: BinValue(16) }, s);
368 try std.testing.expectError(Error.InvalidEncoding, v);
369}
370
371test "phc format - calcSize" {
372 const s = "$scrypt$v=1$ln=15,r=8,p=1$c2FsdHNhbHQ$dGVzdHBhc3M";
373 const v = try deserialize(struct {
374 alg_id: []const u8,
375 alg_version: u16,
376 ln: u6,
377 r: u30,
378 p: u30,
379 salt: BinValue(8),
380 hash: BinValue(8),
381 }, s);
382 try std.testing.expectEqual(calcSize(v), s.len);
383}