diff options
| -rw-r--r-- | src/bun.js/bindings/bindings.zig | 38 | ||||
| -rw-r--r-- | src/bun.js/webcore.zig | 197 | ||||
| -rw-r--r-- | src/deps/boringssl.translated.zig | 29 | ||||
| -rw-r--r-- | src/node-fallbacks/crypto.js | 49 | ||||
| -rw-r--r-- | test/bun.js/crypto-scrypt.test.js | 265 |
5 files changed, 568 insertions, 10 deletions
diff --git a/src/bun.js/bindings/bindings.zig b/src/bun.js/bindings/bindings.zig index d15cfd674..add89ea77 100644 --- a/src/bun.js/bindings/bindings.zig +++ b/src/bun.js/bindings/bindings.zig @@ -1837,20 +1837,40 @@ pub const JSGlobalObject = extern struct { createSyntheticModule_(this, &export_names, names.len, &export_values, names.len); } + pub fn createErrorInstance(this: *JSGlobalObject, comptime fmt: string, args: anytype) JSValue { + if (comptime std.meta.fieldNames(@TypeOf(args)).len > 0) { + var stack_fallback = std.heap.stackFallback(1024 * 4, this.allocator()); + var buf = bun.MutableString.init2048(stack_fallback.get()) catch unreachable; + defer buf.deinit(); + var writer = buf.writer(); + writer.print(fmt, args) catch + // if an exception occurs in the middle of formatting the error message, it's better to just return the formatting string than an error about an error + return ZigString.static(fmt).toErrorInstance(this); + var str = ZigString.fromUTF8(buf.toOwnedSliceLeaky()); + return str.toErrorInstance(this); + } else { + return ZigString.static(fmt).toErrorInstance(this); + } + } + + pub fn createRangeError(this: *JSGlobalObject, comptime fmt: string, args: anytype) JSValue { + const err = createErrorInstance(this, fmt, args); + err.put(this, ZigString.static("code"), ZigString.static(@tagName(JSC.Node.ErrorCode.ERR_OUT_OF_RANGE)).toValue(this)); + return err; + } + + pub fn createInvalidArgs(this: *JSGlobalObject, comptime fmt: string, args: anytype) JSValue { + const err = createErrorInstance(this, fmt, args); + err.put(this, ZigString.static("code"), ZigString.static(@tagName(JSC.Node.ErrorCode.ERR_INVALID_ARG_TYPE)).toValue(this)); + return err; + } + pub fn throw( this: *JSGlobalObject, comptime fmt: string, args: anytype, ) void { - if (comptime std.meta.fieldNames(@TypeOf(args)).len > 0) { - var str = ZigString.init(std.fmt.allocPrint(this.bunVM().allocator, fmt, args) catch return); - str.markUTF8(); - var err = str.toErrorInstance(this); - this.vm().throwError(this, err); - this.bunVM().allocator.free(ZigString.untagged(str.ptr)[0..str.len]); - } else { - this.vm().throwError(this, ZigString.static(fmt).toValue(this)); - } + this.vm().throwError(this, this.createErrorInstance(fmt, args)); } pub fn throwValue( diff --git a/src/bun.js/webcore.zig b/src/bun.js/webcore.zig index 10ba5051e..2d1605049 100644 --- a/src/bun.js/webcore.zig +++ b/src/bun.js/webcore.zig @@ -366,6 +366,7 @@ pub const Crypto = struct { .getRandomValues = JSC.DOMCall("Crypto", @This(), "getRandomValues", JSC.JSValue, JSC.DOMEffect.top), .randomUUID = JSC.DOMCall("Crypto", @This(), "randomUUID", *JSC.JSString, JSC.DOMEffect.top), .timingSafeEqual = JSC.DOMCall("Crypto", @This(), "timingSafeEqual", JSC.JSValue, JSC.DOMEffect.top), + .scryptSync = .{ .rfn = JSC.wrapWithHasContainer(Crypto, "scryptSync", false, false, false) }, }, .{}, ); @@ -380,6 +381,202 @@ pub const Crypto = struct { .{}, ); + pub fn scryptSyncValidate( + globalThis: *JSC.JSGlobalObject, + options: ?JSC.JSValue, + ) JSC.JSValue { + var blockSize: usize = 8; + var cost: usize = 16384; + var parallelization: usize = 1; + var maxmem: usize = 32 * 1024 * 1024; + + if (options) |options_value| outer: { + if (options_value.isUndefined() or options_value == .zero) + break :outer; + + if (!options_value.isObject()) { + return globalThis.createInvalidArgs("options must be an object", .{}); + } + + if (options_value.getTruthy(globalThis, "cost") orelse options_value.get(globalThis, "N")) |N_value| { + const N_int = N_value.to(i64); + if (N_int < 0 or !N_value.isNumber()) { + return globalThis.createRangeError("N must be a positive integer", .{}); + } else if (N_int != 0) { + cost = @intCast(usize, N_int); + } + } + + if (options_value.getTruthy(globalThis, "blockSize") orelse options_value.get(globalThis, "r")) |r_value| { + const r_int = r_value.to(i64); + if (r_int < 0 or !r_value.isNumber()) { + return globalThis.createRangeError("r must be a positive integer", .{}); + } else if (r_int != 0) { + blockSize = @intCast(usize, r_int); + } + } + + if (options_value.getTruthy(globalThis, "parallelization") orelse options_value.get(globalThis, "p")) |p_value| { + const p_int = p_value.to(i64); + if (p_int < 0 or !p_value.isNumber()) { + return globalThis.createRangeError("p must be a positive integer", .{}); + } else if (p_int != 0) { + parallelization = @intCast(usize, p_int); + } + } + + if (options_value.getTruthy(globalThis, "maxmem")) |value| { + const p_int = value.to(i64); + if (p_int < 0 or !value.isNumber()) { + return globalThis.createInvalidArgs("maxmem must be a positive integer", .{}); + } else if (p_int != 0) { + maxmem = @intCast(usize, p_int); + } + } + } + + if (cost < 2 or cost > 0x3fffffff) { + return globalThis.createRangeError("N must be greater than 1 and less than 2^30", .{}); + } + + if (cost == 0 or (cost & (cost - 1)) != 0) { + return globalThis.createRangeError("N must be a power of 2 greater than 1", .{}); + } + + if ((BoringSSL.EVP_PBE_scrypt( + null, + 0, + null, + 0, + cost, + blockSize, + parallelization, + maxmem, + null, + 0, + ) != 1)) { + return globalThis.createErrorInstance("scrypt parameters are invalid", .{}); + } + var slice: []u8 = undefined; + slice.len = 0; + return JSC.ArrayBuffer.create(globalThis, slice, .ArrayBuffer); + } + + pub fn scryptSync( + globalThis: *JSC.JSGlobalObject, + password: JSC.Node.StringOrBuffer, + salt: JSC.Node.StringOrBuffer, + keylen_value: JSC.JSValue, + options: ?JSC.JSValue, + ) JSC.JSValue { + const password_string = password.slice(); + const salt_string = salt.slice(); + + if (keylen_value.isEmptyOrUndefinedOrNull()) { + return globalThis.createInvalidArgs("keylen must be a number", .{}); + } + + const keylen_int = keylen_value.to(i64); + if (keylen_int < 0) { + return globalThis.createRangeError("keylen must be a positive integer", .{}); + } else if (keylen_int == 0) { + return scryptSyncValidate(globalThis, options); + } else if (keylen_int > 0x7fffffff) { + return globalThis.createRangeError("keylen must be less than 2^31", .{}); + } + + var blockSize: usize = 8; + var cost: usize = 16384; + var parallelization: usize = 1; + var maxmem: usize = 32 * 1024 * 1024; + const keylen = @intCast(u32, @truncate(i33, keylen_int)); + + if (options) |options_value| outer: { + if (options_value.isUndefined() or options_value == .zero) + break :outer; + + if (!options_value.isObject()) { + return globalThis.createInvalidArgs("options must be an object", .{}); + } + + if (options_value.getTruthy(globalThis, "cost") orelse options_value.get(globalThis, "N")) |N_value| { + const N_int = N_value.to(i64); + if (N_int < 0 or !N_value.isNumber()) { + return globalThis.createRangeError("N must be a positive integer", .{}); + } else if (N_int != 0) { + cost = @intCast(usize, N_int); + } + } + + if (options_value.getTruthy(globalThis, "blockSize") orelse options_value.get(globalThis, "r")) |r_value| { + const r_int = r_value.to(i64); + if (r_int < 0 or !r_value.isNumber()) { + return globalThis.createRangeError("r must be a positive integer", .{}); + } else if (r_int != 0) { + blockSize = @intCast(usize, r_int); + } + } + + if (options_value.getTruthy(globalThis, "parallelization") orelse options_value.get(globalThis, "p")) |p_value| { + const p_int = p_value.to(i64); + if (p_int < 0 or !p_value.isNumber()) { + return globalThis.createRangeError("p must be a positive integer", .{}); + } else if (p_int != 0) { + parallelization = @intCast(usize, p_int); + } + } + + if (options_value.getTruthy(globalThis, "maxmem")) |value| { + const p_int = value.to(i64); + if (p_int < 0 or !value.isNumber()) { + return globalThis.createInvalidArgs("maxmem must be a positive integer", .{}); + } else if (p_int != 0) { + maxmem = @intCast(usize, p_int); + } + } + } + + if (cost < 2 or cost > 0x3fffffff) { + return globalThis.createRangeError("N must be greater than 1 and less than 2^30", .{}); + } + + if (cost == 0 or (cost & (cost - 1)) != 0) { + return globalThis.createRangeError("N must be a power of 2 greater than 1", .{}); + } + + var stackbuf: [1024]u8 = undefined; + var buf: []u8 = &stackbuf; + var needs_deinit = false; + defer if (needs_deinit) globalThis.allocator().free(buf); + if (keylen > buf.len) { + // i don't think its a real scenario, but just in case + buf = globalThis.allocator().alloc(u8, keylen) catch { + globalThis.throw("Failed to allocate memory", .{}); + return JSC.JSValue.jsUndefined(); + }; + needs_deinit = true; + } else { + buf.len = keylen; + } + + if (BoringSSL.EVP_PBE_scrypt( + password_string.ptr, + password_string.len, + salt_string.ptr, + salt_string.len, + cost, + blockSize, + parallelization, + maxmem, + buf.ptr, + keylen, + ) != 1) { + return globalThis.createErrorInstance("Failed to derive key", .{}); + } + + return JSC.ArrayBuffer.create(globalThis, buf, .ArrayBuffer); + } + pub fn timingSafeEqual( globalThis: *JSC.JSGlobalObject, _: JSC.JSValue, diff --git a/src/deps/boringssl.translated.zig b/src/deps/boringssl.translated.zig index 24d54aa1f..ceaa67890 100644 --- a/src/deps/boringssl.translated.zig +++ b/src/deps/boringssl.translated.zig @@ -1457,7 +1457,34 @@ pub extern fn EVP_PKEY_print_private(out: [*c]BIO, pkey: [*c]const EVP_PKEY, ind pub extern fn EVP_PKEY_print_params(out: [*c]BIO, pkey: [*c]const EVP_PKEY, indent: c_int, pctx: ?*ASN1_PCTX) c_int; pub extern fn PKCS5_PBKDF2_HMAC(password: [*c]const u8, password_len: usize, salt: [*c]const u8, salt_len: usize, iterations: c_uint, digest: ?*const EVP_MD, key_len: usize, out_key: [*c]u8) c_int; pub extern fn PKCS5_PBKDF2_HMAC_SHA1(password: [*c]const u8, password_len: usize, salt: [*c]const u8, salt_len: usize, iterations: c_uint, key_len: usize, out_key: [*c]u8) c_int; -pub extern fn EVP_PBE_scrypt(password: [*c]const u8, password_len: usize, salt: [*c]const u8, salt_len: usize, N: u64, r: u64, p: u64, max_mem: usize, out_key: [*c]u8, key_len: usize) c_int; +/// EVP_PBE_scrypt expands |password| into a secret key of length |key_len| using +/// scrypt, as described in RFC 7914, and writes the result to |out_key|. It +/// returns one on success and zero on allocation failure, if the memory required +/// for the operation exceeds |max_mem|, or if any of the parameters are invalid +/// as described below. +/// +/// |N|, |r|, and |p| are as described in RFC 7914 section 6. They determine the +/// cost of the operation. If |max_mem| is zero, a defult limit of 32MiB will be +/// used. +/// +/// The parameters are considered invalid under any of the following conditions: +/// - |r| or |p| are zero +/// - |p| > (2^30 - 1) / |r| +/// - |N| is not a power of two +/// - |N| > 2^32 +/// - |N| > 2^(128 * |r| / 8) +pub extern fn EVP_PBE_scrypt( + password: [*c]const u8, + password_len: usize, + salt: [*c]const u8, + salt_len: usize, + N: u64, + r: u64, + p: u64, + max_mem: usize, + out_key: [*c]u8, + key_len: usize, +) c_int; pub extern fn EVP_PKEY_CTX_new(pkey: [*c]EVP_PKEY, e: ?*ENGINE) ?*EVP_PKEY_CTX; pub extern fn EVP_PKEY_CTX_new_id(id: c_int, e: ?*ENGINE) ?*EVP_PKEY_CTX; pub extern fn EVP_PKEY_CTX_free(ctx: ?*EVP_PKEY_CTX) void; diff --git a/src/node-fallbacks/crypto.js b/src/node-fallbacks/crypto.js index 0f428df13..4a0e4c735 100644 --- a/src/node-fallbacks/crypto.js +++ b/src/node-fallbacks/crypto.js @@ -1,5 +1,7 @@ export * from "crypto-browserify"; +export var DEFAULT_ENCODING = "buffer"; + // we deliberately reference crypto. directly here because we want to preserve the This binding export const getRandomValues = (array) => { return crypto.getRandomValues(array); @@ -25,15 +27,62 @@ export const timingSafeEqual = throw new RangeError("Input buffers must have the same length"); } + // these error checks are also performed in the function + // however there is a bug where exceptions return no value return crypto.timingSafeEqual(a, b); } : undefined; +export const scryptSync = + "scryptSync" in crypto + ? (password, salt, keylen, options) => { + const res = crypto.scryptSync(password, salt, keylen, options); + return DEFAULT_ENCODING !== "buffer" + ? new Buffer(res).toString(DEFAULT_ENCODING) + : new Buffer(res); + } + : undefined; + +export const scrypt = + "scryptSync" in crypto + ? function (password, salt, keylen, options, callback) { + if (typeof options === "function") { + callback = options; + options = undefined; + } + + if (typeof callback !== "function") { + var err = new TypeError("callback must be a function"); + err.code = "ERR_INVALID_CALLBACK"; + throw err; + } + + try { + const result = crypto.scryptSync(password, salt, keylen, options); + process.nextTick( + callback, + null, + DEFAULT_ENCODING !== "buffer" + ? new Buffer(result).toString(DEFAULT_ENCODING) + : new Buffer(result), + ); + } catch (err) { + throw err; + } + } + : undefined; + if (timingSafeEqual) { // hide it from stack trace Object.defineProperty(timingSafeEqual, "name", { value: "::bunternal::", }); + Object.defineProperty(scrypt, "name", { + value: "::bunternal::", + }); + Object.defineProperty(scryptSync, "name", { + value: "::bunternal::", + }); } export const webcrypto = crypto; diff --git a/test/bun.js/crypto-scrypt.test.js b/test/bun.js/crypto-scrypt.test.js new file mode 100644 index 000000000..4b9f632c0 --- /dev/null +++ b/test/bun.js/crypto-scrypt.test.js @@ -0,0 +1,265 @@ +// most of these tests are taken from Node.js +// thank you Node.js team for the tests +import { expect, it } from "bun:test"; +const crypto = require("crypto"); + +const assert = { + strictEqual: (a, b) => { + expect(a).toEqual(b); + }, + deepStrictEqual: (a, b) => { + expect(a).toEqual(b); + }, + throws: (fn, err) => { + try { + fn(); + throw "Fail"; + } catch (e) { + if (err.name) { + expect(e?.name).toEqual(err.name); + } + + // if (err.message) { + // expect(err.message.test(e?.message)).toBeTruthy(); + // } + if (err.code) { + expect(e?.code).toEqual(err.code); + } + + expect(e).not.toEqual("Fail"); + return; + } + }, +}; + +const good = [ + // Zero-length key is legal, functions as a parameter validation check. + { + pass: "", + salt: "", + keylen: 0, + N: 16, + p: 1, + r: 1, + expected: "", + }, + // Test vectors from https://tools.ietf.org/html/rfc7914#page-13 that + // should pass. Note that the test vector with N=1048576 is omitted + // because it takes too long to complete and uses over 1 GB of memory. + { + pass: "", + salt: "", + keylen: 64, + N: 16, + p: 1, + r: 1, + expected: + "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442" + + "fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906", + }, + { + pass: "password", + salt: "NaCl", + keylen: 64, + N: 1024, + p: 16, + r: 8, + expected: + "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b373162" + + "2eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640", + }, + { + pass: "pleaseletmein", + salt: "SodiumChloride", + keylen: 64, + N: 16384, + p: 1, + r: 8, + expected: + "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2" + + "d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + }, + { + pass: "", + salt: "", + keylen: 64, + cost: 16, + parallelization: 1, + blockSize: 1, + expected: + "77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442" + + "fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906", + }, + { + pass: "password", + salt: "NaCl", + keylen: 64, + cost: 1024, + parallelization: 16, + blockSize: 8, + expected: + "fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b373162" + + "2eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640", + }, + { + pass: "pleaseletmein", + salt: "SodiumChloride", + keylen: 64, + cost: 16384, + parallelization: 1, + blockSize: 8, + expected: + "7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2" + + "d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887", + }, +]; + +// Test vectors that should fail. +const bad = [ + { N: 1, p: 1, r: 1 }, // N < 2 + { N: 3, p: 1, r: 1 }, // Not power of 2. + { N: 1, cost: 1 }, // Both N and cost + // TODO: these should error, but I don't quite understand why. + // { p: 1, parallelization: 1 }, // Both p and parallelization + // { r: 1, blockSize: 1 }, // Both r and blocksize +]; + +// Test vectors where 128*N*r exceeds maxmem. +const toobig = [ + { N: 2 ** 16, p: 1, r: 1 }, // N >= 2**(r*16) + { N: 2, p: 2 ** 30, r: 1 }, // p > (2**30-1)/r + { N: 2 ** 20, p: 1, r: 8 }, + { N: 2 ** 10, p: 1, r: 8, maxmem: 2 ** 20 }, +]; + +const badargs = [ + { + args: [], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"password"/ */ }, + }, + { + args: [null], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"password"/ */ }, + }, + { + args: [""], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"salt"/ */ }, + }, + { + args: ["", null], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"salt"/ */ }, + }, + { + args: ["", ""], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"keylen"/ */ }, + }, + { + args: ["", "", null], + expected: { code: "ERR_INVALID_ARG_TYPE" /*message: /"keylen"/ */ }, + }, + // TODO: throw on these + // { + // args: ["", "", 0.42], + // expected: { code: "ERR_OUT_OF_RANGE" /*message: /"keylen"/ */ }, + // }, + // { + // args: ["", "", -42], + // expected: { code: "ERR_OUT_OF_RANGE" /*message: /"keylen"/ */ }, + // }, + // { + // args: ["", "", 2147485780], + // expected: { code: "ERR_OUT_OF_RANGE" /*message: /"keylen"/ */ }, + // }, +]; + +it("scrypt good", () => { + for (const options of good) { + const { pass, salt, keylen, expected } = options; + const actual = crypto.scryptSync(pass, salt, keylen, options); + assert.strictEqual(actual.toString("hex"), expected); + } +}); + +it("scrypt bad", () => { + for (const options of bad) { + const expected = { + message: /Invalid scrypt param/, + }; + assert.throws( + () => crypto.scryptSync("pass", "salt", 1, options), + expected, + ); + } +}); + +it("scrypt toobig", () => { + for (const options of toobig) { + const expected = { + message: /Invalid scrypt param/, + }; + assert.throws( + () => crypto.scryptSync("pass", "salt", 1, options), + expected, + ); + } +}); + +it("scrypt defaults eql", () => { + { + const defaults = { N: 16384, p: 1, r: 8 }; + const expected = crypto.scryptSync("pass", "salt", 1, defaults); + const actual = crypto.scryptSync("pass", "salt", 1); + assert.deepStrictEqual(actual.toString("hex"), expected.toString("hex")); + } +}); + +// TODO: DEFAULT_ENCODING is read-only +// it("scrypt defaults encoding", () => { +// { +// const defaultEncoding = crypto.DEFAULT_ENCODING; +// const defaults = { N: 16384, p: 1, r: 8 }; +// const expected = crypto.scryptSync("pass", "salt", 1, defaults); + +// const testEncoding = "latin1"; +// crypto.DEFAULT_ENCODING = testEncoding; +// const actual = crypto.scryptSync("pass", "salt", 1); +// assert.deepStrictEqual(actual, expected.toString(testEncoding)); + +// crypto.DEFAULT_ENCODING = defaultEncoding; +// } +// }); + +it("scrypt badargs", () => { + { + for (const { args, expected } of badargs) { + assert.throws(() => crypto.scryptSync(...args), expected); + } + } + + { + const expected = { code: "ERR_INVALID_ARG_TYPE" }; + assert.throws(() => crypto.scryptSync("", "", 42, null), expected); + // assert.throws(() => crypto.scryptSync("", "", 42, {}, null), expected); + // assert.throws(() => crypto.scryptSync("", "", 42, {}), expected); + // assert.throws(() => crypto.scryptSync("", "", 42, {}, {}), expected); + } + + // { + // // Values for maxmem that do not fit in 32 bits but that are still safe + // // integers should be allowed. + // crypto.scrypt( + // "", + // "", + // 4, + // { maxmem: 2 ** 52 }, + // common.mustSucceed((actual) => { + // assert.strictEqual(actual.toString("hex"), "d72c87d0"); + // }), + // ); + + // // Values that exceed Number.isSafeInteger should not be allowed. + // assert.throws(() => crypto.scryptSync("", "", 0, { maxmem: 2 ** 53 }), { + // code: "ERR_OUT_OF_RANGE", + // }); + // } +}); |
