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
153 lines
4.5 KiB
Dart
153 lines
4.5 KiB
Dart
import "dart:convert";
|
|
import "dart:typed_data";
|
|
|
|
import "package:flutter_test/flutter_test.dart";
|
|
import "package:garage_iap/src/jwks_verify.dart";
|
|
import "package:garage_iap/src/models.dart";
|
|
import "package:pointycastle/export.dart";
|
|
|
|
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 with 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)}";
|
|
}
|
|
|
|
void main() {
|
|
test("verifies a signed licence and reads products", () {
|
|
final pair = _genKey(1);
|
|
final pub = pair.publicKey as RSAPublicKey;
|
|
final priv = pair.privateKey as RSAPrivateKey;
|
|
|
|
final jwks = {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"use": "sig",
|
|
"alg": "RS256",
|
|
"kid": "k1",
|
|
"n": _b64uBig(pub.modulus!),
|
|
"e": _b64uBig(pub.exponent!)
|
|
}
|
|
]
|
|
};
|
|
|
|
final now = DateTime.now().toUtc();
|
|
final token = _signJwt(priv, {
|
|
"alg": "RS256",
|
|
"kid": "k1"
|
|
}, {
|
|
"sub": "user-123",
|
|
"app": "my-app",
|
|
"products": [
|
|
{"sku": "pro", "kind": "app"}
|
|
],
|
|
"exp": now.add(const Duration(hours: 24)).millisecondsSinceEpoch ~/ 1000,
|
|
});
|
|
|
|
final lic = verifyLicence(token, jwks,
|
|
expectedApp: "my-app", expectedSub: "user-123");
|
|
expect(lic.grants("pro"), isTrue);
|
|
expect(lic.grants("nope"), isFalse);
|
|
expect(lic.isExpired, isFalse);
|
|
});
|
|
|
|
test("rejects wrong app, wrong sub, and a tampered signature", () {
|
|
final pair = _genKey(7);
|
|
final pub = pair.publicKey as RSAPublicKey;
|
|
final priv = pair.privateKey as RSAPrivateKey;
|
|
final jwks = {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"kid": "k1",
|
|
"alg": "RS256",
|
|
"n": _b64uBig(pub.modulus!),
|
|
"e": _b64uBig(pub.exponent!)
|
|
}
|
|
]
|
|
};
|
|
|
|
final now = DateTime.now().toUtc();
|
|
final exp =
|
|
now.add(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000;
|
|
final good = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
|
|
{"sub": "u", "app": "my-app", "products": [], "exp": exp});
|
|
|
|
expect(
|
|
() => verifyLicence(good, jwks, expectedApp: "other", expectedSub: "u"),
|
|
throwsA(isA<IapError>()));
|
|
expect(
|
|
() => verifyLicence(good, jwks,
|
|
expectedApp: "my-app", expectedSub: "someone-else"),
|
|
throwsA(isA<IapError>()));
|
|
|
|
final tampered = "${good.substring(0, good.length - 4)}AAAA";
|
|
expect(
|
|
() => verifyLicence(tampered, jwks,
|
|
expectedApp: "my-app", expectedSub: "u"),
|
|
throwsA(isA<IapError>()));
|
|
});
|
|
|
|
test("rejects an expired licence", () {
|
|
final pair = _genKey(3);
|
|
final pub = pair.publicKey as RSAPublicKey;
|
|
final priv = pair.privateKey as RSAPrivateKey;
|
|
final jwks = {
|
|
"keys": [
|
|
{
|
|
"kty": "RSA",
|
|
"kid": "k1",
|
|
"alg": "RS256",
|
|
"n": _b64uBig(pub.modulus!),
|
|
"e": _b64uBig(pub.exponent!)
|
|
}
|
|
]
|
|
};
|
|
|
|
final past = DateTime.now()
|
|
.toUtc()
|
|
.subtract(const Duration(hours: 1))
|
|
.millisecondsSinceEpoch ~/
|
|
1000;
|
|
final token = _signJwt(priv, {"alg": "RS256", "kid": "k1"},
|
|
{"sub": "u", "app": "my-app", "products": [], "exp": past});
|
|
|
|
expect(
|
|
() =>
|
|
verifyLicence(token, jwks, expectedApp: "my-app", expectedSub: "u"),
|
|
throwsA(predicate((e) => e is IapError && e.code == "expired")));
|
|
});
|
|
}
|