diff --git a/example/README.md b/example/README.md index bf6d1011..c1103c75 100644 --- a/example/README.md +++ b/example/README.md @@ -11,3 +11,10 @@ To run the Android JNI/JCA digest smoke test: flutter test integration_test/jni_digest_test.dart \ -d emulator-name ``` + +To run the Android JNI/JCA HMAC smoke test: + +```sh +flutter test integration_test/jni_hmac_test.dart \ + -d emulator-name +``` diff --git a/example/integration_test/jni_hmac_test.dart b/example/integration_test/jni_hmac_test.dart new file mode 100644 index 00000000..4b4184d4 --- /dev/null +++ b/example/integration_test/jni_hmac_test.dart @@ -0,0 +1,24 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:webcrypto/webcrypto.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('public API HMAC-SHA-256 signature matches known vector', ( + _, + ) async { + final key = await HmacSecretKey.importRawKey( + List.filled(20, 0x0b), + Hash.sha256, + ); + final signature = await key.signBytes(utf8.encode('Hi There')); + + expect( + base64Encode(signature), + 'sDRMYdjbOFNcqK/OrwvxK4gdwgDJgz2nJuk3bC4yz/c=', + ); + }); +} diff --git a/lib/src/impl_jni/impl_jni.dart b/lib/src/impl_jni/impl_jni.dart index 02b2a639..594d5794 100644 --- a/lib/src/impl_jni/impl_jni.dart +++ b/lib/src/impl_jni/impl_jni.dart @@ -20,11 +20,13 @@ library; import 'dart:async'; +import 'dart:convert' show base64Url; import 'dart:typed_data'; import 'package:jni/jni.dart' as jni; import 'package:webcrypto/src/impl_interface/impl_interface.dart'; +import '../jsonwebkey.dart' show JsonWebKey; import '../third_party/jca/generated_bindings.dart'; part 'impl_jni.aescbc.dart'; diff --git a/lib/src/impl_jni/impl_jni.hmac.dart b/lib/src/impl_jni/impl_jni.hmac.dart index 3c3a5bb2..f8cb11a5 100644 --- a/lib/src/impl_jni/impl_jni.hmac.dart +++ b/lib/src/impl_jni/impl_jni.hmac.dart @@ -1,4 +1,4 @@ -// Copyright 2020 Google LLC +// 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. @@ -14,6 +14,45 @@ part of 'impl_jni.dart'; +_HashImpl _hmacHashFromHash(HashImpl hash) { + if (hash is _HashImpl) { + return hash; + } + throw AssertionError('Custom implementations of HashImpl are not supported.'); +} + +extension _HmacHashMetadata on _HashImpl { + String get _hmacJcaName { + return switch (_jcaName) { + 'SHA-1' => 'HmacSHA1', + 'SHA-256' => 'HmacSHA256', + 'SHA-384' => 'HmacSHA384', + 'SHA-512' => 'HmacSHA512', + _ => throw AssertionError('Unknown hash algorithm: $_jcaName'), + }; + } + + String get _hmacJwkAlg { + return switch (_jcaName) { + 'SHA-1' => 'HS1', + 'SHA-256' => 'HS256', + 'SHA-384' => 'HS384', + 'SHA-512' => 'HS512', + _ => throw AssertionError('Unknown hash algorithm: $_jcaName'), + }; + } + + int get _hmacDefaultLengthBits { + return switch (_jcaName) { + 'SHA-1' => 160, + 'SHA-256' => 256, + 'SHA-384' => 384, + 'SHA-512' => 512, + _ => throw AssertionError('Unknown hash algorithm: $_jcaName'), + }; + } +} + final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl { const _StaticHmacSecretKeyImpl(); @@ -22,9 +61,10 @@ final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl { List keyData, HashImpl hash, { int? length, - }) { - throw UnimplementedError('Not implemented'); - } + }) async => _HmacSecretKeyImpl( + _asUint8ListZeroedToBitLength(keyData, length), + _hmacHashFromHash(hash), + ); @override Future importJsonWebKey( @@ -32,11 +72,133 @@ final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl { HashImpl hash, { int? length, }) { - throw UnimplementedError('Not implemented'); + final h = _hmacHashFromHash(hash); + final key = JsonWebKey.fromJson(jwk); + + void checkJwk(bool condition, String prop, String message) => + _checkData(condition, 'JWK property "$prop" $message'); + + checkJwk(key.kty == 'oct', 'kty', 'must be "oct"'); + checkJwk(key.k != null, 'k', 'must be present'); + checkJwk( + key.use == null || key.use == 'sig', + 'use', + 'must be "sig", if present', + ); + checkJwk( + key.alg == null || key.alg == h._hmacJwkAlg, + 'alg', + 'must be "${h._hmacJwkAlg}"', + ); + + final keyData = _jwkDecodeBase64UrlNoPadding(key.k!, 'k'); + return importRawKey(keyData, hash, length: length); } @override Future generateKey(HashImpl hash, {int? length = 32}) { - throw UnimplementedError('Not implemented'); + final h = _hmacHashFromHash(hash); + length ??= h._hmacDefaultLengthBits; + final keyData = _randomBytes((length + 7) ~/ 8); + return importRawKey(keyData, hash, length: length); + } +} + +final class _HmacSecretKeyImpl implements HmacSecretKeyImpl { + _HmacSecretKeyImpl(this._keyData, this._hash); + + final Uint8List _keyData; + final _HashImpl _hash; + + @override + Future signBytes(List data) async { + return jni.using((arena) { + final mac = _createMac(arena); + final input = arena.copyToJByteArray(_asUint8List(data)); + return _copyMacResult(arena, mac.doFinal$2(input)); + }); + } + + @override + Future signStream(Stream> data) async { + final arena = jni.Arena(); + try { + final mac = _createMac(arena); + final buffer = jni.JByteArray(_defaultChunkSize)..releasedBy(arena); + await for (final chunk in data) { + _updateMacWithChunk(mac, buffer, chunk); + } + + return _copyMacResult(arena, mac.doFinal()); + } finally { + arena.releaseAll(); + } + } + + @override + Future verifyBytes(List signature, List data) async { + return _verifySignature(await signBytes(data), signature); + } + + @override + Future verifyStream(List signature, Stream> data) async { + return _verifySignature(await signStream(data), signature); + } + + @override + Future exportRawKey() async => Uint8List.fromList(_keyData); + + @override + Future> exportJsonWebKey() async { + return JsonWebKey( + kty: 'oct', + use: 'sig', + alg: _hash._hmacJwkAlg, + k: _jwkEncodeBase64UrlNoPadding(_keyData), + ).toJson(); + } + + Mac _createMac(jni.Arena arena) { + final algorithm = _hash._hmacJcaName.toJString()..releasedBy(arena); + final keyData = arena.copyToJByteArray(_keyData); + final key = SecretKeySpec(keyData, algorithm)..releasedBy(arena); + + final mac = Mac.getInstance(algorithm); + if (mac == null) { + throw AssertionError('JCA Mac(${_hash._hmacJcaName}) returned null'); + } + mac.releasedBy(arena); + mac.init(key); + return mac; + } + + void _updateMacWithChunk(Mac mac, jni.JByteArray buffer, List chunk) { + final bytes = _asUint8List(chunk); + var offset = 0; + while (offset < bytes.length) { + final remaining = bytes.length - offset; + final length = remaining < _defaultChunkSize + ? remaining + : _defaultChunkSize; + buffer.setRange(0, length, bytes, offset); + mac.update$2(buffer, 0, length); + offset += length; + } + } + + Uint8List _copyMacResult(jni.Arena arena, jni.JByteArray? result) { + if (result == null) { + throw AssertionError('JCA Mac(${_hash._hmacJcaName}) returned null'); + } + result.releasedBy(arena); + return result.copyToDartBytes(); + } + + bool _verifySignature(Uint8List computedMac, List suppliedSignature) { + return jni.using((arena) { + final computed = arena.copyToJByteArray(computedMac); + final supplied = arena.copyToJByteArray(_asUint8List(suppliedSignature)); + return MessageDigest.isEqual(computed, supplied); + }); } } diff --git a/lib/src/impl_jni/impl_jni.random.dart b/lib/src/impl_jni/impl_jni.random.dart index 351ad0df..270931e9 100644 --- a/lib/src/impl_jni/impl_jni.random.dart +++ b/lib/src/impl_jni/impl_jni.random.dart @@ -18,7 +18,5 @@ final class _RandomImpl implements RandomImpl { const _RandomImpl(); @override - void fillRandomBytes(TypedData destination) { - throw UnimplementedError('Not implemented'); - } + void fillRandomBytes(TypedData destination) => _fillRandomBytes(destination); } diff --git a/lib/src/impl_jni/impl_jni.utils.dart b/lib/src/impl_jni/impl_jni.utils.dart index f5c64eaf..51354de2 100644 --- a/lib/src/impl_jni/impl_jni.utils.dart +++ b/lib/src/impl_jni/impl_jni.utils.dart @@ -14,15 +14,116 @@ part of 'impl_jni.dart'; +const _defaultChunkSize = 4096; + +void _checkData(bool condition, String message) { + if (!condition) { + throw FormatException(message); + } +} + +Uint8List _asUint8List(List data) { + return data is Uint8List ? data : Uint8List.fromList(data); +} + +Uint8List _asUint8ListZeroedToBitLength(List data, [int? lengthInBits]) { + final bytes = Uint8List.fromList(data); + if (lengthInBits == null) { + return bytes; + } + + final startFrom = lengthInBits ~/ 8; + var remainder = lengthInBits % 8; + for (var i = startFrom; i < bytes.length; i++) { + final mask = 0xff & (0xff << (8 - remainder)); + bytes[i] = bytes[i] & mask; + remainder = 8; + } + return bytes; +} + +Uint8List _jwkDecodeBase64UrlNoPadding(String unpadded, String prop) { + try { + final padded = unpadded.padRight( + unpadded.length + ((4 - (unpadded.length % 4)) % 4), + '=', + ); + return base64Url.decode(padded); + } on FormatException { + throw FormatException( + 'JWK property "$prop" is not url-safe base64 without padding', + unpadded, + ); + } +} + +String _jwkEncodeBase64UrlNoPadding(Uint8List data) { + final padded = base64Url.encode(data); + final paddingStart = padded.indexOf('='); + return paddingStart == -1 ? padded : padded.substring(0, paddingStart); +} + +extension _JniArenaByteArray on jni.Arena { + jni.JByteArray copyToJByteArray(Uint8List data) { + return jni.JByteArray.of(data)..releasedBy(this); + } +} + extension _JByteArrayCopy on jni.JByteArray { /// Copies this JVM byte array into Dart-owned memory. - /// - /// `getRange` returns a typed list backed by a native buffer whose lifetime - /// is finalizer-driven. Copy once more before releasing the Java array so - /// webcrypto callers receive a normal Dart-managed [Uint8List]. Uint8List copyToDartBytes() { final bytes = getRange(0, length); - final view = bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.length); + final view = Uint8List.sublistView(bytes); return Uint8List.fromList(view); } + + void copyRangeToDart( + Uint8List destination, + int destinationOffset, + int length, + ) { + final bytes = getRange(0, length); + final view = Uint8List.sublistView(bytes); + destination.setRange(destinationOffset, destinationOffset + length, view); + } +} + +Uint8List _randomBytes(int length) { + final output = Uint8List(length); + _fillRandomBytes(output); + return output; +} + +void _fillRandomBytes(TypedData destination) { + final output = destination.buffer.asUint8List( + destination.offsetInBytes, + destination.lengthInBytes, + ); + if (output.isEmpty) { + return; + } + + jni.using((arena) { + final random = SecureRandom()..releasedBy(arena); + final bufferLength = output.length < _defaultChunkSize + ? output.length + : _defaultChunkSize; + final fullBuffer = jni.JByteArray(bufferLength)..releasedBy(arena); + + // TODO: Should revisit bulk input/output transfer helpers with JByteBuffer for + // JCA APIs that accept ByteBuffer directly. SecureRandom.nextBytes only accepts + // byte[]. + var offset = 0; + while (offset < output.length) { + final remaining = output.length - offset; + final chunkLength = remaining < bufferLength ? remaining : bufferLength; + final bytes = chunkLength == bufferLength + ? fullBuffer + : (jni.JByteArray(chunkLength)..releasedBy(arena)); + + random.nextBytes(bytes); + bytes.copyRangeToDart(output, offset, chunkLength); + offset += chunkLength; + } + }); } diff --git a/test/impl_jni_hmac_test.dart b/test/impl_jni_hmac_test.dart new file mode 100644 index 00000000..fe3ab357 --- /dev/null +++ b/test/impl_jni_hmac_test.dart @@ -0,0 +1,131 @@ +// Copyright 2020 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. + +@TestOn('vm') +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:test/test.dart'; +import 'package:webcrypto/src/impl_ffi/impl_ffi.dart' as ffi_impl; +import 'package:webcrypto/src/impl_jni/impl_jni.dart' as jni_impl; + +import 'src/jni_test_setup.dart' + if (dart.library.io) 'src/jni_test_setup_io.dart'; + +void main() { + final skipReason = jniHelperSetupSkipReason; + + setUpAll(() { + if (skipReason != null) { + return; + } + + spawnJniForDesktopTests(); + }); + + test('JCA HMAC-SHA-256 matches RFC 4231 test vector', () async { + final keyData = List.filled(20, 0x0b); + final data = utf8.encode('Hi There'); + + final key = await jni_impl.webCryptImpl.hmacSecretKey.importRawKey( + keyData, + jni_impl.webCryptImpl.sha256, + ); + final signature = await key.signBytes(data); + + final ffiKey = await ffi_impl.webCryptImpl.hmacSecretKey.importRawKey( + keyData, + ffi_impl.webCryptImpl.sha256, + ); + final ffiSignature = await ffiKey.signBytes(data); + + expect(signature, ffiSignature); + expect( + base64Encode(signature), + 'sDRMYdjbOFNcqK/OrwvxK4gdwgDJgz2nJuk3bC4yz/c=', + ); + expect(await key.verifyBytes(signature, data), isTrue); + + final badSignature = Uint8List.fromList(signature); + badSignature[badSignature.length - 1] ^= 0x01; + expect(await key.verifyBytes(badSignature, data), isFalse); + expect(await key.verifyBytes(signature.sublist(1), data), isFalse); + }, skip: skipReason); + + test('JCA HMAC-SHA-512 stream signing matches FFI', () async { + final keyData = List.filled(20, 0x0b); + final chunks = [utf8.encode('Hi '), utf8.encode('There')]; + + final key = await jni_impl.webCryptImpl.hmacSecretKey.importRawKey( + keyData, + jni_impl.webCryptImpl.sha512, + ); + final signature = await key.signStream(Stream.fromIterable(chunks)); + + final ffiKey = await ffi_impl.webCryptImpl.hmacSecretKey.importRawKey( + keyData, + ffi_impl.webCryptImpl.sha512, + ); + final ffiSignature = await ffiKey.signStream(Stream.fromIterable(chunks)); + + expect(signature, ffiSignature); + expect( + base64Encode(signature), + 'h6p83qXvYZ1P8LQkGh1ssCN59OLOTsJ4etCzBUXhfN7aqDO31rinAgOLJ06uo/Tk' + 'vp2RTuth8XAuaWwgOhJoVA==', + ); + expect( + await key.verifyStream(signature, Stream.fromIterable(chunks)), + isTrue, + ); + }, skip: skipReason); + + test('JCA HMAC exports and imports JSON Web Keys', () async { + final keyData = utf8.encode('sample-hmac-key'); + final key = await jni_impl.webCryptImpl.hmacSecretKey.importRawKey( + keyData, + jni_impl.webCryptImpl.sha384, + ); + + final jwk = await key.exportJsonWebKey(); + expect(jwk['kty'], 'oct'); + expect(jwk['use'], 'sig'); + expect(jwk['alg'], 'HS384'); + expect(jwk['k'], 'c2FtcGxlLWhtYWMta2V5'); + + final imported = await jni_impl.webCryptImpl.hmacSecretKey.importJsonWebKey( + jwk, + jni_impl.webCryptImpl.sha384, + ); + final data = utf8.encode('message'); + + expect(await imported.signBytes(data), await key.signBytes(data)); + }, skip: skipReason); + + test('JCA HMAC generateKey supports non-byte-aligned key lengths', () async { + final key = await jni_impl.webCryptImpl.hmacSecretKey.generateKey( + jni_impl.webCryptImpl.sha512, + length: 37, + ); + + final keyData = await key.exportRawKey(); + expect(keyData, hasLength(5)); + expect(keyData.last & 0x07, 0); + + final signature = await key.signBytes(utf8.encode('message')); + expect(await key.verifyBytes(signature, utf8.encode('message')), isTrue); + }, skip: skipReason); +}