diff --git a/lib/src/impl_ffi/impl_ffi.aes_common.dart b/lib/src/impl_ffi/impl_ffi.aes_common.dart index 219267f74..b21d212f8 100644 --- a/lib/src/impl_ffi/impl_ffi.aes_common.dart +++ b/lib/src/impl_ffi/impl_ffi.aes_common.dart @@ -38,6 +38,23 @@ Uint8List _aesImportJwkKey( checkJwk(k.kty == 'oct', 'kty', 'must be "oct"'); checkJwk(k.k != null, 'k', 'must be present'); checkJwk(k.use == null || k.use == 'enc', 'use', 'must be "enc", if present'); + if (k.key_ops != null) { + const allowedOps = { + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', + }; + for (final op in k.key_ops!) { + checkJwk( + allowedOps.contains(op), + op, + 'is not consistent with use "enc"', + ); + } + } final keyData = _jwkDecodeBase64UrlNoPadding(k.k!, 'k'); if (keyData.length == 24) { diff --git a/lib/src/impl_ffi/impl_ffi.ec_common.dart b/lib/src/impl_ffi/impl_ffi.ec_common.dart index b068d9f11..59e27e1da 100644 --- a/lib/src/impl_ffi/impl_ffi.ec_common.dart +++ b/lib/src/impl_ffi/impl_ffi.ec_common.dart @@ -155,9 +155,25 @@ _EvpPKey _importJwkEcPrivateOrPublicKey( jwk.use == null || jwk.use == expectedUse, message: 'JWK property "use" should be "$expectedUse", if present', ); - - // TODO: Reject keys with key_ops in inconsistent with isPrivateKey - // Also in the js implementation... + if (jwk.key_ops != null) { + final allowedOps = expectedUse == 'sig' + ? {'sign', 'verify'} + : { + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', + }; + for (final op in jwk.key_ops!) { + _checkData( + allowedOps.contains(op), + message: 'JWK key_ops entry "$op" is not consistent with use ' + '"$expectedUse"', + ); + } + } return _Scope.sync((scope) { final ec = ssl.EC_KEY_new_by_curve_name(_ecCurveToNID(curve)); diff --git a/lib/src/impl_ffi/impl_ffi.hmac.dart b/lib/src/impl_ffi/impl_ffi.hmac.dart index 71780f08d..061225406 100644 --- a/lib/src/impl_ffi/impl_ffi.hmac.dart +++ b/lib/src/impl_ffi/impl_ffi.hmac.dart @@ -57,6 +57,16 @@ Future hmacSecretKey_importJsonWebKey( checkJwk(k.kty == 'oct', 'kty', 'must be "oct"'); checkJwk(k.k != null, 'k', 'must be present'); checkJwk(k.use == null || k.use == 'sig', 'use', 'must be "sig", if present'); + if (k.key_ops != null) { + const allowedOps = {'sign', 'verify'}; + for (final op in k.key_ops!) { + checkJwk( + allowedOps.contains(op), + op, + 'is not consistent with use "sig"', + ); + } + } final expectedAlg = h.hmacJwkAlg; checkJwk( k.alg == null || k.alg == expectedAlg, diff --git a/lib/src/impl_ffi/impl_ffi.rsa_common.dart b/lib/src/impl_ffi/impl_ffi.rsa_common.dart index ad7246255..1bc24d75f 100644 --- a/lib/src/impl_ffi/impl_ffi.rsa_common.dart +++ b/lib/src/impl_ffi/impl_ffi.rsa_common.dart @@ -80,13 +80,25 @@ _EvpPKey _importJwkRsaPrivateOrPublicKey( 'use', 'must be "$expectedUse", if present', ); - - // TODO: Consider rejecting private keys with 'use', as it's only valid for - // public keys according to the RFC -- maybe read the RFC again to be - // perfectly sure this is correct behavior. - // Also the web crypto spec on this, which says to reject invalid 'use' - // TODO: Consider rejecting keys with key_ops inconsistent with isPrivateKey - // See also JWK import logic for EC keys + if (jwk.key_ops != null) { + final allowedOps = expectedUse == 'sig' + ? {'sign', 'verify'} + : { + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', + }; + for (final op in jwk.key_ops!) { + checkJwk( + allowedOps.contains(op), + op, + 'is not consistent with use "$expectedUse"', + ); + } + } ffi.Pointer readBN(String value, String prop) { final bin = _jwkDecodeBase64UrlNoPadding(value, prop); diff --git a/lib/src/impl_js/impl_js.ecdh.dart b/lib/src/impl_js/impl_js.ecdh.dart index da0737cf5..a5f2cf38e 100644 --- a/lib/src/impl_js/impl_js.ecdh.dart +++ b/lib/src/impl_js/impl_js.ecdh.dart @@ -40,12 +40,6 @@ Future ecdhPrivateKey_importJsonWebKey( Map jwk, EllipticCurve curve, ) async { - if (jwk['use'] == 'enc') { - // Chrome incorrectly forbids the 'enc' value for ECDH, hence, we strip it - // when importing to ensure compatibility across browsers. - // See: https://crbug.com/641499 - jwk = Map.fromEntries(jwk.entries.where((e) => e.key != 'use')); - } return _EcdhPrivateKeyImpl( await _importJsonWebKey( jwk, @@ -111,12 +105,6 @@ Future ecdhPublicKey_importJsonWebKey( Map jwk, EllipticCurve curve, ) async { - if (jwk['use'] == 'enc') { - // Chrome incorrectly forbids the 'enc' value for ECDH, hence, we strip it - // when importing to ensure compatibility across browsers. - // See: https://crbug.com/641499 - jwk = Map.fromEntries(jwk.entries.where((e) => e.key != 'use')); - } return _EcdhPublicKeyImpl( await _importJsonWebKey( jwk, diff --git a/lib/src/impl_js/impl_js.utils.dart b/lib/src/impl_js/impl_js.utils.dart index 3a7b8de28..cfaaade3a 100644 --- a/lib/src/impl_js/impl_js.utils.dart +++ b/lib/src/impl_js/impl_js.utils.dart @@ -147,6 +147,34 @@ final _usagesDecrypt = ['decrypt']; final _usagesEncrypt = ['encrypt']; final _usagesDeriveBits = ['deriveBits']; +/// Validate that [jwk]'s `use` and `key_ops` are consistent with each other. +/// +/// If both `use` and `key_ops` are present, every entry in `key_ops` must be +/// consistent with the value of `use` per the JWK specification (RFC 7517). +void _checkJwkUseAndKeyOps(subtle.JsonWebKey jwk) { + if (jwk.use != null && jwk.key_ops != null) { + final allowedOps = jwk.use == 'sig' + ? {'sign', 'verify'} + : { + 'encrypt', + 'decrypt', + 'wrapKey', + 'unwrapKey', + 'deriveKey', + 'deriveBits', + }; + for (final op in jwk.key_ops!) { + if (!allowedOps.contains(op)) { + throw ArgumentError.value( + jwk, + 'jwk', + 'key_ops contains "$op" which is inconsistent with use "${jwk.use}"', + ); + } + } + } +} + /// Adapt `crypto.subtle.importKey` to Dart types for JWK. Future _importJsonWebKey( Map jwk, @@ -156,11 +184,15 @@ Future _importJsonWebKey( ) { return _handleDomException(() async { final jwkObj = subtle.JsonWebKey.fromJson(jwk); - // TODO: Validate expected 'use' the way we have it in the FFI implementation - // Remove 'key_ops' and 'ext' as this library doesn't configuring - // _usages_ and _extractable_. - // Notice that we also strip 'key_ops' and 'ext' in [_exportJsonWebKey]. + // Validate consistency of 'use' and 'key_ops' before stripping. + _checkJwkUseAndKeyOps(jwkObj); + + // Remove 'use', 'key_ops', and 'ext' as this library doesn't configure + // _usages_ and _extractable_. The browser's usages are driven by the + // explicit `usages` parameter passed to importKey. + // Notice that we also strip these in [_exportJsonWebKey]. + jwkObj.use = null; jwkObj.key_ops = null; jwkObj.ext = null; final k = await subtle.importJsonWebKey( diff --git a/test/jwk_key_ops_validation_test.dart b/test/jwk_key_ops_validation_test.dart new file mode 100644 index 000000000..c2a6b328e --- /dev/null +++ b/test/jwk_key_ops_validation_test.dart @@ -0,0 +1,81 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:test/test.dart'; +import 'package:webcrypto/webcrypto.dart'; + +void main() { + group('JWK import rejects conflicting use and key_ops', () { + test('HMAC: use=sig with key_ops=[encrypt]', () async { + await expectLater( + () => HmacSecretKey.importJsonWebKey( + { + 'kty': 'oct', + 'k': 'YWJjZGVmZ2hpamtsbW5vcA', + 'use': 'sig', + 'key_ops': ['encrypt'], + }, + Hash.sha256, + ), + throwsA(anything), + ); + }); + + test('AES: use=enc with key_ops=[sign]', () async { + await expectLater( + () => AesCbcSecretKey.importJsonWebKey( + { + 'kty': 'oct', + 'k': 'YWJjZGVmZ2hpamtsbW5vcA', + 'use': 'enc', + 'key_ops': ['sign'], + }, + ), + throwsA(anything), + ); + }); + + test('ECDSA: use=sig with key_ops=[deriveBits]', () async { + await expectLater( + () => EcdsaPrivateKey.importJsonWebKey( + { + 'kty': 'EC', + 'crv': 'P-256', + 'x': 'MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4', + 'y': '4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', + 'd': '870MB6gfuTJ4HtUnUvYMyJpr5eUZNP4Bk43bVdj3eAE', + 'use': 'sig', + 'key_ops': ['deriveBits'], + }, + EllipticCurve.p256, + ), + throwsA(anything), + ); + }); + + test('RSA: use=sig with key_ops=[encrypt,decrypt]', () async { + await expectLater( + () => RsaSsaPkcs1V15PrivateKey.importJsonWebKey( + { + 'kty': 'RSA', + 'use': 'sig', + 'key_ops': ['encrypt', 'decrypt'], + }, + Hash.sha256, + ), + throwsA(anything), + ); + }); + }); +}