Merge pull request #2666 from lightpanda-io/crypto

WPT /WebCryptoAPI/
This commit is contained in:
Karl Seguin
2026-06-09 07:07:12 +08:00
committed by GitHub
13 changed files with 1488 additions and 111 deletions

View File

@@ -1411,12 +1411,6 @@ pub fn rejectPromise(self: *const Local, err: js.PromiseResolver.RejectError) js
return resolver.promise();
}
pub fn rejectErrorPromise(self: *const Local, value: js.PromiseResolver.RejectError) !js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.rejectError("Local.rejectPromise", value);
return resolver.promise();
}
pub fn resolvePromise(self: *const Local, value: anytype) !js.Promise {
var resolver = js.PromiseResolver.init(self);
resolver.resolve("Local.resolvePromise", value);

View File

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

View File

@@ -20,27 +20,44 @@ const crypto = @import("../../sys/libcrypto.zig");
const js = @import("../js/js.zig");
const Execution = js.Execution;
/// Represents a cryptographic key obtained from one of the SubtleCrypto methods
/// generateKey(), deriveKey(), importKey(), or unwrapKey().
const CryptoKey = @This();
/// Algorithm being used.
_type: Type,
/// Whether this is a secret (symmetric), public, or private key. Surfaced as
/// the JS `.type` attribute.
_kind: Kind = .secret,
/// Whether the key is extractable.
_extractable: bool,
/// Bit flags of `usages`; see `Usages` type.
_usages: u8,
/// Raw bytes of key.
_key: []const u8,
/// Metadata needed to reconstruct the JS `.algorithm` dictionary. The strings
/// are expected to outlive the key (arena-allocated alongside it).
_algorithm: Algorithm,
/// Different algorithms may use different data structures;
/// this union can be used for such situations. Active field is understood
/// from `_type`.
_vary: extern union {
_vary: union(enum) {
none,
/// Used by HMAC.
digest: *const crypto.EVP_MD,
/// Used by asymmetric algorithms (X25519, Ed25519).
pkey: *crypto.EVP_PKEY,
},
} = .none,
/// Captures the algorithm parameters reported back via the `.algorithm`
/// accessor. `hash` is only set for HMAC (and other hashed algorithms).
pub const Algorithm = struct {
name: []const u8,
hash: ?[]const u8 = null,
named_curve: ?[]const u8 = null,
};
/// https://developer.mozilla.org/en-US/docs/Web/API/CryptoKeyPair
pub const Pair = struct {
@@ -51,7 +68,17 @@ pub const Pair = struct {
/// Key-creating functions expect this format.
pub const KeyOrPair = union(enum) { key: *CryptoKey, pair: Pair };
pub const Type = enum(u8) { hmac, rsa, x25519 };
pub const Type = enum(u8) { hmac, rsa, x25519, aes, derive, ec };
pub const Kind = enum {
secret,
public,
private,
pub fn toString(self: Kind) []const u8 {
return @tagName(self);
}
};
/// Changing the names of fields would affect bitmask creation.
pub const Usages = struct {
@@ -67,32 +94,105 @@ pub const Usages = struct {
// zig fmt: on
};
pub inline fn canSign(self: *const CryptoKey) bool {
pub fn canEncrypt(self: *const CryptoKey) bool {
return self._usages & Usages.encrypt != 0;
}
pub fn canDecrypt(self: *const CryptoKey) bool {
return self._usages & Usages.decrypt != 0;
}
pub fn canSign(self: *const CryptoKey) bool {
return self._usages & Usages.sign != 0;
}
pub inline fn canVerify(self: *const CryptoKey) bool {
pub fn canVerify(self: *const CryptoKey) bool {
return self._usages & Usages.verify != 0;
}
pub inline fn canDeriveBits(self: *const CryptoKey) bool {
pub fn canDeriveBits(self: *const CryptoKey) bool {
return self._usages & Usages.deriveBits != 0;
}
pub inline fn canExportKey(self: *const CryptoKey) bool {
pub fn canDeriveKey(self: *const CryptoKey) bool {
return self._usages & Usages.deriveKey != 0;
}
pub fn canExportKey(self: *const CryptoKey) bool {
return self._extractable;
}
/// Only valid for HMAC.
pub inline fn getDigest(self: *const CryptoKey) *const crypto.EVP_MD {
pub fn getDigest(self: *const CryptoKey) *const crypto.EVP_MD {
return self._vary.digest;
}
/// Only valid for asymmetric algorithms (X25519, Ed25519).
pub inline fn getKeyObject(self: *const CryptoKey) *crypto.EVP_PKEY {
pub fn getKeyObject(self: *const CryptoKey) *crypto.EVP_PKEY {
return self._vary.pkey;
}
pub fn getType(self: *const CryptoKey) Kind {
return self._kind;
}
pub fn getExtractable(self: *const CryptoKey) bool {
return self._extractable;
}
/// The shape of the `.algorithm` dictionary depends on the algorithm. AES and
/// HMAC expose a `length` (in bits, derived from the key material); HMAC also
/// exposes a nested `hash`.
const AlgorithmReport = union(enum) {
keyed: struct { name: []const u8, length: u32 },
hmac: struct { name: []const u8, length: u32, hash: struct { name: []const u8 } },
ec: struct { name: []const u8, namedCurve: []const u8 },
named: struct { name: []const u8 },
};
pub fn getAlgorithm(self: *const CryptoKey) AlgorithmReport {
const length: u32 = @intCast(self._key.len * 8);
return switch (self._type) {
.aes => .{ .keyed = .{ .name = self._algorithm.name, .length = length } },
.hmac => .{ .hmac = .{
.name = self._algorithm.name,
.length = length,
.hash = .{ .name = self._algorithm.hash orelse "" },
} },
.ec => .{ .ec = .{
.name = self._algorithm.name,
.namedCurve = self._algorithm.named_curve orelse "",
} },
else => .{ .named = .{ .name = self._algorithm.name } },
};
}
/// Returns the active usages, de-duplicated, in a stable order.
pub fn getUsages(self: *const CryptoKey, exec: *const Execution) ![]const []const u8 {
// zig fmt: off
const all = [_]struct { mask: u8, name: []const u8 }{
.{ .mask = Usages.encrypt, .name = "encrypt" },
.{ .mask = Usages.decrypt, .name = "decrypt" },
.{ .mask = Usages.sign, .name = "sign" },
.{ .mask = Usages.verify, .name = "verify" },
.{ .mask = Usages.deriveKey, .name = "deriveKey" },
.{ .mask = Usages.deriveBits, .name = "deriveBits" },
.{ .mask = Usages.wrapKey, .name = "wrapKey" },
.{ .mask = Usages.unwrapKey, .name = "unwrapKey" },
};
// zig fmt: on
var buf: [all.len][]const u8 = undefined;
var n: usize = 0;
for (all) |entry| {
if (self._usages & entry.mask != 0) {
buf[n] = entry.name;
n += 1;
}
}
return exec.call_arena.dupe([]const u8, buf[0..n]);
}
pub const JsApi = struct {
pub const bridge = js.Bridge(CryptoKey);
@@ -102,4 +202,9 @@ pub const JsApi = struct {
pub var class_id: bridge.ClassId = undefined;
pub const prototype_chain = bridge.prototypeChain();
};
pub const @"type" = bridge.accessor(CryptoKey.getType, null, .{});
pub const extractable = bridge.accessor(CryptoKey.getExtractable, null, .{});
pub const algorithm = bridge.accessor(CryptoKey.getAlgorithm, null, .{});
pub const usages = bridge.accessor(CryptoKey.getUsages, null, .{});
};

View File

@@ -60,14 +60,15 @@ pub fn fromError(err: anyerror) ?DOMException {
error.DataClone => .{ ._code = .data_clone_error },
error.InvalidAccessError => .{ ._code = .invalid_access_error },
error.OperationError => .{ ._code = .operation_error },
error.DataError => .{ ._code = .data_error },
else => null,
};
}
pub fn getCode(self: *const DOMException) u8 {
return switch (self._code) {
// WebCrypto-only error: no legacy numeric code.
.operation_error => 0,
// WebCrypto-only errors: no legacy numeric code.
.operation_error, .data_error => 0,
else => @intFromEnum(self._code),
};
}
@@ -101,6 +102,7 @@ pub fn getName(self: *const DOMException) []const u8 {
.invalid_node_type_error => "InvalidNodeTypeError",
.data_clone_error => "DataCloneError",
.operation_error => "OperationError",
.data_error => "DataError",
};
}
@@ -132,6 +134,7 @@ pub fn getMessage(self: *const DOMException) []const u8 {
.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",
.data_error => "Data provided to an operation does not meet requirements",
};
}
@@ -172,6 +175,8 @@ const Code = enum(u8) {
invalid_node_type_error = 24,
data_clone_error = 25,
/// Defined by WebCrypto; no legacy code, exposed via name only.
data_error = 0xFE,
/// Defined by WebCrypto; no legacy code, exposed via name only.
operation_error = 0xFF,
/// Maps a standard error name to its legacy code
@@ -200,6 +205,7 @@ const Code = enum(u8) {
.{ "InvalidNodeTypeError", .invalid_node_type_error },
.{ "DataCloneError", .data_clone_error },
.{ "OperationError", .operation_error },
.{ "DataError", .data_error },
});
return lookup.get(name) orelse .none;
}

View File

@@ -30,6 +30,8 @@ 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 KDF = @import("crypto/KDF.zig");
const common = @import("crypto/common.zig");
const log = lp.log;
const String = lp.String;
@@ -54,18 +56,8 @@ pub fn generateKey(
const local = exec.js.local.?;
switch (algo) {
.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 } });
};
log.warn(.not_implemented, "generateKey", .{ .name = params.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 });
},
.aes_key_gen => |params| return AES.generate(params, extractable, key_usages, exec),
.ec_key_gen => |params| return EC.generate(params, extractable, key_usages, exec),
.rsa_hashed_key_gen => |params| {
RSA.validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
@@ -108,10 +100,10 @@ fn _generateKeyFromName(
const allowed: []const []const u8 = blk: {
const str = name.str();
if (std.ascii.eqlIgnoreCase(str, "Ed25519") or std.ascii.eqlIgnoreCase(str, "Ed448")) {
if (eqlIgnoreCase(str, "Ed25519") or eqlIgnoreCase(str, "Ed448")) {
break :blk &.{ "sign", "verify" };
}
if (std.ascii.eqlIgnoreCase(str, "X448")) {
if (eqlIgnoreCase(str, "X448")) {
break :blk &.{ "deriveKey", "deriveBits" };
}
return error.NotSupported;
@@ -139,6 +131,140 @@ fn _generateKeyFromName(
return error.NotSupported;
}
/// Imports a key from an external, portable format and returns a `CryptoKey`.
pub fn importKey(
_: *const SubtleCrypto,
format: []const u8,
key_data: algorithm.KeyData,
algo: algorithm.Import,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const name = algo.algoName();
// Asymmetric algorithms (EC, OKP). Usage validation runs first (bad/empty
// usages → SyntaxError) so the failure-path tests pass regardless of whether
// the key material itself can be parsed yet.
const is_private = importKind(format, key_data);
if (asymmetricAllowedUsages(name, is_private)) |allowed| {
// Public keys may have empty usages; secret/private keys may not.
const mask = common.usageMaskInner(allowed, key_usages, is_private) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
if (EC.canonicalName(name) != null) {
const der = switch (key_data) {
.bytes => |b| b.values,
.jwk => return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }),
};
return EC.import(name, algo.namedCurve(), format, der, is_private, extractable, mask, exec);
}
log.warn(.not_implemented, "SubtleCrypto.importKey", .{ .name = name });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
// Resolve the raw key bytes from the requested format. Symmetric keys
// support "raw" (a BufferSource) and "jwk" (an "oct" JSON Web Key).
const raw: []const u8 = blk: {
if (std.mem.eql(u8, format, "raw")) {
break :blk switch (key_data) {
.bytes => |b| b.values,
// A JWK object passed where a BufferSource is expected.
.jwk => return local.rejectPromise(.{ .type_error = "raw format expects a BufferSource" }),
};
}
if (std.mem.eql(u8, format, "jwk")) {
const jwk = switch (key_data) {
.jwk => |j| j,
.bytes => return local.rejectPromise(.{ .type_error = "jwk format expects an object" }),
};
if (!std.mem.eql(u8, jwk.kty, "oct")) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
}
const k = jwk.k orelse {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
};
break :blk common.base64Decode(exec.call_arena, k) catch |err| switch (err) {
error.DataError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } }),
else => |e| return e,
};
}
// spki / pkcs8 (asymmetric formats) are not supported for these algorithms.
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
if (AES.canonicalName(name) != null) {
return AES.import(name, raw, extractable, key_usages, exec);
}
// HKDF / PBKDF2: key-derivation inputs. The raw bytes are the key; there's
// no length constraint and these keys are non-extractable.
inline for ([_][]const u8{ "HKDF", "PBKDF2" }) |derive_name| {
if (eqlIgnoreCase(name, derive_name)) {
const mask = common.usageMask(&.{ "deriveKey", "deriveBits" }, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
const key = try exec.arena.dupe(u8, raw);
const crypto_key = try exec._factory.create(CryptoKey{
._type = .derive,
._kind = .secret,
._extractable = extractable,
._usages = mask,
._key = key,
._algorithm = .{ .name = derive_name },
});
return local.resolvePromise(crypto_key);
}
}
if (eqlIgnoreCase(name, "HMAC")) {
const hash_name = switch (algo) {
.hmac => |h| switch (h.hash) {
.string => |s| s,
.object => |o| o.name,
},
else => return local.rejectPromise(.{ .type_error = "HMAC import requires a hash" }),
};
return HMAC.import(hash_name, raw, extractable, key_usages, exec);
}
log.warn(.not_implemented, "SubtleCrypto.importKey", .{ .name = name });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
/// Whether the requested format/key-data describe a private key. The format
/// alone decides for DER/raw variants; for JWK the presence of `d` marks it.
fn importKind(format: []const u8, key_data: algorithm.KeyData) bool {
if (eqlIgnoreCase(format, "pkcs8") or eqlIgnoreCase(format, "raw-private") or eqlIgnoreCase(format, "raw-seed")) {
return true;
}
if (eqlIgnoreCase(format, "jwk")) {
return switch (key_data) {
.jwk => |j| j.d != null,
.bytes => false,
};
}
// spki, raw, raw-public, ...
return false;
}
/// The usages permitted for an EC/OKP key of the given privacy, or null if the
/// algorithm isn't one of the asymmetric algorithms handled here. Real key
/// import for these isn't implemented yet — this only drives usage validation.
fn asymmetricAllowedUsages(name: []const u8, is_private: bool) ?[]const []const u8 {
if (eqlIgnoreCase(name, "ECDSA") or eqlIgnoreCase(name, "Ed25519") or eqlIgnoreCase(name, "Ed448")) {
return if (is_private) &.{"sign"} else &.{"verify"};
}
if (eqlIgnoreCase(name, "ECDH") or eqlIgnoreCase(name, "X25519") or eqlIgnoreCase(name, "X448")) {
return if (is_private) &.{ "deriveKey", "deriveBits" } else &.{};
}
return null;
}
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}
/// Exports a key: that is, it takes as input a CryptoKey object and gives you
/// the key in an external, portable format.
pub fn exportKey(
@@ -156,9 +282,11 @@ pub fn exportKey(
return local.resolvePromise(js.ArrayBuffer{ .values = key._key });
}
const is_unsupported = std.mem.eql(u8, format, "pkcs8") or
std.mem.eql(u8, format, "spki") or std.mem.eql(u8, format, "jwk");
if (std.mem.eql(u8, format, "jwk")) {
return exportJwk(key, exec);
}
const is_unsupported = std.mem.eql(u8, format, "pkcs8") or std.mem.eql(u8, format, "spki");
if (is_unsupported) {
log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = format });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
@@ -167,36 +295,192 @@ pub fn exportKey(
return local.rejectPromise(.{ .type_error = "invalid format" });
}
/// Derive a secret key from a master key.
pub fn deriveBits(
_: *const SubtleCrypto,
algo: algorithm.Derive,
base_key: *const CryptoKey, // Private key.
length: usize,
exec: *const Execution,
) !js.Promise {
/// The JSON Web Key returned for symmetric ("oct") keys.
const JwkSecret = struct {
kty: []const u8 = "oct",
k: []const u8,
alg: []const u8,
ext: bool,
key_ops: []const []const u8,
};
fn exportJwk(key: *CryptoKey, exec: *const Execution) !js.Promise {
const local = exec.js.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, exec) catch |err| switch (err) {
error.InvalidAccessError => return local.rejectPromise(.{
.dom_exception = .{ .err = error.InvalidAccessError },
}),
else => return err,
};
return local.resolvePromise(result);
}
if (std.mem.eql(u8, name, "ECDH")) {
log.warn(.not_implemented, "SubtleCrypto.deriveBits", .{ .name = name });
}
// The `alg` registry value depends on the algorithm and key length.
const alg: []const u8 = switch (key._type) {
.aes => try std.fmt.allocPrint(exec.call_arena, "A{d}{s}", .{
key._key.len * 8,
key._algorithm.name[4..], // strip "AES-"
}),
.hmac => blk: {
const hash: []const u8 = key._algorithm.hash orelse "SHA-";
break :blk try std.fmt.allocPrint(exec.call_arena, "HS{s}", .{hash[4..]}); // strip "SHA-"
},
else => {
log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = "jwk", .type = key._type });
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
},
};
return local.resolvePromise(JwkSecret{
.k = try common.base64Encode(exec.call_arena, key._key),
.alg = alg,
.ext = key._extractable,
.key_ops = try key.getUsages(exec),
});
}
/// Derive an array of bits from a base key. `length` is in bits and may be null
/// (the WebIDL type is `unsigned long?`).
pub fn deriveBits(
_: *const SubtleCrypto,
algo: algorithm.Derive,
base_key: *const CryptoKey,
length: ?u32,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const bits = deriveRaw(algo, base_key, length, base_key.canDeriveBits(), exec) catch |err| {
return rejectDerive(local, err);
};
return local.resolvePromise(js.ArrayBuffer{ .values = bits });
}
/// Derive a new CryptoKey from a base key: derive the right number of bits for
/// `derived`, then import them as that key type.
pub fn deriveKey(
_: *const SubtleCrypto,
algo: algorithm.Derive,
base_key: *const CryptoKey,
derived: algorithm.DerivedKey,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
// The base key's deriveKey usage (not deriveBits) gates this operation.
const usage_ok = base_key.canDeriveKey();
switch (derived) {
.keyed => |k| {
if (AES.canonicalName(k.name) == null) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
const bits = deriveRaw(algo, base_key, k.length, usage_ok, exec) catch |err| {
return rejectDerive(local, err);
};
return AES.import(k.name, bits, extractable, key_usages, exec);
},
.hmac => |h| {
const hash_name = h.hash.name();
const hash_md = crypto.findDigest(hash_name) catch {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
// Default length, per spec, is the hash's block size (in bits).
const length: u32 = h.length orelse @intCast(crypto.EVP_MD_block_size(hash_md) * 8);
const bits = deriveRaw(algo, base_key, length, usage_ok, exec) catch |err| {
return rejectDerive(local, err);
};
return HMAC.import(hash_name, bits, extractable, key_usages, exec);
},
.object, .name => {
log.warn(.not_implemented, "SubtleCrypto.deriveKey", .{});
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
},
}
}
/// Shared derivation core for deriveBits/deriveKey. `usage_ok` is the relevant
/// usage gate already evaluated by the caller. Returns the raw derived bytes.
fn deriveRaw(
algo: algorithm.Derive,
base_key: *const CryptoKey,
length: ?u32,
usage_ok: bool,
exec: *const Execution,
) KDF.Error![]const u8 {
switch (algo) {
.pbkdf2 => |params| return KDF.pbkdf2(base_key, params, length, usage_ok, exec),
.hkdf => |params| return KDF.hkdf(base_key, params, length, usage_ok, exec),
.ecdh_or_x25519 => |params| {
if (!usage_ok) {
return error.InvalidAccessError;
}
// The base key must have been created for this same algorithm.
if (!eqlIgnoreCase(base_key._algorithm.name, params.name)) {
return error.InvalidAccessError;
}
if (eqlIgnoreCase(params.name, "X25519")) {
// null length means "derive the full shared secret" (256 bits).
const result = X25519.deriveBits(base_key, params.public, length orelse 256, exec) catch |err| switch (err) {
error.InvalidAccessError => return error.InvalidAccessError,
error.OutOfMemory => return error.OutOfMemory,
else => return error.OperationError,
};
return result.values;
}
if (eqlIgnoreCase(params.name, "ECDH")) {
return EC.deriveBits(base_key, params.public, length, exec);
}
return error.NotSupported;
},
}
}
/// Maps a KDF error to the spec-mandated DOMException (OutOfMemory propagates).
fn rejectDerive(local: *const js.Local, err: KDF.Error) !js.Promise {
return switch (err) {
error.InvalidAccessError => local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }),
error.NotSupported => local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }),
error.OperationError => local.rejectPromise(.{ .dom_exception = .{ .err = error.OperationError } }),
error.OutOfMemory => error.OutOfMemory,
};
}
/// Encrypts data with the given key and algorithm.
pub fn encrypt(
_: *const SubtleCrypto,
algo: algorithm.Encrypt,
key: *CryptoKey,
data: js.TypedArray(u8),
exec: *const Execution,
) !js.Promise {
return cryptOp(algo, key, data.values, true, exec);
}
/// Decrypts data with the given key and algorithm.
pub fn decrypt(
_: *const SubtleCrypto,
algo: algorithm.Encrypt,
key: *CryptoKey,
data: js.TypedArray(u8),
exec: *const Execution,
) !js.Promise {
return cryptOp(algo, key, data.values, false, exec);
}
fn cryptOp(
algo: algorithm.Encrypt,
key: *CryptoKey,
data: []const u8,
encrypting: bool,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const params = switch (algo) {
.params => |p| p,
// A bare string identifier carries no iv/counter, so it can't drive AES.
.name => return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }),
};
const out = AES.crypt(params, key, data, encrypting, exec) catch |err| switch (err) {
error.InvalidAccessError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.InvalidAccessError } }),
error.OperationError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.OperationError } }),
error.NotSupported => return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } }),
error.OutOfMemory => return error.OutOfMemory,
};
return local.resolvePromise(js.ArrayBuffer{ .values = out });
}
/// Generate a digital signature.
@@ -238,15 +522,31 @@ pub fn verify(
};
}
/// `digest()` accepts an AlgorithmIdentifier: either a bare string (`"SHA-256"`)
/// or an object (`{name: "SHA-256"}`). The object variant must come first — a
/// `[]const u8` coerces *any* JS value to a string, so it has to be the fallback.
const DigestInput = union(enum) {
obj: struct { name: []const u8 },
str: []const u8,
fn name(self: DigestInput) []const u8 {
return switch (self) {
.obj => |o| o.name,
.str => |s| s,
};
}
};
/// Generates a digest of the given data, using the specified hash function.
pub fn digest(_: *const SubtleCrypto, algo: []const u8, data: js.TypedArray(u8), exec: *const Execution) !js.Promise {
pub fn digest(_: *const SubtleCrypto, algo: DigestInput, data: js.TypedArray(u8), exec: *const Execution) !js.Promise {
const local = exec.js.local.?;
if (algo.len > 10) {
const algo_name = algo.name();
if (algo_name.len > 10) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
}
const normalized = std.ascii.upperString(exec.buf, algo);
const normalized = std.ascii.upperString(exec.buf, algo_name);
const digest_type = crypto.findDigest(normalized) catch {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
@@ -271,9 +571,13 @@ pub const JsApi = struct {
};
pub const generateKey = bridge.function(SubtleCrypto.generateKey, .{ .dom_exception = true });
pub const importKey = bridge.function(SubtleCrypto.importKey, .{ .dom_exception = true });
pub const exportKey = bridge.function(SubtleCrypto.exportKey, .{ .dom_exception = true });
pub const encrypt = bridge.function(SubtleCrypto.encrypt, .{ .dom_exception = true });
pub const decrypt = bridge.function(SubtleCrypto.decrypt, .{ .dom_exception = true });
pub const sign = bridge.function(SubtleCrypto.sign, .{ .dom_exception = true });
pub const verify = bridge.function(SubtleCrypto.verify, .{ .dom_exception = true });
pub const deriveBits = bridge.function(SubtleCrypto.deriveBits, .{ .dom_exception = true });
pub const deriveKey = bridge.function(SubtleCrypto.deriveKey, .{ .dom_exception = true });
pub const digest = bridge.function(SubtleCrypto.digest, .{ .dom_exception = true });
};

View File

@@ -16,32 +16,44 @@
// 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`.
//! AES for AES-CBC/CTR/GCM/KW: key generation, import, and encrypt/decrypt
//! (CBC/CTR/GCM). Keys are raw byte strings; wrapKey/unwrapKey is not yet
//! implemented.
const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../../sys/libcrypto.zig");
const js = @import("../../js/js.zig");
const CryptoKey = @import("../CryptoKey.zig");
const common = @import("common.zig");
const algorithm = @import("algorithm.zig");
const Execution = js.Execution;
pub fn canonicalName(name: []const u8) ?[]const u8 {
inline for ([_][]const u8{ "AES-CBC", "AES-CTR", "AES-GCM", "AES-KW" }) |canonical| {
if (eqlIgnoreCase(name, canonical)) return canonical;
}
return null;
}
/// The usages permitted for the given AES variant.
fn allowedUsages(name: []const u8) ?[]const []const u8 {
if (eqlIgnoreCase(name, "AES-CBC") or eqlIgnoreCase(name, "AES-CTR") or eqlIgnoreCase(name, "AES-GCM")) {
return &.{ "encrypt", "decrypt", "wrapKey", "unwrapKey" };
}
if (eqlIgnoreCase(name, "AES-KW")) {
return &.{ "wrapKey", "unwrapKey" };
}
return null;
}
/// 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;
};
const allowed = allowedUsages(params.name) orelse return error.NotSupported;
for (key_usages) |usage| {
var ok = false;
@@ -65,6 +77,282 @@ pub fn validate(params: algorithm.Init.AesKeyGen, key_usages: []const []const u8
}
}
fn eql(a: []const u8, b: []const u8) bool {
/// Generates a fresh AES key (random bytes of the requested length).
pub fn generate(
params: algorithm.Init.AesKeyGen,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
// validate() already confirmed the usages and length are well-formed.
const allowed = allowedUsages(params.name).?;
const mask = common.usageMask(allowed, key_usages) catch unreachable;
const key = try exec.arena.alloc(u8, params.length / 8);
const res = crypto.RAND_bytes(key.ptr, key.len);
lp.assert(res == 1, "AES.generate", .{ .res = res });
const crypto_key = try exec._factory.create(CryptoKey{
._type = .aes,
._kind = .secret,
._extractable = extractable,
._usages = mask,
._key = key,
._algorithm = .{ .name = canonicalName(params.name).? },
});
return local.resolvePromise(crypto_key);
}
/// Imports raw AES key material (from the `raw` or `jwk` formats; the caller has
/// already turned both into the underlying bytes).
pub fn import(
name: []const u8,
raw: []const u8,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const canonical = canonicalName(name) orelse {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
const mask = common.usageMask(allowedUsages(name).?, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
if (raw.len != 16 and raw.len != 24 and raw.len != 32) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
}
const key = try exec.arena.dupe(u8, raw);
const crypto_key = try exec._factory.create(CryptoKey{
._type = .aes,
._kind = .secret,
._extractable = extractable,
._usages = mask,
._key = key,
._algorithm = .{ .name = canonical },
});
return local.resolvePromise(crypto_key);
}
pub const CipherError = error{ InvalidAccessError, OperationError, NotSupported, OutOfMemory };
const Mode = enum { cbc, ctr, gcm };
fn modeFor(name: []const u8) ?Mode {
if (eqlIgnoreCase(name, "AES-CBC")) return .cbc;
if (eqlIgnoreCase(name, "AES-CTR")) return .ctr;
if (eqlIgnoreCase(name, "AES-GCM")) return .gcm;
return null;
}
fn cipherFor(mode: Mode, key_len: usize) ?*const crypto.EVP_CIPHER {
return switch (mode) {
.cbc => switch (key_len) {
16 => crypto.EVP_aes_128_cbc(),
24 => crypto.EVP_aes_192_cbc(),
32 => crypto.EVP_aes_256_cbc(),
else => null,
},
.ctr => switch (key_len) {
16 => crypto.EVP_aes_128_ctr(),
24 => crypto.EVP_aes_192_ctr(),
32 => crypto.EVP_aes_256_ctr(),
else => null,
},
.gcm => switch (key_len) {
16 => crypto.EVP_aes_128_gcm(),
24 => crypto.EVP_aes_192_gcm(),
32 => crypto.EVP_aes_256_gcm(),
else => null,
},
};
}
/// AES encrypt (`encrypting = true`) or decrypt. Returns the resulting bytes
/// (ciphertext for GCM includes the appended tag).
pub fn crypt(
params: anytype, // algorithm.Encrypt.params
key: *const CryptoKey,
data: []const u8,
encrypting: bool,
exec: *const Execution,
) CipherError![]const u8 {
const mode = modeFor(params.name) orelse return error.NotSupported;
// The key must be an AES key for this exact algorithm, with the matching
// usage.
if (key._type != .aes or !eqlIgnoreCase(key._algorithm.name, params.name)) {
return error.InvalidAccessError;
}
if ((encrypting and !key.canEncrypt()) or (!encrypting and !key.canDecrypt())) {
return error.InvalidAccessError;
}
const cipher = cipherFor(mode, key._key.len) orelse return error.OperationError;
return switch (mode) {
.cbc => cbcOrCtr(cipher, key._key, ivOf(params.iv) orelse return error.OperationError, data, encrypting, true, exec),
.ctr => blk: {
// The counter `length` (bits of the block used as the counter) must
// be in 1..128.
const len = params.length orelse return error.OperationError;
if (len == 0 or len > 128) return error.OperationError;
break :blk cbcOrCtr(cipher, key._key, ivOf(params.counter) orelse return error.OperationError, data, encrypting, false, exec);
},
.gcm => gcm(cipher, key._key, params, data, encrypting, exec),
};
}
fn ivOf(opt: anytype) ?[]const u8 {
const v = opt orelse return null;
return v.values;
}
// libcrypto's EVP_*Init/Update/Final are distinct functions for encrypt vs
// decrypt; extern fns can't be selected into a runtime variable, so branch here.
fn cipherInit(ctx: *crypto.EVP_CIPHER_CTX, cipher: ?*const crypto.EVP_CIPHER, key: [*c]const u8, iv: [*c]const u8, encrypting: bool) c_int {
return if (encrypting)
crypto.EVP_EncryptInit_ex(ctx, cipher, null, key, iv)
else
crypto.EVP_DecryptInit_ex(ctx, cipher, null, key, iv);
}
fn cipherUpdate(ctx: *crypto.EVP_CIPHER_CTX, out: [*c]u8, out_len: *c_int, in: [*c]const u8, in_len: c_int, encrypting: bool) c_int {
return if (encrypting)
crypto.EVP_EncryptUpdate(ctx, out, out_len, in, in_len)
else
crypto.EVP_DecryptUpdate(ctx, out, out_len, in, in_len);
}
fn cbcOrCtr(
cipher: *const crypto.EVP_CIPHER,
key: []const u8,
iv: []const u8,
data: []const u8,
encrypting: bool,
padded: bool, // CBC pads, CTR does not
exec: *const Execution,
) CipherError![]const u8 {
if (iv.len != 16) return error.OperationError;
const ctx = crypto.EVP_CIPHER_CTX_new() orelse return error.OutOfMemory;
defer crypto.EVP_CIPHER_CTX_free(ctx);
if (cipherInit(ctx, cipher, @ptrCast(key.ptr), @ptrCast(iv.ptr), encrypting) != 1) {
return error.OperationError;
}
if (!padded) {
_ = crypto.EVP_CIPHER_CTX_set_padding(ctx, 0);
}
// Block ciphers may emit up to one extra block on top of the input.
const out = try exec.call_arena.alloc(u8, data.len + 16);
var out_len: c_int = 0;
if (cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(data.ptr), @intCast(data.len), encrypting) != 1) {
return error.OperationError;
}
var final_len: c_int = 0;
const tail = out.ptr + @as(usize, @intCast(out_len));
// Decrypt final fails on bad padding → OperationError, per spec.
const final_ok = if (encrypting)
crypto.EVP_EncryptFinal_ex(ctx, tail, &final_len)
else
crypto.EVP_DecryptFinal_ex(ctx, tail, &final_len);
if (final_ok != 1) {
return error.OperationError;
}
return out[0..@intCast(out_len + final_len)];
}
fn gcm(
cipher: *const crypto.EVP_CIPHER,
key: []const u8,
params: anytype,
data: []const u8,
encrypting: bool,
exec: *const Execution,
) CipherError![]const u8 {
const iv = ivOf(params.iv) orelse return error.OperationError;
const tag_bits = params.tagLength orelse 128;
switch (tag_bits) {
32, 64, 96, 104, 112, 120, 128 => {},
else => return error.OperationError,
}
const tag_len: usize = tag_bits / 8;
const ctx = crypto.EVP_CIPHER_CTX_new() orelse return error.OutOfMemory;
defer crypto.EVP_CIPHER_CTX_free(ctx);
if (cipherInit(ctx, cipher, null, null, encrypting) != 1) return error.OperationError;
if (crypto.EVP_CIPHER_CTX_ctrl(ctx, crypto.EVP_CTRL_GCM_SET_IVLEN, @intCast(iv.len), null) != 1) {
return error.OperationError;
}
if (cipherInit(ctx, null, @ptrCast(key.ptr), @ptrCast(iv.ptr), encrypting) != 1) return error.OperationError;
// Additional authenticated data (optional), fed with a null output buffer.
if (ivOf(params.additionalData)) |aad| {
if (aad.len > 0) {
var aad_len: c_int = 0;
if (cipherUpdate(ctx, null, &aad_len, @ptrCast(aad.ptr), @intCast(aad.len), encrypting) != 1) {
return error.OperationError;
}
}
}
if (encrypting) {
const out = try exec.call_arena.alloc(u8, data.len + tag_len);
var out_len: c_int = 0;
if (data.len > 0 and cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(data.ptr), @intCast(data.len), true) != 1) {
return error.OperationError;
}
var final_len: c_int = 0;
if (crypto.EVP_EncryptFinal_ex(ctx, out.ptr + @as(usize, @intCast(out_len)), &final_len) != 1) {
return error.OperationError;
}
const written: usize = @intCast(out_len + final_len);
// Append the authentication tag.
if (crypto.EVP_CIPHER_CTX_ctrl(ctx, crypto.EVP_CTRL_GCM_GET_TAG, @intCast(tag_len), out.ptr + written) != 1) {
return error.OperationError;
}
return out[0 .. written + tag_len];
}
// Decrypt: the ciphertext carries the tag as its final `tag_len` bytes.
if (data.len < tag_len) return error.OperationError;
const ct = data[0 .. data.len - tag_len];
const tag = data[data.len - tag_len ..];
if (crypto.EVP_CIPHER_CTX_ctrl(ctx, crypto.EVP_CTRL_GCM_SET_TAG, @intCast(tag_len), @ptrCast(@constCast(tag.ptr))) != 1) {
return error.OperationError;
}
const out = try exec.call_arena.alloc(u8, ct.len + 16);
var out_len: c_int = 0;
if (ct.len > 0 and cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(ct.ptr), @intCast(ct.len), false) != 1) {
return error.OperationError;
}
var final_len: c_int = 0;
// Tag verification happens here: failure → OperationError.
if (crypto.EVP_DecryptFinal_ex(ctx, out.ptr + @as(usize, @intCast(out_len)), &final_len) != 1) {
return error.OperationError;
}
return out[0..@intCast(out_len + final_len)];
}
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -16,43 +16,69 @@
// 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.
//! ECDSA / ECDH key generation, import (spki/pkcs8 via DER) and ECDH key
//! agreement. Key bytes live as a libcrypto EVP_PKEY in `CryptoKey._vary.pkey`.
const std = @import("std");
const lp = @import("lightpanda");
const crypto = @import("../../../sys/libcrypto.zig");
const js = @import("../../js/js.zig");
const CryptoKey = @import("../CryptoKey.zig");
const common = @import("common.zig");
const algorithm = @import("algorithm.zig");
const Execution = js.Execution;
/// Errors mapped to DOMException names by the caller.
pub const Error = error{ InvalidAccessError, NotSupported, OperationError, OutOfMemory };
/// The registered name (case-insensitive), or null if not an EC algorithm.
pub fn canonicalName(name: []const u8) ?[]const u8 {
inline for ([_][]const u8{ "ECDSA", "ECDH" }) |canonical| {
if (eqlIgnoreCase(name, canonical)) return canonical;
}
return null;
}
fn curveNid(named_curve: []const u8) ?c_int {
if (eqlIgnoreCase(named_curve, "P-256")) return crypto.NID_X9_62_prime256v1;
if (eqlIgnoreCase(named_curve, "P-384")) return crypto.NID_secp384r1;
if (eqlIgnoreCase(named_curve, "P-521")) return crypto.NID_secp521r1;
return null;
}
fn curveCanonical(named_curve: []const u8) ?[]const u8 {
inline for ([_][]const u8{ "P-256", "P-384", "P-521" }) |c| {
if (eqlIgnoreCase(named_curve, c)) return c;
}
return null;
}
/// The (private, public) usages legal for the algorithm.
fn usageSets(name: []const u8) struct { private: []const []const u8, public: []const []const u8 } {
if (eqlIgnoreCase(name, "ECDSA")) {
return .{ .private = &.{"sign"}, .public = &.{"verify"} };
}
// ECDH
return .{ .private = &.{ "deriveKey", "deriveBits" }, .public = &.{} };
}
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" };
}
const sets = blk: {
if (eqlIgnoreCase(params.name, "ECDSA") or eqlIgnoreCase(params.name, "ECDH")) break :blk usageSets(params.name);
return error.NotSupported;
};
// A usage that belongs to neither the private nor the public set is illegal.
for (key_usages) |usage| {
var ok = false;
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
ok = true;
break;
}
}
if (!ok) {
if (!contains(sets.private, usage) and !contains(sets.public, usage)) {
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"))
{
if (curveNid(params.namedCurve) == null) {
// Per spec, an unsupported `namedCurve` is NotSupportedError.
return error.NotSupported;
}
@@ -61,6 +87,197 @@ pub fn validate(params: algorithm.Init.EcKeyGen, key_usages: []const []const u8)
}
}
fn eql(a: []const u8, b: []const u8) bool {
/// Generates an EC key pair on the requested curve.
pub fn generate(
params: algorithm.Init.EcKeyGen,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
validate(params, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
const name = canonicalName(params.name).?;
const curve = curveCanonical(params.namedCurve).?;
const nid = curveNid(params.namedCurve).?;
const sets = usageSets(name);
// Split the requested usages across the pair: each key keeps only the
// usages legal for it. validate() already rejected anything illegal.
const private_mask = maskOf(sets.private, key_usages);
const public_mask = maskOf(sets.public, key_usages);
const ec = crypto.EC_KEY_new_by_curve_name(nid) orelse return error.OutOfMemory;
defer crypto.EC_KEY_free(ec);
if (crypto.EC_KEY_generate_key(ec) != 1) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.OperationError } });
}
const private_pkey = crypto.EVP_PKEY_new() orelse return error.OutOfMemory;
errdefer crypto.EVP_PKEY_free(private_pkey);
if (crypto.EVP_PKEY_set1_EC_KEY(private_pkey, ec) != 1) return error.OutOfMemory;
// A public-only EVP_PKEY (same curve, just the public point) for the peer.
const pub_ec = crypto.EC_KEY_new_by_curve_name(nid) orelse return error.OutOfMemory;
defer crypto.EC_KEY_free(pub_ec);
const point = crypto.EC_KEY_get0_public_key(ec) orelse return error.OperationError;
if (crypto.EC_KEY_set_public_key(pub_ec, point) != 1) return error.OperationError;
const public_pkey = crypto.EVP_PKEY_new() orelse return error.OutOfMemory;
errdefer crypto.EVP_PKEY_free(public_pkey);
if (crypto.EVP_PKEY_set1_EC_KEY(public_pkey, pub_ec) != 1) return error.OutOfMemory;
const private = try exec._factory.create(CryptoKey{
._type = .ec,
._kind = .private,
._extractable = extractable,
._usages = private_mask,
._key = &.{},
._algorithm = .{ .name = name, .named_curve = curve },
._vary = .{ .pkey = private_pkey },
});
errdefer exec._factory.destroy(private);
const public = try exec._factory.create(CryptoKey{
._type = .ec,
._kind = .public,
// Public keys are always extractable.
._extractable = true,
._usages = public_mask,
._key = &.{},
._algorithm = .{ .name = name, .named_curve = curve },
._vary = .{ .pkey = public_pkey },
});
return local.resolvePromise(CryptoKey.Pair{ .privateKey = private, .publicKey = public });
}
/// Imports an EC key from DER (`spki` public / `pkcs8` private). jwk/raw aren't
/// handled yet. libcrypto parses the DER, so a malformed structure is DataError.
pub fn import(
name: []const u8,
named_curve: []const u8,
format: []const u8,
der: []const u8,
is_private: bool,
extractable: bool,
usages_mask: u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const canonical = canonicalName(name).?;
const curve = curveCanonical(named_curve) orelse {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
var ptr: [*c]const u8 = der.ptr;
const pkey: *crypto.EVP_PKEY = blk: {
if (std.mem.eql(u8, format, "spki")) {
break :blk crypto.d2i_PUBKEY(null, &ptr, @intCast(der.len)) orelse {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
};
}
if (std.mem.eql(u8, format, "pkcs8")) {
break :blk crypto.d2i_AutoPrivateKey(null, &ptr, @intCast(der.len)) orelse {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
};
}
// jwk / raw not implemented yet.
return local.rejectPromise(.{ .dom_exception = .{ .err = error.NotSupported } });
};
errdefer crypto.EVP_PKEY_free(pkey);
if (crypto.EVP_PKEY_id(pkey) != crypto.EVP_PKEY_EC) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
}
const crypto_key = try exec._factory.create(CryptoKey{
._type = .ec,
._kind = if (is_private) .private else .public,
._extractable = extractable,
._usages = usages_mask,
._key = &.{},
._algorithm = .{ .name = canonical, .named_curve = curve },
._vary = .{ .pkey = pkey },
});
return local.resolvePromise(crypto_key);
}
/// ECDH key agreement: derive `length_in_bits` from `private` + the peer
/// `public`. Mirrors X25519's truncation rules.
pub fn deriveBits(
private: *const CryptoKey,
public: *const CryptoKey,
length_in_bits: ?u32,
exec: *const Execution,
) Error![]const u8 {
// The peer must be an ECDH *public* key on the *same* curve.
if (public._type != .ec or public._kind != .public) {
return error.InvalidAccessError;
}
if (!eqlIgnoreCase(public._algorithm.name, "ECDH")) {
return error.InvalidAccessError;
}
if (!eqlIgnoreCase(private._algorithm.named_curve orelse "", public._algorithm.named_curve orelse "")) {
return error.InvalidAccessError;
}
const ctx = crypto.EVP_PKEY_CTX_new(private.getKeyObject(), null) orelse return error.OperationError;
defer crypto.EVP_PKEY_CTX_free(ctx);
if (crypto.EVP_PKEY_derive_init(ctx) != 1 or crypto.EVP_PKEY_derive_set_peer(ctx, public.getKeyObject()) != 1) {
return error.OperationError;
}
// First call with a null buffer reports the full shared-secret length.
var secret_len: usize = 0;
if (crypto.EVP_PKEY_derive(ctx, null, &secret_len) != 1 or secret_len == 0) {
return error.OperationError;
}
const secret = try exec.call_arena.alloc(u8, secret_len);
if (crypto.EVP_PKEY_derive(ctx, secret.ptr, &secret_len) != 1) {
return error.OperationError;
}
// null length means "the full shared secret".
const bits = length_in_bits orelse @as(u32, @intCast(secret_len * 8));
const byte_len = (bits + 7) / 8;
if (byte_len > secret_len) {
return error.OperationError;
}
const out = secret[0..byte_len];
// Zero the unused trailing bits of the final byte.
const remainder_bits: u3 = @intCast(bits % 8);
if (remainder_bits != 0 and out.len > 0) {
out[out.len - 1] &= ~(@as(u8, 0xFF) >> remainder_bits);
}
return out;
}
fn contains(set: []const []const u8, usage: []const u8) bool {
for (set) |s| {
if (std.mem.eql(u8, s, usage)) {
return true;
}
}
return false;
}
/// The usage bitmask of those `usages` that belong to `set`.
fn maskOf(set: []const []const u8, usages: []const []const u8) u8 {
var mask: u8 = 0;
for (usages) |u| {
if (contains(set, u)) {
mask |= common.usageBit(u).?;
}
}
return mask;
}
fn eqlIgnoreCase(a: []const u8, b: []const u8) bool {
return std.ascii.eqlIgnoreCase(a, b);
}

View File

@@ -24,6 +24,7 @@ const crypto = @import("../../../sys/libcrypto.zig");
const js = @import("../../js/js.zig");
const algorithm = @import("algorithm.zig");
const common = @import("common.zig");
const CryptoKey = @import("../CryptoKey.zig");
@@ -36,12 +37,13 @@ pub fn init(
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
// Per spec, an unrecognized hash is caught during algorithm normalization
// and surfaces as NotSupportedError.
const digest = crypto.findDigest(switch (params.hash) {
const hash_name = switch (params.hash) {
.string => |str| str,
.object => |obj| obj.name,
}) catch return local.rejectPromise(.{
};
// Per spec, an unrecognized hash is caught during algorithm normalization
// and surfaces as NotSupportedError.
const digest = crypto.findDigest(hash_name) catch return local.rejectPromise(.{
.dom_exception = .{ .err = error.NotSupported },
});
@@ -76,7 +78,6 @@ pub fn init(
// Should we reject this in promise too?
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);
@@ -87,6 +88,46 @@ pub fn init(
._extractable = extractable,
._usages = mask,
._key = key,
._algorithm = .{ .name = "HMAC", .hash = try exec.arena.dupe(u8, hash_name) },
._vary = .{ .digest = digest },
});
return local.resolvePromise(crypto_key);
}
/// Imports raw HMAC key material (from the `raw` or `jwk` formats; the caller
/// has already turned both into the underlying bytes).
pub fn import(
hash_name: []const u8,
raw: []const u8,
extractable: bool,
key_usages: []const []const u8,
exec: *const Execution,
) !js.Promise {
const local = exec.js.local.?;
const digest = crypto.findDigest(hash_name) catch return local.rejectPromise(.{
.dom_exception = .{ .err = error.NotSupported },
});
const mask = common.usageMask(&.{ "sign", "verify" }, key_usages) catch |err| {
return local.rejectPromise(.{ .dom_exception = .{ .err = err } });
};
if (raw.len == 0) {
return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } });
}
const key = try exec.arena.dupe(u8, raw);
errdefer exec.arena.free(key);
const crypto_key = try exec._factory.create(CryptoKey{
._type = .hmac,
._kind = .secret,
._extractable = extractable,
._usages = mask,
._key = key,
._algorithm = .{ .name = "HMAC", .hash = try exec.arena.dupe(u8, hash_name) },
._vary = .{ .digest = digest },
});

View File

@@ -0,0 +1,124 @@
// 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/>.
//! Key-derivation functions for `deriveBits()`: PBKDF2 and HKDF. Both operate
//! on a raw "secret" base key (imported via the `raw` format).
const std = @import("std");
const js = @import("../../js/js.zig");
const CryptoKey = @import("../CryptoKey.zig");
const crypto = @import("../../../sys/libcrypto.zig");
const algorithm = @import("algorithm.zig");
const Execution = js.Execution;
/// Errors a derivation can raise, each mapping to a specific DOMException name.
pub const Error = error{ InvalidAccessError, NotSupported, OperationError, OutOfMemory };
/// Validates the base key against the requested algorithm (shared by PBKDF2 and
/// HKDF). `usage_ok` is the caller-checked usage gate (deriveBits for
/// `deriveBits()`, deriveKey for `deriveKey()`); the key must also have been
/// created for this same algorithm. Either mismatch is an InvalidAccessError.
fn checkBaseKey(base_key: *const CryptoKey, name: []const u8, usage_ok: bool) Error!void {
if (!usage_ok) {
return error.InvalidAccessError;
}
if (!std.ascii.eqlIgnoreCase(base_key._algorithm.name, name)) {
return error.InvalidAccessError;
}
}
/// `length` is the requested output size in bits: null and non-multiples of 8
/// are OperationErrors. Returns the output byte length.
fn outputLen(length: ?u32) Error!usize {
const bits = length orelse return error.OperationError;
if (bits % 8 != 0) {
return error.OperationError;
}
return bits / 8;
}
pub fn pbkdf2(
base_key: *const CryptoKey,
params: algorithm.Derive.Pbkdf2Params,
length: ?u32,
usage_ok: bool,
exec: *const Execution,
) Error![]const u8 {
try checkBaseKey(base_key, "PBKDF2", usage_ok);
const digest = crypto.findDigest(params.hash.name()) catch return error.NotSupported;
const out = try exec.call_arena.alloc(u8, try outputLen(length));
// A zero-length derivation is valid and yields an empty buffer; the C
// routines reject a zero output length, so short-circuit here.
if (out.len == 0) {
return out;
}
const salt = params.salt.values;
const res = crypto.PKCS5_PBKDF2_HMAC(
@ptrCast(base_key._key.ptr),
base_key._key.len,
@ptrCast(salt.ptr),
salt.len,
params.iterations,
digest,
out.len,
out.ptr,
);
if (res != 1) {
return error.OperationError;
}
return out;
}
pub fn hkdf(
base_key: *const CryptoKey,
params: algorithm.Derive.HkdfParams,
length: ?u32,
usage_ok: bool,
exec: *const Execution,
) Error![]const u8 {
try checkBaseKey(base_key, "HKDF", usage_ok);
const digest = crypto.findDigest(params.hash.name()) catch return error.NotSupported;
const out = try exec.call_arena.alloc(u8, try outputLen(length));
// A zero-length derivation is valid and yields an empty buffer; the C
// routines reject a zero output length, so short-circuit here.
if (out.len == 0) {
return out;
}
const salt = params.salt.values;
const info = params.info.values;
const res = crypto.HKDF(
out.ptr,
out.len,
digest,
@ptrCast(base_key._key.ptr),
base_key._key.len,
@ptrCast(salt.ptr),
salt.len,
@ptrCast(info.ptr),
info.len,
);
if (res != 1) {
return error.OperationError;
}
return out;
}

View File

@@ -93,20 +93,24 @@ pub fn init(
const private = try exec._factory.create(CryptoKey{
._type = .x25519,
._kind = .private,
._extractable = extractable,
._usages = mask,
._key = private_key,
._algorithm = .{ .name = "X25519" },
._vary = .{ .pkey = private_pkey },
});
errdefer exec._factory.destroy(private);
const public = try exec._factory.create(CryptoKey{
._type = .x25519,
._kind = .public,
// Public keys are always extractable.
._extractable = true,
// Always empty for public key.
._usages = 0,
._key = public_value,
._algorithm = .{ .name = "X25519" },
._vary = .{ .pkey = public_pkey },
});

View File

@@ -89,9 +89,126 @@ pub const Init = union(enum) {
};
};
/// Algorithm for deriveBits() and deriveKey().
/// Algorithm for deriveBits() and deriveKey(). Variants are distinguished by
/// their required members (`iterations` for PBKDF2, `info` for HKDF, `public`
/// for ECDH/X25519), so probe order doesn't cause ambiguity.
pub const Derive = union(enum) {
pbkdf2: Pbkdf2Params,
hkdf: HkdfParams,
ecdh_or_x25519: Init.EcdhKeyDeriveParams,
/// A hash AlgorithmIdentifier — either `"SHA-256"` or `{name: "SHA-256"}`.
pub const Hash = union(enum) {
string: []const u8,
object: struct { name: []const u8 },
pub fn name(self: Hash) []const u8 {
return switch (self) {
.string => |s| s,
.object => |o| o.name,
};
}
};
pub const Pbkdf2Params = struct {
name: []const u8,
hash: Hash,
salt: js.TypedArray(u8),
iterations: u32,
};
pub const HkdfParams = struct {
name: []const u8,
hash: Hash,
salt: js.TypedArray(u8),
info: js.TypedArray(u8),
};
};
/// The `derivedKeyType` argument to `deriveKey()` — the algorithm of the key to
/// produce. HMAC carries a hash; AES carries a length. Probed in that order so
/// the more specific shapes win.
pub const DerivedKey = union(enum) {
hmac: struct { name: []const u8, hash: Derive.Hash, length: ?u32 = null },
keyed: struct { name: []const u8, length: u32 },
object: struct { name: []const u8 },
name: []const u8,
};
/// Algorithm passed to `importKey()`. HMAC carries a hash, so it must be probed
/// before the bare-`{name}` object; the plain string is the final fallback (any
/// JS value coerces to a string).
pub const Import = union(enum) {
hmac: struct {
name: []const u8,
hash: union(enum) {
string: []const u8,
object: struct { name: []const u8 },
},
length: ?u32 = null,
},
ec: struct { name: []const u8, namedCurve: []const u8 },
object: struct { name: []const u8 },
name: []const u8,
pub fn algoName(self: Import) []const u8 {
return switch (self) {
.hmac => |h| h.name,
.ec => |e| e.name,
.object => |o| o.name,
.name => |n| n,
};
}
/// The `namedCurve` if this is an EC import, else empty.
pub fn namedCurve(self: Import) []const u8 {
return switch (self) {
.ec => |e| e.namedCurve,
else => "",
};
}
};
/// Key material handed to `importKey()`: either a BufferSource (raw/spki/pkcs8)
/// or a JSON Web Key object (jwk). `bytes` is probed first — a JWK is a plain
/// object and won't coerce to a TypedArray.
pub const KeyData = union(enum) {
bytes: js.TypedArray(u8),
jwk: Jwk,
/// Minimal JWK fields we read on import. Symmetric ("oct") keys only need
/// `kty` and `k`; `d` marks an asymmetric private key. The rest are accepted
/// for forward-compatibility.
pub const Jwk = struct {
kty: []const u8,
k: ?[]const u8 = null,
d: ?[]const u8 = null,
alg: ?[]const u8 = null,
use: ?[]const u8 = null,
ext: ?bool = null,
};
};
/// Algorithm for `encrypt()` / `decrypt()`. AES-CBC/CTR/GCM share one struct
/// (fields are mode-specific and optional) and dispatch on `name`; a bare string
/// is the fallback form.
pub const Encrypt = union(enum) {
params: struct {
name: []const u8,
iv: ?js.TypedArray(u8) = null,
counter: ?js.TypedArray(u8) = null,
length: ?u32 = null,
additionalData: ?js.TypedArray(u8) = null,
tagLength: ?u32 = null,
},
name: []const u8,
pub fn algoName(self: Encrypt) []const u8 {
return switch (self) {
.params => |p| p.name,
.name => |n| n,
};
}
};
/// For `sign()` functionality.

View File

@@ -0,0 +1,100 @@
// 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/>.
//! Shared helpers for the symmetric SubtleCrypto algorithms (AES, HMAC):
//! usage-mask validation and the base64url codec used by the JWK format.
const std = @import("std");
const Allocator = std.mem.Allocator;
const CryptoKey = @import("../CryptoKey.zig");
/// Maps a usage string to its `CryptoKey.Usages` bit, or null if unknown.
pub fn usageBit(name: []const u8) ?u8 {
const U = CryptoKey.Usages;
const map = std.StaticStringMap(u8).initComptime(.{
.{ "encrypt", U.encrypt },
.{ "decrypt", U.decrypt },
.{ "sign", U.sign },
.{ "verify", U.verify },
.{ "deriveKey", U.deriveKey },
.{ "deriveBits", U.deriveBits },
.{ "wrapKey", U.wrapKey },
.{ "unwrapKey", U.unwrapKey },
});
return map.get(name);
}
/// Builds the usage bitmask, rejecting any usage not in `allowed` with
/// SyntaxError — matching the WebCrypto "Bad usages" failure path. An empty
/// list is also a SyntaxError (the "Empty usages" path), which is correct for
/// secret and private keys; public keys permit empty usages.
pub fn usageMask(allowed: []const []const u8, usages: []const []const u8) error{SyntaxError}!u8 {
return usageMaskInner(allowed, usages, true);
}
/// As `usageMask`, but `reject_empty` controls whether an empty usage list is a
/// SyntaxError. Pass false for public keys.
pub fn usageMaskInner(allowed: []const []const u8, usages: []const []const u8, reject_empty: bool) error{SyntaxError}!u8 {
var mask: u8 = 0;
outer: for (usages) |usage| {
for (allowed) |a| {
if (std.mem.eql(u8, a, usage)) {
mask |= usageBit(a).?;
continue :outer;
}
}
return error.SyntaxError;
}
if (reject_empty and usages.len == 0) {
return error.SyntaxError;
}
return mask;
}
/// Decodes a base64url (or base64) string, tolerating either alphabet and
/// optional padding. Returns DataError on malformed input, per the JWK import
/// rules.
pub fn base64Decode(allocator: Allocator, input: []const u8) error{ OutOfMemory, DataError }![]u8 {
// Normalize to base64url-no-pad: map the standard alphabet's +/ to -_ and
// drop any '=' padding so a single decoder handles both forms.
const normalized = try allocator.alloc(u8, input.len);
var n: usize = 0;
for (input) |c| {
normalized[n] = switch (c) {
'+' => '-',
'/' => '_',
'=' => continue,
else => c,
};
n += 1;
}
const decoder = std.base64.url_safe_no_pad.Decoder;
const size = decoder.calcSizeForSlice(normalized[0..n]) catch return error.DataError;
const out = try allocator.alloc(u8, size);
decoder.decode(out, normalized[0..n]) catch return error.DataError;
return out;
}
/// Encodes bytes as base64url without padding (the JWK `k` representation).
pub fn base64Encode(allocator: Allocator, bytes: []const u8) error{OutOfMemory}![]const u8 {
const encoder = std.base64.url_safe_no_pad.Encoder;
const out = try allocator.alloc(u8, encoder.calcSize(bytes.len));
return encoder.encode(out, bytes);
}

View File

@@ -239,12 +239,88 @@ pub extern fn HMAC(
out_len: *c_uint,
) ?[*]u8;
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 HKDF(
out_key: [*c]u8,
out_len: usize,
digest: *const EVP_MD,
secret: [*c]const u8,
secret_len: usize,
salt: [*c]const u8,
salt_len: usize,
info: [*c]const u8,
info_len: usize,
) c_int;
pub const X25519_PRIVATE_KEY_LEN = 32;
pub const X25519_PUBLIC_VALUE_LEN = 32;
pub const X25519_SHARED_KEY_LEN = 32;
pub extern fn X25519_keypair(out_public_value: *[32]u8, out_private_key: *[32]u8) void;
pub const struct_ec_point_st = opaque {};
pub const EC_POINT = struct_ec_point_st;
pub const EVP_CIPHER = opaque {};
pub const EVP_CIPHER_CTX = opaque {};
pub extern fn EVP_aes_128_cbc() *const EVP_CIPHER;
pub extern fn EVP_aes_192_cbc() *const EVP_CIPHER;
pub extern fn EVP_aes_256_cbc() *const EVP_CIPHER;
pub extern fn EVP_aes_128_ctr() *const EVP_CIPHER;
pub extern fn EVP_aes_192_ctr() *const EVP_CIPHER;
pub extern fn EVP_aes_256_ctr() *const EVP_CIPHER;
pub extern fn EVP_aes_128_gcm() *const EVP_CIPHER;
pub extern fn EVP_aes_192_gcm() *const EVP_CIPHER;
pub extern fn EVP_aes_256_gcm() *const EVP_CIPHER;
pub extern fn EVP_CIPHER_CTX_new() ?*EVP_CIPHER_CTX;
pub extern fn EVP_CIPHER_CTX_free(ctx: ?*EVP_CIPHER_CTX) void;
pub extern fn EVP_CIPHER_CTX_ctrl(ctx: *EVP_CIPHER_CTX, command: c_int, arg: c_int, ptr: ?*anyopaque) c_int;
pub extern fn EVP_CIPHER_CTX_set_padding(ctx: *EVP_CIPHER_CTX, padding: c_int) c_int;
pub extern fn EVP_EncryptInit_ex(ctx: *EVP_CIPHER_CTX, cipher: ?*const EVP_CIPHER, impl: ?*ENGINE, key: [*c]const u8, iv: [*c]const u8) c_int;
pub extern fn EVP_EncryptUpdate(ctx: *EVP_CIPHER_CTX, out: [*c]u8, out_len: *c_int, in: [*c]const u8, in_len: c_int) c_int;
pub extern fn EVP_EncryptFinal_ex(ctx: *EVP_CIPHER_CTX, out: [*c]u8, out_len: *c_int) c_int;
pub extern fn EVP_DecryptInit_ex(ctx: *EVP_CIPHER_CTX, cipher: ?*const EVP_CIPHER, impl: ?*ENGINE, key: [*c]const u8, iv: [*c]const u8) c_int;
pub extern fn EVP_DecryptUpdate(ctx: *EVP_CIPHER_CTX, out: [*c]u8, out_len: *c_int, in: [*c]const u8, in_len: c_int) c_int;
pub extern fn EVP_DecryptFinal_ex(ctx: *EVP_CIPHER_CTX, out: [*c]u8, out_len: *c_int) c_int;
// EVP_CIPHER_CTX_ctrl commands for AES-GCM.
pub const EVP_CTRL_GCM_SET_IVLEN = 0x9;
pub const EVP_CTRL_GCM_GET_TAG = 0x10;
pub const EVP_CTRL_GCM_SET_TAG = 0x11;
// EC key type + curve identifiers.
pub const EVP_PKEY_EC = 408; // NID_X9_62_id_ecPublicKey
pub const NID_X9_62_prime256v1 = 415; // P-256
pub const NID_secp384r1 = 715; // P-384
pub const NID_secp521r1 = 716; // P-521
pub extern fn EVP_PKEY_new() ?*EVP_PKEY;
pub extern fn EVP_PKEY_id(pkey: *const EVP_PKEY) c_int;
pub extern fn EVP_PKEY_set1_EC_KEY(pkey: *EVP_PKEY, key: *EC_KEY) c_int;
pub extern fn EC_KEY_new_by_curve_name(nid: c_int) ?*EC_KEY;
pub extern fn EC_KEY_generate_key(key: *EC_KEY) c_int;
pub extern fn EC_KEY_free(key: ?*EC_KEY) void;
pub extern fn EC_KEY_get0_public_key(key: *const EC_KEY) ?*const EC_POINT;
pub extern fn EC_KEY_set_public_key(key: *EC_KEY, point: *const EC_POINT) c_int;
// DER decoders (advance `inp` past the parsed structure).
pub extern fn d2i_PUBKEY(out: ?*?*EVP_PKEY, inp: *[*c]const u8, len: c_long) ?*EVP_PKEY;
pub extern fn d2i_AutoPrivateKey(out: ?*?*EVP_PKEY, inp: *[*c]const u8, len: c_long) ?*EVP_PKEY;
pub const NID_X25519 = @as(c_int, 948);
pub const EVP_PKEY_X25519 = NID_X25519;
pub const NID_ED25519 = 949;