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}