From b7c3814ece672eb34b66cc7d1e4edb85e1edb329 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 8 Jun 2026 14:04:15 +0800 Subject: [PATCH 1/2] WPT /WebCryptoAPI/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I threw Claude at WPT's /WebCryptoAPI/ which represents a significant number of failing cases. There were a few very low-hanging fruit, like enabling CryptoKey on Worker and supporting `{name: string}` in addition to just `string` for some functions. Some of these "fixes" just handle the error case / validation, which tends to represent a greater number of WPT cases, e.g. some of the importKey algorithms aren't fully supported, but they still validate the input and return the correct errors. To that end, while there should be considerably more passing cases (~42K), some of these changes merely unlocked more failing cases, so our total case count should go up. Claude's summary: 1. digest {name} object fix (16→80) 2. Symmetric importKey/exportKey/generateKey (AES, HMAC; raw + jwk) + CryptoKey accessors + DataError 3. EC/OKP importKey usage validation (failure-path) 4. PBKDF2 + HKDF deriveBits, then deriveKey 5. ECDH generateKey + import (spki/pkcs8) + deriveBits/deriveKey 6. AES encrypt/decrypt (CBC/CTR/GCM) --- src/browser/js/Local.zig | 6 - src/browser/js/bridge.zig | 1 + src/browser/webapi/CryptoKey.zig | 123 +++++++- src/browser/webapi/DOMException.zig | 10 +- src/browser/webapi/SubtleCrypto.zig | 392 +++++++++++++++++++++--- src/browser/webapi/crypto/AES.zig | 326 ++++++++++++++++++-- src/browser/webapi/crypto/EC.zig | 268 ++++++++++++++-- src/browser/webapi/crypto/HMAC.zig | 51 ++- src/browser/webapi/crypto/KDF.zig | 124 ++++++++ src/browser/webapi/crypto/X25519.zig | 4 + src/browser/webapi/crypto/algorithm.zig | 119 ++++++- src/browser/webapi/crypto/common.zig | 100 ++++++ src/sys/libcrypto.zig | 76 +++++ 13 files changed, 1489 insertions(+), 111 deletions(-) create mode 100644 src/browser/webapi/crypto/KDF.zig create mode 100644 src/browser/webapi/crypto/common.zig diff --git a/src/browser/js/Local.zig b/src/browser/js/Local.zig index ef24f43e..bc4f6d7c 100644 --- a/src/browser/js/Local.zig +++ b/src/browser/js/Local.zig @@ -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); diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 6bcf3a3a..feb40676 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -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"), diff --git a/src/browser/webapi/CryptoKey.zig b/src/browser/webapi/CryptoKey.zig index 664d23d0..3874b3fb 100644 --- a/src/browser/webapi/CryptoKey.zig +++ b/src/browser/webapi/CryptoKey.zig @@ -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, .{}); }; diff --git a/src/browser/webapi/DOMException.zig b/src/browser/webapi/DOMException.zig index aa0cf117..296d2965 100644 --- a/src/browser/webapi/DOMException.zig +++ b/src/browser/webapi/DOMException.zig @@ -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; } diff --git a/src/browser/webapi/SubtleCrypto.zig b/src/browser/webapi/SubtleCrypto.zig index 242938aa..285feef2 100644 --- a/src/browser/webapi/SubtleCrypto.zig +++ b/src/browser/webapi/SubtleCrypto.zig @@ -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 }); }; diff --git a/src/browser/webapi/crypto/AES.zig b/src/browser/webapi/crypto/AES.zig index 3e9c12b5..28b97797 100644 --- a/src/browser/webapi/crypto/AES.zig +++ b/src/browser/webapi/crypto/AES.zig @@ -16,32 +16,44 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! 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); } diff --git a/src/browser/webapi/crypto/EC.zig b/src/browser/webapi/crypto/EC.zig index 8d99ef11..44b9ba67 100644 --- a/src/browser/webapi/crypto/EC.zig +++ b/src/browser/webapi/crypto/EC.zig @@ -16,43 +16,69 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -//! 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,198 @@ 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); } diff --git a/src/browser/webapi/crypto/HMAC.zig b/src/browser/webapi/crypto/HMAC.zig index 89277177..c7a59e07 100644 --- a/src/browser/webapi/crypto/HMAC.zig +++ b/src/browser/webapi/crypto/HMAC.zig @@ -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 }, }); diff --git a/src/browser/webapi/crypto/KDF.zig b/src/browser/webapi/crypto/KDF.zig new file mode 100644 index 00000000..98aa87b6 --- /dev/null +++ b/src/browser/webapi/crypto/KDF.zig @@ -0,0 +1,124 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! 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; +} diff --git a/src/browser/webapi/crypto/X25519.zig b/src/browser/webapi/crypto/X25519.zig index 048ab999..14e4e137 100644 --- a/src/browser/webapi/crypto/X25519.zig +++ b/src/browser/webapi/crypto/X25519.zig @@ -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 }, }); diff --git a/src/browser/webapi/crypto/algorithm.zig b/src/browser/webapi/crypto/algorithm.zig index 28861a60..aa96c0e5 100644 --- a/src/browser/webapi/crypto/algorithm.zig +++ b/src/browser/webapi/crypto/algorithm.zig @@ -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. diff --git a/src/browser/webapi/crypto/common.zig b/src/browser/webapi/crypto/common.zig new file mode 100644 index 00000000..d25758c4 --- /dev/null +++ b/src/browser/webapi/crypto/common.zig @@ -0,0 +1,100 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +//! 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); +} diff --git a/src/sys/libcrypto.zig b/src/sys/libcrypto.zig index f1833268..0e62df33 100644 --- a/src/sys/libcrypto.zig +++ b/src/sys/libcrypto.zig @@ -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; From d11d7fdbd4399e2bf3bac6ea92316b51af36783a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Mon, 8 Jun 2026 15:10:54 +0800 Subject: [PATCH 2/2] zig fmt --- src/browser/webapi/crypto/EC.zig | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/browser/webapi/crypto/EC.zig b/src/browser/webapi/crypto/EC.zig index 44b9ba67..236b2efd 100644 --- a/src/browser/webapi/crypto/EC.zig +++ b/src/browser/webapi/crypto/EC.zig @@ -228,8 +228,7 @@ pub fn deriveBits( 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) - { + if (crypto.EVP_PKEY_derive_init(ctx) != 1 or crypto.EVP_PKEY_derive_set_peer(ctx, public.getKeyObject()) != 1) { return error.OperationError; }