diff --git a/CHANGELOG.md b/CHANGELOG.md index 8af964d..8006c4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ utilised by the flutter version_widget package. ## 1.1 ++ Sign DPoP proofs with Web Crypto on web [1.1.0 20260925 jesscmoore] + ## 1.0 Migrate to using OIDC OpenID certified + Remove the temporary store logging diagnostic [1.0.11 20260921 gjw] diff --git a/lib/src/dpop/dpop_signer.dart b/lib/src/dpop/dpop_signer.dart new file mode 100644 index 0000000..bf6dd83 --- /dev/null +++ b/lib/src/dpop/dpop_signer.dart @@ -0,0 +1,42 @@ +/// Signs DPoP proofs: RS256 (RSASSA-PKCS1-v1_5 with SHA-256) over a JWS +/// signing input, on whichever platform the app runs. +/// +/// Every request to a POD carries a freshly signed proof, so this is on the +/// path of every read and write. On web it uses the browser's Web Crypto +/// API: signing in pure Dart compiled to JavaScript takes about 250 ms per +/// proof, Web Crypto well under 1 ms. Elsewhere the pure-Dart signer takes +/// 2-3 ms, and runs with the key parsed once rather than on every proof. +/// +/// RS256 is deterministic, so both produce identical signatures for the +/// same key and input. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +export 'package:solid_auth/src/dpop/dpop_signer_native.dart' + if (dart.library.js_interop) 'package:solid_auth/src/dpop/dpop_signer_web.dart'; diff --git a/lib/src/dpop/dpop_signer_common.dart b/lib/src/dpop/dpop_signer_common.dart new file mode 100644 index 0000000..55f3cf7 --- /dev/null +++ b/lib/src/dpop/dpop_signer_common.dart @@ -0,0 +1,56 @@ +/// Pure-Dart RS256 signing (dart_jsonwebtoken, over pointycastle), for +/// platforms other than web and for the synchronous +/// [DpopTokenGenerator.generate]. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; + +/// Parsed keys by PEM, so each key is parsed once rather than per proof. +final Map _keys = {}; + +/// Parses [privateKeyPem] (PKCS#1 or PKCS#8), once per key. +RSAPrivateKey parsedPrivateKey(String privateKeyPem) => + _keys[privateKeyPem] ??= RSAPrivateKey(privateKeyPem); + +/// The base64url (unpadded) RS256 signature of [signingInput]. +String signRs256Sync(String signingInput, String privateKeyPem) => + base64UrlUnpadded( + JWTAlgorithm.RS256.sign( + parsedPrivateKey(privateKeyPem), + Uint8List.fromList(utf8.encode(signingInput)), + ), + ); + +/// [bytes] as unpadded base64url, as JWS uses. +String base64UrlUnpadded(List bytes) => + base64Url.encode(bytes).replaceAll('=', ''); diff --git a/lib/src/dpop/dpop_signer_native.dart b/lib/src/dpop/dpop_signer_native.dart new file mode 100644 index 0000000..5c38b97 --- /dev/null +++ b/lib/src/dpop/dpop_signer_native.dart @@ -0,0 +1,35 @@ +/// RS256 signing on platforms other than web: see dpop_signer.dart. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'package:solid_auth/src/dpop/dpop_signer_common.dart'; + +/// The base64url (unpadded) RS256 signature of [signingInput]. +Future signRs256(String signingInput, String privateKeyPem) async => + signRs256Sync(signingInput, privateKeyPem); diff --git a/lib/src/dpop/dpop_signer_web.dart b/lib/src/dpop/dpop_signer_web.dart new file mode 100644 index 0000000..ccd2a5c --- /dev/null +++ b/lib/src/dpop/dpop_signer_web.dart @@ -0,0 +1,92 @@ +/// RS256 signing on web, with the browser's Web Crypto API: see +/// dpop_signer.dart. +/// +/// Copyright (C) 2026, Software Innovation Institute, ANU. +/// +/// Licensed under the MIT License (the "License"). +/// +/// License: https://choosealicense.com/licenses/mit/. +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +/// +/// Authors: Jess Moore + +library; + +import 'dart:convert'; +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; + +import 'package:solid_auth/src/dpop/dpop_signer_common.dart'; + +/// Imported keys by PEM: importing is far slower than signing, so each key +/// is imported once. Not extractable, and usable only for signing. +final Map> _keys = {}; + +String _jwkInt(BigInt value) { + var hex = value.toRadixString(16); + if (hex.length.isOdd) { + hex = '0$hex'; + } + final bytes = Uint8List(hex.length ~/ 2); + for (var i = 0; i < bytes.length; i++) { + bytes[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16); + } + return base64UrlUnpadded(bytes); +} + +final JSObject _algorithm = + {'name': 'RSASSA-PKCS1-v1_5', 'hash': 'SHA-256'}.jsify()! as JSObject; + +Future _importKey(String privateKeyPem) { + final key = parsedPrivateKey(privateKeyPem).key; + final p = key.p!; + final q = key.q!; + final d = key.privateExponent!; + final jwk = + { + 'kty': 'RSA', + 'n': _jwkInt(key.modulus!), + 'e': _jwkInt(key.publicExponent!), + 'd': _jwkInt(d), + 'p': _jwkInt(p), + 'q': _jwkInt(q), + 'dp': _jwkInt(d % (p - BigInt.one)), + 'dq': _jwkInt(d % (q - BigInt.one)), + 'qi': _jwkInt(q.modInverse(p)), + }.jsify()! + as web.JsonWebKey; + return web.window.crypto.subtle + .importKey('jwk', jwk, _algorithm, false, ['sign'.toJS].toJS) + .toDart; +} + +// Reached through dpop_signer.dart's conditional export, which the +// unused-code check doesn't follow (it only sees the native branch). +// ignore: unused-code +/// The base64url (unpadded) RS256 signature of [signingInput]. +Future signRs256(String signingInput, String privateKeyPem) async { + final key = await (_keys[privateKeyPem] ??= _importKey(privateKeyPem)); + final signature = await web.window.crypto.subtle + .sign(_algorithm, key, Uint8List.fromList(utf8.encode(signingInput)).toJS) + .toDart; + return base64UrlUnpadded((signature! as JSArrayBuffer).toDart.asUint8List()); +} diff --git a/lib/src/dpop/dpop_token_generator.dart b/lib/src/dpop/dpop_token_generator.dart index 0b2ffc0..791f679 100644 --- a/lib/src/dpop/dpop_token_generator.dart +++ b/lib/src/dpop/dpop_token_generator.dart @@ -30,12 +30,13 @@ library; import 'dart:convert'; import 'package:crypto/crypto.dart'; -import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart'; import 'package:fast_rsa/fast_rsa.dart'; import 'package:logging/logging.dart'; import 'package:uuid/uuid.dart'; import 'package:solid_auth/src/dpop/dpop_key_manager.dart'; +import 'package:solid_auth/src/dpop/dpop_signer.dart'; +import 'package:solid_auth/src/dpop/dpop_signer_common.dart'; import 'package:solid_auth/src/utils/server_clock.dart'; final _log = Logger('solid_auth.DpopTokenGenerator'); @@ -96,7 +97,7 @@ abstract class DpopTokenGenerator { }) async { final km = keyManager ?? await DpopKeyManager.getInstance(); _log.fine('Generating DPoP token-endpoint proof for: $tokenEndpointUrl'); - return generate( + return _generateAsync( httpMethod: 'POST', endpointUrl: tokenEndpointUrl, keyPair: km.keyPair, @@ -118,7 +119,7 @@ abstract class DpopTokenGenerator { }) async { // final keyManager = await DpopKeyManager.getInstance(); final km = keyManager ?? await DpopKeyManager.getInstance(); - return generate( + return _generateAsync( endpointUrl: endpointUrl, keyPair: km.keyPair, publicKeyJwk: km.publicKeyJwk, @@ -141,21 +142,69 @@ abstract class DpopTokenGenerator { /// - [httpMethod] — the HTTP method (GET, POST, PUT, PATCH, DELETE, etc.). /// - [accessToken] — when provided, the `ath` claim (SHA-256 of the token) /// is added, binding the proof to the specific token. + /// + /// Signs synchronously in pure Dart, which on web takes about 250 ms per + /// proof; [generateForRequest] and [generateForTokenEndpoint] use the + /// platform's own crypto there instead. Both produce the same proof. static String generate({ required String endpointUrl, required KeyPair keyPair, required Map publicKeyJwk, required String httpMethod, String? accessToken, + }) { + final input = signingInput( + endpointUrl: endpointUrl, + publicKeyJwk: publicKeyJwk, + httpMethod: httpMethod, + accessToken: accessToken, + ); + return '$input.${signRs256Sync(input, keyPair.privateKey)}'; + } + + /// [generate], signed with the platform's own crypto where it's faster + /// (Web Crypto on web; see `dpop_signer.dart`). + static Future _generateAsync({ + required String endpointUrl, + required KeyPair keyPair, + required Map publicKeyJwk, + required String httpMethod, + String? accessToken, + }) async { + final input = signingInput( + endpointUrl: endpointUrl, + publicKeyJwk: publicKeyJwk, + httpMethod: httpMethod, + accessToken: accessToken, + ); + return '$input.${await signRs256(input, keyPair.privateKey)}'; + } + + /// The JWS signing input of a DPoP proof, `
.` each + /// base64url-encoded JSON: what [generate] signs. + /// + /// Built here rather than by dart_jsonwebtoken's `JWT.sign`, which + /// replaces `iat` with the device's clock: the proof must carry the + /// server's (see [ServerClock]). + /// + /// [jti] and [issuedAt] are for tests; a proof always gets a fresh + /// unique id and the server's current time. + static String signingInput({ + required String endpointUrl, + required Map publicKeyJwk, + required String httpMethod, + String? accessToken, + String? jti, + DateTime? issuedAt, }) { _log.fine('Generating DPoP proof: $httpMethod $endpointUrl'); - final String tokenId = _uuid.v4(); // Unique token ID (replay protection) + final String tokenId = jti ?? _uuid.v4(); // Unique id (replay protection) /// Initialising token head and body (payload) /// https://solid.github.io/solid-oidc/primer/#authorization-code-pkce-flow /// https://datatracker.ietf.org/doc/html/rfc7519 - var tokenHead = {'alg': 'RS256', 'typ': 'dpop+jwt', 'jwk': publicKeyJwk}; + final tokenHead = {'alg': 'RS256', 'typ': 'dpop+jwt', 'jwk': publicKeyJwk}; // RFC 9449 §4.2: htu MUST NOT include query or fragment components. final parsedUrl = Uri.parse(endpointUrl); @@ -177,7 +226,8 @@ abstract class DpopTokenGenerator { // back to the device clock until a sync succeeds, which is the // behaviour this line had before. - 'iat': (ServerClock.now.millisecondsSinceEpoch / 1000).round(), + 'iat': ((issuedAt ?? ServerClock.now).millisecondsSinceEpoch / 1000) + .round(), }; // `ath` claim: base64url(sha256(ascii(access_token))) @@ -186,14 +236,9 @@ abstract class DpopTokenGenerator { payload['ath'] = _sha256Base64Url(accessToken); } - /// Create a json web token - final jwt = JWT(payload, header: tokenHead); - - /// Sign the JWT using private key - return jwt.sign( - RSAPrivateKey(keyPair.privateKey), - algorithm: JWTAlgorithm.RS256, - ); + String encode(Map json) => + base64UrlUnpadded(utf8.encode(jsonEncode(json))); + return '${encode(tokenHead)}.${encode(payload)}'; } // ── Internal ─────────────────────────────────────────────────────────────── diff --git a/pubspec.yaml b/pubspec.yaml index 07febc5..be8743c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: solid_auth description: Authenticate to a Solid POD server using Solid-OIDC with certified oidc. -version: 1.0.11 +version: 1.1.0 homepage: https://github.com/anusii/solid_auth repository: https://github.com/anusii/solid_auth @@ -20,6 +20,7 @@ dependencies: oidc_core: ^3.0.0 oidc_default_store: ^1.1.2 uuid: ^4.5.1 + web: ^1.1.1 dev_dependencies: flutter: @@ -27,6 +28,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 + pointycastle: ^4.0.0 url_launcher: any # 20260904 gjw The example is declared here so the dependency # checker sees the packages that only example/ imports (such as diff --git a/test/dpop_token_generator_test.dart b/test/dpop_token_generator_test.dart new file mode 100644 index 0000000..f211189 --- /dev/null +++ b/test/dpop_token_generator_test.dart @@ -0,0 +1,175 @@ +// Tests for DpopTokenGenerator: proofs carry the right claims (including the +// server's clock), verify against the key, and are signed identically by the +// synchronous pure-Dart signer and the platform signer (Web Crypto on web). +// +// Runs in the VM and in a browser: `flutter test` and +// `flutter test --platform chrome`. + +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart' as dj; +import 'package:fast_rsa/fast_rsa.dart' show KeyPair; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:pointycastle/asymmetric/api.dart' as pc; + +import 'package:solid_auth/src/dpop/dpop_signer.dart'; +import 'package:solid_auth/src/dpop/dpop_signer_common.dart'; +import 'package:solid_auth/src/dpop/dpop_token_generator.dart'; +import 'package:solid_auth/src/utils/server_clock.dart'; + +/// A throwaway 2048-bit key, for these tests only. +const _pem = '''-----BEGIN RSA PRIVATE KEY----- +MIIEpQIBAAKCAQEAulLJBDHw9Eu7TIFmwMlfgvN09fR8776YAsmGkMForyjQMq7a +InxRPIkxiiFdiQSNufkFqNPDTqAGoFgJfWBMGN5y6/fjejiWVKe5ImEHoon4I5Km +DSDGZXNIXuTlUelLrCoFghk+q58qrblv8xXtcIhCGqszAosaoevF5mmTGVbRQIul +J26pxZoBvpWT550Cx2/TSZ2BtszRXwpN4p74wx6IGIhTVp0TT7X3aiWDxqtLV4Bv +Hc8kLGNFQSAoQCDDU1B1YRCsobsOWwOpaSDdEYw8DnfHSmkOVNp0Vj4dm7liIDPs +gG/Kn+5TxdxUU6lSv5WhthCdahTqW6Jw8l5h3QIDAQABAoIBACQ7/z2inLJVm/oX +3Cy3vKxRvjgqsLVLAnLgUBwMkNgnfr2shV1Zgc7c+1ZagL8ptIorJG+dpwi+VCuQ +k1/ff00CzaSYE5PsN0gFShqmdf6lCC2a0lIRQqPuFG/n4bTZQs8baPDRCgAENx+L +xXqnlAJjbT+UdZoUBTziBh12AJZXlw3XHxXKzF0SsQt51Vp9zVYsuN7Xkxn/t+pS +R+do/JwV5WBTgfDIzUQouigId0eqK+THltFuzFABKPBDKfSr/m90xo0tCoojG8OM +p6znIYmkV14RmzLyQOdviheI9m+AagWr0vbZBjkrf4BUhe2+wP/Q8vgWFkhMsT5v +V+QJ+F0CgYEA/BRB2rNmm5Q8S6xvge+KyVeSlfotb6e3Ui+2BurzmndPLNB7KDCd +jswwzrWlKFxxLf1c9VDc3Ul9S8hR3lkMjFziNVjcYv8IXGJWHteGdDMMvdiqRoi8 +kKL7q7HesHeiwNVhep/qXRVXddNE0BIlE6znI+nC2XobGQNJo39NLs8CgYEAvTiy +s/+dEpglhwaAR/6i47BhWKLteHKI7z7fP/aq01uRTPm8vNQchXBB7H27zmgMcn+6 +/rDvBy0wJ4V1SCiPtALMsh+NfyRScY+9FWTlGg4VvQgqzGkaqtrlzamDhaMOlcLS +HFYGvfuuS1IunueybhBAkDdsROI8wScp70Rjr5MCgYEAkw3ePQ9bVHdtlVfK1SpA +9KQ5x3Ri/TgCIdfjgLWf1wSzE5mrvw5dW+iSsIQXDSygegvMJvA9aHputb7uw59/ +SoMFE8n7B2VwIzTauLNSpIcDb9ztuKgcGOR7nPXuy1N/hq70ZuzTc+n3U60j/54W +Mxwy2yiLmwM4u6bHVrH0/NECgYEAhbbTUa+IZ+NsYYaOkFG4+f1iTSiVd1A4xBhB +2wmMnd9PRn4Uibu6i/FQJLaVSL7uTNtGYUTXJNMh/EurHVrMcgCodhcl/nrEZ8uT +atLpswfRBMwIsnpzhdk6G6N2dbFMVThfEfcYvJhmCoQAvfotdOm3NjJ0KBlXpYbv +c014xFECgYEA7ft02OeoUCo9K83gVrF0fbdXm8wIpxziichGYzsUpry0MHkTu/Y/ +6hM6Y+caP3uSFbWsdMdBGcpTRdvdoKVxBFS5IgpPfeDReyKruF8f5a2Ib1uUsVO8 +uCINA24WVTeIFlq23edyzcbMbpGAajRVKe+Jot1DimaxVRooOPcwdBw= +-----END RSA PRIVATE KEY-----'''; + +final _key = dj.RSAPrivateKey(_pem).key; + +final _publicKey = dj.RSAPublicKey.raw( + pc.RSAPublicKey(_key.modulus!, _key.publicExponent!), +); + +final _jwk = { + 'kty': 'RSA', + 'e': base64UrlUnpadded(_bytes(_key.publicExponent!)), + 'n': base64UrlUnpadded(_bytes(_key.modulus!)), + 'alg': 'RS256', +}; + +List _bytes(BigInt v) { + final hex = v.toRadixString(16); + final even = hex.length.isOdd ? '0$hex' : hex; + return [ + for (var i = 0; i < even.length; i += 2) + int.parse(even.substring(i, i + 2), radix: 16), + ]; +} + +Map _claims(String proof) => + jsonDecode( + utf8.decode( + base64Url.decode(base64Url.normalize(proof.split('.')[1])), + ), + ) + as Map; + +void main() { + tearDown(ServerClock.reset); + + test('a proof verifies against the key and carries the request', () { + final proof = DpopTokenGenerator.generate( + endpointUrl: 'https://pod.example.org/alice/data/inbox/1.ttl?x=1#y', + keyPair: KeyPair('', _pem), + publicKeyJwk: _jwk, + httpMethod: 'get', + accessToken: 'the-access-token', + ); + + final verified = dj.JWT.verify( + proof, + _publicKey, + // DPoP proofs are typed `dpop+jwt`, not `JWT`. + checkHeaderType: false, + ); + expect(verified.header!['typ'], 'dpop+jwt'); + expect(verified.header!['jwk'], _jwk); + + final claims = _claims(proof); + expect(claims['htu'], 'https://pod.example.org/alice/data/inbox/1.ttl'); + expect(claims['htm'], 'GET'); + expect(claims['jti'], isNotEmpty); + expect( + claims['ath'], + base64UrlUnpadded(sha256.convert(ascii.encode('the-access-token')).bytes), + ); + }); + + test("a proof carries the server's time, not the device's", () async { + final serverNow = DateTime.now().toUtc().add(const Duration(hours: 1)); + await ServerClock.syncWith( + Uri.parse('https://pod.example.org/'), + client: MockClient( + (_) async => + http.Response('', 200, headers: {'date': _httpDate(serverNow)}), + ), + ); + + final proof = DpopTokenGenerator.generate( + endpointUrl: 'https://pod.example.org/alice/', + keyPair: KeyPair('', _pem), + publicKeyJwk: _jwk, + httpMethod: 'GET', + ); + + final iat = _claims(proof)['iat'] as int; + expect( + (iat - serverNow.millisecondsSinceEpoch ~/ 1000).abs(), + lessThanOrEqualTo(2), + ); + }); + + test('the platform signer signs exactly as the pure-Dart one', () async { + final input = DpopTokenGenerator.signingInput( + endpointUrl: 'https://pod.example.org/alice/data/inbox/1.ttl', + publicKeyJwk: _jwk, + httpMethod: 'GET', + accessToken: 'the-access-token', + jti: 'fixed-id', + issuedAt: DateTime.utc(2026, 9, 25), + ); + + // RS256 is deterministic: same key and input, same signature. + expect(await signRs256(input, _pem), signRs256Sync(input, _pem)); + // And again, from the cached key. + expect(await signRs256(input, _pem), signRs256Sync(input, _pem)); + }); +} + +/// [when] as an RFC 1123 date, the form a server sends. +String _httpDate(DateTime when) { + const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + const months = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ]; + final u = when.toUtc(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${days[u.weekday - 1]}, ${two(u.day)} ${months[u.month - 1]} ' + '${u.year} ${two(u.hour)}:${two(u.minute)}:${two(u.second)} GMT'; +}