Skip to content

Commit 9de2dcf

Browse files
authored
feat(impl_jni): add JCA HMAC implementation (#298)
* feat(impl_jni): add JCA HMAC implementation * fix(impl_jni): address HMAC review feedback * fix(impl_jni): do tighten random byte chunking * refactor(impl_jni): address HMAC review cleanup
1 parent 91302dc commit 9de2dcf

7 files changed

Lines changed: 439 additions & 14 deletions

File tree

example/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ To run the Android JNI/JCA digest smoke test:
1111
flutter test integration_test/jni_digest_test.dart \
1212
-d emulator-name
1313
```
14+
15+
To run the Android JNI/JCA HMAC smoke test:
16+
17+
```sh
18+
flutter test integration_test/jni_hmac_test.dart \
19+
-d emulator-name
20+
```
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import 'dart:convert';
2+
3+
import 'package:flutter_test/flutter_test.dart';
4+
import 'package:integration_test/integration_test.dart';
5+
import 'package:webcrypto/webcrypto.dart';
6+
7+
void main() {
8+
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
9+
10+
testWidgets('public API HMAC-SHA-256 signature matches known vector', (
11+
_,
12+
) async {
13+
final key = await HmacSecretKey.importRawKey(
14+
List<int>.filled(20, 0x0b),
15+
Hash.sha256,
16+
);
17+
final signature = await key.signBytes(utf8.encode('Hi There'));
18+
19+
expect(
20+
base64Encode(signature),
21+
'sDRMYdjbOFNcqK/OrwvxK4gdwgDJgz2nJuk3bC4yz/c=',
22+
);
23+
});
24+
}

lib/src/impl_jni/impl_jni.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
library;
2121

2222
import 'dart:async';
23+
import 'dart:convert' show base64Url;
2324
import 'dart:typed_data';
2425

2526
import 'package:jni/jni.dart' as jni;
2627
import 'package:webcrypto/src/impl_interface/impl_interface.dart';
2728

29+
import '../jsonwebkey.dart' show JsonWebKey;
2830
import '../third_party/jca/generated_bindings.dart';
2931

3032
part 'impl_jni.aescbc.dart';

lib/src/impl_jni/impl_jni.hmac.dart

Lines changed: 168 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2020 Google LLC
1+
// Copyright 2026 Google LLC
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -14,6 +14,45 @@
1414

1515
part of 'impl_jni.dart';
1616

17+
_HashImpl _hmacHashFromHash(HashImpl hash) {
18+
if (hash is _HashImpl) {
19+
return hash;
20+
}
21+
throw AssertionError('Custom implementations of HashImpl are not supported.');
22+
}
23+
24+
extension _HmacHashMetadata on _HashImpl {
25+
String get _hmacJcaName {
26+
return switch (_jcaName) {
27+
'SHA-1' => 'HmacSHA1',
28+
'SHA-256' => 'HmacSHA256',
29+
'SHA-384' => 'HmacSHA384',
30+
'SHA-512' => 'HmacSHA512',
31+
_ => throw AssertionError('Unknown hash algorithm: $_jcaName'),
32+
};
33+
}
34+
35+
String get _hmacJwkAlg {
36+
return switch (_jcaName) {
37+
'SHA-1' => 'HS1',
38+
'SHA-256' => 'HS256',
39+
'SHA-384' => 'HS384',
40+
'SHA-512' => 'HS512',
41+
_ => throw AssertionError('Unknown hash algorithm: $_jcaName'),
42+
};
43+
}
44+
45+
int get _hmacDefaultLengthBits {
46+
return switch (_jcaName) {
47+
'SHA-1' => 160,
48+
'SHA-256' => 256,
49+
'SHA-384' => 384,
50+
'SHA-512' => 512,
51+
_ => throw AssertionError('Unknown hash algorithm: $_jcaName'),
52+
};
53+
}
54+
}
55+
1756
final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl {
1857
const _StaticHmacSecretKeyImpl();
1958

@@ -22,21 +61,144 @@ final class _StaticHmacSecretKeyImpl implements StaticHmacSecretKeyImpl {
2261
List<int> keyData,
2362
HashImpl hash, {
2463
int? length,
25-
}) {
26-
throw UnimplementedError('Not implemented');
27-
}
64+
}) async => _HmacSecretKeyImpl(
65+
_asUint8ListZeroedToBitLength(keyData, length),
66+
_hmacHashFromHash(hash),
67+
);
2868

2969
@override
3070
Future<HmacSecretKeyImpl> importJsonWebKey(
3171
Map<String, dynamic> jwk,
3272
HashImpl hash, {
3373
int? length,
3474
}) {
35-
throw UnimplementedError('Not implemented');
75+
final h = _hmacHashFromHash(hash);
76+
final key = JsonWebKey.fromJson(jwk);
77+
78+
void checkJwk(bool condition, String prop, String message) =>
79+
_checkData(condition, 'JWK property "$prop" $message');
80+
81+
checkJwk(key.kty == 'oct', 'kty', 'must be "oct"');
82+
checkJwk(key.k != null, 'k', 'must be present');
83+
checkJwk(
84+
key.use == null || key.use == 'sig',
85+
'use',
86+
'must be "sig", if present',
87+
);
88+
checkJwk(
89+
key.alg == null || key.alg == h._hmacJwkAlg,
90+
'alg',
91+
'must be "${h._hmacJwkAlg}"',
92+
);
93+
94+
final keyData = _jwkDecodeBase64UrlNoPadding(key.k!, 'k');
95+
return importRawKey(keyData, hash, length: length);
3696
}
3797

3898
@override
3999
Future<HmacSecretKeyImpl> generateKey(HashImpl hash, {int? length = 32}) {
40-
throw UnimplementedError('Not implemented');
100+
final h = _hmacHashFromHash(hash);
101+
length ??= h._hmacDefaultLengthBits;
102+
final keyData = _randomBytes((length + 7) ~/ 8);
103+
return importRawKey(keyData, hash, length: length);
104+
}
105+
}
106+
107+
final class _HmacSecretKeyImpl implements HmacSecretKeyImpl {
108+
_HmacSecretKeyImpl(this._keyData, this._hash);
109+
110+
final Uint8List _keyData;
111+
final _HashImpl _hash;
112+
113+
@override
114+
Future<Uint8List> signBytes(List<int> data) async {
115+
return jni.using((arena) {
116+
final mac = _createMac(arena);
117+
final input = arena.copyToJByteArray(_asUint8List(data));
118+
return _copyMacResult(arena, mac.doFinal$2(input));
119+
});
120+
}
121+
122+
@override
123+
Future<Uint8List> signStream(Stream<List<int>> data) async {
124+
final arena = jni.Arena();
125+
try {
126+
final mac = _createMac(arena);
127+
final buffer = jni.JByteArray(_defaultChunkSize)..releasedBy(arena);
128+
await for (final chunk in data) {
129+
_updateMacWithChunk(mac, buffer, chunk);
130+
}
131+
132+
return _copyMacResult(arena, mac.doFinal());
133+
} finally {
134+
arena.releaseAll();
135+
}
136+
}
137+
138+
@override
139+
Future<bool> verifyBytes(List<int> signature, List<int> data) async {
140+
return _verifySignature(await signBytes(data), signature);
141+
}
142+
143+
@override
144+
Future<bool> verifyStream(List<int> signature, Stream<List<int>> data) async {
145+
return _verifySignature(await signStream(data), signature);
146+
}
147+
148+
@override
149+
Future<Uint8List> exportRawKey() async => Uint8List.fromList(_keyData);
150+
151+
@override
152+
Future<Map<String, dynamic>> exportJsonWebKey() async {
153+
return JsonWebKey(
154+
kty: 'oct',
155+
use: 'sig',
156+
alg: _hash._hmacJwkAlg,
157+
k: _jwkEncodeBase64UrlNoPadding(_keyData),
158+
).toJson();
159+
}
160+
161+
Mac _createMac(jni.Arena arena) {
162+
final algorithm = _hash._hmacJcaName.toJString()..releasedBy(arena);
163+
final keyData = arena.copyToJByteArray(_keyData);
164+
final key = SecretKeySpec(keyData, algorithm)..releasedBy(arena);
165+
166+
final mac = Mac.getInstance(algorithm);
167+
if (mac == null) {
168+
throw AssertionError('JCA Mac(${_hash._hmacJcaName}) returned null');
169+
}
170+
mac.releasedBy(arena);
171+
mac.init(key);
172+
return mac;
173+
}
174+
175+
void _updateMacWithChunk(Mac mac, jni.JByteArray buffer, List<int> chunk) {
176+
final bytes = _asUint8List(chunk);
177+
var offset = 0;
178+
while (offset < bytes.length) {
179+
final remaining = bytes.length - offset;
180+
final length = remaining < _defaultChunkSize
181+
? remaining
182+
: _defaultChunkSize;
183+
buffer.setRange(0, length, bytes, offset);
184+
mac.update$2(buffer, 0, length);
185+
offset += length;
186+
}
187+
}
188+
189+
Uint8List _copyMacResult(jni.Arena arena, jni.JByteArray? result) {
190+
if (result == null) {
191+
throw AssertionError('JCA Mac(${_hash._hmacJcaName}) returned null');
192+
}
193+
result.releasedBy(arena);
194+
return result.copyToDartBytes();
195+
}
196+
197+
bool _verifySignature(Uint8List computedMac, List<int> suppliedSignature) {
198+
return jni.using((arena) {
199+
final computed = arena.copyToJByteArray(computedMac);
200+
final supplied = arena.copyToJByteArray(_asUint8List(suppliedSignature));
201+
return MessageDigest.isEqual(computed, supplied);
202+
});
41203
}
42204
}

lib/src/impl_jni/impl_jni.random.dart

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,5 @@ final class _RandomImpl implements RandomImpl {
1818
const _RandomImpl();
1919

2020
@override
21-
void fillRandomBytes(TypedData destination) {
22-
throw UnimplementedError('Not implemented');
23-
}
21+
void fillRandomBytes(TypedData destination) => _fillRandomBytes(destination);
2422
}

lib/src/impl_jni/impl_jni.utils.dart

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,116 @@
1414

1515
part of 'impl_jni.dart';
1616

17+
const _defaultChunkSize = 4096;
18+
19+
void _checkData(bool condition, String message) {
20+
if (!condition) {
21+
throw FormatException(message);
22+
}
23+
}
24+
25+
Uint8List _asUint8List(List<int> data) {
26+
return data is Uint8List ? data : Uint8List.fromList(data);
27+
}
28+
29+
Uint8List _asUint8ListZeroedToBitLength(List<int> data, [int? lengthInBits]) {
30+
final bytes = Uint8List.fromList(data);
31+
if (lengthInBits == null) {
32+
return bytes;
33+
}
34+
35+
final startFrom = lengthInBits ~/ 8;
36+
var remainder = lengthInBits % 8;
37+
for (var i = startFrom; i < bytes.length; i++) {
38+
final mask = 0xff & (0xff << (8 - remainder));
39+
bytes[i] = bytes[i] & mask;
40+
remainder = 8;
41+
}
42+
return bytes;
43+
}
44+
45+
Uint8List _jwkDecodeBase64UrlNoPadding(String unpadded, String prop) {
46+
try {
47+
final padded = unpadded.padRight(
48+
unpadded.length + ((4 - (unpadded.length % 4)) % 4),
49+
'=',
50+
);
51+
return base64Url.decode(padded);
52+
} on FormatException {
53+
throw FormatException(
54+
'JWK property "$prop" is not url-safe base64 without padding',
55+
unpadded,
56+
);
57+
}
58+
}
59+
60+
String _jwkEncodeBase64UrlNoPadding(Uint8List data) {
61+
final padded = base64Url.encode(data);
62+
final paddingStart = padded.indexOf('=');
63+
return paddingStart == -1 ? padded : padded.substring(0, paddingStart);
64+
}
65+
66+
extension _JniArenaByteArray on jni.Arena {
67+
jni.JByteArray copyToJByteArray(Uint8List data) {
68+
return jni.JByteArray.of(data)..releasedBy(this);
69+
}
70+
}
71+
1772
extension _JByteArrayCopy on jni.JByteArray {
1873
/// Copies this JVM byte array into Dart-owned memory.
19-
///
20-
/// `getRange` returns a typed list backed by a native buffer whose lifetime
21-
/// is finalizer-driven. Copy once more before releasing the Java array so
22-
/// webcrypto callers receive a normal Dart-managed [Uint8List].
2374
Uint8List copyToDartBytes() {
2475
final bytes = getRange(0, length);
25-
final view = bytes.buffer.asUint8List(bytes.offsetInBytes, bytes.length);
76+
final view = Uint8List.sublistView(bytes);
2677
return Uint8List.fromList(view);
2778
}
79+
80+
void copyRangeToDart(
81+
Uint8List destination,
82+
int destinationOffset,
83+
int length,
84+
) {
85+
final bytes = getRange(0, length);
86+
final view = Uint8List.sublistView(bytes);
87+
destination.setRange(destinationOffset, destinationOffset + length, view);
88+
}
89+
}
90+
91+
Uint8List _randomBytes(int length) {
92+
final output = Uint8List(length);
93+
_fillRandomBytes(output);
94+
return output;
95+
}
96+
97+
void _fillRandomBytes(TypedData destination) {
98+
final output = destination.buffer.asUint8List(
99+
destination.offsetInBytes,
100+
destination.lengthInBytes,
101+
);
102+
if (output.isEmpty) {
103+
return;
104+
}
105+
106+
jni.using((arena) {
107+
final random = SecureRandom()..releasedBy(arena);
108+
final bufferLength = output.length < _defaultChunkSize
109+
? output.length
110+
: _defaultChunkSize;
111+
final fullBuffer = jni.JByteArray(bufferLength)..releasedBy(arena);
112+
113+
// TODO: Should revisit bulk input/output transfer helpers with JByteBuffer for
114+
// JCA APIs that accept ByteBuffer directly. SecureRandom.nextBytes only accepts
115+
// byte[].
116+
var offset = 0;
117+
while (offset < output.length) {
118+
final remaining = output.length - offset;
119+
final chunkLength = remaining < bufferLength ? remaining : bufferLength;
120+
final bytes = chunkLength == bufferLength
121+
? fullBuffer
122+
: (jni.JByteArray(chunkLength)..releasedBy(arena));
123+
124+
random.nextBytes(bytes);
125+
bytes.copyRangeToDart(output, offset, chunkLength);
126+
offset += chunkLength;
127+
}
128+
});
28129
}

0 commit comments

Comments
 (0)