diff --git a/docs/api/hashing.md b/docs/api/hashing.md index 1ff4a83f4ff1a9..c85b9db621ba74 100644 --- a/docs/api/hashing.md +++ b/docs/api/hashing.md @@ -206,4 +206,42 @@ console.log(arr); // => Uint8Array(32) [ 185, 77, 39, 185, 147, ... ] ``` - +### HMAC in `Bun.CryptoHasher` + +`Bun.CryptoHasher` can be used to compute HMAC digests. To do so, pass the key to the constructor. + +```ts +const hasher = new Bun.CryptoHasher("sha256", "secret-key"); +hasher.update("hello world"); +console.log(hasher.digest("hex")); +// => "095d5a21fe6d0646db223fdf3de6436bb8dfb2fab0b51677ecf6441fcf5f2a67" +``` + +When using HMAC, a more limited set of algorithms are supported: + +- `"blake2b512"` +- `"md5"` +- `"sha1"` +- `"sha224"` +- `"sha256"` +- `"sha384"` +- `"sha512-224"` +- `"sha512-256"` +- `"sha512"` + +Unlike the non-HMAC `Bun.CryptoHasher`, the HMAC `Bun.CryptoHasher` instance is not reset after `.digest()` is called, and attempting to use the same instance again will throw an error. + +Other methods like `.copy()` and `.update()` are supported (as long as it's before `.digest()`), but methods like `.digest()` that finalize the hasher are not. + +```ts +const hasher = new Bun.CryptoHasher("sha256", "secret-key"); +hasher.update("hello world"); + +const copy = hasher.copy(); +copy.update("!"); +console.log(copy.digest("hex")); +// => "3840176c3d8923f59ac402b7550404b28ab11cb0ef1fa199130a5c37864b5497" + +console.log(hasher.digest("hex")); +// => "095d5a21fe6d0646db223fdf3de6436bb8dfb2fab0b51677ecf6441fcf5f2a67" +``` diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 79114830bdc463..f669ca9bf687ce 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3345,8 +3345,9 @@ declare module "bun" { * Create a new hasher * * @param algorithm The algorithm to use. See {@link algorithms} for a list of supported algorithms + * @param hmacKey Optional key for HMAC. Must be a string or `TypedArray`. If not provided, the hasher will be a non-HMAC hasher. */ - constructor(algorithm: SupportedCryptoAlgorithms); + constructor(algorithm: SupportedCryptoAlgorithms, hmacKey?: string | NodeJS.TypedArray); /** * Update the hash with data diff --git a/src/bun.js/api/BunObject.zig b/src/bun.js/api/BunObject.zig index 21e729c6382b90..242afcad8da75a 100644 --- a/src/bun.js/api/BunObject.zig +++ b/src/bun.js/api/BunObject.zig @@ -1190,6 +1190,58 @@ pub const Crypto = struct { const Hashers = @import("../../sha.zig"); const BoringSSL = bun.BoringSSL; + pub const HMAC = struct { + ctx: BoringSSL.HMAC_CTX, + algorithm: EVP.Algorithm, + + pub usingnamespace bun.New(@This()); + + pub fn init(algorithm: EVP.Algorithm, key: []const u8) ?*HMAC { + var ctx: BoringSSL.HMAC_CTX = undefined; + BoringSSL.HMAC_CTX_init(&ctx); + if (BoringSSL.HMAC_Init_ex(&ctx, key.ptr, @intCast(key.len), algorithm.md(), null) != 1) { + BoringSSL.HMAC_CTX_cleanup(&ctx); + return null; + } + return HMAC.new(.{ + .ctx = ctx, + .algorithm = algorithm, + }); + } + + pub fn update(this: *HMAC, data: []const u8) void { + _ = BoringSSL.HMAC_Update(&this.ctx, data.ptr, data.len); + } + + pub fn size(this: *const HMAC) usize { + return BoringSSL.HMAC_size(&this.ctx); + } + + pub fn copy(this: *HMAC) !*HMAC { + var ctx: BoringSSL.HMAC_CTX = undefined; + BoringSSL.HMAC_CTX_init(&ctx); + if (BoringSSL.HMAC_CTX_copy(&ctx, &this.ctx) != 1) { + BoringSSL.HMAC_CTX_cleanup(&ctx); + return error.BoringSSLError; + } + return HMAC.new(.{ + .ctx = ctx, + .algorithm = this.algorithm, + }); + } + + pub fn final(this: *HMAC, out: []u8) []u8 { + var outlen: c_uint = undefined; + _ = BoringSSL.HMAC_Final(&this.ctx, out.ptr, &outlen); + return out[0..outlen]; + } + + pub fn deinit(this: *HMAC) void { + BoringSSL.HMAC_CTX_cleanup(&this.ctx); + this.destroy(); + } + }; + pub const EVP = struct { ctx: BoringSSL.EVP_MD_CTX = undefined, md: *const BoringSSL.EVP_MD = undefined, @@ -2433,6 +2485,9 @@ pub const Crypto = struct { }; pub const CryptoHasher = union(enum) { + // HMAC_CTX contains 3 EVP_CTX, so let's store it as a pointer. + hmac: ?*HMAC, + evp: EVP, zig: CryptoHasherZig, @@ -2444,12 +2499,20 @@ pub const Crypto = struct { pub const digest = JSC.wrapInstanceMethod(CryptoHasher, "digest_", false); pub const hash = JSC.wrapStaticMethod(CryptoHasher, "hash_", false); + fn throwHmacConsumed(globalThis: *JSC.JSGlobalObject) void { + globalThis.throw("HMAC has been consumed and is no longer usable", .{}); + } + pub fn getByteLength( this: *CryptoHasher, - _: *JSC.JSGlobalObject, + globalThis: *JSC.JSGlobalObject, ) JSC.JSValue { return JSC.JSValue.jsNumber(switch (this.*) { .evp => |*inner| inner.size(), + .hmac => |inner| if (inner) |hmac| hmac.size() else { + throwHmacConsumed(globalThis); + return JSC.JSValue.zero; + }, .zig => |*inner| inner.digest_length, }); } @@ -2459,7 +2522,11 @@ pub const Crypto = struct { globalObject: *JSC.JSGlobalObject, ) JSC.JSValue { return switch (this.*) { - inline else => |*inner| ZigString.fromUTF8(bun.asByteSlice(@tagName(inner.algorithm))).toJS(globalObject), + inline .evp, .zig => |*inner| ZigString.fromUTF8(bun.asByteSlice(@tagName(inner.algorithm))).toJS(globalObject), + .hmac => |inner| if (inner) |hmac| ZigString.fromUTF8(bun.asByteSlice(@tagName(hmac.algorithm))).toJS(globalObject) else { + throwHmacConsumed(globalObject); + return JSC.JSValue.zero; + }, }; } @@ -2569,6 +2636,7 @@ pub const Crypto = struct { } } + // Bun.CryptoHasher(algorithm, hmacKey?: string | Buffer) pub fn constructor(globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame) ?*CryptoHasher { const arguments = callframe.arguments(2); if (arguments.len == 0) { @@ -2589,13 +2657,54 @@ pub const Crypto = struct { return null; } - var this: CryptoHasher = undefined; - const evp = EVP.byName(algorithm, globalThis) orelse return CryptoHasherZig.constructor(algorithm) orelse { - globalThis.throwInvalidArguments("Unsupported algorithm {any}", .{algorithm}); - return null; - }; - this = .{ .evp = evp }; - return CryptoHasher.new(this); + const hmac_value = arguments.ptr[1]; + var hmac_key: ?JSC.Node.StringOrBuffer = null; + defer { + if (hmac_key) |*key| { + key.deinit(); + } + } + + if (!hmac_value.isEmptyOrUndefinedOrNull()) { + hmac_key = JSC.Node.StringOrBuffer.fromJS(globalThis, bun.default_allocator, hmac_value) orelse { + globalThis.throwInvalidArguments("key must be a string or buffer", .{}); + return null; + }; + } + + return CryptoHasher.new(brk: { + if (hmac_key) |*key| { + const chosen_algorithm = algorithm_name.toEnumFromMap(globalThis, "algorithm", EVP.Algorithm, EVP.Algorithm.map) catch return null; + if (chosen_algorithm == .ripemd160) { + // crashes at runtime. + globalThis.throw("ripemd160 is not supported", .{}); + return null; + } + + break :brk .{ + .hmac = HMAC.init(chosen_algorithm, key.slice()) orelse { + if (!globalThis.hasException()) { + const err = BoringSSL.ERR_get_error(); + if (err != 0) { + const instance = createCryptoError(globalThis, err); + BoringSSL.ERR_clear_error(); + globalThis.throwValue(instance); + } else { + globalThis.throwTODO("HMAC is not supported for this algorithm"); + } + } + return null; + }, + }; + } + + break :brk .{ + .evp = EVP.byName(algorithm, globalThis) orelse return CryptoHasherZig.constructor(algorithm) orelse { + globalThis.throwInvalidArguments("Unsupported algorithm {any}", .{algorithm}); + return null; + }, + }; + }); } pub fn getter( @@ -2635,6 +2744,21 @@ pub const Crypto = struct { return .zero; } }, + .hmac => |inner| { + const hmac = inner orelse { + throwHmacConsumed(globalThis); + return JSC.JSValue.zero; + }; + + hmac.update(buffer.slice()); + const err = BoringSSL.ERR_get_error(); + if (err != 0) { + const instance = createCryptoError(globalThis, err); + BoringSSL.ERR_clear_error(); + globalThis.throwValue(instance); + return .zero; + } + }, .zig => |*inner| { inner.update(buffer.slice()); return thisValue; @@ -2654,6 +2778,20 @@ pub const Crypto = struct { .evp => |*inner| { new = .{ .evp = inner.copy(globalObject.bunVM().rareData().boringEngine()) catch bun.outOfMemory() }; }, + .hmac => |inner| { + const hmac = inner orelse { + throwHmacConsumed(globalObject); + return JSC.JSValue.zero; + }; + new = .{ + .hmac = hmac.copy() catch { + const err = createCryptoError(globalObject, BoringSSL.ERR_get_error()); + BoringSSL.ERR_clear_error(); + globalObject.throwValue(err); + return JSC.JSValue.zero; + }, + }; + }, .zig => |*inner| { new = .{ .zig = inner.copy() }; }, @@ -2704,6 +2842,9 @@ pub const Crypto = struct { } const result = this.final(globalThis, output_digest_slice); + if (globalThis.hasException()) { + return JSC.JSValue.zero; + } if (output) |output_buf| { return output_buf.value; @@ -2717,11 +2858,23 @@ pub const Crypto = struct { var output_digest_buf: EVP.Digest = std.mem.zeroes(EVP.Digest); const output_digest_slice: []u8 = &output_digest_buf; const out = this.final(globalThis, output_digest_slice); + if (globalThis.hasException()) { + return JSC.JSValue.zero; + } return encoding.encodeWithMaxSize(globalThis, BoringSSL.EVP_MAX_MD_SIZE, out); } fn final(this: *CryptoHasher, globalThis: *JSGlobalObject, output_digest_slice: []u8) []u8 { return switch (this.*) { + .hmac => |inner| brk: { + const hmac: *HMAC = inner orelse { + throwHmacConsumed(globalThis); + return &.{}; + }; + this.hmac = null; + defer hmac.deinit(); + break :brk hmac.final(output_digest_slice); + }, .evp => |*inner| inner.final(globalThis.bunVM().rareData().boringEngine(), output_digest_slice), .zig => |*inner| inner.final(output_digest_slice), }; @@ -2736,6 +2889,11 @@ pub const Crypto = struct { .zig => |*inner| { inner.deinit(); }, + .hmac => |inner| { + if (inner) |hmac| { + hmac.deinit(); + } + }, } this.destroy(); } diff --git a/src/js/node/crypto.ts b/src/js/node/crypto.ts index 41edf9d099110a..a43aee7db8b783 100644 --- a/src/js/node/crypto.ts +++ b/src/js/node/crypto.ts @@ -1194,7 +1194,7 @@ var require_browser2 = __commonJS({ // does not become a node stream unless you create it into one const LazyHash = function Hash(algorithm, options) { this._options = options; - this._hasher = new CryptoHasher(algorithm, options); + this._hasher = new CryptoHasher(algorithm); this._finalized = false; }; LazyHash.prototype = Object.create(StreamModule.Transform.prototype); diff --git a/test/js/bun/util/bun-cryptohasher.test.ts b/test/js/bun/util/bun-cryptohasher.test.ts index 499328208f9b2e..b5b44ed7f38e2d 100644 --- a/test/js/bun/util/bun-cryptohasher.test.ts +++ b/test/js/bun/util/bun-cryptohasher.test.ts @@ -14,6 +14,54 @@ test("CryptoHasher update should throw when no parameter/null/undefined is passe // @ts-expect-error expect(() => new Bun.CryptoHasher("sha1").update(null)).toThrow(); }); + +describe("HMAC", () => { + const hashes = { + "sha1": "e2e1f7f597941d9b0021978618218a9e08731426", + "sha256": "c7a7c96c73af32ea6e5b1ca6768b1d822249eb88f85160433d7b09bb2b21e170", + "sha384": "2483522dcb7cb65fa13f0a3c1efe867abbd79ecb19a6ba4bac45d4f4bac31de2e2463b11838b8055601fad73d0b5af4c", + "sha512": + "f82266c950db24eba03f899466fdf905494709f09f98f4b7d7db31f1443a33b4fe5ca82f74fb360609d8a05a87fb065dd77bee912c27de89cbba7897061ac735", + "blake2b512": + "9e66ba10f4d7e80abc2584150fc5f9a246634118280fd9ae086794d37cb9919d681ee285b68f9cec2eda9f878d157125cc465c8b0e3c023a7040ed0be7f25023", + "md5": "4e7eb9f9332e4eb1dc5a2d7d065ba1bf", + "sha224": "d34c3a2647d4f82a4e6baeaa7d94379eafd931e0c16cbc44b4ba4d1e", + "sha512-224": "af398c7f21f58e1377580227a89590d3ab8be52b31182fad9ec4d667", + "sha512-256": "0ed15b2750a2a7281e96af006ab79e82ed54a7a2081bdb49e70a70d8c6bfeff0", + }; + for (let key of ["key", Buffer.from("key"), Buffer.from("key").buffer]) { + test.each(Object.entries(hashes))("%s (key: " + key.constructor.name + ")", (algorithm, expected) => { + const hmac = new Bun.CryptoHasher(algorithm, key); + hmac.update("data\n"); + const copied = hmac.copy(); + expect(hmac.algorithm).toEqual(algorithm); + expect(hmac.byteLength).toEqual(hashes[algorithm].length / 2); + expect(copied.copy()).toBeInstanceOf(Bun.CryptoHasher); + + expect(hmac.digest("hex")).toEqual(expected); + + expect(copied.algorithm).toEqual(algorithm); + expect(copied.byteLength).toEqual(hashes[algorithm].length / 2); + + expect(copied.digest("hex")).toEqual(expected); + expect(() => hmac.digest()).toThrow(); + expect(() => copied.digest()).toThrow(); + expect(() => hmac.byteLength).toThrow(); + expect(() => copied.byteLength).toThrow(); + expect(() => copied.copy()).toThrow(); + expect(() => hmac.copy()).toThrow(); + + // Note that algorithm may throw if the first time the property was accessed is after it was already consumed. + // This is a property caching edgecase that it does not always throw. + // But let's see if anyone complains about it. It is extremely minor + }); + } + + test("ripemd160 is not supported", () => { + expect(() => new Bun.CryptoHasher("ripemd160", "key")).toThrow(); + }); +}); + describe("Hash is consistent", () => { const sourceInputs = [ Buffer.from([