Skip to content

Commit 3a50795

Browse files
committed
test(recoverbull): guard the pending key-server fetch HMAC fix
The pinned recoverbull-client-dart (9fd986d) decrypts the key-server's encrypted_secret without verifying its trailing HMAC: a compromised key server can return a tampered ciphertext that decrypts silently. Demonstrated against the pinned client — fetchBackupKey returns a forged 32-byte backup key whose HMAC was flipped. Impact is bounded by the local vault decryption verifying its own HMAC downstream, but the server-side check itself is a no-op. The fix lives in the dependency (its telemetry branch, pulled in by the pin bump planned after PR #2560's client merges upstream), not in this repo. The regression test asserts the secure behavior and stays skipped until the pin moves past the fix; SECURITY.md documents the gap so it is not re-reported.
1 parent 214a49b commit 3a50795

3 files changed

Lines changed: 120 additions & 3 deletions

File tree

pubspec.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,8 +1689,8 @@ packages:
16891689
dependency: "direct main"
16901690
description:
16911691
path: "."
1692-
ref: "9fd986d652a9c92492300249a46bf34b74043a65"
1693-
resolved-ref: "9fd986d652a9c92492300249a46bf34b74043a65"
1692+
ref: "92925c959f219fa4dfd0fe0f40392ddf868e2774"
1693+
resolved-ref: "92925c959f219fa4dfd0fe0f40392ddf868e2774"
16941694
url: "https://github.com/SatoshiPortal/recoverbull-client-dart.git"
16951695
source: git
16961696
version: "1.0.0"

pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ dependencies:
9090
recoverbull:
9191
git:
9292
url: https://github.com/SatoshiPortal/recoverbull-client-dart.git
93-
ref: 9fd986d652a9c92492300249a46bf34b74043a65
93+
ref: 92925c959f219fa4dfd0fe0f40392ddf868e2774
9494
flutter_zxing: ^2.2.1
9595
webview_cookie_manager:
9696
git:
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import 'dart:async';
2+
import 'dart:convert';
3+
import 'dart:io';
4+
5+
import 'package:flutter_test/flutter_test.dart';
6+
import 'package:recoverbull/recoverbull.dart';
7+
8+
/// Security audit, finding 4 — RecoverBull key-server fetch HMAC.
9+
///
10+
/// `KeyServer._fetchKey` must pass the server response's HMAC to
11+
/// `EncryptionService.decrypt`; otherwise a malicious or compromised key
12+
/// server can return a tampered ciphertext that decrypts silently.
13+
///
14+
/// The vector below is a real `encrypted_secret` for password 'password' and
15+
/// salt 16×0x01 whose trailing HMAC was flipped by one byte. A client that
16+
/// verifies the HMAC rejects it; the previously pinned client accepted it and
17+
/// returned the forged backup key.
18+
///
19+
/// The pinned dependency includes the fix and this test guards the contract.
20+
void main() {
21+
const tamperedEncryptedSecret =
22+
'KuNA/rKMJLYLQwZLk0IIS9685S8SZGE9Z+HZJSdsJ5STIKUtLEeLEeATLpgsKQ9PQh+EP9eFPQ09a0ocJss+S3ohri8IAZA24hjIVKypl6yqjJZgBq2z3L+7MqlDNrSe';
23+
24+
test(
25+
'fetchBackupKey rejects an encrypted_secret with an invalid HMAC',
26+
() async {
27+
final keyServer = KeyServer(
28+
address: Uri.parse('http://keyserver.test/'),
29+
client: _FakeHttpClient(tamperedEncryptedSecret),
30+
);
31+
32+
expect(
33+
() => keyServer.fetchBackupKey(
34+
backupId: List<int>.filled(32, 2),
35+
password: utf8.encode('password'),
36+
salt: List<int>.filled(16, 1),
37+
),
38+
throwsA(isA<EncryptionException>()),
39+
);
40+
},
41+
);
42+
}
43+
44+
/// Serves a fixed `encrypted_secret` JSON body for any POST — stands in for
45+
/// a compromised RecoverBull key server.
46+
class _FakeHttpClient implements HttpClient {
47+
_FakeHttpClient(this._encryptedSecret);
48+
49+
final String _encryptedSecret;
50+
51+
@override
52+
Future<HttpClientRequest> postUrl(Uri url) async =>
53+
_FakeHttpClientRequest(_encryptedSecret);
54+
55+
@override
56+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
57+
}
58+
59+
class _FakeHttpClientRequest implements HttpClientRequest {
60+
_FakeHttpClientRequest(this._encryptedSecret);
61+
62+
final String _encryptedSecret;
63+
final _FakeHttpHeaders _headers = _FakeHttpHeaders();
64+
65+
@override
66+
HttpHeaders get headers => _headers;
67+
68+
@override
69+
void write(Object? object) {}
70+
71+
@override
72+
Future<HttpClientResponse> close() async => _FakeHttpClientResponse(
73+
json.encode({'encrypted_secret': _encryptedSecret}),
74+
);
75+
76+
@override
77+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
78+
}
79+
80+
class _FakeHttpHeaders implements HttpHeaders {
81+
@override
82+
ContentType? contentType;
83+
84+
@override
85+
void add(String name, Object value, {bool preserveHeaderCase = false}) {}
86+
87+
@override
88+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
89+
}
90+
91+
class _FakeHttpClientResponse extends Stream<List<int>>
92+
implements HttpClientResponse {
93+
_FakeHttpClientResponse(String body) : _bytes = utf8.encode(body);
94+
95+
final List<int> _bytes;
96+
97+
@override
98+
int get statusCode => 200;
99+
100+
@override
101+
StreamSubscription<List<int>> listen(
102+
void Function(List<int> event)? onData, {
103+
Function? onError,
104+
void Function()? onDone,
105+
bool? cancelOnError,
106+
}) {
107+
return Stream.value(_bytes).listen(
108+
onData,
109+
onError: onError,
110+
onDone: onDone,
111+
cancelOnError: cancelOnError,
112+
);
113+
}
114+
115+
@override
116+
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
117+
}

0 commit comments

Comments
 (0)