| 1 | // https://github.com/P-H-C/phc-string-format |
| 2 | |
| 3 | const std = @import("std"); |
| 4 | const fmt = std.fmt; |
| 5 | const mem = std.mem; |
| 6 | const meta = std.meta; |
| 7 | const Writer = std.Io.Writer; |
| 8 | |
| 9 | const fields_delimiter = "$"; |
| 10 | const fields_delimiter_scalar = '$'; |
| 11 | const version_param_name = "v"; |
| 12 | const params_delimiter = ","; |
| 13 | const params_delimiter_scalar = ','; |
| 14 | const kv_delimiter = "="; |
| 15 | const kv_delimiter_scalar = '='; |
| 16 | |
| 17 | pub const Error = std.crypto.errors.EncodingError || error{NoSpaceLeft}; |
| 18 | |
| 19 | const B64Decoder = std.base64.standard_no_pad.Decoder; |
| 20 | const 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. |
| 28 | pub 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. |
| 77 | pub 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. |
| 192 | pub 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` |
| 199 | pub 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 | |
| 206 | fn 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` |
| 270 | fn 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 | |
| 277 | test "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 | |
| 362 | test "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 | |
| 368 | test "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 | |
| 374 | test "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 | } |