Skip to content

Commit

Permalink
Support hmac in Bun.CryptoHasher (#14210)
Browse files Browse the repository at this point in the history
  • Loading branch information
Jarred-Sumner authored Sep 28, 2024
1 parent dd12715 commit af82a44
Show file tree
Hide file tree
Showing 5 changed files with 257 additions and 12 deletions.
40 changes: 39 additions & 1 deletion docs/api/hashing.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,42 @@ console.log(arr);
// => Uint8Array(32) [ 185, 77, 39, 185, 147, ... ]
```

<!-- Bun.sha; -->
### 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"
```
3 changes: 2 additions & 1 deletion packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
176 changes: 167 additions & 9 deletions src/bun.js/api/BunObject.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,

Expand All @@ -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,
});
}
Expand All @@ -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;
},
};
}

Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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() };
},
Expand Down Expand Up @@ -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;
Expand All @@ -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),
};
Expand All @@ -2736,6 +2889,11 @@ pub const Crypto = struct {
.zig => |*inner| {
inner.deinit();
},
.hmac => |inner| {
if (inner) |hmac| {
hmac.deinit();
}
},
}
this.destroy();
}
Expand Down
2 changes: 1 addition & 1 deletion src/js/node/crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions test/js/bun/util/bun-cryptohasher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down

0 comments on commit af82a44

Please sign in to comment.