diff --git a/lib/packages/service/dfx/dfx_auth_service.dart b/lib/packages/service/dfx/dfx_auth_service.dart index 0ac444786..23ac17bb1 100644 --- a/lib/packages/service/dfx/dfx_auth_service.dart +++ b/lib/packages/service/dfx/dfx_auth_service.dart @@ -38,7 +38,16 @@ abstract class DFXAuthService { static const _signMessageTimeout = Duration(minutes: 3); static const _httpTimeout = Duration(seconds: 20); - final String signMessagePath = '/v1/auth/signMessage'; + /// Auth sign-in message, derived locally from the address. Mirrors the + /// server's `Config.auth.signMessageGeneral` template (DFXswiss/api): the + /// backend re-derives this exact string from the address on every verify + /// (stateless, no nonce) and accepts it, so there is no need to first + /// round-trip through `GET /v1/auth/signMessage`. Dropping that call also + /// removes a network-timeout failure mode from the onboarding/pairing flow. + static const _signMessagePrefix = + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_' + 'of_the_provided_Blockchain_address._Your_ID:_'; + final String authPath = '/v1/auth'; final AppStore appStore; final WalletService walletService; @@ -51,7 +60,10 @@ abstract class DFXAuthService { String get walletAddress => wallet.primaryAddress.address.hexEip55; - Future getSignMessage() => _fetchSignMessage(walletAddress); + String getSignMessage() => buildSignMessage(walletAddress); + + /// Builds the deterministic auth sign-in message for [address] (EIP-55). + String buildSignMessage(String address) => '$_signMessagePrefix$address'; /// Create-and-persist the auth signature for [account] without going through /// `appStore.wallet`. Used during the BitBox pairing flow so the signature is @@ -68,7 +80,7 @@ abstract class DFXAuthService { return; } - final message = await _fetchSignMessage(address); + final message = buildSignMessage(address); final signature = await account.signMessage(message).timeout(_signMessageTimeout); if (signature.isEmpty || signature == '0x') { throw const SigningCancelledException(); @@ -76,19 +88,6 @@ abstract class DFXAuthService { await appStore.sessionCache.saveSignature(address, signature); } - Future _fetchSignMessage(String address) async { - final uri = buildUri(host, signMessagePath, {'address': address}); - final response = await appStore.httpClient - .get(uri, headers: {'accept': 'application/json'}) - .timeout(_httpTimeout); - if (response.statusCode != 200) { - throw Exception( - 'Failed to get sign message. Status: ${response.statusCode} ${response.body}', - ); - } - return (jsonDecode(response.body) as Map)['message'] as String; - } - // Exceptions this method can throw on the BitBox path: // * `BitboxNotConnectedException` — `BitboxCredentials.signPersonalMessage` // aborts up front when the device is disconnected (BLE link dropped). @@ -122,7 +121,7 @@ abstract class DFXAuthService { } Future> getAuthResponse([bool sendWalletName = true]) async { - final signature = await getSignature(await getSignMessage()); + final signature = await getSignature(getSignMessage()); final requestBody = jsonEncode( sendWalletName @@ -178,13 +177,15 @@ abstract class DFXAuthService { Uri uri, { Map headers = const {}, }) { - return _authenticated((token) => appStore.httpClient.get( - uri, - headers: { - ...headers, - 'Authorization': 'Bearer $token', - }, - )); + return _authenticated( + (token) => appStore.httpClient.get( + uri, + headers: { + ...headers, + 'Authorization': 'Bearer $token', + }, + ), + ); } Future authenticatedPut( @@ -193,15 +194,17 @@ abstract class DFXAuthService { Object? body, Encoding? encoding, }) { - return _authenticated((token) => appStore.httpClient.put( - uri, - headers: { - ...headers, - 'Authorization': 'Bearer $token', - }, - body: body, - encoding: encoding, - )); + return _authenticated( + (token) => appStore.httpClient.put( + uri, + headers: { + ...headers, + 'Authorization': 'Bearer $token', + }, + body: body, + encoding: encoding, + ), + ); } Future authenticatedPost( @@ -210,15 +213,17 @@ abstract class DFXAuthService { Object? body, Encoding? encoding, }) { - return _authenticated((token) => appStore.httpClient.post( - uri, - headers: { - ...headers, - 'Authorization': 'Bearer $token', - }, - body: body, - encoding: encoding, - )); + return _authenticated( + (token) => appStore.httpClient.post( + uri, + headers: { + ...headers, + 'Authorization': 'Bearer $token', + }, + body: body, + encoding: encoding, + ), + ); } /// Runs [request] with a Bearer token and retries once on a 401 with a diff --git a/pubspec.lock b/pubspec.lock index cf5905a02..d8b9567bd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,8 +77,8 @@ packages: dependency: "direct main" description: path: "." - ref: "v0.0.8" - resolved-ref: "7786f6e70b716287f08f4b7082762e6d7d0546bf" + ref: "v0.0.9" + resolved-ref: "6172a2e76ccb5f45a92a37e89f92ff224e0ca4d1" url: "https://github.com/DFXswiss/bitbox_flutter.git" source: git version: "0.0.1" diff --git a/pubspec.yaml b/pubspec.yaml index a277d4e39..fce1f4d8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -79,7 +79,7 @@ dependencies: bitbox_flutter: git: url: https://github.com/DFXswiss/bitbox_flutter.git - ref: v0.0.8 + ref: v0.0.9 dev_dependencies: flutter_test: diff --git a/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart b/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart index c2e9c7bdb..84c79d12b 100644 --- a/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart +++ b/test/integration/dfx_auth_sign_ceremony_bitbox_test.dart @@ -77,7 +77,6 @@ void main() { late FakeBitboxCredentials credentials; late BitboxWalletAccount account; - const signMessage = 'Sign me to log in to api.dfx.swiss'; const jwt = 'jwt-fresh-token'; setUp(() { @@ -111,20 +110,12 @@ void main() { } test( - 'happy path: cold cache → signMessage GET → BitBox sign → auth POST 201 → JWT cached', + 'happy path: cold cache → local sign message → BitBox sign → auth POST 201 → JWT cached', () async { final calls = []; Map? authBody; final client = MockClient((request) async { calls.add('${request.method} ${request.url.path}'); - if (request.method == 'GET' && request.url.path == '/v1/auth/signMessage') { - // Address query param must be the credentials' EIP-55 address. - expect( - request.url.queryParameters['address'], - credentials.address.hexEip55, - ); - return http.Response(jsonEncode({'message': signMessage}), 200); - } if (request.method == 'POST' && request.url.path == '/v1/auth') { authBody = jsonDecode(request.body) as Map; return http.Response(jsonEncode({'accessToken': jwt}), 201); @@ -137,11 +128,9 @@ void main() { final token = await service.getAuthToken(); expect(token, jwt); - // The full ceremony happened in order: sign-message GET, then auth POST. - expect(calls, [ - 'GET /v1/auth/signMessage', - 'POST /v1/auth', - ]); + // The sign message is built locally now, so the only HTTP call is the + // auth POST — no /v1/auth/signMessage round-trip. + expect(calls, ['POST /v1/auth']); // BitBox signed exactly once — the cancel/disconnect guards never // forced a retry on the happy path. expect(credentials.signCallCount, 1); @@ -176,9 +165,6 @@ void main() { final calls = []; final client = MockClient((request) async { calls.add('${request.method} ${request.url.path}'); - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': signMessage}), 200); - } // If the POST ever fires the cancel guard didn't fire — fail loudly. return http.Response('cancel did not short-circuit', 500); }); @@ -190,8 +176,9 @@ void main() { throwsA(isA()), ); - // signMessage was fetched but the cancel happened before any POST. - expect(calls, ['GET /v1/auth/signMessage']); + // No HTTP at all: the message is built locally and the cancel happens + // during signing, before any auth POST. + expect(calls, isEmpty); expect(credentials.signCallCount, 1); expect(sessionCache.authToken, isNull); expect(sessionCache.signature, isNull); @@ -211,12 +198,9 @@ void main() { credentials.signDelay = Duration.zero; fakeAsync((async) { - final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': signMessage}), 200); - } - return http.Response('should never POST on a timed-out sign', 500); - }); + final client = MockClient( + (_) async => http.Response('should never POST on a timed-out sign', 500), + ); final service = buildService(client); Object? caught; @@ -248,15 +232,12 @@ void main() { test( '403 country-blocked: sign succeeds, auth POST returns 403 with message → Exception propagated to caller', () async { - final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': signMessage}), 200); - } - return http.Response( + final client = MockClient( + (_) async => http.Response( jsonEncode({'statusCode': 403, 'message': 'country-blocked: CH'}), 403, - ); - }); + ), + ); final service = buildService(client); await expectLater( diff --git a/test/packages/service/dfx/dfx_auth_service_test.dart b/test/packages/service/dfx/dfx_auth_service_test.dart index e33757467..c5b685dd0 100644 --- a/test/packages/service/dfx/dfx_auth_service_test.dart +++ b/test/packages/service/dfx/dfx_auth_service_test.dart @@ -455,23 +455,20 @@ void main() { verifyNever(() => sessionCache.saveSignature(any(), any())); }); - test('fetches a sign message, signs, and persists when the cache is cold', () async { - String? capturedPath; - String? capturedAddrParam; - final client = MockClient((request) async { - capturedPath = request.url.path; - capturedAddrParam = request.url.queryParameters['address']; - return http.Response(jsonEncode({'message': 'please sign'}), 200); + test('builds the sign message locally, signs, and persists when the cache is cold', () async { + var httpCalled = false; + final client = MockClient((_) async { + httpCalled = true; + return http.Response('unexpected', 500); }); when(() => appStore.httpClient).thenReturn(client); await buildService().ensureSignatureFor(account); - expect(capturedPath, '/v1/auth/signMessage'); - // Address query param is the account's address (EIP-55 checksummed), - // not the service's active wallet address — this is the BitBox-pairing + // No /v1/auth/signMessage round-trip — the message is derived locally + // from the account address (EIP-55 checksummed), the BitBox-pairing // entry point. - expect(capturedAddrParam, accountAddressEip55); + expect(httpCalled, isFalse); expect(account.signCallCount, 1); verify(() => sessionCache.saveSignature(accountAddressEip55, stubSignature)).called(1); }); @@ -508,21 +505,6 @@ void main() { ); }); } - - test('propagates the HTTP error when the sign-message endpoint is non-200', () async { - when(() => appStore.httpClient).thenReturn( - MockClient( - (_) async => http.Response('upstream broken', 502), - ), - ); - - expect( - () => buildService().ensureSignatureFor(account), - throwsA(isA()), - ); - // No sign ceremony triggered when the message fetch fails. - expect(account.signCallCount, 0); - }); }); // ------------------------------------------------------------------------- @@ -596,29 +578,21 @@ void main() { return _SignatureTestAuthService(appStore, walletService, account, walletAddress); } - test('getSignMessage GETs /v1/auth/signMessage with the wallet address', () async { - String? capturedPath; - String? capturedAddr; - final client = MockClient((request) async { - capturedPath = request.url.path; - capturedAddr = request.url.queryParameters['address']; - return http.Response(jsonEncode({'message': 'please sign me'}), 200); + test('getSignMessage builds the deterministic message locally, no HTTP', () async { + var httpCalled = false; + final client = MockClient((_) async { + httpCalled = true; + return http.Response('unexpected', 500); }); - final message = await buildService(client).getSignMessage(); - - expect(message, 'please sign me'); - expect(capturedPath, '/v1/auth/signMessage'); - expect(capturedAddr, walletAddress); - }); - - test('getSignMessage throws on non-200', () async { - final client = MockClient((_) async => http.Response('boom', 500)); + final message = buildService(client).getSignMessage(); expect( - () => buildService(client).getSignMessage(), - throwsA(isA()), + message, + 'By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_' + 'of_the_provided_Blockchain_address._Your_ID:_$walletAddress', ); + expect(httpCalled, isFalse, reason: 'no /v1/auth/signMessage round-trip'); }); test( @@ -631,10 +605,6 @@ void main() { var seq = 0; final client = MockClient((request) async { seq++; - if (request.method == 'GET') { - // /v1/auth/signMessage round-trip. - return http.Response(jsonEncode({'message': 'please sign'}), 200); - } sentMethod = request.method; sentPath = request.url.path; sentHeaders = request.headers; @@ -651,17 +621,14 @@ void main() { expect(sentBody!['wallet'], 'RealUnit'); expect(sentBody!['address'], walletAddress); expect(sentBody!['signature'], validSignature); - // Two HTTP calls: sign-message + auth. - expect(seq, 2); + // One HTTP call: auth only — the sign message is built locally. + expect(seq, 1); }, ); test('getAuthResponse omits walletName when sendWalletName=false', () async { Map? sentBody; final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } sentBody = jsonDecode(request.body) as Map; return http.Response(jsonEncode({'accessToken': 'jwt'}), 201); }); @@ -675,9 +642,6 @@ void main() { test('getAuthResponse surfaces the 403 message when present', () async { final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } return http.Response( jsonEncode({'statusCode': 403, 'message': 'country-blocked'}), 403, @@ -698,9 +662,6 @@ void main() { test('getAuthResponse falls back to the canned 403 message when the body has none', () async { final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } return http.Response(jsonEncode({'statusCode': 403}), 403); }); @@ -718,9 +679,6 @@ void main() { test('getAuthResponse throws on an arbitrary non-201/403 status', () async { final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } return http.Response('upstream broken', 502); }); @@ -735,9 +693,6 @@ void main() { () async { var authCalls = 0; final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } authCalls++; return http.Response(jsonEncode({'accessToken': 'jwt-1'}), 201); }); @@ -767,9 +722,6 @@ void main() { sessionCache.setAuthToken('stale-jwt'); var authCalls = 0; final client = MockClient((request) async { - if (request.method == 'GET') { - return http.Response(jsonEncode({'message': 'm'}), 200); - } authCalls++; return http.Response(jsonEncode({'accessToken': 'jwt-fresh'}), 201); });