Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
42 changes: 42 additions & 0 deletions lib/src/dpop/dpop_signer.dart
Original file line number Diff line number Diff line change
@@ -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';
56 changes: 56 additions & 0 deletions lib/src/dpop/dpop_signer_common.dart
Original file line number Diff line number Diff line change
@@ -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<String, RSAPrivateKey> _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<int> bytes) =>
base64Url.encode(bytes).replaceAll('=', '');
35 changes: 35 additions & 0 deletions lib/src/dpop/dpop_signer_native.dart
Original file line number Diff line number Diff line change
@@ -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<String> signRs256(String signingInput, String privateKeyPem) async =>
signRs256Sync(signingInput, privateKeyPem);
92 changes: 92 additions & 0 deletions lib/src/dpop/dpop_signer_web.dart
Original file line number Diff line number Diff line change
@@ -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<String, Future<web.CryptoKey>> _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<web.CryptoKey> _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<String> 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());
}
73 changes: 59 additions & 14 deletions lib/src/dpop/dpop_token_generator.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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<String, dynamic> 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<String> _generateAsync({
required String endpointUrl,
required KeyPair keyPair,
required Map<String, dynamic> 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, `<header>.<payload>` 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<String, dynamic> 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);
Expand All @@ -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)))
Expand All @@ -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<String, dynamic> json) =>
base64UrlUnpadded(utf8.encode(jsonEncode(json)));
return '${encode(tokenHead)}.${encode(payload)}';
}

// ── Internal ───────────────────────────────────────────────────────────────
Expand Down
4 changes: 3 additions & 1 deletion pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -20,13 +20,15 @@ dependencies:
oidc_core: ^3.0.0
oidc_default_store: ^1.1.2
uuid: ^4.5.1
web: ^1.1.1

dev_dependencies:
flutter:
sdk: flutter
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
Expand Down
Loading
Loading