Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions example/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
24 changes: 24 additions & 0 deletions example/integration_test/jni_hmac_test.dart
Original file line number Diff line number Diff line change
@@ -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<int>.filled(20, 0x0b),
Hash.sha256,
);
final signature = await key.signBytes(utf8.encode('Hi There'));

expect(
base64Encode(signature),
'sDRMYdjbOFNcqK/OrwvxK4gdwgDJgz2nJuk3bC4yz/c=',
);
});
}
2 changes: 2 additions & 0 deletions lib/src/impl_jni/impl_jni.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
174 changes: 168 additions & 6 deletions lib/src/impl_jni/impl_jni.hmac.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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();

Expand All @@ -22,21 +61,144 @@ final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl {
List<int> keyData,
HashImpl hash, {
int? length,
}) {
throw UnimplementedError('Not implemented');
}
}) async => _HmacSecretKeyImpl(
_asUint8ListZeroedToBitLength(keyData, length),
_hmacHashFromHash(hash),
);

@override
Future<HmacSecretKeyImpl> importJsonWebKey(
Map<String, dynamic> jwk,
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<HmacSecretKeyImpl> 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<Uint8List> signBytes(List<int> 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<Uint8List> signStream(Stream<List<int>> 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<bool> verifyBytes(List<int> signature, List<int> data) async {
return _verifySignature(await signBytes(data), signature);
}

@override
Future<bool> verifyStream(List<int> signature, Stream<List<int>> data) async {
return _verifySignature(await signStream(data), signature);
}

@override
Future<Uint8List> exportRawKey() async => Uint8List.fromList(_keyData);

@override
Future<Map<String, dynamic>> 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<int> 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<int> suppliedSignature) {
return jni.using((arena) {
final computed = arena.copyToJByteArray(computedMac);
final supplied = arena.copyToJByteArray(_asUint8List(suppliedSignature));
return MessageDigest.isEqual(computed, supplied);
});
}
}
4 changes: 1 addition & 3 deletions lib/src/impl_jni/impl_jni.random.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
111 changes: 106 additions & 5 deletions lib/src/impl_jni/impl_jni.utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> data) {
return data is Uint8List ? data : Uint8List.fromList(data);
}

Uint8List _asUint8ListZeroedToBitLength(List<int> data, [int? lengthInBits]) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

single line documentation comment, be concise

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shhortened the copyToDartBytes doc comment.

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;
}
});
}
Loading