diff options
author | 2023-06-04 18:20:04 -0700 | |
---|---|---|
committer | 2023-06-04 18:20:04 -0700 | |
commit | 9b996e702ef32d03b01b745642292e7a747485fa (patch) | |
tree | 4b49c2010c694afe5cf6ef738402f80e0ab7c5b8 | |
parent | 2cb1376a93a59acca548769155e0d3b6110a7bd2 (diff) | |
download | bun-9b996e702ef32d03b01b745642292e7a747485fa.tar.gz bun-9b996e702ef32d03b01b745642292e7a747485fa.tar.zst bun-9b996e702ef32d03b01b745642292e7a747485fa.zip |
Implement `Bun.password` and `Bun.passwordSync` (#3204)
* Implement `Bun.password.{verify, hash}` and `Bun.passwordSync.{verify, hash}`
* flip the booleans
* delete unused
* Add `cost` for `"bcrypt"`, add `"memoryCost"` and `"timeCost'` for argon2, use SHA512
* Update bun.zig
---------
Co-authored-by: Jarred Sumner <709451+Jarred-Sumner@users.noreply.github.com>
-rw-r--r-- | packages/bun-types/bun.d.ts | 191 | ||||
-rw-r--r-- | src/bun.js/api/bun.zig | 803 | ||||
-rw-r--r-- | src/bun.js/bindings/ZigGlobalObject.cpp | 50 | ||||
-rw-r--r-- | src/bun.js/bindings/ZigGlobalObject.h | 5 | ||||
-rw-r--r-- | src/bun.js/node/types.zig | 12 | ||||
-rw-r--r-- | src/bun.zig | 2 | ||||
-rw-r--r-- | test/js/bun/util/password.test.ts | 279 |
7 files changed, 1339 insertions, 3 deletions
diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 27b01433c..2396ffbd9 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -1012,6 +1012,197 @@ declare module "bun" { // importSource?: string; // default: "react" // }; } + namespace Password { + export type AlgorithmLabel = "bcrypt" | "argon2id" | "argon2d" | "argon2i"; + + export interface Argon2Algorithm { + algorithm: "argon2id" | "argon2d" | "argon2i"; + /** + * Memory cost, which defines the memory usage, given in kibibytes. + */ + memoryCost?: number; + /** + * Defines the amount of computation realized and therefore the execution + * time, given in number of iterations. + */ + timeCost?: number; + } + + export interface BCryptAlgorithm { + algorithm: "bcrypt"; + /** + * A number between 4 and 31. The default is 10. + */ + cost?: number; + } + } + + /** + * Hash and verify passwords using argon2 or bcrypt. The default is argon2. + * Password hashing functions are necessarily slow, and this object will + * automatically run in a worker thread. + * + * The underlying implementation of these functions are provided by the Zig + * Standard Library. Thanks to @jedisct1 and other Zig constributors for their + * work on this. + * + * ### Example with argon2 + * + * ```ts + * import {password} from "bun"; + * + * const hash = await password.hash("hello world"); + * const verify = await password.verify("hello world", hash); + * console.log(verify); // true + * ``` + * + * ### Example with bcrypt + * ```ts + * import {password} from "bun"; + * + * const hash = await password.hash("hello world", "bcrypt"); + * // algorithm is optional, will be inferred from the hash if not specified + * const verify = await password.verify("hello world", hash, "bcrypt"); + * + * console.log(verify); // true + * ``` + */ + export const password: { + /** + * Verify a password against a previously hashed password. + * + * @returns true if the password matches, false otherwise + * + * @example + * ```ts + * import {password} from "bun"; + * await password.verify("hey", "$argon2id$v=19$m=65536,t=2,p=1$ddbcyBcbAcagei7wSkZFiouX6TqnUQHmTyS5mxGCzeM$+3OIaFatZ3n6LtMhUlfWbgJyNp7h8/oIsLK+LzZO+WI"); + * // true + * ``` + * + * @throws If the algorithm is specified and does not match the hash + * @throws If the algorithm is invalid + * @throws if the hash is invalid + * + */ + verify( + /** + * The password to verify. + * + * If empty, always returns false + */ + password: StringOrBuffer, + /** + * Previously hashed password. + * If empty, always returns false + */ + hash: StringOrBuffer, + /** + * If not specified, the algorithm will be inferred from the hash. + * + * If specified and the algorithm does not match the hash, this function + * throws an error. + */ + algorithm?: Password.AlgorithmLabel, + ): Promise<boolean>; + /** + * Asynchronously hash a password using argon2 or bcrypt. The default is argon2. + * + * @returns A promise that resolves to the hashed password + * + * ## Example with argon2 + * ```ts + * import {password} from "bun"; + * const hash = await password.hash("hello world"); + * console.log(hash); // $argon2id$v=1... + * const verify = await password.verify("hello world", hash); + * ``` + * ## Example with bcrypt + * ```ts + * import {password} from "bun"; + * const hash = await password.hash("hello world", "bcrypt"); + * console.log(hash); // $2b$10$... + * const verify = await password.verify("hello world", hash); + * ``` + */ + hash( + /** + * The password to hash + * + * If empty, this function throws an error. It is usually a programming + * mistake to hash an empty password. + */ + password: StringOrBuffer, + /** + * @default "argon2id" + * + * When using bcrypt, passwords exceeding 72 characters will be SHA512'd before + */ + algorithm?: + | Password.AlgorithmLabel + | Password.Argon2Algorithm + | Password.BCryptAlgorithm, + ): Promise<string>; + }; + + /** + * Synchronously hash and verify passwords using argon2 or bcrypt. The default is argon2. + * Warning: password hashing is slow, consider using {@link Bun.password} + * instead which runs in a worker thread. + * + * The underlying implementation of these functions are provided by the Zig + * Standard Library. Thanks to @jedisct1 and other Zig constributors for their + * work on this. + * + * ### Example with argon2 + * + * ```ts + * import {password} from "bun"; + * + * const hash = await password.hash("hello world"); + * const verify = await password.verify("hello world", hash); + * console.log(verify); // true + * ``` + * + * ### Example with bcrypt + * ```ts + * import {password} from "bun"; + * + * const hash = await password.hash("hello world", "bcrypt"); + * // algorithm is optional, will be inferred from the hash if not specified + * const verify = await password.verify("hello world", hash, "bcrypt"); + * + * console.log(verify); // true + * ``` + */ + export const passwordSync: { + verify( + password: StringOrBuffer, + hash: StringOrBuffer, + /** + * If not specified, the algorithm will be inferred from the hash. + */ + algorithm?: Password.AlgorithmLabel, + ): boolean; + hash( + /** + * The password to hash + * + * If empty, this function throws an error. It is usually a programming + * mistake to hash an empty password. + */ + password: StringOrBuffer, + /** + * @default "argon2id" + * + * When using bcrypt, passwords exceeding 72 characters will be SHA256'd before + */ + algorithm?: + | Password.AlgorithmLabel + | Password.Argon2Algorithm + | Password.BCryptAlgorithm, + ): string; + }; interface BuildArtifact extends Blob { path: string; diff --git a/src/bun.js/api/bun.zig b/src/bun.js/api/bun.zig index 3e09075e7..78a7379c2 100644 --- a/src/bun.js/api/bun.zig +++ b/src/bun.js/api/bun.zig @@ -1632,6 +1632,803 @@ pub const Crypto = struct { return ZigString.fromUTF8(error_message).toErrorInstance(globalThis); } + const unknwon_password_algorithm_message = "unknown algorithm, expected one of: \"bcrypt\", \"argon2id\", \"argon2d\", \"argon2i\" (default is \"argon2id\")"; + + pub const PasswordObject = struct { + pub const pwhash = std.crypto.pwhash; + pub const Algorithm = enum { + argon2i, + argon2d, + argon2id, + bcrypt, + + pub const Value = union(Algorithm) { + argon2i: Argon2Params, + argon2d: Argon2Params, + argon2id: Argon2Params, + // bcrypt only accepts "cost" + bcrypt: u6, + + pub const bcrpyt_default = 10; + + pub const default = Algorithm.Value{ + .argon2id = .{}, + }; + + pub fn fromJS(globalObject: *JSC.JSGlobalObject, value: JSC.JSValue) ?Value { + if (value.isObject()) { + if (value.getTruthy(globalObject, "algorithm")) |algorithm_value| { + if (!algorithm_value.isString()) { + globalObject.throwInvalidArgumentType("hash", "algorithm", "string"); + return null; + } + + const algorithm_string = algorithm_value.getZigString(globalObject); + + switch (PasswordObject.Algorithm.label.getWithEql(algorithm_string, JSC.ZigString.eqlComptime) orelse { + globalObject.throwInvalidArgumentType("hash", "algorithm", unknwon_password_algorithm_message); + return null; + }) { + .bcrypt => { + var algorithm = PasswordObject.Algorithm.Value{ + .bcrypt = PasswordObject.Algorithm.Value.bcrpyt_default, + }; + + if (value.getTruthy(globalObject, "cost")) |rounds_value| { + if (!rounds_value.isNumber()) { + globalObject.throwInvalidArgumentType("hash", "cost", "number"); + return null; + } + + const rounds = rounds_value.coerce(i32, globalObject); + + if (rounds < 4 or rounds > 31) { + globalObject.throwInvalidArguments("Rounds must be between 4 and 31", .{}); + return null; + } + + algorithm.bcrypt = @intCast(u6, rounds); + } + + return algorithm; + }, + inline .argon2id, .argon2d, .argon2i => |tag| { + var argon = Algorithm.Argon2Params{}; + + if (value.getTruthy(globalObject, "timeCost")) |time_value| { + if (!time_value.isNumber()) { + globalObject.throwInvalidArgumentType("hash", "timeCost", "number"); + return null; + } + + const time_cost = time_value.coerce(i32, globalObject); + + if (time_cost < 1) { + globalObject.throwInvalidArguments("Time cost must be greater than 0", .{}); + return null; + } + + argon.time_cost = @intCast(u32, time_cost); + } + + if (value.getTruthy(globalObject, "memoryCost")) |memory_value| { + if (!memory_value.isNumber()) { + globalObject.throwInvalidArgumentType("hash", "memoryCost", "number"); + return null; + } + + const memory_cost = memory_value.coerce(i32, globalObject); + + if (memory_cost < 1) { + globalObject.throwInvalidArguments("Memory cost must be greater than 0", .{}); + return null; + } + + argon.memory_cost = @intCast(u32, memory_cost); + } + + return @unionInit(Algorithm.Value, @tagName(tag), argon); + }, + } + + unreachable; + } else { + globalObject.throwInvalidArgumentType("hash", "options.algorithm", "string"); + return null; + } + } else if (value.isString()) { + const algorithm_string = value.getZigString(globalObject); + + switch (PasswordObject.Algorithm.label.getWithEql(algorithm_string, JSC.ZigString.eqlComptime) orelse { + globalObject.throwInvalidArgumentType("hash", "algorithm", unknwon_password_algorithm_message); + return null; + }) { + .bcrypt => { + return PasswordObject.Algorithm.Value{ + .bcrypt = PasswordObject.Algorithm.Value.bcrpyt_default, + }; + }, + .argon2id => { + return PasswordObject.Algorithm.Value{ + .argon2id = .{}, + }; + }, + .argon2d => { + return PasswordObject.Algorithm.Value{ + .argon2d = .{}, + }; + }, + .argon2i => { + return PasswordObject.Algorithm.Value{ + .argon2i = .{}, + }; + }, + } + } else { + globalObject.throwInvalidArgumentType("hash", "algorithm", "string"); + return null; + } + + unreachable; + } + }; + + pub const Argon2Params = struct { + // we don't support the other options right now, but can add them later if someone asks + memory_cost: u32 = pwhash.argon2.Params.interactive_2id.m, + time_cost: u32 = pwhash.argon2.Params.interactive_2id.t, + + pub fn toParams(this: Argon2Params) pwhash.argon2.Params { + return pwhash.argon2.Params{ + .t = this.time_cost, + .m = this.memory_cost, + .p = 1, + }; + } + }; + + pub const argon2 = Algorithm.argon2id; + + pub const label = bun.ComptimeStringMap( + Algorithm, + .{ + .{ "argon2i", .argon2i }, + .{ "argon2d", .argon2d }, + .{ "argon2id", .argon2id }, + .{ "bcrypt", .bcrypt }, + }, + ); + + pub const default = Algorithm.argon2; + + pub fn get(pw: []const u8) ?Algorithm { + if (pw[0] != '$') { + return null; + } + + // PHC format looks like $<algorithm>$<params>$<salt>$<hash><optional stuff> + if (strings.hasPrefixComptime(pw[1..], "argon2d$")) { + return .argon2d; + } + if (strings.hasPrefixComptime(pw[1..], "argon2i$")) { + return .argon2i; + } + if (strings.hasPrefixComptime(pw[1..], "argon2id$")) { + return .argon2id; + } + + if (strings.hasPrefixComptime(pw[1..], "bcrypt")) { + return .bcrypt; + } + + // https://en.wikipedia.org/wiki/Crypt_(C) + if (strings.hasPrefixComptime(pw[1..], "2")) { + return .bcrypt; + } + + return null; + } + }; + + pub const HashError = pwhash.Error || error{UnsupportedAlgorithm}; + + // This is purposely simple because nobody asked to make it more complicated + pub fn hash( + allocator: std.mem.Allocator, + password: []const u8, + algorithm: Algorithm.Value, + ) HashError![]const u8 { + switch (algorithm) { + inline .argon2i, .argon2d, .argon2id => |argon| { + var outbuf: [4096]u8 = undefined; + const hash_options = pwhash.argon2.HashOptions{ + .params = argon.toParams(), + .allocator = allocator, + .mode = switch (algorithm) { + .argon2i => .argon2i, + .argon2d => .argon2d, + .argon2id => .argon2id, + else => unreachable, + }, + .encoding = .phc, + }; + // warning: argon2's code may spin up threads if paralellism is set to > 0 + // we don't expose this option + // but since it parses from phc format, it's possible that it will be set + // eventually we should do something that about that. + const out_bytes = try pwhash.argon2.strHash(password, hash_options, &outbuf); + return try allocator.dupe(u8, out_bytes); + }, + .bcrypt => |cost| { + var outbuf: [4096]u8 = undefined; + var outbuf_slice: []u8 = outbuf[0..]; + var password_to_use = password; + // bcrypt silently truncates passwords longer than 72 bytes + // we use SHA512 to hash the password if it's longer than 72 bytes + if (password.len > 72) { + var sha_256 = bun.sha.SHA512.init(); + sha_256.update(password); + sha_256.final(outbuf[0..bun.sha.SHA512.digest]); + password_to_use = outbuf[0..bun.sha.SHA512.digest]; + outbuf_slice = outbuf[bun.sha.SHA512.digest..]; + } + + const hash_options = pwhash.bcrypt.HashOptions{ + .params = pwhash.bcrypt.Params{ .rounds_log = cost }, + .allocator = allocator, + .encoding = .crypt, + }; + const out_bytes = try pwhash.bcrypt.strHash(password_to_use, hash_options, outbuf_slice); + return try allocator.dupe(u8, out_bytes); + }, + } + } + + pub fn verify( + allocator: std.mem.Allocator, + password: []const u8, + previous_hash: []const u8, + algorithm: ?Algorithm, + ) HashError!bool { + if (previous_hash.len == 0) { + return false; + } + + return verifyWithAlgorithm( + allocator, + password, + previous_hash, + algorithm orelse Algorithm.get(previous_hash) orelse return error.UnsupportedAlgorithm, + ); + } + + pub fn verifyWithAlgorithm( + allocator: std.mem.Allocator, + password: []const u8, + previous_hash: []const u8, + algorithm: Algorithm, + ) HashError!bool { + switch (algorithm) { + .argon2id, .argon2d, .argon2i => { + pwhash.argon2.strVerify(previous_hash, password, .{ .allocator = allocator }) catch |err| { + if (err == error.PasswordVerificationFailed) { + return false; + } + + return err; + }; + return true; + }, + .bcrypt => { + pwhash.bcrypt.strVerify(previous_hash, password, .{ .allocator = allocator }) catch |err| { + if (err == error.PasswordVerificationFailed) { + return false; + } + + return err; + }; + return true; + }, + } + } + }; + + pub const JSPasswordObject = struct { + const PascalToUpperUnderscoreCaseFormatter = struct { + input: []const u8, + pub fn format(self: @This(), comptime _: []const u8, _: std.fmt.FormatOptions, writer: anytype) !void { + for (self.input) |c| { + if (std.ascii.isUpper(c)) { + try writer.writeByte('_'); + try writer.writeByte(c); + } else if (std.ascii.isLower(c)) { + try writer.writeByte(std.ascii.toUpper(c)); + } else { + try writer.writeByte(c); + } + } + } + }; + + pub export fn JSPasswordObject__create(globalObject: *JSC.JSGlobalObject, sync: bool) JSC.JSValue { + var object = JSValue.createEmptyObject(globalObject, 2); + object.put( + globalObject, + ZigString.static("hash"), + if (!sync) + JSC.NewFunction(globalObject, ZigString.static("hash"), 2, JSPasswordObject__hash, false) + else + JSC.NewFunction(globalObject, ZigString.static("hash"), 2, JSPasswordObject__hashSync, false), + ); + object.put( + globalObject, + ZigString.static("verify"), + if (!sync) + JSC.NewFunction(globalObject, ZigString.static("verify"), 2, JSPasswordObject__verify, false) + else + JSC.NewFunction(globalObject, ZigString.static("verify"), 2, JSPasswordObject__verifySync, false), + ); + return object; + } + + const HashJob = struct { + algorithm: PasswordObject.Algorithm.Value, + password: []const u8, + promise: JSC.JSPromise.Strong, + event_loop: *JSC.EventLoop, + global: *JSC.JSGlobalObject, + ref: JSC.PollRef = .{}, + task: JSC.WorkPoolTask = .{ .callback = &run }, + + pub const Result = struct { + value: Value, + ref: JSC.PollRef = .{}, + + task: JSC.AnyTask = undefined, + promise: JSC.JSPromise.Strong, + global: *JSC.JSGlobalObject, + + pub const Value = union(enum) { + err: PasswordObject.HashError, + hash: []const u8, + + pub fn toErrorInstance(this: Value, globalObject: *JSC.JSGlobalObject) JSC.JSValue { + var error_code = std.fmt.allocPrint(bun.default_allocator, "PASSWORD_{}", .{PascalToUpperUnderscoreCaseFormatter{ .input = @errorName(this.err) }}) catch @panic("out of memory"); + defer bun.default_allocator.free(error_code); + const instance = globalObject.createErrorInstance("Password hashing failed with error \"{s}\"", .{@errorName(this.err)}); + instance.put(globalObject, ZigString.static("code"), JSC.ZigString.init(error_code).toValueGC(globalObject)); + return instance; + } + }; + + pub fn runFromJS(this: *Result) void { + var promise = this.promise; + this.promise = .{}; + this.ref.unref(this.global.bunVM()); + var global = this.global; + switch (this.value) { + .err => { + const error_instance = this.value.toErrorInstance(global); + bun.default_allocator.destroy(this); + promise.reject(global, error_instance); + }, + .hash => |value| { + const js_string = JSC.ZigString.init(value).toValueGC(global); + bun.default_allocator.destroy(this); + promise.resolve(global, js_string); + }, + } + } + }; + + pub fn deinit(this: *HashJob) void { + this.ref = .{}; + this.promise.strong.deinit(); + bun.default_allocator.free(this.password); + bun.default_allocator.destroy(this); + } + + pub fn getValue(password: []const u8, algorithm: PasswordObject.Algorithm.Value) Result.Value { + const value = PasswordObject.hash(bun.default_allocator, password, algorithm) catch |err| { + return Result.Value{ .err = err }; + }; + return Result.Value{ .hash = value }; + } + + pub fn run(task: *bun.ThreadPool.Task) void { + var this = @fieldParentPtr(HashJob, "task", task); + + var result = bun.default_allocator.create(Result) catch @panic("out of memory"); + result.* = Result{ + .value = getValue(this.password, this.algorithm), + .task = JSC.AnyTask.New(Result, Result.runFromJS).init(result), + .promise = this.promise, + .global = this.global, + .ref = this.ref, + }; + this.ref = .{}; + this.promise.strong = .{}; + + var concurrent_task = bun.default_allocator.create(JSC.ConcurrentTask) catch @panic("out of memory"); + concurrent_task.* = JSC.ConcurrentTask{ + .task = JSC.Task.init(&result.task), + .auto_delete = true, + }; + this.event_loop.enqueueTaskConcurrent(concurrent_task); + this.deinit(); + } + }; + pub fn hash( + globalObject: *JSC.JSGlobalObject, + password: []const u8, + algorithm: PasswordObject.Algorithm.Value, + comptime sync: bool, + ) JSC.JSValue { + std.debug.assert(password.len > 0); // caller must check + + if (comptime sync) { + const value = HashJob.getValue(password, algorithm); + switch (value) { + .err => { + const error_instance = value.toErrorInstance(globalObject); + globalObject.throwValue(error_instance); + }, + .hash => |h| { + return JSC.ZigString.init(h).toValueGC(globalObject); + }, + } + + unreachable; + } + + var job = bun.default_allocator.create(HashJob) catch @panic("out of memory"); + var promise = JSC.JSPromise.Strong.init(globalObject); + + job.* = HashJob{ + .algorithm = algorithm, + .password = password, + .promise = promise, + .event_loop = globalObject.bunVM().eventLoop(), + .global = globalObject, + }; + + job.ref.ref(globalObject.bunVM()); + JSC.WorkPool.schedule(&job.task); + + return promise.value(); + } + + pub fn verify( + globalObject: *JSC.JSGlobalObject, + password: []const u8, + prev_hash: []const u8, + algorithm: ?PasswordObject.Algorithm, + comptime sync: bool, + ) JSC.JSValue { + std.debug.assert(password.len > 0); // caller must check + + if (comptime sync) { + const value = VerifyJob.getValue(password, prev_hash, algorithm); + switch (value) { + .err => { + const error_instance = value.toErrorInstance(globalObject); + globalObject.throwValue(error_instance); + return JSC.JSValue.undefined; + }, + .pass => |pass| { + return JSC.JSValue.jsBoolean(pass); + }, + } + + unreachable; + } + + var job = bun.default_allocator.create(VerifyJob) catch @panic("out of memory"); + var promise = JSC.JSPromise.Strong.init(globalObject); + + job.* = VerifyJob{ + .algorithm = algorithm, + .password = password, + .prev_hash = prev_hash, + .promise = promise, + .event_loop = globalObject.bunVM().eventLoop(), + .global = globalObject, + }; + + job.ref.ref(globalObject.bunVM()); + JSC.WorkPool.schedule(&job.task); + + return promise.value(); + } + + // Once we have bindings generator, this should be replaced with a generated function + pub export fn JSPasswordObject__hash( + globalObject: *JSC.JSGlobalObject, + callframe: *JSC.CallFrame, + ) callconv(.C) JSC.JSValue { + const arguments_ = callframe.arguments(2); + const arguments = arguments_.ptr[0..arguments_.len]; + + if (arguments.len < 1) { + globalObject.throwNotEnoughArguments("hash", 1, 0); + return JSC.JSValue.undefined; + } + + var algorithm = PasswordObject.Algorithm.Value.default; + + if (arguments.len > 1 and !arguments[1].isEmptyOrUndefinedOrNull()) { + algorithm = PasswordObject.Algorithm.Value.fromJS(globalObject, arguments[1]) orelse + return JSC.JSValue.undefined; + } + + var string_or_buffer = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[0]) orelse { + globalObject.throwInvalidArgumentType("hash", "password", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + if (string_or_buffer.slice().len == 0) { + globalObject.throwInvalidArguments("password must not be empty", .{}); + string_or_buffer.deinit(); + return JSC.JSValue.undefined; + } + + string_or_buffer.ensureCloned(bun.default_allocator) catch { + globalObject.throwOutOfMemory(); + return JSC.JSValue.undefined; + }; + + return hash(globalObject, string_or_buffer.slice(), algorithm, false); + } + + // Once we have bindings generator, this should be replaced with a generated function + pub export fn JSPasswordObject__hashSync( + globalObject: *JSC.JSGlobalObject, + callframe: *JSC.CallFrame, + ) callconv(.C) JSC.JSValue { + const arguments_ = callframe.arguments(2); + const arguments = arguments_.ptr[0..arguments_.len]; + + if (arguments.len < 1) { + globalObject.throwNotEnoughArguments("hash", 1, 0); + return JSC.JSValue.undefined; + } + + var algorithm = PasswordObject.Algorithm.Value.default; + + if (arguments.len > 1 and !arguments[1].isEmptyOrUndefinedOrNull()) { + algorithm = PasswordObject.Algorithm.Value.fromJS(globalObject, arguments[1]) orelse + return JSC.JSValue.undefined; + } + + var string_or_buffer = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[0]) orelse { + globalObject.throwInvalidArgumentType("hash", "password", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + if (string_or_buffer.slice().len == 0) { + globalObject.throwInvalidArguments("password must not be empty", .{}); + string_or_buffer.deinit(); + return JSC.JSValue.undefined; + } + + string_or_buffer.ensureCloned(bun.default_allocator) catch { + globalObject.throwOutOfMemory(); + return JSC.JSValue.undefined; + }; + defer string_or_buffer.deinit(); + + return hash(globalObject, string_or_buffer.slice(), algorithm, true); + } + + const VerifyJob = struct { + algorithm: ?PasswordObject.Algorithm = null, + password: []const u8, + prev_hash: []const u8, + promise: JSC.JSPromise.Strong, + event_loop: *JSC.EventLoop, + global: *JSC.JSGlobalObject, + ref: JSC.PollRef = .{}, + task: JSC.WorkPoolTask = .{ .callback = &run }, + + pub const Result = struct { + value: Value, + ref: JSC.PollRef = .{}, + + task: JSC.AnyTask = undefined, + promise: JSC.JSPromise.Strong, + global: *JSC.JSGlobalObject, + + pub const Value = union(enum) { + err: PasswordObject.HashError, + pass: bool, + + pub fn toErrorInstance(this: Value, globalObject: *JSC.JSGlobalObject) JSC.JSValue { + var error_code = std.fmt.allocPrint(bun.default_allocator, "PASSWORD{}", .{PascalToUpperUnderscoreCaseFormatter{ .input = @errorName(this.err) }}) catch @panic("out of memory"); + defer bun.default_allocator.free(error_code); + const instance = globalObject.createErrorInstance("Password verification failed with error \"{s}\"", .{@errorName(this.err)}); + instance.put(globalObject, ZigString.static("code"), JSC.ZigString.init(error_code).toValueGC(globalObject)); + return instance; + } + }; + + pub fn runFromJS(this: *Result) void { + var promise = this.promise; + this.promise = .{}; + this.ref.unref(this.global.bunVM()); + var global = this.global; + switch (this.value) { + .err => { + const error_instance = this.value.toErrorInstance(global); + bun.default_allocator.destroy(this); + promise.reject(global, error_instance); + }, + .pass => |pass| { + bun.default_allocator.destroy(this); + promise.resolve(global, JSC.JSValue.jsBoolean(pass)); + }, + } + } + }; + + pub fn deinit(this: *VerifyJob) void { + this.ref = .{}; + this.promise.strong.deinit(); + bun.default_allocator.free(this.password); + bun.default_allocator.free(this.prev_hash); + bun.default_allocator.destroy(this); + } + + pub fn getValue(password: []const u8, prev_hash: []const u8, algorithm: ?PasswordObject.Algorithm) Result.Value { + const pass = PasswordObject.verify(bun.default_allocator, password, prev_hash, algorithm) catch |err| { + return Result.Value{ .err = err }; + }; + return Result.Value{ .pass = pass }; + } + + pub fn run(task: *bun.ThreadPool.Task) void { + var this = @fieldParentPtr(VerifyJob, "task", task); + + var result = bun.default_allocator.create(Result) catch @panic("out of memory"); + result.* = Result{ + .value = getValue(this.password, this.prev_hash, this.algorithm), + .task = JSC.AnyTask.New(Result, Result.runFromJS).init(result), + .promise = this.promise, + .global = this.global, + .ref = this.ref, + }; + this.ref = .{}; + this.promise.strong = .{}; + + var concurrent_task = bun.default_allocator.create(JSC.ConcurrentTask) catch @panic("out of memory"); + concurrent_task.* = JSC.ConcurrentTask{ + .task = JSC.Task.init(&result.task), + .auto_delete = true, + }; + this.event_loop.enqueueTaskConcurrent(concurrent_task); + this.deinit(); + } + }; + + // Once we have bindings generator, this should be replaced with a generated function + pub export fn JSPasswordObject__verify( + globalObject: *JSC.JSGlobalObject, + callframe: *JSC.CallFrame, + ) callconv(.C) JSC.JSValue { + const arguments_ = callframe.arguments(3); + const arguments = arguments_.ptr[0..arguments_.len]; + + if (arguments.len < 2) { + globalObject.throwNotEnoughArguments("verify", 2, 0); + return JSC.JSValue.undefined; + } + + var algorithm: ?PasswordObject.Algorithm = null; + + if (arguments.len > 2 and !arguments[2].isEmptyOrUndefinedOrNull()) { + if (!arguments[2].isString()) { + globalObject.throwInvalidArgumentType("verify", "algorithm", "string"); + return JSC.JSValue.undefined; + } + + const algorithm_string = arguments[2].getZigString(globalObject); + + algorithm = PasswordObject.Algorithm.label.getWithEql(algorithm_string, JSC.ZigString.eqlComptime) orelse { + globalObject.throwInvalidArgumentType("verify", "algorithm", unknwon_password_algorithm_message); + return JSC.JSValue.undefined; + }; + } + + var password = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[0]) orelse { + globalObject.throwInvalidArgumentType("verify", "password", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + var hash_ = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[1]) orelse { + password.deinit(); + globalObject.throwInvalidArgumentType("verify", "hash", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + if (hash_.slice().len == 0) { + password.deinit(); + return JSC.JSPromise.resolvedPromiseValue(globalObject, JSC.JSValue.jsBoolean(false)); + } + + if (password.slice().len == 0) { + hash_.deinit(); + return JSC.JSPromise.resolvedPromiseValue(globalObject, JSC.JSValue.jsBoolean(false)); + } + + password.ensureCloned(bun.default_allocator) catch { + hash_.deinit(); + globalObject.throwOutOfMemory(); + return JSC.JSValue.undefined; + }; + + hash_.ensureCloned(bun.default_allocator) catch { + password.deinit(); + globalObject.throwOutOfMemory(); + return JSC.JSValue.undefined; + }; + + return verify(globalObject, password.slice(), hash_.slice(), algorithm, false); + } + + // Once we have bindings generator, this should be replaced with a generated function + pub export fn JSPasswordObject__verifySync( + globalObject: *JSC.JSGlobalObject, + callframe: *JSC.CallFrame, + ) callconv(.C) JSC.JSValue { + const arguments_ = callframe.arguments(3); + const arguments = arguments_.ptr[0..arguments_.len]; + + if (arguments.len < 2) { + globalObject.throwNotEnoughArguments("verify", 2, 0); + return JSC.JSValue.undefined; + } + + var algorithm: ?PasswordObject.Algorithm = null; + + if (arguments.len > 2 and !arguments[2].isEmptyOrUndefinedOrNull()) { + if (!arguments[2].isString()) { + globalObject.throwInvalidArgumentType("verify", "algorithm", "string"); + return JSC.JSValue.undefined; + } + + const algorithm_string = arguments[2].getZigString(globalObject); + + algorithm = PasswordObject.Algorithm.label.getWithEql(algorithm_string, JSC.ZigString.eqlComptime) orelse { + globalObject.throwInvalidArgumentType("verify", "algorithm", unknwon_password_algorithm_message); + return JSC.JSValue.undefined; + }; + } + + var password = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[0]) orelse { + globalObject.throwInvalidArgumentType("verify", "password", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + var hash_ = JSC.Node.SliceOrBuffer.fromJS(globalObject, bun.default_allocator, arguments[1]) orelse { + password.deinit(); + globalObject.throwInvalidArgumentType("verify", "hash", "string or TypedArray"); + return JSC.JSValue.undefined; + }; + + defer password.deinit(); + defer hash_.deinit(); + + if (hash_.slice().len == 0) { + return JSC.JSValue.jsBoolean(false); + } + + if (password.slice().len == 0) { + return JSC.JSValue.jsBoolean(false); + } + + return verify(globalObject, password.slice(), hash_.slice(), algorithm, true); + } + }; + pub const CryptoHasher = struct { evp: EVP = undefined, @@ -4306,3 +5103,9 @@ pub const JSZlib = struct { }; pub usingnamespace @import("./bun/subprocess.zig"); + +comptime { + if (!JSC.is_bindgen) { + _ = Crypto.JSPasswordObject.JSPasswordObject__create; + } +} diff --git a/src/bun.js/bindings/ZigGlobalObject.cpp b/src/bun.js/bindings/ZigGlobalObject.cpp index 0a453a9c8..bd9c19133 100644 --- a/src/bun.js/bindings/ZigGlobalObject.cpp +++ b/src/bun.js/bindings/ZigGlobalObject.cpp @@ -361,6 +361,17 @@ extern "C" bool Zig__GlobalObject__resetModuleRegistryMap(JSC__JSGlobalObject* g return true; } +#define BUN_LAZY_GETTER_FN_NAME(GetterName) BunLazyGetter##GetterName##_getter + +#define DEFINE_BUN_LAZY_GETTER(GetterName, __propertyName) \ + JSC_DEFINE_CUSTOM_GETTER(GetterName, \ + (JSC::JSGlobalObject * lexicalGlobalObject, JSC::EncodedJSValue thisValue, \ + JSC::PropertyName)) \ + { \ + Zig::GlobalObject* thisObject = JSC::jsCast<Zig::GlobalObject*>(lexicalGlobalObject); \ + return JSC::JSValue::encode(thisObject->__propertyName()); \ + } + #define GENERATED_CONSTRUCTOR_GETTER(ConstructorName) \ JSC_DECLARE_CUSTOM_GETTER(ConstructorName##_getter); \ JSC_DEFINE_CUSTOM_GETTER(ConstructorName##_getter, \ @@ -2492,6 +2503,7 @@ JSC::JSValue GlobalObject::formatStackTrace(JSC::VM& vm, JSC::JSGlobalObject* le } extern "C" void Bun__remapStackFramePositions(JSC::JSGlobalObject*, ZigStackFrame*, size_t); +extern "C" EncodedJSValue JSPasswordObject__create(JSC::JSGlobalObject*, bool); JSC_DECLARE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace); JSC_DEFINE_HOST_FUNCTION(errorConstructorFuncCaptureStackTrace, (JSC::JSGlobalObject * lexicalGlobalObject, JSC::CallFrame* callFrame)) @@ -2599,6 +2611,24 @@ void GlobalObject::finishCreation(VM& vm) init.set(result.toObject(globalObject)); }); + m_lazyPasswordObject.initLater( + [](const Initializer<JSObject>& init) { + JSC::VM& vm = init.vm; + JSC::JSGlobalObject* globalObject = init.owner; + + JSValue result = JSValue::decode(JSPasswordObject__create(globalObject, false)); + init.set(result.toObject(globalObject)); + }); + + m_lazyPasswordSyncObject.initLater( + [](const Initializer<JSObject>& init) { + JSC::VM& vm = init.vm; + JSC::JSGlobalObject* globalObject = init.owner; + + JSValue result = JSValue::decode(JSPasswordObject__create(globalObject, true)); + init.set(result.toObject(globalObject)); + }); + m_lazyPreloadTestModuleObject.initLater( [](const Initializer<JSObject>& init) { JSC::VM& vm = init.vm; @@ -3525,6 +3555,9 @@ void GlobalObject::addBuiltinGlobals(JSC::VM& vm) extern "C" void Crypto__randomUUID__put(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue value); extern "C" void Crypto__getRandomValues__put(JSC::JSGlobalObject* globalObject, JSC::EncodedJSValue value); +DEFINE_BUN_LAZY_GETTER(BUN_LAZY_GETTER_FN_NAME(password), passwordObject) +DEFINE_BUN_LAZY_GETTER(BUN_LAZY_GETTER_FN_NAME(passwordSync), passwordSyncObject) + // This is not a publicly exposed API currently. // This is used by the bundler to make Response, Request, FetchEvent, // and any other objects available globally. @@ -3583,6 +3616,19 @@ void GlobalObject::installAPIGlobals(JSClassRef* globals, int count, JSC::VM& vm JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::Function | JSC::PropertyAttribute::DontDelete | 0); } + // TODO: code generate these + { + JSC::Identifier identifier = JSC::Identifier::fromString(vm, "password"_s); + object->putDirectCustomAccessor(vm, identifier, JSC::CustomGetterSetter::create(vm, BUN_LAZY_GETTER_FN_NAME(password), nullptr), + JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete | 0); + } + + { + JSC::Identifier identifier = JSC::Identifier::fromString(vm, "passwordSync"_s); + object->putDirectCustomAccessor(vm, identifier, JSC::CustomGetterSetter::create(vm, BUN_LAZY_GETTER_FN_NAME(passwordSync), nullptr), + JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::DontDelete | 0); + } + { JSC::Identifier identifier = JSC::Identifier::fromString(vm, "readableStreamToArrayBuffer"_s); object->putDirectBuiltinFunction(vm, this, identifier, readableStreamReadableStreamToArrayBufferCodeGenerator(vm), @@ -3853,6 +3899,8 @@ void GlobalObject::visitChildrenImpl(JSCell* cell, Visitor& visitor) thisObject->m_lazyTestModuleObject.visit(visitor); thisObject->m_lazyPreloadTestModuleObject.visit(visitor); thisObject->m_commonJSModuleObjectStructure.visit(visitor); + thisObject->m_lazyPasswordObject.visit(visitor); + thisObject->m_lazyPasswordSyncObject.visit(visitor); thisObject->m_commonJSFunctionArgumentsStructure.visit(visitor); thisObject->m_cachedGlobalObjectStructure.visit(visitor); thisObject->m_cachedGlobalProxyStructure.visit(visitor); @@ -4094,7 +4142,6 @@ JSC::JSObject* GlobalObject::moduleLoaderCreateImportMetaProperties(JSGlobalObje JSModuleRecord* record, JSValue val) { - JSC::VM& vm = globalObject->vm(); JSC::JSString* keyString = key.toStringOrNull(globalObject); if (UNLIKELY(!keyString)) @@ -4108,7 +4155,6 @@ JSC::JSValue GlobalObject::moduleLoaderEvaluate(JSGlobalObject* globalObject, JSValue moduleRecordValue, JSValue scriptFetcher, JSValue sentValue, JSValue resumeMode) { - if (UNLIKELY(scriptFetcher && scriptFetcher.isObject())) { return scriptFetcher; } diff --git a/src/bun.js/bindings/ZigGlobalObject.h b/src/bun.js/bindings/ZigGlobalObject.h index 66853c909..2363df74d 100644 --- a/src/bun.js/bindings/ZigGlobalObject.h +++ b/src/bun.js/bindings/ZigGlobalObject.h @@ -264,6 +264,9 @@ public: Structure* commonJSFunctionArgumentsStructure() { return m_commonJSFunctionArgumentsStructure.getInitializedOnMainThread(this); } + JSObject* passwordSyncObject() { return m_lazyPasswordSyncObject.getInitializedOnMainThread(this); } + JSObject* passwordObject() { return m_lazyPasswordObject.getInitializedOnMainThread(this); } + JSWeakMap* vmModuleContextMap() { return m_vmModuleContextMap.getInitializedOnMainThread(this); } JSC::JSObject* processObject() @@ -476,6 +479,8 @@ private: LazyProperty<JSGlobalObject, JSObject> m_lazyRequireCacheObject; LazyProperty<JSGlobalObject, JSObject> m_lazyTestModuleObject; LazyProperty<JSGlobalObject, JSObject> m_lazyPreloadTestModuleObject; + LazyProperty<JSGlobalObject, JSObject> m_lazyPasswordSyncObject; + LazyProperty<JSGlobalObject, JSObject> m_lazyPasswordObject; LazyProperty<JSGlobalObject, JSFunction> m_bunSleepThenCallback; LazyProperty<JSGlobalObject, Structure> m_cachedGlobalObjectStructure; diff --git a/src/bun.js/node/types.zig b/src/bun.js/node/types.zig index 987b30d3c..23af9cc7c 100644 --- a/src/bun.js/node/types.zig +++ b/src/bun.js/node/types.zig @@ -312,6 +312,18 @@ pub const SliceOrBuffer = union(Tag) { string: JSC.ZigString.Slice, buffer: Buffer, + pub fn ensureCloned(this: *SliceOrBuffer, allocator: std.mem.Allocator) !void { + if (this.* == .string) { + this.string = try this.string.cloneIfNeeded(allocator); + return; + } + + const bytes = this.buffer.buffer.byteSlice(); + this.* = .{ + .string = JSC.ZigString.Slice.from(try allocator.dupe(u8, bytes), allocator), + }; + } + pub fn deinit(this: SliceOrBuffer) void { switch (this) { .string => { diff --git a/src/bun.zig b/src/bun.zig index 04537feb4..72a2ab8b7 100644 --- a/src/bun.zig +++ b/src/bun.zig @@ -25,7 +25,7 @@ pub const huge_allocator_threshold: comptime_int = @import("./memory_allocator.z pub const fs_allocator = default_allocator; pub const C = @import("c.zig"); - +pub const sha = @import("./sha.zig"); pub const FeatureFlags = @import("feature_flags.zig"); pub const meta = @import("./meta.zig"); pub const ComptimeStringMap = @import("./comptime_string_map.zig").ComptimeStringMap; diff --git a/test/js/bun/util/password.test.ts b/test/js/bun/util/password.test.ts new file mode 100644 index 000000000..67ea332c4 --- /dev/null +++ b/test/js/bun/util/password.test.ts @@ -0,0 +1,279 @@ +import { test, expect, describe } from "bun:test"; + +import { Password, password, passwordSync } from "bun"; + +const placeholder = "hey"; + +describe("hash", () => { + describe("arguments parsing", () => { + for (let { hash } of [password, passwordSync]) { + test("no blank password allowed", () => { + expect(() => hash("")).toThrow("password must not be empty"); + }); + + test("password is required", () => { + // @ts-expect-error + expect(() => hash()).toThrow(); + }); + + test("invalid algorithm throws", () => { + // @ts-expect-error + expect(() => hash(placeholder, "scrpyt")).toThrow(); + // @ts-expect-error + expect(() => hash(placeholder, 123)).toThrow(); + + expect(() => + hash(placeholder, { + // @ts-expect-error + toString() { + return "scrypt"; + }, + }), + ).toThrow(); + + expect(() => + hash(placeholder, { + // @ts-expect-error + algorithm: "poop", + }), + ).toThrow(); + + expect(() => + hash(placeholder, { + algorithm: "bcrypt", + cost: Infinity, + }), + ).toThrow(); + + expect(() => + hash(placeholder, { + algorithm: "argon2id", + memoryCost: -1, + }), + ).toThrow(); + + expect(() => + hash(placeholder, { + algorithm: "argon2id", + timeCost: -1, + }), + ).toThrow(); + + expect(() => + hash(placeholder, { + algorithm: "bcrypt", + cost: -999, + }), + ).toThrow(); + }); + + test("coercion throwing doesn't crash", () => { + // @ts-expect-error + expect(() => hash(Symbol())).toThrow(); + expect(() => + // @ts-expect-error + hash({ + toString() { + throw new Error("toString() failed"); + }, + }), + ).toThrow(); + }); + + for (let ArrayBufferView of [ + Uint8Array, + Uint16Array, + Uint32Array, + Int8Array, + Int16Array, + Int32Array, + Float32Array, + Float64Array, + ArrayBuffer, + ]) { + test(`empty ${ArrayBufferView.name} throws`, () => { + expect(() => hash(new ArrayBufferView(0))).toThrow("password must not be empty"); + }); + } + } + }); +}); + +describe("verify", () => { + describe("arguments parsing", () => { + for (let { verify } of [password, passwordSync]) { + test("minimum args", () => { + // @ts-expect-error + expect(() => verify()).toThrow(); + // @ts-expect-error + expect(() => verify("")).toThrow(); + }); + + test("empty values return false", async () => { + expect(await verify("", "$")).toBeFalse(); + expect(await verify("$", "")).toBeFalse(); + }); + + test("invalid algorithm throws", () => { + // @ts-expect-error + expect(() => verify(placeholder, "$", "scrpyt")).toThrow(); + // @ts-expect-error + expect(() => verify(placeholder, "$", 123)).toThrow(); + expect(() => + // @ts-expect-error + verify(placeholder, "$", { + toString() { + return "scrypt"; + }, + }), + ).toThrow(); + }); + + test("coercion throwing doesn't crash", () => { + // @ts-expect-error + expect(() => verify(Symbol(), Symbol())).toThrow(); + expect(() => + verify( + // @ts-expect-error + { + toString() { + throw new Error("toString() failed"); + }, + }, + "valid", + ), + ).toThrow(); + expect(() => + // @ts-expect-error + verify("valid", { + toString() { + throw new Error("toString() failed"); + }, + }), + ).toThrow(); + }); + + for (let ArrayBufferView of [ + Uint8Array, + Uint16Array, + Uint32Array, + Int8Array, + Int16Array, + Int32Array, + Float32Array, + Float64Array, + ArrayBuffer, + ]) { + test(`empty ${ArrayBufferView.name} returns false`, async () => { + expect(await verify(new ArrayBufferView(0), new ArrayBufferView(0))).toBeFalse(); + expect(await verify("", new ArrayBufferView(0))).toBeFalse(); + expect(await verify(new ArrayBufferView(0), "")).toBeFalse(); + }); + } + } + }); +}); + +test("bcrypt longer than 72 characters is the SHA-512", async () => { + const boop = Buffer.from("hey".repeat(100)); + const hashed = await password.hash(boop, "bcrypt"); + expect(await password.verify(Bun.SHA512.hash(boop), hashed, "bcrypt")).toBeTrue(); +}); + +test("bcrypt shorter than 72 characters is NOT the SHA-512", async () => { + const boop = Buffer.from("hey".repeat(3)); + const hashed = await password.hash(boop, "bcrypt"); + expect(await password.verify(Bun.SHA512.hash(boop), hashed, "bcrypt")).toBeFalse(); +}); + +const defaultAlgorithm = "argon2id"; +const algorithms = [undefined, "argon2id", "bcrypt"]; +const argons = ["argon2i", "argon2id", "argon2d"]; + +for (let algorithmValue of algorithms) { + const prefix = algorithmValue === "bcrypt" ? "$2" : "$" + (algorithmValue || defaultAlgorithm); + + describe(algorithmValue ? algorithmValue : "default", () => { + const hash = (value: string | TypedArray) => { + return algorithmValue ? passwordSync.hash(value, algorithmValue as any) : passwordSync.hash(value); + }; + + const hashSync = (value: string | TypedArray) => { + return algorithmValue ? passwordSync.hash(value, algorithmValue as any) : passwordSync.hash(value); + }; + + const verify = (pw: string | TypedArray, value: string | TypedArray) => { + return algorithmValue ? password.verify(pw, value, algorithmValue as any) : password.verify(pw, value); + }; + + const verifySync = (pw: string | TypedArray, value: string | TypedArray) => { + return algorithmValue ? passwordSync.verify(pw, value, algorithmValue as any) : passwordSync.verify(pw, value); + }; + + for (let input of [placeholder, Buffer.from(placeholder)]) { + describe(typeof input === "string" ? "string" : "buffer", () => { + test("passwordSync", () => { + const hashed = hashSync(input); + expect(hashed).toStartWith(prefix); + expect(verifySync(input, hashed)).toBeTrue(); + expect(() => verifySync(hashed, input)).toThrow(); + expect(verifySync(input + "\0", hashed)).toBeFalse(); + }); + + test("password", async () => { + async function runSlowTest(algorithm = algorithmValue as any) { + const hashed = await password.hash(input, algorithm); + const prefix = "$" + algorithm; + expect(hashed).toStartWith(prefix); + expect(await password.verify(input, hashed, algorithm)).toBeTrue(); + expect(() => password.verify(hashed, input, algorithm)).toThrow(); + expect(await password.verify(input + "\0", hashed, algorithm)).toBeFalse(); + } + + async function runSlowTestWithOptions(algorithmLabel: any) { + const algorithm = { algorithm: algorithmLabel, timeCost: 5, memoryCost: 4 }; + const hashed = await password.hash(input, algorithm); + const prefix = "$" + algorithmLabel; + expect(hashed).toStartWith(prefix); + expect(hashed).toContain("t=5"); + expect(hashed).toContain("m=4"); + expect(await password.verify(input, hashed, algorithmLabel)).toBeTrue(); + expect(() => password.verify(hashed, input, algorithmLabel)).toThrow(); + expect(await password.verify(input + "\0", hashed, algorithmLabel)).toBeFalse(); + } + + async function runSlowBCryptTest() { + const algorithm = { algorithm: "bcrypt", cost: 4 } as const; + const hashed = await password.hash(input, algorithm); + const prefix = "$" + "2b"; + expect(hashed).toStartWith(prefix); + expect(await password.verify(input, hashed, "bcrypt")).toBeTrue(); + expect(() => password.verify(hashed, input, "bcrypt")).toThrow(); + expect(await password.verify(input + "\0", hashed, "bcrypt")).toBeFalse(); + } + + if (algorithmValue === defaultAlgorithm) { + // these tests are very slow + // run the hashing tests in parallel + await Promise.all([...argons.map(runSlowTest), ...argons.map(runSlowTestWithOptions)]); + return; + } + + async function defaultTest() { + const hashed = await hash(input); + expect(hashed).toStartWith(prefix); + expect(await verify(input, hashed)).toBeTrue(); + expect(() => verify(hashed, input)).toThrow(); + expect(await verify(input + "\0", hashed)).toBeFalse(); + } + + if (algorithmValue === "bcrypt") { + await Promise.all([defaultTest(), runSlowBCryptTest()]); + } else { + await defaultTest(); + } + }); + }); + } + }); +} |