Merge pull request #2372 from lightpanda-io/worker_subtle_crypto

Make SubtleCrypto work on Worker
This commit is contained in:
Karl Seguin
2026-05-07 06:47:12 +08:00
committed by GitHub
6 changed files with 248 additions and 57 deletions

View File

@@ -949,6 +949,7 @@ pub const WorkerJsApis = flattenTypes(&.{
@import("../webapi/File.zig"),
@import("../webapi/Console.zig"),
@import("../webapi/Crypto.zig"),
@import("../webapi/SubtleCrypto.zig"),
@import("../webapi/net/FormData.zig"),
@import("../webapi/net/Headers.zig"),
@import("../webapi/net/Request.zig"),

View File

@@ -0,0 +1,92 @@
// Exercises crypto APIs inside a worker. Posts 'ready' once the message
// handler is wired so the page knows it can send a command without racing
// worker startup. Receives the command, runs the crypto operation, and
// posts the result back.
self.onmessage = async function(e) {
const cmd = e.data;
try {
if (cmd.kind === 'getRandomValues') {
const ta = new Uint8Array(32);
const same = crypto.getRandomValues(ta) === ta;
const uniq = new Set(Array.from(ta));
postMessage({ ok: true, same, looks_random: uniq.size > 8 });
return;
}
if (cmd.kind === 'randomUUID') {
const uuid = crypto.randomUUID();
const regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
postMessage({ ok: true, type: typeof uuid, length: uuid.length, valid: regex.test(uuid) });
return;
}
if (cmd.kind === 'digest') {
const buffer = await crypto.subtle.digest('sha-256', new TextEncoder().encode('over 9000'));
const hex = [...new Uint8Array(buffer)].map(x => x.toString(16).padStart(2, '0')).join('');
postMessage({ ok: true, hex });
return;
}
if (cmd.kind === 'hmac') {
const key = await crypto.subtle.generateKey(
{ name: 'HMAC', hash: { name: 'SHA-512' } },
true,
['sign', 'verify'],
);
const raw = await crypto.subtle.exportKey('raw', key);
const encoder = new TextEncoder();
const signature = await crypto.subtle.sign('HMAC', key, encoder.encode('Hello, world!'));
const verified = await crypto.subtle.verify(
{ name: 'HMAC' },
key,
signature,
encoder.encode('Hello, world!'),
);
postMessage({
ok: true,
key_type: typeof key,
raw_byte_length: raw.byteLength,
is_array_buffer: signature instanceof ArrayBuffer,
verified,
});
return;
}
if (cmd.kind === 'x25519') {
const { privateKey, publicKey } = await crypto.subtle.generateKey(
{ name: 'X25519' },
true,
['deriveBits'],
);
const sharedKey = await crypto.subtle.deriveBits(
{ name: 'X25519', public: publicKey },
privateKey,
128,
);
postMessage({
ok: true,
private_key_type: typeof privateKey,
public_key_type: typeof publicKey,
shared_byte_length: sharedKey.byteLength,
});
return;
}
if (cmd.kind === 'generateKey-rejection') {
let err_name = null;
try {
await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['sign']);
} catch (err) {
err_name = err.name;
}
postMessage({ ok: true, err_name });
return;
}
postMessage({ ok: false, err: 'unknown command' });
} catch (err) {
postMessage({ ok: false, err: String(err), stack: err.stack });
}
};
postMessage({ ready: true });

View File

@@ -261,3 +261,96 @@
testing.expectEqual("6cad17e6f3f76680d6dd18ed043b75b4f6e1aa1d08b917294942e882fb6466c3510948c34af8b903ed0725b582b3b39c0e485ae2c1b7dfdb192ee38b79c782b6", await hash('sha-512', 'over 9000'));
});
</script>
<script>
// Helper for worker-based crypto tests. Spawns a worker, waits for it to
// signal readiness, then sends `kind` and forwards the worker's reply to
// `state.resolve`. Avoids racing worker startup, which matters on slow CI.
window.runCryptoInWorker = function(kind, state) {
const worker = new Worker('./crypto-worker.js');
worker.onmessage = (e) => {
if (e.data && e.data.ready) {
worker.postMessage({ kind });
return;
}
state.resolve(e.data);
};
};
</script>
<script id=worker-getRandomValues type=module>
{
const state = await testing.async();
runCryptoInWorker('getRandomValues', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual(true, data.same);
testing.expectEqual(true, data.looks_random);
});
}
</script>
<script id=worker-randomUUID type=module>
{
const state = await testing.async();
runCryptoInWorker('randomUUID', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual('string', data.type);
testing.expectEqual(36, data.length);
testing.expectEqual(true, data.valid);
});
}
</script>
<script id=worker-digest type=module>
{
const state = await testing.async();
runCryptoInWorker('digest', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual(
'1bc375bb92459685194dda18a4b835f4e2972ec1bde6d9ab3db53fcc584a6580',
data.hex,
);
});
}
</script>
<script id=worker-hmac-sign-verify type=module>
{
const state = await testing.async();
runCryptoInWorker('hmac', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual('object', data.key_type);
testing.expectEqual(128, data.raw_byte_length);
testing.expectEqual(true, data.is_array_buffer);
testing.expectEqual(true, data.verified);
});
}
</script>
<script id=worker-x25519-derive type=module>
{
const state = await testing.async();
runCryptoInWorker('x25519', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual('object', data.private_key_type);
testing.expectEqual('object', data.public_key_type);
testing.expectEqual(16, data.shared_byte_length);
});
}
</script>
<script id=worker-generateKey-rejects type=module>
{
const state = await testing.async();
runCryptoInWorker('generateKey-rejection', state);
await state.done((data) => {
testing.expectTrue(data.ok);
testing.expectEqual('SyntaxError', data.err_name);
});
}
</script>

View File

@@ -20,7 +20,6 @@ const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../sys/libcrypto.zig");
const Frame = @import("../Frame.zig");
const js = @import("../js/js.zig");
const CryptoKey = @import("CryptoKey.zig");
@@ -34,6 +33,7 @@ const X25519 = @import("crypto/X25519.zig");
const log = lp.log;
const String = lp.String;
const Execution = js.Execution;
/// The SubtleCrypto interface of the Web Crypto API provides a number of low-level
/// cryptographic functions.
@@ -49,11 +49,11 @@ pub fn generateKey(
algo: algorithm.Init,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
const local = frame.js.local.?;
const local = exec.context.local.?;
switch (algo) {
.hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, frame),
.hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, exec),
.aes_key_gen => |params| {
AES.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
@@ -72,8 +72,8 @@ pub fn generateKey(
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
.name => |js_name| return generateKeyFromName(try js_name.toSSO(false), extractable, key_usages, frame),
.object => |object| return generateKeyFromName(try object.name.toSSO(false), extractable, key_usages, frame),
.name => |js_name| return generateKeyFromName(try js_name.toSSO(false), extractable, key_usages, exec),
.object => |object| return generateKeyFromName(try object.name.toSSO(false), extractable, key_usages, exec),
.invalid => return local.rejectPromise(.{ .type_error = "invalid algorithm" }),
}
@@ -84,10 +84,10 @@ fn generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
return _generateKeyFromName(name, extractable, key_usages, frame) catch |err| {
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } });
return _generateKeyFromName(name, extractable, key_usages, exec) catch |err| {
return exec.context.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
}
@@ -95,10 +95,10 @@ fn _generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
if (name.eql(comptime .wrap("X25519"))) {
return X25519.init(extractable, key_usages, frame);
return X25519.init(extractable, key_usages, exec);
}
{
@@ -145,14 +145,15 @@ pub fn exportKey(
_: *const SubtleCrypto,
format: []const u8,
key: *CryptoKey,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
const local = exec.context.local.?;
if (!key.canExportKey()) {
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
}
if (std.mem.eql(u8, format, "raw")) {
return frame.js.local.?.resolvePromise(js.ArrayBuffer{ .values = key._key });
return local.resolvePromise(js.ArrayBuffer{ .values = key._key });
}
const is_unsupported = std.mem.eql(u8, format, "pkcs8") or
@@ -160,10 +161,10 @@ pub fn exportKey(
if (is_unsupported) {
log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = format });
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
return frame.js.local.?.rejectPromise(.{ .type_error = "invalid format" });
return local.rejectPromise(.{ .type_error = "invalid format" });
}
/// Derive a secret key from a master key.
@@ -172,27 +173,28 @@ pub fn deriveBits(
algo: algorithm.Derive,
base_key: *const CryptoKey, // Private key.
length: usize,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
const local = exec.context.local.?;
return switch (algo) {
.ecdh_or_x25519 => |params| {
const name = params.name;
if (std.mem.eql(u8, name, "X25519")) {
const result = X25519.deriveBits(base_key, params.public, length, frame) catch |err| switch (err) {
error.InvalidAccessError => return frame.js.local.?.rejectPromise(.{
const result = X25519.deriveBits(base_key, params.public, length, exec) catch |err| switch (err) {
error.InvalidAccessError => return local.rejectPromise(.{
.dom_exception = .{ .err = error.InvalidAccessError },
}),
else => return err,
};
return frame.js.local.?.resolvePromise(result);
return local.resolvePromise(result);
}
if (std.mem.eql(u8, name, "ECDH")) {
log.warn(.not_implemented, "SubtleCrypto.deriveBits", .{ .name = name });
}
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
},
};
}
@@ -204,14 +206,14 @@ pub fn sign(
algo: algorithm.Sign,
key: *CryptoKey,
data: []const u8, // ArrayBuffer.
frame: *Frame,
exec: *const Execution,
) !js.Promise {
return switch (key._type) {
// Call sign for HMAC.
.hmac => return HMAC.sign(algo, key, data, frame),
.hmac => return HMAC.sign(algo, key, data, exec),
else => {
log.warn(.not_implemented, "SubtleCrypto.sign", .{ .key_type = key._type });
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
return exec.context.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
},
};
}
@@ -223,33 +225,34 @@ pub fn verify(
key: *const CryptoKey,
signature: []const u8, // ArrayBuffer.
data: []const u8, // ArrayBuffer.
frame: *Frame,
exec: *const Execution,
) !js.Promise {
const local = exec.context.local.?;
if (!algo.isHMAC()) {
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } });
}
return switch (key._type) {
.hmac => HMAC.verify(key, signature, data, frame),
else => frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }),
.hmac => HMAC.verify(key, signature, data, exec),
else => local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }),
};
}
/// Generates a digest of the given data, using the specified hash function.
pub fn digest(_: *const SubtleCrypto, algo: []const u8, data: js.TypedArray(u8), frame: *Frame) !js.Promise {
const local = frame.js.local.?;
pub fn digest(_: *const SubtleCrypto, algo: []const u8, data: js.TypedArray(u8), exec: *const Execution) !js.Promise {
const local = exec.context.local.?;
if (algo.len > 10) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
const normalized = std.ascii.upperString(&frame.buf, algo);
const normalized = std.ascii.upperString(exec.buf, algo);
const digest_type = crypto.findDigest(normalized) catch {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
const bytes = data.values;
const out = frame.buf[0..crypto.EVP_MAX_MD_SIZE];
const out = exec.buf[0..crypto.EVP_MAX_MD_SIZE];
var out_size: c_uint = 0;
const result = crypto.EVP_Digest(bytes.ptr, bytes.len, out, &out_size, digest_type, null);
lp.assert(result == 1, "SubtleCrypto.digest", .{ .algo = algo });

View File

@@ -22,19 +22,20 @@ const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../../sys/libcrypto.zig");
const Frame = @import("../../Frame.zig");
const js = @import("../../js/js.zig");
const algorithm = @import("algorithm.zig");
const CryptoKey = @import("../CryptoKey.zig");
const Execution = js.Execution;
pub fn init(
params: algorithm.Init.HmacKeyGen,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
const local = frame.js.local.?;
const local = exec.context.local.?;
// Per spec, an unrecognized hash is caught during algorithm normalization
// and surfaces as NotSupportedError.
const digest = crypto.findDigest(switch (params.hash) {
@@ -74,14 +75,14 @@ pub fn init(
};
// Should we reject this in promise too?
const key = try frame.arena.alloc(u8, block_size);
errdefer frame.arena.free(key);
const key = try exec.arena.alloc(u8, block_size);
errdefer exec.arena.free(key);
// HMAC is simply CSPRNG.
const res = crypto.RAND_bytes(key.ptr, key.len);
lp.assert(res == 1, "HMAC.init", .{ .res = res });
const crypto_key = try frame._factory.create(CryptoKey{
const crypto_key = try exec._factory.create(CryptoKey{
._type = .hmac,
._extractable = extractable,
._usages = mask,
@@ -96,16 +97,16 @@ pub fn sign(
algo: algorithm.Sign,
crypto_key: *const CryptoKey,
data: []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
var resolver = frame.js.local.?.createPromiseResolver();
var resolver = exec.context.local.?.createPromiseResolver();
if (!algo.isHMAC() or !crypto_key.canSign()) {
resolver.rejectError("HMAC.sign", .{ .dom_exception = .{ .err = error.InvalidAccessError } });
return resolver.promise();
}
const buffer = try frame.call_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest()));
const buffer = try exec.call_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest()));
var out_len: u32 = 0;
// Try to sign.
_ = crypto.HMAC(
@@ -117,7 +118,7 @@ pub fn sign(
buffer.ptr,
&out_len,
) orelse {
frame.call_arena.free(buffer);
exec.call_arena.free(buffer);
// Failure.
resolver.rejectError("HMAC.sign", .{ .dom_exception = .{ .err = error.InvalidAccessError } });
return resolver.promise();
@@ -132,9 +133,9 @@ pub fn verify(
crypto_key: *const CryptoKey,
signature: []const u8,
data: []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
var resolver = frame.js.local.?.createPromiseResolver();
var resolver = exec.context.local.?.createPromiseResolver();
if (!crypto_key.canVerify()) {
resolver.rejectError("HMAC.verify", .{ .dom_exception = .{ .err = error.InvalidAccessError } });

View File

@@ -22,21 +22,22 @@ const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../../sys/libcrypto.zig");
const Frame = @import("../../Frame.zig");
const js = @import("../../js/js.zig");
const CryptoKey = @import("../CryptoKey.zig");
const Execution = js.Execution;
pub fn init(
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
exec: *const Execution,
) !js.Promise {
// This code has too many allocations here and there, might be nice to
// gather them together with a single alloc call. Not sure if factory
// pattern is suitable for it though.
const local = frame.js.local.?;
const local = exec.context.local.?;
// Calculate usages; only matters for private key.
// Only deriveKey() and deriveBits() be used for X25519.
@@ -59,11 +60,11 @@ pub fn init(
});
}
const public_value = try frame.arena.alloc(u8, crypto.X25519_PUBLIC_VALUE_LEN);
errdefer frame.arena.free(public_value);
const public_value = try exec.arena.alloc(u8, crypto.X25519_PUBLIC_VALUE_LEN);
errdefer exec.arena.free(public_value);
const private_key = try frame.arena.alloc(u8, crypto.X25519_PRIVATE_KEY_LEN);
errdefer frame.arena.free(private_key);
const private_key = try exec.arena.alloc(u8, crypto.X25519_PRIVATE_KEY_LEN);
errdefer exec.arena.free(private_key);
// There's no info about whether this can fail; so I assume it cannot.
crypto.X25519_keypair(@ptrCast(public_value), @ptrCast(private_key));
@@ -90,16 +91,16 @@ pub fn init(
private_key.len,
) orelse return error.OutOfMemory;
const private = try frame._factory.create(CryptoKey{
const private = try exec._factory.create(CryptoKey{
._type = .x25519,
._extractable = extractable,
._usages = mask,
._key = private_key,
._vary = .{ .pkey = private_pkey },
});
errdefer frame._factory.destroy(private);
errdefer exec._factory.destroy(private);
const public = try frame._factory.create(CryptoKey{
const public = try exec._factory.create(CryptoKey{
._type = .x25519,
// Public keys are always extractable.
._extractable = true,
@@ -116,7 +117,7 @@ pub fn deriveBits(
private: *const CryptoKey,
public: *const CryptoKey,
length_in_bits: usize,
frame: *Frame,
exec: *const Execution,
) !js.ArrayBuffer {
if (!private.canDeriveBits()) {
return error.InvalidAccessError;
@@ -137,8 +138,8 @@ pub fn deriveBits(
return error.Internal;
}
const derived_key = try frame.call_arena.alloc(u8, 32);
errdefer frame.call_arena.free(derived_key);
const derived_key = try exec.call_arena.alloc(u8, 32);
errdefer exec.call_arena.free(derived_key);
var out_key_len: usize = derived_key.len;
const result = crypto.EVP_PKEY_derive(ctx, derived_key.ptr, &out_key_len);