diff --git a/lib/src/impl_ffi/impl_ffi.hmac.dart b/lib/src/impl_ffi/impl_ffi.hmac.dart index f48135d4..d5f96bc4 100644 --- a/lib/src/impl_ffi/impl_ffi.hmac.dart +++ b/lib/src/impl_ffi/impl_ffi.hmac.dart @@ -165,7 +165,18 @@ final class _HmacSecretKeyImpl implements HmacSecretKeyImpl { verifyStream(signature, Stream.value(data)); @override - Future verifyStream(List signature, Stream> data) async { + Future verifyStream(List signature, Stream> data) { + // Snapshot caller-owned input before any asynchronous work. This matches + // Web Crypto semantics and keeps verification independent of mutations to + // the original list while the data stream is being consumed. + final signatureSnapshot = List.of(signature, growable: false); + return _verifySignatureStream(signatureSnapshot, data); + } + + Future _verifySignatureStream( + List signature, + Stream> data, + ) async { final other = await signStream(data); if (signature.length != other.length) { return false; diff --git a/lib/src/impl_js/impl_js.hmac.dart b/lib/src/impl_js/impl_js.hmac.dart index da0313b0..e8d7546d 100644 --- a/lib/src/impl_js/impl_js.hmac.dart +++ b/lib/src/impl_js/impl_js.hmac.dart @@ -164,7 +164,15 @@ final class _HmacSecretKeyImpl implements HmacSecretKeyImpl { } @override - Future verifyStream(List signature, Stream> data) async { + Future verifyStream(List signature, Stream> data) { + final signatureSnapshot = List.of(signature, growable: false); + return _verifySignatureStream(signatureSnapshot, data); + } + + Future _verifySignatureStream( + List signature, + Stream> data, + ) async { return await verifyBytes(signature, await _bufferStream(data)); } diff --git a/lib/src/testing/regression/hmac_signature_snapshot.dart b/lib/src/testing/regression/hmac_signature_snapshot.dart new file mode 100644 index 00000000..3f499eb3 --- /dev/null +++ b/lib/src/testing/regression/hmac_signature_snapshot.dart @@ -0,0 +1,92 @@ +// 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 'dart:async'; + +import 'package:webcrypto/webcrypto.dart'; + +import '../utils/utils.dart'; + +void main() => tests().runTests(); + +List<({String name, Future Function() test})> tests() { + final tests = <({String name, Future Function() test})>[]; + void test(String name, Future Function() fn) => + tests.add((name: name, test: fn)); + + test('Hmac: verifyStream snapshots an invalid signature', () async { + final key = await HmacSecretKey.importRawKey( + List.filled(32, 7), + Hash.sha256, + ); + final data = List.filled(128, 3); + final signature = await key.signBytes(data); + signature[0] ^= 1; + + final controller = StreamController>(); + final pending = key.verifyStream(signature, controller.stream); + + // Restore the valid bytes while verification is waiting for stream data. + signature[0] ^= 1; + controller.add(data); + await controller.close(); + + check( + !await pending, + 'verification must use the invalid signature supplied at invocation', + ); + }); + + test('Hmac: verifyStream snapshots a valid signature', () async { + final key = await HmacSecretKey.importRawKey( + List.filled(32, 7), + Hash.sha256, + ); + final data = List.filled(128, 3); + final signature = await key.signBytes(data); + + final controller = StreamController>(); + final pending = key.verifyStream(signature, controller.stream); + + // Corrupt the caller-owned list while verification is waiting. + signature[0] ^= 1; + controller.add(data); + await controller.close(); + + check( + await pending, + 'verification must use the valid signature supplied at invocation', + ); + }); + + test('Hmac: verifyBytes snapshots the signature at invocation', () async { + final key = await HmacSecretKey.importRawKey( + List.filled(32, 7), + Hash.sha256, + ); + final data = List.filled(50000, 3); + final signature = await key.signBytes(data); + signature[0] ^= 1; + + final pending = key.verifyBytes(signature, data); + signature[0] ^= 1; + + check( + !await pending, + 'verifyBytes must not observe a later signature mutation', + ); + }); + + return tests; +} diff --git a/lib/src/testing/testing.dart b/lib/src/testing/testing.dart index 35883c10..bc523f28 100644 --- a/lib/src/testing/testing.dart +++ b/lib/src/testing/testing.dart @@ -31,6 +31,7 @@ import 'webcrypto/rsassapkcs1v15.dart' as rsassapkcs1v15; import 'webcrypto/random.dart' as random; import 'webcrypto/digest.dart' as digest; import 'regression/issue_302_hmac_jwk_length.dart' as issue_302_hmac_jwk_length; +import 'regression/hmac_signature_snapshot.dart' as hmac_signature_snapshot; import 'regression/aes_gcm_invalid_tag_length.dart' as aes_gcm_invalid_tag_length; import 'regression/derive_bits_zero_length.dart' as derive_bits_zero_length; @@ -69,6 +70,7 @@ void runAllTests( ...random.tests(), ...digest.tests(), ...issue_302_hmac_jwk_length.tests(), + ...hmac_signature_snapshot.tests(), ...aes_gcm_invalid_tag_length.tests(), ...ecdh_derive_bits.tests(), ...issue_60_trailing_bytes.tests(),