From 1d923fb452ec9ab4669e939260ad5e87a17ba05f Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Fri, 8 May 2026 04:37:00 +0530 Subject: [PATCH 1/3] docs: add wrapKey parity guide and browser equivalence tests --- README.md | 2 +- doc/webcrypto-parity.md | 3 + doc/wrap-key.md | 407 +++++++++++++++++++++++++ test/wrap_key_equivalence_test.dart | 443 ++++++++++++++++++++++++++++ 4 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 doc/wrap-key.md create mode 100644 test/wrap_key_equivalence_test.dart diff --git a/README.md b/README.md index 029d266a..f63648e7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Web Cryptography APIs see `doc/webcrypto-parity.md`. * `deriveKey` is not supported, however, keys can always be created from `derivedBits` which is supported. - * `wrapKey` is not supported, however, keys can be exported an encrypted. + * `wrapKey` is not supported, however, keys can be exported and encrypted. * `unwrapKey` is not supported, however, keys can be decrypted and imported. * `AES-KW` is not supported because it does not support `encrypt`/`decrypt`. diff --git a/doc/webcrypto-parity.md b/doc/webcrypto-parity.md index aba5ed90..ba597564 100644 --- a/doc/webcrypto-parity.md +++ b/doc/webcrypto-parity.md @@ -112,6 +112,9 @@ export+encrypt and decrypt+import and it has complex typing, this package shall omit this functionality. Adding these operations in the future only impact developers who have custom implementations of `CryptoKey` subclasses. +See also [wrap-key.md](wrap-key.md) for a tutorial showing how to express these +operations with the current API. + ## Notes on the `'AES-KW'` Algorithm The `'AES-KW'` algorithm only supports the `crypto.sutble.wrapKey` and `crypto.sutble.unwrapKey` operations, which we have argued to omit in the diff --git a/doc/wrap-key.md b/doc/wrap-key.md new file mode 100644 index 00000000..2c210b0b --- /dev/null +++ b/doc/wrap-key.md @@ -0,0 +1,407 @@ +# Wrapping Keys with exportKey and encryptBytes + +As mentioned in the [Web Crypto API Parity](webcrypto-parity.md) document, the +`wrapKey` and `unwrapKey` operations are omitted from webcrypto.dart because +they can be expressed using key export/import plus encryption/decryption. For +the wrapping algorithms supported by this package, wrapping is: + +- export the key in a chosen format, +- encrypt the exported bytes, and +- later decrypt the bytes and import them again. + +This tutorial demonstrates how to do that with the APIs that webcrypto.dart +already supports. + +In this tutorial, we demonstrate key wrapping using: +- `raw` for symmetric keys, +- `spki` for public keys, +- `pkcs8` for private keys, +- `AES-CBC`, `AES-CTR`, and `AES-GCM` as deterministic wrapping ciphers, and, +- `RSA-OAEP` as a randomized wrapping cipher. + +For the canonical byte formats `raw`, `spki`, and `pkcs8`, the wrapped bytes +can match browser `crypto.subtle.wrapKey(...)` output exactly when the same key +material and algorithm parameters are used. For `RSA-OAEP`, ciphertext is +randomized, so the correct parity claim is cross-compatibility when unwrapping, +not byte-for-byte ciphertext equality. For `jwk`, there is an additional caveat +around capability metadata discussed later in this tutorial. + +The claims in this document are backed by browser-side tests in +`test/wrap_key_equivalence_test.dart`. + +# AES-GCM: Wrapping a Secret Key in raw Format + +Symmetric keys are the simplest case. In the Web Crypto API, +`wrapKey("raw", keyToWrap, wrappingKey, ...)` is equivalent to: + +1. `exportKey("raw", keyToWrap)`, +2. encrypt the exported bytes with the wrapping key, and, +3. later decrypt those bytes and import them with `importRawKey(...)`. + +## Wrapping a raw HMAC key with AES-GCM + +To wrap a raw secret key using AES-GCM, follow these steps: + +1. Import or generate the AES-GCM wrapping key. We use a 128-bit AES key here: + +```dart +final wrappingKey = await AesGcmSecretKey.importRawKey( + Uint8List.fromList(List.generate(16, (i) => i + 1)), +); +``` + +2. Import the key that should be wrapped. In this example we wrap an HMAC key: + +```dart +final hmacKey = await HmacSecretKey.importRawKey( + Uint8List.fromList(List.generate(32, (i) => 0x80 + i)), + Hash.sha256, +); +``` + +3. Export the HMAC key in `raw` format and encrypt the exported bytes: + +```dart +final iv = Uint8List.fromList(List.generate(12, (i) => 0x20 + i)); +final additionalData = + Uint8List.fromList(List.generate(8, (i) => 0x40 + i)); + +final wrappedKey = await wrappingKey.encryptBytes( + await hmacKey.exportRawKey(), + iv, + additionalData: additionalData, +); +``` + +At this point `wrappedKey` contains exactly the ciphertext that a browser +`wrapKey("raw", ...)` call would have returned for the same inputs. + +4. To unwrap, decrypt the ciphertext and import the result again: + +```dart +final unwrappedRaw = await wrappingKey.decryptBytes( + wrappedKey, + iv, + additionalData: additionalData, +); +final unwrappedKey = await HmacSecretKey.importRawKey( + unwrappedRaw, + Hash.sha256, +); +``` + +The `unwrappedKey` is a normal `HmacSecretKey`. It can be used for signing and +verification, just like a key obtained from `crypto.subtle.unwrapKey("raw", ...)`. + +## Validating raw key wrapping + +The following JavaScript example uses browser `wrapKey("raw", ...)` with fixed +inputs: + +```javascript +(async () => { + const wrappingKeyBytes = Uint8Array.from( + {length: 16}, + (_, i) => i + 1, + ); + const hmacKeyBytes = Uint8Array.from( + {length: 32}, + (_, i) => 0x80 + i, + ); + const iv = Uint8Array.from({length: 12}, (_, i) => 0x20 + i); + const additionalData = Uint8Array.from({length: 8}, (_, i) => 0x40 + i); + + const wrappingKey = await crypto.subtle.importKey( + "raw", + wrappingKeyBytes, + {name: "AES-GCM", length: 128}, + true, + ["encrypt", "decrypt", "wrapKey", "unwrapKey"], + ); + const hmacKey = await crypto.subtle.importKey( + "raw", + hmacKeyBytes, + {name: "HMAC", hash: "SHA-256"}, + true, + ["sign", "verify"], + ); + + const jsWrapped = new Uint8Array(await crypto.subtle.wrapKey( + "raw", + hmacKey, + wrappingKey, + {name: "AES-GCM", iv, additionalData, tagLength: 128}, + )); + const jsWrappedHex = [...jsWrapped] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + + console.log("JS_WRAPPED_HEX =", jsWrappedHex); +})(); +``` + +The corresponding Dart code performs `exportRawKey() + encryptBytes(...)` and +verifies that the ciphertext is identical: + +```dart +import 'dart:convert' show base64Encode; +import 'dart:typed_data'; +import 'package:webcrypto/webcrypto.dart'; + +Future main() async { + final wrappingKey = await AesGcmSecretKey.importRawKey( + Uint8List.fromList(List.generate(16, (i) => i + 1)), + ); + final hmacKey = await HmacSecretKey.importRawKey( + Uint8List.fromList(List.generate(32, (i) => 0x80 + i)), + Hash.sha256, + ); + final iv = Uint8List.fromList(List.generate(12, (i) => 0x20 + i)); + final additionalData = + Uint8List.fromList(List.generate(8, (i) => 0x40 + i)); + + const jsWrappedHex = ''; + + final wrappedKey = await wrappingKey.encryptBytes( + await hmacKey.exportRawKey(), + iv, + additionalData: additionalData, + ); + final dartWrappedHex = wrappedKey + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + + final unwrappedRaw = await wrappingKey.decryptBytes( + wrappedKey, + iv, + additionalData: additionalData, + ); + + print('DART_WRAPPED_HEX = $dartWrappedHex'); + print('JS_WRAPPED_HEX = $jsWrappedHex'); + print('DART_WRAPPED_HEX == JS_WRAPPED_HEX: ${dartWrappedHex == jsWrappedHex}'); + print('UNWRAPPED_RAW_OK = ${base64Encode(unwrappedRaw) == base64Encode(await hmacKey.exportRawKey())}'); +} +``` + +The automated browser test in `test/wrap_key_equivalence_test.dart` verifies +this exact equality for: +- `AES-CBC` wrapping `raw`, +- `AES-CTR` wrapping `raw`, and, +- `AES-GCM` wrapping `raw`. + +# AES-GCM: Wrapping Public and Private Keys in spki and pkcs8 + +Public and private keys work the same way. The only difference is the export +format: +- use `exportSpkiKey()` and `importSpkiKey(...)` for public keys, +- use `exportPkcs8Key()` and `importPkcs8Key(...)` for private keys. + +## Wrapping a public key as spki + +The following wraps an RSA-OAEP public key as `spki` bytes using AES-GCM: + +```dart +final wrappingKey = await AesGcmSecretKey.generateKey(128); +final rsaKeyPair = + await RsaOaepPrivateKey.generateKey(2048, BigInt.from(65537), Hash.sha256); + +final iv = Uint8List(12); +fillRandomBytes(iv); + +final wrappedPublicKey = await wrappingKey.encryptBytes( + await rsaKeyPair.publicKey.exportSpkiKey(), + iv, +); + +final unwrappedSpki = await wrappingKey.decryptBytes(wrappedPublicKey, iv); +final publicKey = await RsaOaepPublicKey.importSpkiKey( + unwrappedSpki, + Hash.sha256, +); +``` + +## Wrapping a private key as pkcs8 + +The same pattern applies to a private key: + +```dart +final wrappedPrivateKey = await wrappingKey.encryptBytes( + await rsaKeyPair.privateKey.exportPkcs8Key(), + iv, +); + +final unwrappedPkcs8 = await wrappingKey.decryptBytes(wrappedPrivateKey, iv); +final privateKey = await RsaOaepPrivateKey.importPkcs8Key( + unwrappedPkcs8, + Hash.sha256, +); +``` + +For `spki` and `pkcs8`, browser `wrapKey(...)` and the webcrypto.dart +equivalent operate on the same canonical byte sequence. This means +byte-for-byte ciphertext equality is achievable with deterministic wrapping +algorithms such as AES-GCM, provided the same key bytes and parameters are +used. + +## Validating spki and pkcs8 wrapping + +The automated browser test in `test/wrap_key_equivalence_test.dart` verifies +that: +- `wrapKey("spki", ...)` using AES-GCM matches + `exportSpkiKey() + encryptBytes(...)` exactly, and, +- `wrapKey("pkcs8", ...)` using AES-GCM matches + `exportPkcs8Key() + encryptBytes(...)` exactly. + +The test also checks the reverse direction: +- ciphertext produced by the package can be passed to browser `unwrapKey(...)`, + and, +- ciphertext produced by browser `wrapKey(...)` can be decrypted and imported by + the package. + +# RSA-OAEP: Wrapping a Raw Secret Key + +`RSA-OAEP` is also a valid wrapping algorithm in the Web Crypto API. In +webcrypto.dart, the equivalent operation is: + +1. export the key bytes, +2. encrypt them with `RsaOaepPublicKey.encryptBytes(...)`, and, +3. later decrypt them with `RsaOaepPrivateKey.decryptBytes(...)` and import + them again. + +## Wrapping a raw HMAC key with RSA-OAEP + +```dart +final rsaKeyPair = + await RsaOaepPrivateKey.generateKey(2048, BigInt.from(65537), Hash.sha256); +final hmacKey = await HmacSecretKey.generateKey(Hash.sha256); + +final wrappedKey = await rsaKeyPair.publicKey.encryptBytes( + await hmacKey.exportRawKey(), + label: const [1, 2, 3, 4], +); + +final unwrappedRaw = await rsaKeyPair.privateKey.decryptBytes( + wrappedKey, + label: const [1, 2, 3, 4], +); +final unwrappedKey = await HmacSecretKey.importRawKey( + unwrappedRaw, + Hash.sha256, +); +``` + +This is equivalent in behavior to browser +`wrapKey("raw", ..., wrappingKey = rsaPublicKey, {name: "RSA-OAEP", ...})` +followed by `unwrapKey(...)`. + +## Validating RSA-OAEP wrapping + +Unlike AES-CBC, AES-CTR, and AES-GCM, RSA-OAEP is randomized. Even when the +same key, plaintext, and label are used, two valid ciphertexts will usually be +different. Therefore, browser `wrapKey(...)` and +`exportRawKey() + encryptBytes(...)` should not be expected to return identical +ciphertext bytes. + +The correct parity claim is cross-compatibility: +- browser `wrapKey("raw", ...)` output can be decrypted by + `RsaOaepPrivateKey.decryptBytes(...)`, and, +- package `encryptBytes(...)` output can be passed to browser `unwrapKey(...)`. + +The automated browser test in `test/wrap_key_equivalence_test.dart` verifies +exactly that. It confirms that: +- `jsWrapped != dartWrapped`, and, +- both unwrap to the same raw HMAC key bytes. + +# JWK: Wrapping JSON Web Keys + +For `jwk`, the wrapping flow is still export + encrypt and decrypt + import, +but there is an important difference from the canonical byte formats. + +## Wrapping a JWK manually + +In the browser, `wrapKey("jwk", ...)` serializes a JWK JSON object and wraps +those bytes. In webcrypto.dart, the equivalent manual flow is: + +```dart +import 'dart:convert'; +import 'dart:typed_data'; +import 'package:webcrypto/webcrypto.dart'; + +final wrappingKey = await AesGcmSecretKey.generateKey(128); +final hmacKey = await HmacSecretKey.generateKey(Hash.sha256); + +final iv = Uint8List(12); +fillRandomBytes(iv); + +final wrappedJwk = await wrappingKey.encryptBytes( + utf8.encode(jsonEncode(await hmacKey.exportJsonWebKey())), + iv, +); + +final decryptedJwkJson = jsonDecode( + utf8.decode(await wrappingKey.decryptBytes(wrappedJwk, iv)), +) as Map; + +final unwrappedKey = await HmacSecretKey.importJsonWebKey( + decryptedJwkJson, + Hash.sha256, +); +``` + +This reconstructs the same key material, but the JSON payload is not always +identical to browser `wrapKey("jwk", ...)`. + +## The JWK caveat: ext and key_ops + +The Web Crypto API associates capability metadata with keys: +- `CryptoKey.extractable`, +- `CryptoKey.usages`. + +When a browser exports or unwraps a `jwk`, these values can appear as: +- `ext`, and, +- `key_ops`. + +webcrypto.dart intentionally does not expose capability bits as part of its +public API, and its browser implementation strips `ext` and `key_ops` during +JWK import/export. As a result: + +- browser `wrapKey("jwk", ...)` can produce wrapped JSON that includes `ext` + and `key_ops`, +- `exportJsonWebKey()` in webcrypto.dart omits those fields, and, +- byte-for-byte JWK ciphertext equality should not be claimed. + +The automated browser test in `test/wrap_key_equivalence_test.dart` verifies +the precise boundary: +- decrypting the browser wrapped JWK reveals `ext` and `key_ops`, +- decrypting the package wrapped JWK does not include those fields, and, +- after removing `ext` and `key_ops` from the browser JSON, the remaining JWK + members match exactly. + +Because of this, `jwk` should be documented as: +- the same key material after decrypt + import, but, +- not full capability-bit parity with browser `unwrapKey("jwk", ...)`. + +# AES-KW + +`AES-KW` is not covered by this tutorial. + +The Web Crypto API allows `AES-KW` to be used with `wrapKey` and `unwrapKey`, +but webcrypto.dart does not expose `AES-KW`, and it does not expose the lower +level AES block primitive needed to reconstruct AES-KW directly from the public +API. For that reason, this tutorial only claims parity for wrapping algorithms +already supported through `encryptBytes(...)` and `decryptBytes(...)`. + +# Conclusion + +In this tutorial, we demonstrated how to wrap and unwrap keys in +webcrypto.dart using export/import together with encryption/decryption, +achieving parity with the Web Crypto API's `wrapKey` and `unwrapKey` +functionality for the supported wrapping algorithms. + +For `raw`, `spki`, and `pkcs8`, the package can match browser behavior exactly: +export the canonical bytes, encrypt them, decrypt them, and import them again. +For `RSA-OAEP`, the behavior is cross-compatible but ciphertext is randomized, +so equality should be checked after unwrapping. For `jwk`, the key material is +equivalent, but browser capability metadata (`ext` and `key_ops`) is outside +the package's parity claim. diff --git a/test/wrap_key_equivalence_test.dart b/test/wrap_key_equivalence_test.dart new file mode 100644 index 00000000..3a21d849 --- /dev/null +++ b/test/wrap_key_equivalence_test.dart @@ -0,0 +1,443 @@ +@TestOn('browser') +library; + +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:webcrypto/src/crypto_subtle.dart' as subtle; +import 'package:webcrypto/webcrypto.dart'; + +extension type JSSubtleCryptoWrap(subtle.JSSubtleCrypto _) + implements JSObject { + external JSPromise wrapKey( + String format, + subtle.JSCryptoKey key, + subtle.JSCryptoKey wrappingKey, + JSAny algorithm, + ); + + external JSPromise unwrapKey( + String format, + JSTypedArray wrappedKey, + subtle.JSCryptoKey unwrappingKey, + JSAny unwrapAlgorithm, + JSAny unwrappedKeyAlgorithm, + bool extractable, + JSArray keyUsages, + ); +} + +void main() { + final wrap = JSSubtleCryptoWrap(subtle.window.crypto.subtle); + + final aesKeyBytes = Uint8List.fromList(List.generate(16, (i) => i + 1)); + final iv16 = Uint8List.fromList(List.generate(16, (i) => 0x10 + i)); + final iv = Uint8List.fromList(List.generate(12, (i) => 0x20 + i)); + final counter = Uint8List.fromList(List.generate(16, (i) => 0x30 + i)); + final additionalData = Uint8List.fromList( + List.generate(8, (i) => 0x40 + i), + ); + final hmacKeyBytes = Uint8List.fromList( + List.generate(32, (i) => 0x80 + i), + ); + final rsaAlgorithm = subtle.Algorithm( + name: 'RSA-OAEP', + hash: 'SHA-256', + modulusLength: 2048, + publicExponent: Uint8List.fromList([0x01, 0x00, 0x01]), + ); + final aesGcmAlgorithm = subtle.Algorithm( + name: 'AES-GCM', + iv: iv, + additionalData: additionalData, + tagLength: 128, + ); + + late subtle.JSCryptoKey jsAesWrappingKey; + late subtle.JSCryptoKey jsAesCbcWrappingKey; + late subtle.JSCryptoKey jsAesCtrWrappingKey; + late subtle.JSCryptoKey jsHmacKey; + late subtle.JSCryptoKeyPair jsRsaPair; + late AesGcmSecretKey packageAesWrappingKey; + late AesCbcSecretKey packageAesCbcWrappingKey; + late AesCtrSecretKey packageAesCtrWrappingKey; + late HmacSecretKey packageHmacKey; + late RsaOaepPublicKey packageRsaPublicKey; + late RsaOaepPrivateKey packageRsaPrivateKey; + late Uint8List rsaSpkiBytes; + late Uint8List rsaPkcs8Bytes; + + setUpAll(() async { + jsAesWrappingKey = await subtle.importKey( + 'raw', + aesKeyBytes, + const subtle.Algorithm(name: 'AES-GCM', length: 128), + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + ); + jsAesCbcWrappingKey = await subtle.importKey( + 'raw', + aesKeyBytes, + const subtle.Algorithm(name: 'AES-CBC', length: 128), + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + ); + jsAesCtrWrappingKey = await subtle.importKey( + 'raw', + aesKeyBytes, + const subtle.Algorithm(name: 'AES-CTR', length: 128), + true, + ['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'], + ); + jsHmacKey = await subtle.importKey( + 'raw', + hmacKeyBytes, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + true, + ['sign', 'verify'], + ); + jsRsaPair = await subtle.generateKeyPair( + rsaAlgorithm, + true, + ['wrapKey', 'unwrapKey'], + ); + + packageAesWrappingKey = await AesGcmSecretKey.importRawKey(aesKeyBytes); + packageAesCbcWrappingKey = await AesCbcSecretKey.importRawKey(aesKeyBytes); + packageAesCtrWrappingKey = await AesCtrSecretKey.importRawKey(aesKeyBytes); + packageHmacKey = await HmacSecretKey.importRawKey(hmacKeyBytes, Hash.sha256); + + rsaSpkiBytes = (await subtle.exportKey('spki', jsRsaPair.publicKey)) + .asUint8List(); + rsaPkcs8Bytes = (await subtle.exportKey('pkcs8', jsRsaPair.privateKey)) + .asUint8List(); + + packageRsaPublicKey = await RsaOaepPublicKey.importSpkiKey( + rsaSpkiBytes, + Hash.sha256, + ); + packageRsaPrivateKey = await RsaOaepPrivateKey.importPkcs8Key( + rsaPkcs8Bytes, + Hash.sha256, + ); + }); + + group('wrapKey equivalence', () { + test('AES-CBC raw wrapping matches exportRawKey + encryptBytes exactly', () async { + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesCbcWrappingKey, + subtle.Algorithm(name: 'AES-CBC', iv: iv16), + ); + + final packageWrapped = await packageAesCbcWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + iv16, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesCbcWrappingKey, + subtle.Algorithm(name: 'AES-CBC', iv: iv16), + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesCbcWrappingKey.decryptBytes( + jsWrapped, + iv16, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }); + + test('AES-CTR raw wrapping matches exportRawKey + encryptBytes exactly', () async { + final ctrAlgorithm = subtle.Algorithm( + name: 'AES-CTR', + counter: counter, + length: 64, + ); + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesCtrWrappingKey, + ctrAlgorithm, + ); + + final packageWrapped = await packageAesCtrWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + counter, + 64, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesCtrWrappingKey, + ctrAlgorithm, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesCtrWrappingKey.decryptBytes( + jsWrapped, + counter, + 64, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }); + + test('AES-GCM raw wrapping matches exportRawKey + encryptBytes exactly', () async { + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }); + + test('AES-GCM spki wrapping matches exportSpkiKey + encryptBytes exactly', () async { + final jsWrapped = await _wrapKey( + wrap, + 'spki', + jsRsaPair.publicKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageRsaPublicKey.exportSpkiKey(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'spki', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), + ['encrypt'], + ); + final jsUnwrappedSpki = (await subtle.exportKey('spki', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedSpki, orderedEquals(rsaSpkiBytes)); + + final packageUnwrappedSpki = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedSpki, orderedEquals(rsaSpkiBytes)); + }); + + test('AES-GCM pkcs8 wrapping matches exportPkcs8Key + encryptBytes exactly', () async { + final jsWrapped = await _wrapKey( + wrap, + 'pkcs8', + jsRsaPair.privateKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageRsaPrivateKey.exportPkcs8Key(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'pkcs8', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), + ['decrypt'], + ); + final jsUnwrappedPkcs8 = (await subtle.exportKey('pkcs8', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); + + final packageUnwrappedPkcs8 = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); + }); + + test('RSA-OAEP raw wrapping is cross-compatible even though ciphertext is randomized', () async { + final wrapParams = subtle.Algorithm( + name: 'RSA-OAEP', + label: Uint8List.fromList([1, 2, 3, 4]), + ); + + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsRsaPair.publicKey, + wrapParams, + ); + final packageWrapped = await packageRsaPublicKey.encryptBytes( + await packageHmacKey.exportRawKey(), + label: const [1, 2, 3, 4], + ); + + expect(jsWrapped, isNot(orderedEquals(packageWrapped))); + + final packageUnwrappedRaw = await packageRsaPrivateKey.decryptBytes( + jsWrapped, + label: const [1, 2, 3, 4], + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsRsaPair.privateKey, + wrapParams, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) + .asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }); + + test('JWK wrapKey carries ext/key_ops metadata that package exportJsonWebKey omits', () async { + final jsWrapped = await _wrapKey( + wrap, + 'jwk', + jsHmacKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + utf8.encode(jsonEncode(await packageHmacKey.exportJsonWebKey())), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, isNot(orderedEquals(packageWrapped))); + + final jsJwk = jsonDecode( + utf8.decode( + await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ), + ), + ) as Map; + final packageJwk = jsonDecode( + utf8.decode( + await packageAesWrappingKey.decryptBytes( + packageWrapped, + iv, + additionalData: additionalData, + ), + ), + ) as Map; + + expect(jsJwk['ext'], isTrue); + expect(jsJwk['key_ops'], orderedEquals(['sign', 'verify'])); + expect(packageJwk.containsKey('ext'), isFalse); + expect(packageJwk.containsKey('key_ops'), isFalse); + + final normalizedJsJwk = Map.from(jsJwk) + ..remove('ext') + ..remove('key_ops'); + expect(normalizedJsJwk, equals(packageJwk)); + }); + }); +} + +Future _wrapKey( + JSSubtleCryptoWrap wrap, + String format, + subtle.JSCryptoKey key, + subtle.JSCryptoKey wrappingKey, + subtle.Algorithm algorithm, +) async { + final wrapped = await wrap + .wrapKey(format, key, wrappingKey, algorithm.toJS) + .toDart; + return wrapped.toDart.asUint8List(); +} + +Future _unwrapKey( + JSSubtleCryptoWrap wrap, + String format, + Uint8List wrappedKey, + subtle.JSCryptoKey unwrappingKey, + subtle.Algorithm unwrapAlgorithm, + subtle.Algorithm unwrappedKeyAlgorithm, + List keyUsages, +) { + return wrap + .unwrapKey( + format, + wrappedKey.toJS, + unwrappingKey, + unwrapAlgorithm.toJS, + unwrappedKeyAlgorithm.toJS, + true, + keyUsages.toJS, + ) + .toDart; +} From 0a4fc24a34f92b3b19a0987bc6210fe6b30b8dc8 Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Fri, 8 May 2026 04:42:24 +0530 Subject: [PATCH 2/3] test: mark wrap-key parity test experimental and exclude it from CI --- .github/workflows/test.yml | 8 ++++---- test/wrap_key_equivalence_test.dart | 1 + 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed372901..b4a4258b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: sudo apt-get install -y ninja-build libgtk-3-dev flutter config --no-analytics - run: flutter pub get - - run: xvfb-run dart test -p vm,chrome,firefox -c dart2js,dart2wasm --coverage ./coverage + - run: xvfb-run dart test --exclude-tags experimental -p vm,chrome,firefox -c dart2js,dart2wasm --coverage ./coverage - run: xvfb-run flutter test integration_test/webcrypto_test.dart -d linux working-directory: ./example # Report collected coverage @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 - run: dart pub get --no-example - - run: xvfb-run dart test -p vm,chrome,firefox -c dart2js,dart2wasm + - run: xvfb-run dart test --exclude-tags experimental -p vm,chrome,firefox -c dart2js,dart2wasm macos: name: MacOS desktop / Chrome runs-on: macos-15 # Test with xcode 16 @@ -72,7 +72,7 @@ jobs: run: | flutter config --no-analytics - run: flutter pub get - - run: dart test -p vm,chrome -c dart2js,dart2wasm + - run: dart test --exclude-tags experimental -p vm,chrome -c dart2js,dart2wasm - run: flutter test integration_test/webcrypto_test.dart -d macos --timeout 5m working-directory: ./example windows: @@ -90,7 +90,7 @@ jobs: run: | flutter config --no-analytics - run: flutter pub get - - run: dart test -p vm,chrome,firefox + - run: dart test --exclude-tags experimental -p vm,chrome,firefox - run: flutter test integration_test/webcrypto_test.dart -d windows working-directory: ./example ios: diff --git a/test/wrap_key_equivalence_test.dart b/test/wrap_key_equivalence_test.dart index 3a21d849..a11bad74 100644 --- a/test/wrap_key_equivalence_test.dart +++ b/test/wrap_key_equivalence_test.dart @@ -1,4 +1,5 @@ @TestOn('browser') +@Tags(['experimental']) library; import 'dart:convert'; From 4b23b95ff0322ec90d91ba5cecb6106a6b891138 Mon Sep 17 00:00:00 2001 From: HamdaanAliQuatil Date: Fri, 8 May 2026 04:42:24 +0530 Subject: [PATCH 3/3] test: mark wrap-key parity test experimental and exclude it from CI --- .github/workflows/test.yml | 8 +- test/wrap_key_equivalence_test.dart | 625 +++++++++++++++------------- 2 files changed, 338 insertions(+), 295 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ed372901..b4a4258b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,7 @@ jobs: sudo apt-get install -y ninja-build libgtk-3-dev flutter config --no-analytics - run: flutter pub get - - run: xvfb-run dart test -p vm,chrome,firefox -c dart2js,dart2wasm --coverage ./coverage + - run: xvfb-run dart test --exclude-tags experimental -p vm,chrome,firefox -c dart2js,dart2wasm --coverage ./coverage - run: xvfb-run flutter test integration_test/webcrypto_test.dart -d linux working-directory: ./example # Report collected coverage @@ -57,7 +57,7 @@ jobs: - uses: actions/checkout@v4 - uses: dart-lang/setup-dart@v1 - run: dart pub get --no-example - - run: xvfb-run dart test -p vm,chrome,firefox -c dart2js,dart2wasm + - run: xvfb-run dart test --exclude-tags experimental -p vm,chrome,firefox -c dart2js,dart2wasm macos: name: MacOS desktop / Chrome runs-on: macos-15 # Test with xcode 16 @@ -72,7 +72,7 @@ jobs: run: | flutter config --no-analytics - run: flutter pub get - - run: dart test -p vm,chrome -c dart2js,dart2wasm + - run: dart test --exclude-tags experimental -p vm,chrome -c dart2js,dart2wasm - run: flutter test integration_test/webcrypto_test.dart -d macos --timeout 5m working-directory: ./example windows: @@ -90,7 +90,7 @@ jobs: run: | flutter config --no-analytics - run: flutter pub get - - run: dart test -p vm,chrome,firefox + - run: dart test --exclude-tags experimental -p vm,chrome,firefox - run: flutter test integration_test/webcrypto_test.dart -d windows working-directory: ./example ios: diff --git a/test/wrap_key_equivalence_test.dart b/test/wrap_key_equivalence_test.dart index 3a21d849..bc9de695 100644 --- a/test/wrap_key_equivalence_test.dart +++ b/test/wrap_key_equivalence_test.dart @@ -1,4 +1,5 @@ @TestOn('browser') +@Tags(['experimental']) library; import 'dart:convert'; @@ -9,8 +10,7 @@ import 'package:test/test.dart'; import 'package:webcrypto/src/crypto_subtle.dart' as subtle; import 'package:webcrypto/webcrypto.dart'; -extension type JSSubtleCryptoWrap(subtle.JSSubtleCrypto _) - implements JSObject { +extension type JSSubtleCryptoWrap(subtle.JSSubtleCrypto _) implements JSObject { external JSPromise wrapKey( String format, subtle.JSCryptoKey key, @@ -98,21 +98,27 @@ void main() { true, ['sign', 'verify'], ); - jsRsaPair = await subtle.generateKeyPair( - rsaAlgorithm, - true, - ['wrapKey', 'unwrapKey'], - ); + jsRsaPair = await subtle.generateKeyPair(rsaAlgorithm, true, [ + 'wrapKey', + 'unwrapKey', + ]); packageAesWrappingKey = await AesGcmSecretKey.importRawKey(aesKeyBytes); packageAesCbcWrappingKey = await AesCbcSecretKey.importRawKey(aesKeyBytes); packageAesCtrWrappingKey = await AesCtrSecretKey.importRawKey(aesKeyBytes); - packageHmacKey = await HmacSecretKey.importRawKey(hmacKeyBytes, Hash.sha256); + packageHmacKey = await HmacSecretKey.importRawKey( + hmacKeyBytes, + Hash.sha256, + ); - rsaSpkiBytes = (await subtle.exportKey('spki', jsRsaPair.publicKey)) - .asUint8List(); - rsaPkcs8Bytes = (await subtle.exportKey('pkcs8', jsRsaPair.privateKey)) - .asUint8List(); + rsaSpkiBytes = (await subtle.exportKey( + 'spki', + jsRsaPair.publicKey, + )).asUint8List(); + rsaPkcs8Bytes = (await subtle.exportKey( + 'pkcs8', + jsRsaPair.privateKey, + )).asUint8List(); packageRsaPublicKey = await RsaOaepPublicKey.importSpkiKey( rsaSpkiBytes, @@ -125,285 +131,322 @@ void main() { }); group('wrapKey equivalence', () { - test('AES-CBC raw wrapping matches exportRawKey + encryptBytes exactly', () async { - final jsWrapped = await _wrapKey( - wrap, - 'raw', - jsHmacKey, - jsAesCbcWrappingKey, - subtle.Algorithm(name: 'AES-CBC', iv: iv16), - ); - - final packageWrapped = await packageAesCbcWrappingKey.encryptBytes( - await packageHmacKey.exportRawKey(), - iv16, - ); - - expect(jsWrapped, orderedEquals(packageWrapped)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'raw', - packageWrapped, - jsAesCbcWrappingKey, - subtle.Algorithm(name: 'AES-CBC', iv: iv16), - const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), - ['sign', 'verify'], - ); - final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); - - final packageUnwrappedRaw = await packageAesCbcWrappingKey.decryptBytes( - jsWrapped, - iv16, - ); - expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); - }); - - test('AES-CTR raw wrapping matches exportRawKey + encryptBytes exactly', () async { - final ctrAlgorithm = subtle.Algorithm( - name: 'AES-CTR', - counter: counter, - length: 64, - ); - final jsWrapped = await _wrapKey( - wrap, - 'raw', - jsHmacKey, - jsAesCtrWrappingKey, - ctrAlgorithm, - ); - - final packageWrapped = await packageAesCtrWrappingKey.encryptBytes( - await packageHmacKey.exportRawKey(), - counter, - 64, - ); - - expect(jsWrapped, orderedEquals(packageWrapped)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'raw', - packageWrapped, - jsAesCtrWrappingKey, - ctrAlgorithm, - const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), - ['sign', 'verify'], - ); - final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); - - final packageUnwrappedRaw = await packageAesCtrWrappingKey.decryptBytes( - jsWrapped, - counter, - 64, - ); - expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); - }); - - test('AES-GCM raw wrapping matches exportRawKey + encryptBytes exactly', () async { - final jsWrapped = await _wrapKey( - wrap, - 'raw', - jsHmacKey, - jsAesWrappingKey, - aesGcmAlgorithm, - ); - - final packageWrapped = await packageAesWrappingKey.encryptBytes( - await packageHmacKey.exportRawKey(), - iv, - additionalData: additionalData, - ); - - expect(jsWrapped, orderedEquals(packageWrapped)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'raw', - packageWrapped, - jsAesWrappingKey, - aesGcmAlgorithm, - const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), - ['sign', 'verify'], - ); - final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); - - final packageUnwrappedRaw = await packageAesWrappingKey.decryptBytes( - jsWrapped, - iv, - additionalData: additionalData, - ); - expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); - }); - - test('AES-GCM spki wrapping matches exportSpkiKey + encryptBytes exactly', () async { - final jsWrapped = await _wrapKey( - wrap, - 'spki', - jsRsaPair.publicKey, - jsAesWrappingKey, - aesGcmAlgorithm, - ); - - final packageWrapped = await packageAesWrappingKey.encryptBytes( - await packageRsaPublicKey.exportSpkiKey(), - iv, - additionalData: additionalData, - ); - - expect(jsWrapped, orderedEquals(packageWrapped)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'spki', - packageWrapped, - jsAesWrappingKey, - aesGcmAlgorithm, - const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), - ['encrypt'], - ); - final jsUnwrappedSpki = (await subtle.exportKey('spki', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedSpki, orderedEquals(rsaSpkiBytes)); - - final packageUnwrappedSpki = await packageAesWrappingKey.decryptBytes( - jsWrapped, - iv, - additionalData: additionalData, - ); - expect(packageUnwrappedSpki, orderedEquals(rsaSpkiBytes)); - }); - - test('AES-GCM pkcs8 wrapping matches exportPkcs8Key + encryptBytes exactly', () async { - final jsWrapped = await _wrapKey( - wrap, - 'pkcs8', - jsRsaPair.privateKey, - jsAesWrappingKey, - aesGcmAlgorithm, - ); - - final packageWrapped = await packageAesWrappingKey.encryptBytes( - await packageRsaPrivateKey.exportPkcs8Key(), - iv, - additionalData: additionalData, - ); - - expect(jsWrapped, orderedEquals(packageWrapped)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'pkcs8', - packageWrapped, - jsAesWrappingKey, - aesGcmAlgorithm, - const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), - ['decrypt'], - ); - final jsUnwrappedPkcs8 = (await subtle.exportKey('pkcs8', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); - - final packageUnwrappedPkcs8 = await packageAesWrappingKey.decryptBytes( - jsWrapped, - iv, - additionalData: additionalData, - ); - expect(packageUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); - }); - - test('RSA-OAEP raw wrapping is cross-compatible even though ciphertext is randomized', () async { - final wrapParams = subtle.Algorithm( - name: 'RSA-OAEP', - label: Uint8List.fromList([1, 2, 3, 4]), - ); - - final jsWrapped = await _wrapKey( - wrap, - 'raw', - jsHmacKey, - jsRsaPair.publicKey, - wrapParams, - ); - final packageWrapped = await packageRsaPublicKey.encryptBytes( - await packageHmacKey.exportRawKey(), - label: const [1, 2, 3, 4], - ); - - expect(jsWrapped, isNot(orderedEquals(packageWrapped))); - - final packageUnwrappedRaw = await packageRsaPrivateKey.decryptBytes( - jsWrapped, - label: const [1, 2, 3, 4], - ); - expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); - - final jsUnwrapped = await _unwrapKey( - wrap, - 'raw', - packageWrapped, - jsRsaPair.privateKey, - wrapParams, - const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), - ['sign', 'verify'], - ); - final jsUnwrappedRaw = (await subtle.exportKey('raw', jsUnwrapped)) - .asUint8List(); - expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); - }); - - test('JWK wrapKey carries ext/key_ops metadata that package exportJsonWebKey omits', () async { - final jsWrapped = await _wrapKey( - wrap, - 'jwk', - jsHmacKey, - jsAesWrappingKey, - aesGcmAlgorithm, - ); - - final packageWrapped = await packageAesWrappingKey.encryptBytes( - utf8.encode(jsonEncode(await packageHmacKey.exportJsonWebKey())), - iv, - additionalData: additionalData, - ); - - expect(jsWrapped, isNot(orderedEquals(packageWrapped))); - - final jsJwk = jsonDecode( - utf8.decode( - await packageAesWrappingKey.decryptBytes( - jsWrapped, - iv, - additionalData: additionalData, - ), - ), - ) as Map; - final packageJwk = jsonDecode( - utf8.decode( - await packageAesWrappingKey.decryptBytes( - packageWrapped, - iv, - additionalData: additionalData, - ), - ), - ) as Map; - - expect(jsJwk['ext'], isTrue); - expect(jsJwk['key_ops'], orderedEquals(['sign', 'verify'])); - expect(packageJwk.containsKey('ext'), isFalse); - expect(packageJwk.containsKey('key_ops'), isFalse); - - final normalizedJsJwk = Map.from(jsJwk) - ..remove('ext') - ..remove('key_ops'); - expect(normalizedJsJwk, equals(packageJwk)); - }); + test( + 'AES-CBC raw wrapping matches exportRawKey + encryptBytes exactly', + () async { + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesCbcWrappingKey, + subtle.Algorithm(name: 'AES-CBC', iv: iv16), + ); + + final packageWrapped = await packageAesCbcWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + iv16, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesCbcWrappingKey, + subtle.Algorithm(name: 'AES-CBC', iv: iv16), + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey( + 'raw', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesCbcWrappingKey.decryptBytes( + jsWrapped, + iv16, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }, + ); + + test( + 'AES-CTR raw wrapping matches exportRawKey + encryptBytes exactly', + () async { + final ctrAlgorithm = subtle.Algorithm( + name: 'AES-CTR', + counter: counter, + length: 64, + ); + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesCtrWrappingKey, + ctrAlgorithm, + ); + + final packageWrapped = await packageAesCtrWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + counter, + 64, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesCtrWrappingKey, + ctrAlgorithm, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey( + 'raw', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesCtrWrappingKey.decryptBytes( + jsWrapped, + counter, + 64, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }, + ); + + test( + 'AES-GCM raw wrapping matches exportRawKey + encryptBytes exactly', + () async { + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageHmacKey.exportRawKey(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey( + 'raw', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final packageUnwrappedRaw = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }, + ); + + test( + 'AES-GCM spki wrapping matches exportSpkiKey + encryptBytes exactly', + () async { + final jsWrapped = await _wrapKey( + wrap, + 'spki', + jsRsaPair.publicKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageRsaPublicKey.exportSpkiKey(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'spki', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), + ['encrypt'], + ); + final jsUnwrappedSpki = (await subtle.exportKey( + 'spki', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedSpki, orderedEquals(rsaSpkiBytes)); + + final packageUnwrappedSpki = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedSpki, orderedEquals(rsaSpkiBytes)); + }, + ); + + test( + 'AES-GCM pkcs8 wrapping matches exportPkcs8Key + encryptBytes exactly', + () async { + final jsWrapped = await _wrapKey( + wrap, + 'pkcs8', + jsRsaPair.privateKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + await packageRsaPrivateKey.exportPkcs8Key(), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, orderedEquals(packageWrapped)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'pkcs8', + packageWrapped, + jsAesWrappingKey, + aesGcmAlgorithm, + const subtle.Algorithm(name: 'RSA-OAEP', hash: 'SHA-256'), + ['decrypt'], + ); + final jsUnwrappedPkcs8 = (await subtle.exportKey( + 'pkcs8', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); + + final packageUnwrappedPkcs8 = await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ); + expect(packageUnwrappedPkcs8, orderedEquals(rsaPkcs8Bytes)); + }, + ); + + test( + 'RSA-OAEP raw wrapping is cross-compatible even though ciphertext is randomized', + () async { + final wrapParams = subtle.Algorithm( + name: 'RSA-OAEP', + label: Uint8List.fromList([1, 2, 3, 4]), + ); + + final jsWrapped = await _wrapKey( + wrap, + 'raw', + jsHmacKey, + jsRsaPair.publicKey, + wrapParams, + ); + final packageWrapped = await packageRsaPublicKey.encryptBytes( + await packageHmacKey.exportRawKey(), + label: const [1, 2, 3, 4], + ); + + expect(jsWrapped, isNot(orderedEquals(packageWrapped))); + + final packageUnwrappedRaw = await packageRsaPrivateKey.decryptBytes( + jsWrapped, + label: const [1, 2, 3, 4], + ); + expect(packageUnwrappedRaw, orderedEquals(hmacKeyBytes)); + + final jsUnwrapped = await _unwrapKey( + wrap, + 'raw', + packageWrapped, + jsRsaPair.privateKey, + wrapParams, + const subtle.Algorithm(name: 'HMAC', hash: 'SHA-256'), + ['sign', 'verify'], + ); + final jsUnwrappedRaw = (await subtle.exportKey( + 'raw', + jsUnwrapped, + )).asUint8List(); + expect(jsUnwrappedRaw, orderedEquals(hmacKeyBytes)); + }, + ); + + test( + 'JWK wrapKey carries ext/key_ops metadata that package exportJsonWebKey omits', + () async { + final jsWrapped = await _wrapKey( + wrap, + 'jwk', + jsHmacKey, + jsAesWrappingKey, + aesGcmAlgorithm, + ); + + final packageWrapped = await packageAesWrappingKey.encryptBytes( + utf8.encode(jsonEncode(await packageHmacKey.exportJsonWebKey())), + iv, + additionalData: additionalData, + ); + + expect(jsWrapped, isNot(orderedEquals(packageWrapped))); + + final jsJwk = + jsonDecode( + utf8.decode( + await packageAesWrappingKey.decryptBytes( + jsWrapped, + iv, + additionalData: additionalData, + ), + ), + ) + as Map; + final packageJwk = + jsonDecode( + utf8.decode( + await packageAesWrappingKey.decryptBytes( + packageWrapped, + iv, + additionalData: additionalData, + ), + ), + ) + as Map; + + expect(jsJwk['ext'], isTrue); + expect(jsJwk['key_ops'], orderedEquals(['sign', 'verify'])); + expect(packageJwk.containsKey('ext'), isFalse); + expect(packageJwk.containsKey('key_ops'), isFalse); + + final normalizedJsJwk = Map.from(jsJwk) + ..remove('ext') + ..remove('key_ops'); + expect(normalizedJsJwk, equals(packageJwk)); + }, + ); }); }