Improve correctness of generateKey error

Previously, the only error generateKey would throw as SyntaxError. This commit
adds validation to the input so that the correct error can be returned. This
helps a couple thousands of WPT tests to pass, e.g.

/WebCryptoAPI/generateKey/failures_AES-CBC.https.any.html

Goes from 210 / 775 to  775/775.

This does not add more crypto capabilities / algos, just validation of the
provided parameters.
This commit is contained in:
Karl Seguin
2026-04-28 15:13:33 +08:00
parent 827626db67
commit 1df011bb2b
8 changed files with 484 additions and 35 deletions

View File

@@ -120,6 +120,135 @@
});
</script>
<script>
// helper
window.expectGenKeyRejects = async function(algo, extractable, usages, expected) {
try {
await crypto.subtle.generateKey(algo, extractable, usages);
testing.fail(`expected ${expected}, but generateKey resolved for ${JSON.stringify(algo)}`);
} catch (err) {
testing.expectEqual(expected, err.name);
}
};
</script>
<script id=generateKey-unrecognized-algorithm>
testing.async(async () => {
// Unknown name (string and object form) -> NotSupportedError.
await expectGenKeyRejects("AES", true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "AES" }, true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "AES-CMAC", length: 128 }, true, ["encrypt"], "NotSupportedError");
await expectGenKeyRejects({ name: "EC", namedCurve: "P-256" }, true, ["sign"], "NotSupportedError");
// Empty algorithm dictionary -> TypeError (missing required `name`).
await expectGenKeyRejects({}, true, ["encrypt"], "TypeError");
});
</script>
<script id=generateKey-aes-validation>
testing.async(async () => {
// Bad usages: SyntaxError.
await expectGenKeyRejects({ name: "AES-CBC", length: 128 }, true, ["sign"], "SyntaxError");
await expectGenKeyRejects({ name: "AES-GCM", length: 128 }, true, ["encrypt", "deriveBits"], "SyntaxError");
// AES-KW only allows wrapKey / unwrapKey.
await expectGenKeyRejects({ name: "AES-KW", length: 128 }, true, ["encrypt"], "SyntaxError");
// Bad length: OperationError.
for (const length of [64, 127, 129, 255, 257, 512]) {
await expectGenKeyRejects({ name: "AES-CBC", length }, true, ["encrypt"], "OperationError");
}
// Empty usages on a secret key: SyntaxError.
await expectGenKeyRejects({ name: "AES-CBC", length: 128 }, true, [], "SyntaxError");
await expectGenKeyRejects({ name: "AES-KW", length: 256 }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-ec-validation>
testing.async(async () => {
// Bad usages: SyntaxError.
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-256" }, true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "ECDH", namedCurve: "P-256" }, true, ["sign"], "SyntaxError");
// Unknown curve: NotSupportedError (not OperationError, per spec).
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-512" }, true, ["sign"], "NotSupportedError");
await expectGenKeyRejects({ name: "ECDH", namedCurve: "Curve25519" }, true, ["deriveBits"], "NotSupportedError");
// Empty usages: SyntaxError (mandatoryUsages aside, the spec rejects empty for private keys).
await expectGenKeyRejects({ name: "ECDSA", namedCurve: "P-256" }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-rsa-validation>
testing.async(async () => {
const goodExp = new Uint8Array([1, 0, 1]); // 65537
// Bad name: NotSupportedError.
await expectGenKeyRejects(
{ name: "RSA", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "NotSupportedError",
);
// Bad hash: NotSupportedError.
await expectGenKeyRejects(
{ name: "RSA-PSS", hash: "SHA", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "NotSupportedError",
);
// Bad usages: SyntaxError. RSASSA only allows sign/verify.
await expectGenKeyRejects(
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["encrypt"], "SyntaxError",
);
// RSA-OAEP only allows encrypt/decrypt/wrapKey/unwrapKey.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, ["sign"], "SyntaxError",
);
// Bad publicExponent: OperationError.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1]) },
true, ["encrypt"], "OperationError",
);
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 0]) },
true, ["encrypt"], "OperationError",
);
// Empty usages: SyntaxError.
await expectGenKeyRejects(
{ name: "RSA-OAEP", hash: "SHA-256", modulusLength: 2048, publicExponent: goodExp },
true, [], "SyntaxError",
);
});
</script>
<script id=generateKey-hmac-validation>
testing.async(async () => {
// Unknown hash: NotSupportedError.
await expectGenKeyRejects({ name: "HMAC", hash: "MD5" }, true, ["sign"], "NotSupportedError");
// Bad usages: SyntaxError. HMAC only allows sign/verify.
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, ["sign", "deriveBits"], "SyntaxError");
// Empty usages: SyntaxError.
await expectGenKeyRejects({ name: "HMAC", hash: "SHA-256" }, true, [], "SyntaxError");
});
</script>
<script id=generateKey-edxxx-validation>
testing.async(async () => {
// Ed25519 / Ed448 only allow sign/verify; X448 only allows deriveKey/deriveBits.
await expectGenKeyRejects("Ed25519", true, ["encrypt"], "SyntaxError");
await expectGenKeyRejects({ name: "Ed448" }, true, ["deriveBits"], "SyntaxError");
await expectGenKeyRejects("X448", true, ["sign"], "SyntaxError");
// Empty usages: SyntaxError.
await expectGenKeyRejects("Ed25519", true, [], "SyntaxError");
});
</script>
<script id="digest">
testing.async(async () => {
async function hash(algo, data) {

View File

@@ -59,12 +59,17 @@ pub fn fromError(err: anyerror) ?DOMException {
error.InvalidNodeType => .{ ._code = .invalid_node_type_error },
error.DataClone => .{ ._code = .data_clone_error },
error.InvalidAccessError => .{ ._code = .invalid_access_error },
error.OperationError => .{ ._code = .operation_error },
else => null,
};
}
pub fn getCode(self: *const DOMException) u8 {
return @intFromEnum(self._code);
return switch (self._code) {
// WebCrypto-only error: no legacy numeric code.
.operation_error => 0,
else => @intFromEnum(self._code),
};
}
pub fn getName(self: *const DOMException) []const u8 {
@@ -95,6 +100,7 @@ pub fn getName(self: *const DOMException) []const u8 {
.timeout_error => "TimeoutError",
.invalid_node_type_error => "InvalidNodeTypeError",
.data_clone_error => "DataCloneError",
.operation_error => "OperationError",
};
}
@@ -125,6 +131,7 @@ pub fn getMessage(self: *const DOMException) []const u8 {
.timeout_error => "The operation timed out",
.invalid_node_type_error => "The supplied node is incorrect or has an incorrect ancestor for this operation",
.data_clone_error => "The object can not be cloned",
.operation_error => "The operation failed for an operation-specific reason",
};
}
@@ -164,6 +171,8 @@ const Code = enum(u8) {
timeout_error = 23,
invalid_node_type_error = 24,
data_clone_error = 25,
/// Defined by WebCrypto; no legacy code, exposed via name only.
operation_error = 0xFF,
/// Maps a standard error name to its legacy code
/// Returns .none (code 0) for non-legacy error names
@@ -190,6 +199,7 @@ const Code = enum(u8) {
.{ "TimeoutError", .timeout_error },
.{ "InvalidNodeTypeError", .invalid_node_type_error },
.{ "DataCloneError", .data_clone_error },
.{ "OperationError", .operation_error },
});
return lookup.get(name) orelse .none;
}

View File

@@ -26,10 +26,14 @@ const js = @import("../js/js.zig");
const CryptoKey = @import("CryptoKey.zig");
const algorithm = @import("crypto/algorithm.zig");
const AES = @import("crypto/AES.zig");
const EC = @import("crypto/EC.zig");
const HMAC = @import("crypto/HMAC.zig");
const RSA = @import("crypto/RSA.zig");
const X25519 = @import("crypto/X25519.zig");
const log = lp.log;
const String = lp.String;
/// The SubtleCrypto interface of the Web Crypto API provides a number of low-level
/// cryptographic functions.
@@ -47,28 +51,92 @@ pub fn generateKey(
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
const local = frame.js.local.?;
switch (algo) {
.hmac_key_gen => |params| return HMAC.init(params, extractable, key_usages, frame),
.name => |name| {
if (std.mem.eql(u8, "X25519", name)) {
return X25519.init(extractable, key_usages, frame);
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
.aes_key_gen => |params| {
AES.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
.object => |object| {
// Ditto.
const name = object.name;
if (std.mem.eql(u8, "X25519", name)) {
return X25519.init(extractable, key_usages, frame);
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
.ec_key_gen => |params| {
EC.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.name });
},
else => log.warn(.not_implemented, "generateKey", .{}),
.rsa_hashed_key_gen => |params| {
RSA.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
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),
.invalid => return local.rejectPromise(.{ .type_error = "invalid algorithm" }),
}
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = error.SyntaxError } });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
fn generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
return _generateKeyFromName(name, extractable, key_usages, frame) catch |err| {
return frame.js.local.?.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
}
fn _generateKeyFromName(
name: String,
extractable: bool,
key_usages: []const []const u8,
frame: *Frame,
) !js.Promise {
if (name.eql(comptime .wrap("X25519"))) {
return X25519.init(extractable, key_usages, frame);
}
{
// Algorithms whose `generateKey` parameters are just `{name}` — Ed25519,
// Ed448, X448. Validates usages so failure-path tests get the spec-mandated
// error name; leaves real key generation to a future change.
const allowed: []const []const u8 = blk: {
const str = name.str();
if (std.ascii.eqlIgnoreCase(str, "Ed25519") or std.ascii.eqlIgnoreCase(str, "Ed448")) {
break :blk &.{ "sign", "verify" };
}
if (std.ascii.eqlIgnoreCase(str, "X448")) {
break :blk &.{ "deriveKey", "deriveBits" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
log.warn(.not_implemented, "generateKey", .{ .name = name });
return error.NotSupported;
}
/// Exports a key: that is, it takes as input a CryptoKey object and gives you

View File

@@ -0,0 +1,70 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! AES generateKey parameter validation.
//!
//! Key generation itself is not implemented; this module rejects malformed
//! input with the spec-mandated error name so the failure-path WPT tests
//! match. Successful inputs fall through to the caller's `not_implemented`
//! warning + `NotSupportedError`.
const std = @import("std");
const algorithm = @import("algorithm.zig");
/// Per WebCrypto: "Generate Key" operation for AES-CBC/CTR/GCM/KW.
/// Validation order matches the spec: usages → length → empty usages.
pub fn validate(params: algorithm.Init.AesKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "AES-CBC") or
eql(params.name, "AES-CTR") or
eql(params.name, "AES-GCM"))
{
break :blk &.{ "encrypt", "decrypt", "wrapKey", "unwrapKey" };
}
if (eql(params.name, "AES-KW")) {
break :blk &.{ "wrapKey", "unwrapKey" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (params.length != 128 and params.length != 192 and params.length != 256) {
return error.OperationError;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -0,0 +1,66 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! ECDSA / ECDH generateKey parameter validation. See AES.zig for
//! the rationale on validate-without-generate.
const std = @import("std");
const algorithm = @import("algorithm.zig");
pub fn validate(params: algorithm.Init.EcKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "ECDSA")) {
break :blk &.{ "sign", "verify" };
}
if (eql(params.name, "ECDH")) {
break :blk &.{ "deriveKey", "deriveBits" };
}
return error.NotSupported;
};
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
// Per spec, an unsupported `namedCurve` is NotSupportedError, not OperationError —
// unlike AES length, where the algorithm registers the value as invalid.
if (!eql(params.namedCurve, "P-256") and
!eql(params.namedCurve, "P-384") and
!eql(params.namedCurve, "P-521"))
{
return error.NotSupported;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -35,30 +35,30 @@ pub fn init(
frame: *Frame,
) !js.Promise {
const local = frame.js.local.?;
// Find digest.
// Per spec, an unrecognized hash is caught during algorithm normalization
// and surfaces as NotSupportedError.
const digest = crypto.findDigest(switch (params.hash) {
.string => |str| str,
.object => |obj| obj.name,
}) catch return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
.dom_exception = .{ .err = error.NotSupported },
});
// Calculate usages mask.
if (key_usages.len == 0) {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});
}
const decls = @typeInfo(CryptoKey.Usages).@"struct".decls;
// HMAC only accepts sign / verify; any other usage is a SyntaxError per
// the spec, even when the entry exists elsewhere in CryptoKey.Usages.
var mask: u8 = 0;
iter_usages: for (key_usages) |usage| {
inline for (decls) |decl| {
if (std.mem.eql(u8, decl.name, usage)) {
mask |= @field(CryptoKey.Usages, decl.name);
continue :iter_usages;
}
for (key_usages) |usage| {
if (std.mem.eql(u8, usage, "sign")) {
mask |= CryptoKey.Usages.sign;
} else if (std.mem.eql(u8, usage, "verify")) {
mask |= CryptoKey.Usages.verify;
} else {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});
}
// Unknown usage if got here.
}
if (key_usages.len == 0) {
return local.rejectPromise(.{
.dom_exception = .{ .err = error.SyntaxError },
});

View File

@@ -0,0 +1,85 @@
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
//! RSA generateKey parameter validation. See AES.zig for the rationale.
const std = @import("std");
const algorithm = @import("algorithm.zig");
pub fn validate(params: algorithm.Init.RsaHashedKeyGen, key_usages: []const []const u8) !void {
const allowed: []const []const u8 = blk: {
if (eql(params.name, "RSASSA-PKCS1-v1_5") or eql(params.name, "RSA-PSS")) {
break :blk &.{ "sign", "verify" };
}
if (eql(params.name, "RSA-OAEP")) {
break :blk &.{ "encrypt", "decrypt", "wrapKey", "unwrapKey" };
}
return error.NotSupported;
};
const hash_name = switch (params.hash) {
.string => |s| s,
.object => |o| o.name,
};
if (!eql(hash_name, "SHA-1") and
!eql(hash_name, "SHA-256") and
!eql(hash_name, "SHA-384") and
!eql(hash_name, "SHA-512"))
{
return error.NotSupported;
}
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
return error.SyntaxError;
}
}
if (!isValidPublicExponent(params.publicExponent.values)) {
return error.OperationError;
}
if (key_usages.len == 0) {
return error.SyntaxError;
}
}
// WebCrypto only mandates rejection on key-generation failure, but in
// practice browsers accept the standard exponents 3 and 65537 and reject
// the rest. Match that.
fn isValidPublicExponent(bytes: []const u8) bool {
if (bytes.len == 0) return false;
var i: usize = 0;
while (i + 1 < bytes.len and bytes[i] == 0) : (i += 1) {}
const trimmed = bytes[i..];
if (trimmed.len == 1 and trimmed[0] == 3) return true;
if (trimmed.len == 3 and trimmed[0] == 0x01 and trimmed[1] == 0x00 and trimmed[2] == 0x01) return true;
return false;
}
fn eql(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -28,10 +28,19 @@ pub const Init = union(enum) {
rsa_hashed_key_gen: RsaHashedKeyGen,
/// For HMAC: pass an HmacKeyGenParams object.
hmac_key_gen: HmacKeyGen,
/// For AES variants: pass an AesKeyGenParams object.
aes_key_gen: AesKeyGen,
/// For ECDSA / ECDH: pass an EcKeyGenParams object.
ec_key_gen: EcKeyGen,
/// don't use []const u8 here, we don't want non-strings coerced. Let those
/// fall to the invalid case
/// Can be Ed25519 or X25519.
name: []const u8,
object: struct { name: js.String },
/// Can be Ed25519 or X25519.
object: struct { name: []const u8 },
name: js.String,
invalid: js.Value,
/// https://developer.mozilla.org/en-US/docs/Web/API/RsaHashedKeyGenParams
pub const RsaHashedKeyGen = struct {
@@ -46,6 +55,18 @@ pub const Init = union(enum) {
},
};
/// https://developer.mozilla.org/en-US/docs/Web/API/AesKeyGenParams
pub const AesKeyGen = struct {
name: []const u8,
length: u32,
};
/// https://developer.mozilla.org/en-US/docs/Web/API/EcKeyGenParams
pub const EcKeyGen = struct {
name: []const u8,
namedCurve: []const u8,
};
/// https://developer.mozilla.org/en-US/docs/Web/API/HmacKeyGenParams
pub const HmacKeyGen = struct {
/// Always HMAC.