The Garage SDKs, in the open
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
This commit is contained in:
@@ -0,0 +1,152 @@
|
||||
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")));
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user