garage_auth, garage_entitlements, garage_iap and garage_ui, moved out of Garage-Services and Metro-Map-Maker into one public repo. MIT, one readme, docs under docs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013F4NWNvYcdeSgqbWMT1VQ7
97 lines
2.8 KiB
Dart
97 lines
2.8 KiB
Dart
import "dart:convert";
|
|
import "dart:typed_data";
|
|
|
|
import "package:pointycastle/export.dart";
|
|
|
|
// A keypair + a signer, shared by the tests. Lifted from garage_iap's verify
|
|
// test — generating a real 2048 bit RSA key beats a fixture, since the whole
|
|
// point is that we verify the same way the backend signs.
|
|
|
|
String b64uBig(BigInt n) {
|
|
final bytes = <int>[];
|
|
var v = n;
|
|
while (v > BigInt.zero) {
|
|
bytes.insert(0, (v & BigInt.from(0xff)).toInt());
|
|
v = v >> 8;
|
|
}
|
|
return base64Url.encode(Uint8List.fromList(bytes)).replaceAll("=", "");
|
|
}
|
|
|
|
String b64uStr(String s) => base64Url.encode(utf8.encode(s)).replaceAll("=", "");
|
|
|
|
String b64uBytes(List<int> b) => base64Url.encode(b).replaceAll("=", "");
|
|
|
|
AsymmetricKeyPair<PublicKey, PrivateKey> genKey(int seed) {
|
|
final rng = SecureRandom("Fortuna")
|
|
..seed(
|
|
KeyParameter(Uint8List.fromList(List.generate(32, (i) => (i + seed) & 0xff))),
|
|
);
|
|
final gen = RSAKeyGenerator()
|
|
..init(
|
|
ParametersWithRandom(
|
|
RSAKeyGeneratorParameters(BigInt.parse("65537"), 2048, 64),
|
|
rng,
|
|
),
|
|
);
|
|
return gen.generateKeyPair();
|
|
}
|
|
|
|
// build a compact RS256 JWT the same way the backend does — header.payload
|
|
// signed PKCS1v15 SHA-256.
|
|
String signJwt(
|
|
RSAPrivateKey priv,
|
|
Map<String, dynamic> header,
|
|
Map<String, dynamic> payload,
|
|
) {
|
|
final h = b64uStr(jsonEncode(header));
|
|
final p = b64uStr(jsonEncode(payload));
|
|
final input = utf8.encode("$h.$p");
|
|
final signer = Signer("SHA-256/RSA") as RSASigner;
|
|
signer.init(true, PrivateKeyParameter<RSAPrivateKey>(priv));
|
|
final sig = signer.generateSignature(Uint8List.fromList(input));
|
|
return "$h.$p.${b64uBytes(sig.bytes)}";
|
|
}
|
|
|
|
Map<String, dynamic> jwksOf(RSAPublicKey pub, {String kid = "k1"}) => {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"kid": kid,
|
|
"n": b64uBig(pub.modulus!),
|
|
"e": b64uBig(pub.exponent!),
|
|
},
|
|
],
|
|
};
|
|
|
|
/// A key the way the store mints them. Everything is overridable so a test can
|
|
/// break exactly one claim.
|
|
String keyToken(
|
|
RSAPrivateKey priv, {
|
|
String kid = "k1",
|
|
String sub = "user-123",
|
|
String iss = "https://pay.imbenji.net",
|
|
String project = "field-notes",
|
|
String sku = "pro",
|
|
String kind = "one_off",
|
|
String mode = "live",
|
|
String? entitlementExpiresAt,
|
|
Duration life = const Duration(hours: 1),
|
|
String? audOverride,
|
|
}) {
|
|
final now = DateTime.now().toUtc();
|
|
return signJwt(priv, {"alg": "RS256", "kid": kid, "typ": "JWT"}, {
|
|
"sub": sub,
|
|
"iss": iss,
|
|
"aud": audOverride ?? "$project/$sku",
|
|
"project": project,
|
|
"sku": sku,
|
|
"kind": kind,
|
|
"mode": mode,
|
|
if (entitlementExpiresAt != null) "expires_at": entitlementExpiresAt,
|
|
"iat": now.millisecondsSinceEpoch ~/ 1000,
|
|
"exp": now.add(life).millisecondsSinceEpoch ~/ 1000,
|
|
});
|
|
}
|