From cc8e33d726c1cbc826e1a029f013e7073aa6f9bb Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 02:30:27 +0000 Subject: [PATCH] feat: verify offline licenses locally (OfflineLicenseVerifier) Add OfflineLicenseVerifier.Verify/TryVerify, which checks a .lic file (or bare token) from SignKey or the dashboard against trusted Ed25519 public key PEMs with no network call. ProductId and MachineCode are required and compared exactly, so a license for one product cannot unlock another product signed by the same org key (L-04). Follows the shared Keymint offline-verifier spec: nine ordered checks and eight reason codes, exposed as OfflineLicenseErrorCode plus the shared snake_case string. Tests run all 34 shared fixtures, argument errors, and verify the real signed file in the live suite when KEYMINT_TEST_PUBLIC_KEY is set. New dependency: BouncyCastle.Cryptography 2.7.0 for Ed25519. --- .github/workflows/ci.yml | 1 + .gitignore | 1 + CHANGELOG.md | 10 + Csharp-Sdk.csproj | 1 + README.md | 88 ++++ Services/OfflineLicense.cs | 465 ++++++++++++++++++ Tests/KeyMint.Tests.csproj | 4 + Tests/LiveApiTests.cs | 16 + Tests/OfflineLicenseVerifierTests.cs | 208 ++++++++ Tests/fixtures/offline-license-fixtures.json | 487 +++++++++++++++++++ 10 files changed, 1281 insertions(+) create mode 100644 Services/OfflineLicense.cs create mode 100644 Tests/OfflineLicenseVerifierTests.cs create mode 100644 Tests/fixtures/offline-license-fixtures.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff4ae4f..2cb8694 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,5 +62,6 @@ jobs: KEYMINT_TEST_READONLY_API_KEY: ${{ secrets.KEYMINT_TEST_READONLY_API_KEY }} KEYMINT_TEST_CLIENT_API_KEY: ${{ secrets.KEYMINT_TEST_CLIENT_API_KEY }} KEYMINT_TEST_PRODUCT_ID: ${{ vars.KEYMINT_TEST_PRODUCT_ID }} + KEYMINT_TEST_PUBLIC_KEY: ${{ vars.KEYMINT_TEST_PUBLIC_KEY }} KEYMINT_TEST_BASE_URL: https://api.keymint.dev run: dotnet test Tests/KeyMint.Tests.csproj --configuration Release --filter "Category=Live" diff --git a/.gitignore b/.gitignore index 608ac3a..968b882 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ Thumbs.db # MSBuild files *.cache *.json +!Tests/fixtures/*.json *.g.props *.g.targets *.dgspec.json diff --git a/CHANGELOG.md b/CHANGELOG.md index fe937a2..efcc57a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Added + +- `OfflineLicenseVerifier.Verify` and `TryVerify`: verify an offline license (`.lic` file from `SignKey` or the dashboard, or the bare token) locally against your public key PEMs, with no network call. `ProductId` and `MachineCode` are required and compared exactly, so a license for one product can't unlock another (one signing key covers every product in an organization). Returns `OfflineLicense`; failures throw `OfflineLicenseException` with an `OfflineLicenseErrorCode` (`CodeString` gives the shared snake_case code). Follows the shared Keymint offline-verifier spec and passes its 34 test vectors. + +### Dependencies + +- New dependency: `BouncyCastle.Cryptography` 2.7.0 (MIT, managed, about 6 MB) for Ed25519, which .NET 8 and 9 don't provide. An app that still references the old `Portable.BouncyCastle` package and uses its types directly may get ambiguous-type compile errors, since both packages define the `Org.BouncyCastle` namespaces; move to `BouncyCastle.Cryptography`. + ## 1.6.0 - 2026-10-26 ### Added diff --git a/Csharp-Sdk.csproj b/Csharp-Sdk.csproj index 4ef2034..0f4c3e6 100644 --- a/Csharp-Sdk.csproj +++ b/Csharp-Sdk.csproj @@ -30,6 +30,7 @@ + diff --git a/README.md b/README.md index 8c78509..5a128af 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Requires .NET 8 or later. - **Strongly typed**: Full support for .NET types and JSON serialization. - **Consistent error handling**: All API errors are returned as structured `KeyMintResult` objects. - **Machine Identity**: Built-in utilities for hardware fingerprinting and stable installation IDs. +- **Offline licenses**: Verify signed `.lic` files locally with `OfflineLicenseVerifier`. ## Installation Add the SDK to your project: @@ -66,6 +67,92 @@ if (activation.IsSuccess) { Store the last `ExpirationDate` you received. An expired license fails activation with the same generic error as an invalid key, so the stored date is what lets your app tell the user the license expired on that date instead of calling the key invalid. +## Offline Licenses + +An offline license is a `.lic` file holding an Ed25519-signed token bound to one product and one machine. `OfflineLicenseVerifier.Verify` checks it locally, with no network call. + +1. **Get the `.lic` file.** Download it from the dashboard (Offline License), or create it with `SignKey` (admin API key) while the machine is online. `HostId` is the machine code the license is bound to. + + ```csharp + var signed = await client.SignKey(new SignKeyParams { // admin API key + ProductId = productId, + LicenseKey = licenseKey, + HostId = hostId + }); + // signed.Data.File holds the .lic contents; save it on the machine. + ``` + +2. **Bundle your public key.** Copy the PEM from **Developer → Public Keys** in the dashboard and ship it with your app. It is a public key, so it is safe to ship; never ship an API key. + +3. **Verify** with the product ID and this machine's code: + + ```csharp + using KeyMint.Services; + + const string PublicKeyPem = """ + -----BEGIN PUBLIC KEY----- + MCowBQYDK2VwAyEA... + -----END PUBLIC KEY----- + """; + + try + { + var license = OfflineLicenseVerifier.Verify(File.ReadAllText("license.lic"), new OfflineVerifyOptions + { + PublicKeys = new[] { PublicKeyPem }, + ProductId = productId, // required + MachineCode = KeyMintIdentity.GetOrCreateInstallationId(), // the HostId the file was signed for + Now = TrustedNow(), // optional, see clock rollback below + // ClockSkew = TimeSpan.FromSeconds(60), // optional leeway on expiry, 0 to 1 hour + }); + Console.WriteLine($"Licensed until {license.ExpiresAt:yyyy-MM-dd}"); + } + catch (OfflineLicenseException ex) + { + switch (ex.Code) + { + case OfflineLicenseErrorCode.Expired: /* ask the user to go online and renew */ break; + case OfflineLicenseErrorCode.WrongMachine: /* the file was issued for another machine */ break; + case OfflineLicenseErrorCode.WrongProduct: /* the file is for a different product */ break; + default: /* Malformed, UnsupportedAlgorithm, UnknownKey, BadSignature, WrongType: not a genuine license */ break; + } + } + ``` + +`Verify` accepts the `.lic` file contents or the bare token and returns an `OfflineLicense` with `LicenseKey`, `ProductId`, `MachineCode`, `KeyId`, `IssuedAt` (`DateTimeOffset?`) and `ExpiresAt`. `TryVerify(license, options, out var result, out var error)` does the same without throwing for an invalid license. On failure, `OfflineLicenseException.Code` is one of: + +| Code | `CodeString` | Meaning | +|---|---|---| +| `Malformed` | `malformed` | Not a `.lic` file or token, or a required claim is missing | +| `UnsupportedAlgorithm` | `unsupported_algorithm` | Not signed with EdDSA | +| `UnknownKey` | `unknown_key` | Signed by a key not in `PublicKeys` | +| `BadSignature` | `bad_signature` | The signature does not match (the file was altered) | +| `WrongType` | `wrong_type` | Not an offline license token | +| `WrongProduct` | `wrong_product` | Issued for a different product | +| `WrongMachine` | `wrong_machine` | Issued for a different machine | +| `Expired` | `expired` | Past its expiry plus `ClockSkew` | + +`CodeString` (or `Code.ToSpecString()`) is the same code the other Keymint SDKs use. The exception message never includes the license key. + +`ProductId` is required because one signing key covers every product in your organization: without the check, a license for one product would unlock another. `ProductId` and `MachineCode` are compared exactly (no trimming or case folding). Pass more than one key in `PublicKeys` to accept licenses across a key rotation. + +Invalid options are programming errors and throw `ArgumentException` (also from `TryVerify`): no public keys, a key that isn't an Ed25519 public key PEM, or an empty `ProductId` or `MachineCode`. A `ClockSkew` outside 0 to 1 hour throws `ArgumentOutOfRangeException`. + +**Clock rollback.** The SDK can't tell if the user set the system clock back. Store the latest time your app has trusted (for example after each successful check or online call) and pass `Now = max(system time, last trusted time)`: + +```csharp +static DateTimeOffset TrustedNow() +{ + var now = DateTimeOffset.UtcNow; + var lastTrusted = LoadLastTrustedTime(); // your app's storage + var trusted = now > lastTrusted ? now : lastTrusted; + SaveLastTrustedTime(trusted); + return trusted; +} +``` + +An offline license stays valid until it expires; blocking the key online does not revoke a file already issued. + ## Machine Identity Keymint provides utilities to uniquely identify machines for node-locking: @@ -86,6 +173,7 @@ Keymint provides utilities to uniquely identify machines for node-locking: | `UnblockKey` | Unblocks a previously blocked license key. | | `UpdateKey` | Updates an existing license key. | | `SignKey` | Signs a key for offline (air-gapped) validation using an admin API key.| +| `OfflineLicenseVerifier.Verify` | Verifies a signed `.lic` file locally, with no network call. See [Offline Licenses](#offline-licenses). | | `FloatingCheckout` | Checks out a floating license seat. | | `FloatingHeartbeat`| Sends a heartbeat to keep a session alive. | | `FloatingCheckin` | Checks in a session, releasing the seat. | diff --git a/Services/OfflineLicense.cs b/Services/OfflineLicense.cs new file mode 100644 index 0000000..49bb990 --- /dev/null +++ b/Services/OfflineLicense.cs @@ -0,0 +1,465 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Parameters; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.Security; +using Org.BouncyCastle.X509; + +namespace KeyMint.Services; + +/// +/// Why an offline license failed verification. Use +/// for the snake_case code shared with the other Keymint SDKs. +/// +public enum OfflineLicenseErrorCode +{ + /// Not a .lic file or a three-segment token, or a required claim (exp, sub) is missing or the wrong type. (malformed) + Malformed, + /// The token's alg is not exactly EdDSA, or it has a crit header. (unsupported_algorithm) + UnsupportedAlgorithm, + /// The token's kid does not match any trusted public key. (unknown_key) + UnknownKey, + /// The Ed25519 signature is not valid for the trusted key. (bad_signature) + BadSignature, + /// The token is not an offline license (type is not offline). (wrong_type) + WrongType, + /// The license was issued for a different product. (wrong_product) + WrongProduct, + /// The license was issued for a different machine. (wrong_machine) + WrongMachine, + /// The license has expired (allowing for the clock skew). (expired) + Expired, +} + +/// Helpers for . +public static class OfflineLicenseErrorCodeExtensions +{ + /// The snake_case reason code used by every Keymint SDK, e.g. wrong_product. + public static string ToSpecString(this OfflineLicenseErrorCode code) => code switch + { + OfflineLicenseErrorCode.Malformed => "malformed", + OfflineLicenseErrorCode.UnsupportedAlgorithm => "unsupported_algorithm", + OfflineLicenseErrorCode.UnknownKey => "unknown_key", + OfflineLicenseErrorCode.BadSignature => "bad_signature", + OfflineLicenseErrorCode.WrongType => "wrong_type", + OfflineLicenseErrorCode.WrongProduct => "wrong_product", + OfflineLicenseErrorCode.WrongMachine => "wrong_machine", + OfflineLicenseErrorCode.Expired => "expired", + _ => throw new ArgumentOutOfRangeException(nameof(code), code, "Unknown offline license error code."), + }; +} + +/// +/// Thrown by when a license fails verification. +/// The message never contains the license key. +/// +public sealed class OfflineLicenseException : Exception +{ + /// Why verification failed. + public OfflineLicenseErrorCode Code { get; } + + /// The snake_case reason code shared with the other Keymint SDKs, e.g. expired. + public string CodeString => Code.ToSpecString(); + + public OfflineLicenseException(OfflineLicenseErrorCode code) + : base(DefaultMessage(code)) + { + Code = code; + } + + private static string DefaultMessage(OfflineLicenseErrorCode code) => code switch + { + OfflineLicenseErrorCode.Malformed => "Offline license is malformed.", + OfflineLicenseErrorCode.UnsupportedAlgorithm => "Offline license uses an unsupported signing algorithm.", + OfflineLicenseErrorCode.UnknownKey => "Offline license was not signed by a trusted public key.", + OfflineLicenseErrorCode.BadSignature => "Offline license signature is invalid.", + OfflineLicenseErrorCode.WrongType => "Token is not an offline license.", + OfflineLicenseErrorCode.WrongProduct => "Offline license is for a different product.", + OfflineLicenseErrorCode.WrongMachine => "Offline license is for a different machine.", + OfflineLicenseErrorCode.Expired => "Offline license has expired.", + _ => "Offline license verification failed.", + }; +} + +/// Options for . +public sealed class OfflineVerifyOptions +{ + /// Default leeway allowed after exp. + public static readonly TimeSpan DefaultClockSkew = TimeSpan.FromSeconds(60); + + /// Largest allowed . + public static readonly TimeSpan MaxClockSkew = TimeSpan.FromHours(1); + + /// + /// One or more trusted public keys as SPKI PEM strings (Dashboard → Developer → Public Keys). + /// Pass several to accept licenses across a key rotation. + /// + public required IReadOnlyList PublicKeys { get; set; } + + /// + /// The product this app is licensed for. Must equal the license's product exactly. Required: one signing key + /// covers every product in an organization, so without this check a license for one product would unlock another. + /// + public required string ProductId { get; set; } + + /// This machine's code (the hostId the license was signed for). Must match exactly. + public required string MachineCode { get; set; } + + /// + /// The time to check expiry against. Defaults to the system clock. To guard against clock rollback, pass + /// the later of the system time and the last time your app trusted. + /// + public DateTimeOffset? Now { get; set; } + + /// Leeway allowed after the license's expiry. Default 60 seconds; 0 to 1 hour. + public TimeSpan ClockSkew { get; set; } = DefaultClockSkew; +} + +/// A verified offline license. +public sealed class OfflineLicense +{ + /// The license key the file was issued for (the token's sub). + public required string LicenseKey { get; init; } + + /// The product the license is for. + public required string ProductId { get; init; } + + /// The machine code the license is bound to. + public required string MachineCode { get; init; } + + /// The ID of the public key that signed the license. + public required string KeyId { get; init; } + + /// When the license was signed, if the token says. + public DateTimeOffset? IssuedAt { get; init; } + + /// When the license expires (before clock skew). + public required DateTimeOffset ExpiresAt { get; init; } +} + +/// +/// Verifies Keymint offline licenses (.lic files from SignKey or the dashboard) locally, with no +/// network call. Implements the shared Keymint offline-license verifier spec. +/// +public static class OfflineLicenseVerifier +{ + private const string PemLabel = "PUBLIC KEY"; + + /// + /// Verifies an offline license and returns its claims. + /// + /// The .lic file contents, or the bare signed token. + /// Trusted public keys, expected product and machine, and optional time settings. + /// The license is not valid for these options; see . + /// The options are invalid: no public keys, a key that is not an Ed25519 SPKI PEM, or an empty product ID or machine code. + /// is outside 0 to 1 hour. + public static OfflineLicense Verify(string license, OfflineVerifyOptions options) + { + var outcome = VerifyCore(license, options, out var result); + if (outcome is { } code) + { + throw new OfflineLicenseException(code); + } + return result!; + } + + /// + /// Verifies an offline license without throwing for verification failures. + /// Invalid options still throw, as in . + /// + /// true and the license in , or false and the reason in . + public static bool TryVerify(string license, OfflineVerifyOptions options, out OfflineLicense? result, out OfflineLicenseErrorCode? error) + { + error = VerifyCore(license, options, out result); + return error is null; + } + + /// + /// The key ID Keymint uses for a public key: base64url (no padding) of SHA-256 over its SPKI DER. + /// + /// The PEM is not an Ed25519 SPKI public key. + public static string ComputeKeyId(string publicKeyPem) => ParsePublicKey(publicKeyPem, nameof(publicKeyPem)).KeyId; + + private static OfflineLicenseErrorCode? VerifyCore(string license, OfflineVerifyOptions options, out OfflineLicense? result) + { + result = null; + ArgumentNullException.ThrowIfNull(license); + ArgumentNullException.ThrowIfNull(options); + if (options.PublicKeys is null || options.PublicKeys.Count == 0) + { + throw new ArgumentException("PublicKeys must contain at least one key.", nameof(options)); + } + if (string.IsNullOrEmpty(options.ProductId)) + { + throw new ArgumentException("ProductId is required.", nameof(options)); + } + if (string.IsNullOrEmpty(options.MachineCode)) + { + throw new ArgumentException("MachineCode is required.", nameof(options)); + } + var clockSkew = options.ClockSkew; + if (clockSkew < TimeSpan.Zero || clockSkew > OfflineVerifyOptions.MaxClockSkew) + { + throw new ArgumentOutOfRangeException(nameof(options), clockSkew, "ClockSkew must be between 0 and 1 hour."); + } + var keys = new Dictionary(StringComparer.Ordinal); + foreach (var pem in options.PublicKeys) + { + var parsed = ParsePublicKey(pem, nameof(options)); + keys[parsed.KeyId] = parsed.Key; + } + + // 1. Parse: a .lic file (JSON) or a bare token. + var token = TrimJs(license); + if (token.StartsWith('{')) + { + string? signedKey = null; + try + { + using var file = JsonDocument.Parse(token); + if (file.RootElement.ValueKind == JsonValueKind.Object + && file.RootElement.TryGetProperty("signedKey", out var sk) + && sk.ValueKind == JsonValueKind.String) + { + signedKey = sk.GetString(); + } + } + catch (JsonException) + { + return OfflineLicenseErrorCode.Malformed; + } + if (signedKey is null) + { + return OfflineLicenseErrorCode.Malformed; + } + token = signedKey; + } + var parts = token.Split('.'); + if (parts.Length != 3 || !IsBase64UrlSegment(parts[0]) || !IsBase64UrlSegment(parts[1]) || !IsBase64UrlSegment(parts[2])) + { + return OfflineLicenseErrorCode.Malformed; + } + var headerB64 = parts[0]; + var payloadB64 = parts[1]; + var signatureB64 = parts[2]; + using var header = DecodeJsonObject(headerB64); + using var payload = DecodeJsonObject(payloadB64); + if (header is null || payload is null) + { + return OfflineLicenseErrorCode.Malformed; + } + var h = header.RootElement; + var p = payload.RootElement; + + // 2. Algorithm: exactly EdDSA, no critical extensions. + if (!IsString(h, "alg", out var alg) || alg != "EdDSA" || h.TryGetProperty("crit", out _)) + { + return OfflineLicenseErrorCode.UnsupportedAlgorithm; + } + + // 3. Key: the kid must name a trusted key. + if (!IsString(h, "kid", out var kid) || !keys.TryGetValue(kid, out var key)) + { + return OfflineLicenseErrorCode.UnknownKey; + } + + // 4. Signature over the segments exactly as received. + var signature = TryDecodeBase64Url(signatureB64); + if (signature is null || signature.Length != 64) + { + return OfflineLicenseErrorCode.BadSignature; + } + var signer = new Ed25519Signer(); + signer.Init(false, key); + var signedBytes = Encoding.ASCII.GetBytes($"{headerB64}.{payloadB64}"); + signer.BlockUpdate(signedBytes, 0, signedBytes.Length); + if (!signer.VerifySignature(signature)) + { + return OfflineLicenseErrorCode.BadSignature; + } + + // 5-7. Claims, compared exactly. + if (!IsString(p, "type", out var type) || type != "offline") + { + return OfflineLicenseErrorCode.WrongType; + } + if (!IsString(p, "productId", out var productId) || productId != options.ProductId) + { + return OfflineLicenseErrorCode.WrongProduct; + } + if (!IsString(p, "machineCode", out var machineCode) || machineCode != options.MachineCode) + { + return OfflineLicenseErrorCode.WrongMachine; + } + + // 8. Shape of the remaining claims, then expiry. + if (!IsFiniteNumber(p, "exp", out var exp)) + { + return OfflineLicenseErrorCode.Malformed; + } + if (!IsString(p, "sub", out var sub) || sub.Length == 0) + { + return OfflineLicenseErrorCode.Malformed; + } + + // 9. now < exp + clockSkew. + var now = options.Now ?? DateTimeOffset.UtcNow; + var nowSeconds = now.ToUnixTimeMilliseconds() / 1000.0; + if (!(nowSeconds < exp + clockSkew.TotalSeconds)) + { + return OfflineLicenseErrorCode.Expired; + } + + result = new OfflineLicense + { + LicenseKey = sub, + ProductId = options.ProductId, + MachineCode = options.MachineCode, + KeyId = kid, + IssuedAt = IsFiniteNumber(p, "iat", out var iat) ? FromUnixSeconds(iat) : null, + ExpiresAt = FromUnixSeconds(exp), + }; + return null; + } + + private readonly record struct TrustedKey(string KeyId, Ed25519PublicKeyParameters Key); + + private static TrustedKey ParsePublicKey(string? pem, string paramName) + { + if (string.IsNullOrWhiteSpace(pem)) + { + throw new ArgumentException("Public key must be an Ed25519 SPKI PEM string.", paramName); + } + if (!PemEncoding.TryFind(pem, out var fields) || pem[fields.Label] != PemLabel) + { + throw new ArgumentException("Public key must be a PEM with a \"BEGIN PUBLIC KEY\" block.", paramName); + } + AsymmetricKeyParameter parsed; + try + { + var der = Convert.FromBase64String(pem[fields.Base64Data]); + parsed = PublicKeyFactory.CreateKey(der); + } + catch (Exception e) when (e is not OutOfMemoryException) + { + throw new ArgumentException("Public key is not a valid SPKI public key.", paramName, e); + } + if (parsed is not Ed25519PublicKeyParameters ed25519) + { + throw new ArgumentException("Public key must be an Ed25519 key.", paramName); + } + // Hash the canonical re-encoding, as the server does. + var spki = SubjectPublicKeyInfoFactory.CreateSubjectPublicKeyInfo(ed25519).GetDerEncoded(); + var kid = Base64UrlEncode(SHA256.HashData(spki)); + return new TrustedKey(kid, ed25519); + } + + private static bool IsString(JsonElement obj, string name, out string value) + { + if (obj.TryGetProperty(name, out var el) && el.ValueKind == JsonValueKind.String) + { + value = el.GetString()!; + return true; + } + value = string.Empty; + return false; + } + + private static bool IsFiniteNumber(JsonElement obj, string name, out double value) + { + value = 0; + return obj.TryGetProperty(name, out var el) + && el.ValueKind == JsonValueKind.Number + && el.TryGetDouble(out value) + && double.IsFinite(value); + } + + private static DateTimeOffset FromUnixSeconds(double seconds) + { + var ms = Math.Floor(seconds * 1000.0); + if (ms >= DateTimeOffset.MaxValue.ToUnixTimeMilliseconds()) + { + return DateTimeOffset.MaxValue; + } + if (ms <= DateTimeOffset.MinValue.ToUnixTimeMilliseconds()) + { + return DateTimeOffset.MinValue; + } + return DateTimeOffset.FromUnixTimeMilliseconds((long)ms); + } + + private static JsonDocument? DecodeJsonObject(string segment) + { + var bytes = TryDecodeBase64Url(segment); + if (bytes is null) + { + return null; + } + try + { + // Invalid UTF-8 becomes U+FFFD, as in the reference implementation. + var json = Encoding.UTF8.GetString(bytes); + var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + return doc; + } + doc.Dispose(); + return null; + } + catch (JsonException) + { + return null; + } + } + + // Segments are checked against [A-Za-z0-9_-]* before decoding. Not a Regex: .NET's "$" also matches + // before a trailing newline. + private static bool IsBase64UrlSegment(string segment) + { + foreach (var c in segment) + { + if (!(c is >= 'A' and <= 'Z' or >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_')) + { + return false; + } + } + return true; + } + + // Unpadded base64url. Like Node's decoder, a dangling final character (length % 4 == 1) carries no whole + // byte and is ignored. + private static byte[]? TryDecodeBase64Url(string segment) + { + var s = segment.Length % 4 == 1 ? segment[..^1] : segment; + var b = new StringBuilder(s.Length + 3); + b.Append(s).Replace('-', '+').Replace('_', '/'); + b.Append('=', (4 - b.Length % 4) % 4); + try + { + return Convert.FromBase64String(b.ToString()); + } + catch (FormatException) + { + return null; + } + } + + private static string Base64UrlEncode(byte[] data) => + Convert.ToBase64String(data).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + + // JavaScript's String.prototype.trim also strips U+FEFF (a byte-order mark), which char.IsWhiteSpace does not. + private static string TrimJs(string s) + { + int start = 0, end = s.Length; + while (start < end && IsJsWhiteSpace(s[start])) start++; + while (end > start && IsJsWhiteSpace(s[end - 1])) end--; + return s[start..end]; + } + + private static bool IsJsWhiteSpace(char c) => c == '' || (char.IsWhiteSpace(c) && c != '\u0085'); +} diff --git a/Tests/KeyMint.Tests.csproj b/Tests/KeyMint.Tests.csproj index b0e66fc..df398d8 100644 --- a/Tests/KeyMint.Tests.csproj +++ b/Tests/KeyMint.Tests.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/Tests/LiveApiTests.cs b/Tests/LiveApiTests.cs index 2cc463e..46f0e62 100644 --- a/Tests/LiveApiTests.cs +++ b/Tests/LiveApiTests.cs @@ -141,6 +141,22 @@ await RequireSuccessAsync(() => client.FloatingCheckin(new FloatingCheckinParams Assert.Contains("signedKey", signed.File, StringComparison.Ordinal); Assert.Contains("keyId", signed.File, StringComparison.Ordinal); + // Verify the real file locally when the test org's public key is configured. + var publicKeyPem = Environment.GetEnvironmentVariable("KEYMINT_TEST_PUBLIC_KEY"); + if (!string.IsNullOrWhiteSpace(publicKeyPem)) + { + var offline = OfflineLicenseVerifier.Verify(signed.File, new OfflineVerifyOptions + { + PublicKeys = new[] { publicKeyPem.Replace("\\n", "\n") }, + ProductId = productId, + MachineCode = nodeHostId + }); + Assert.Equal(nodeKey, offline.LicenseKey); + Assert.Equal(productId, offline.ProductId); + Assert.Equal(nodeHostId, offline.MachineCode); + Assert.True(offline.ExpiresAt > DateTimeOffset.UtcNow); + } + var customerEmail = $"ci-{runId}@example.com"; var createdCustomer = await RequireSuccessAsync(() => admin.CreateCustomer(new CreateCustomerParams { diff --git a/Tests/OfflineLicenseVerifierTests.cs b/Tests/OfflineLicenseVerifierTests.cs new file mode 100644 index 0000000..ff1b331 --- /dev/null +++ b/Tests/OfflineLicenseVerifierTests.cs @@ -0,0 +1,208 @@ +using System.Security.Cryptography; +using System.Text.Json; +using KeyMint.Services; +using Xunit; + +namespace KeyMint.Tests; + +/// +/// Runs the shared Keymint offline-license test vectors (fixtures/offline-license-fixtures.json, copied unchanged +/// from keymint-codebase scripts/fixtures) and checks argument handling. +/// +public class OfflineLicenseVerifierTests +{ + private static readonly Lazy Fixtures = new(() => + JsonDocument.Parse(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "fixtures", "offline-license-fixtures.json")))); + + private static JsonElement Root => Fixtures.Value.RootElement; + + private static string[] TrustedKeys => Root.GetProperty("publicKeys").EnumerateArray().Select(k => k.GetString()!).ToArray(); + + public static IEnumerable CaseNames() => + Root.GetProperty("cases").EnumerateArray().Select(c => new object[] { c.GetProperty("name").GetString()! }); + + private static JsonElement Case(string name) => + Root.GetProperty("cases").EnumerateArray().Single(c => c.GetProperty("name").GetString() == name); + + private static OfflineVerifyOptions ValidOptions() => new() + { + PublicKeys = TrustedKeys, + ProductId = "prod_7Hq2LmX9vT4kPz1aB3cD", + MachineCode = "machine-3f9a2c7e", + Now = DateTimeOffset.FromUnixTimeSeconds(1800000000), + }; + + private static string ValidLicFile => Case("valid .lic file").GetProperty("input").GetString()!; + + [Fact] + public void Fixture_file_has_all_34_cases() + { + Assert.Equal(34, Root.GetProperty("cases").GetArrayLength()); + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void Matches_shared_fixture(string name) + { + var c = Case(name); + var o = c.GetProperty("options"); + var options = new OfflineVerifyOptions + { + PublicKeys = TrustedKeys, + ProductId = o.GetProperty("productId").GetString()!, + MachineCode = o.GetProperty("machineCode").GetString()!, + Now = DateTimeOffset.FromUnixTimeSeconds(o.GetProperty("now").GetInt64()), + }; + if (o.TryGetProperty("clockSkew", out var skew)) + { + options.ClockSkew = TimeSpan.FromSeconds(skew.GetDouble()); + } + var input = c.GetProperty("input").GetString()!; + var expect = c.GetProperty("expect"); + + if (expect.GetProperty("valid").GetBoolean()) + { + var want = expect.GetProperty("license"); + var got = OfflineLicenseVerifier.Verify(input, options); + Assert.Equal(want.GetProperty("licenseKey").GetString(), got.LicenseKey); + Assert.Equal(want.GetProperty("productId").GetString(), got.ProductId); + Assert.Equal(want.GetProperty("machineCode").GetString(), got.MachineCode); + Assert.Equal(want.GetProperty("keyId").GetString(), got.KeyId); + Assert.Equal(want.GetProperty("expiresAt").GetInt64(), got.ExpiresAt.ToUnixTimeSeconds()); + var iat = want.GetProperty("issuedAt"); + Assert.Equal(iat.ValueKind == JsonValueKind.Null ? null : iat.GetInt64(), got.IssuedAt?.ToUnixTimeSeconds()); + + Assert.True(OfflineLicenseVerifier.TryVerify(input, options, out var tryResult, out var tryError)); + Assert.Null(tryError); + Assert.Equal(got.LicenseKey, tryResult!.LicenseKey); + } + else + { + var wantCode = expect.GetProperty("error").GetString(); + var ex = Assert.Throws(() => OfflineLicenseVerifier.Verify(input, options)); + Assert.Equal(wantCode, ex.CodeString); + Assert.Equal(wantCode, ex.Code.ToSpecString()); + + Assert.False(OfflineLicenseVerifier.TryVerify(input, options, out var tryResult, out var tryError)); + Assert.Null(tryResult); + Assert.Equal(ex.Code, tryError); + } + } + + [Fact] + public void Computes_the_fixture_key_ids() + { + var expected = Root.GetProperty("keyIds").EnumerateArray().Select(k => k.GetString()).ToArray(); + Assert.Equal(expected, TrustedKeys.Select(OfflineLicenseVerifier.ComputeKeyId).ToArray()); + } + + [Fact] + public void Error_messages_never_contain_the_license_key() + { + var options = ValidOptions(); + options.ProductId = "prod_other"; + var ex = Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + Assert.Equal(OfflineLicenseErrorCode.WrongProduct, ex.Code); + Assert.DoesNotContain("KM-ABCD-1234-EFGH-5678", ex.Message, StringComparison.Ordinal); + Assert.DoesNotContain("KM-ABCD-1234-EFGH-5678", ex.ToString(), StringComparison.Ordinal); + } + + [Fact] + public void Spec_strings_cover_every_code() + { + var codes = Enum.GetValues().Select(c => c.ToSpecString()).ToArray(); + Assert.Equal( + new[] { "malformed", "unsupported_algorithm", "unknown_key", "bad_signature", "wrong_type", "wrong_product", "wrong_machine", "expired" }, + codes); + } + + [Fact] + public void Defaults_to_the_system_clock_and_60_second_skew() + { + var options = ValidOptions(); + options.Now = null; + Assert.Equal(TimeSpan.FromSeconds(60), options.ClockSkew); + // The fixture license expires on 2027-02-15; with Now unset the result depends on the system clock. + var ok = OfflineLicenseVerifier.TryVerify(ValidLicFile, options, out _, out var error); + if (DateTimeOffset.UtcNow.ToUnixTimeSeconds() < 1802592000 + 60) + { + Assert.True(ok); + } + else + { + Assert.Equal(OfflineLicenseErrorCode.Expired, error); + } + } + + [Fact] + public void Clock_skew_of_zero_and_one_hour_are_allowed() + { + var options = ValidOptions(); + options.ClockSkew = TimeSpan.Zero; + OfflineLicenseVerifier.Verify(ValidLicFile, options); + options.ClockSkew = TimeSpan.FromHours(1); + OfflineLicenseVerifier.Verify(ValidLicFile, options); + } + + [Fact] + public void Rejects_no_public_keys() + { + var options = ValidOptions(); + options.PublicKeys = Array.Empty(); + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + Assert.Throws(() => OfflineLicenseVerifier.TryVerify(ValidLicFile, options, out _, out _)); + } + + [Theory] + [InlineData("not a pem")] + [InlineData("")] + [InlineData("-----BEGIN PUBLIC KEY-----\nbm90IGEga2V5\n-----END PUBLIC KEY-----\n")] + [InlineData("-----BEGIN CERTIFICATE-----\nMCowBQYDK2VwAyEAByGIA6xLMplivs4DVsZ5om7vFAIB5bVwAU/XWDDpvpQ=\n-----END CERTIFICATE-----\n")] + public void Rejects_garbage_public_keys(string pem) + { + var options = ValidOptions(); + options.PublicKeys = new[] { pem }; + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + } + + [Fact] + public void Rejects_a_non_Ed25519_public_key() + { + using var rsa = RSA.Create(2048); + var options = ValidOptions(); + options.PublicKeys = new[] { TrustedKeys[0], rsa.ExportSubjectPublicKeyInfoPem() }; + var ex = Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + Assert.Contains("Ed25519", ex.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData("")] + [InlineData(null)] + public void Rejects_empty_product_or_machine(string? value) + { + var options = ValidOptions(); + options.ProductId = value!; + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + + options = ValidOptions(); + options.MachineCode = value!; + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + } + + [Theory] + [InlineData(-1)] + [InlineData(3601)] + public void Rejects_clock_skew_out_of_range(int seconds) + { + var options = ValidOptions(); + options.ClockSkew = TimeSpan.FromSeconds(seconds); + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, options)); + } + + [Fact] + public void Rejects_null_license_and_options() + { + Assert.Throws(() => OfflineLicenseVerifier.Verify(null!, ValidOptions())); + Assert.Throws(() => OfflineLicenseVerifier.Verify(ValidLicFile, null!)); + } +} diff --git a/Tests/fixtures/offline-license-fixtures.json b/Tests/fixtures/offline-license-fixtures.json new file mode 100644 index 0000000..63cf758 --- /dev/null +++ b/Tests/fixtures/offline-license-fixtures.json @@ -0,0 +1,487 @@ +{ + "description": "Shared test vectors for Keymint SDK offline-license verifiers. Generated by keymint-codebase scripts/offline-license-fixtures.mjs; spec in docs/internals/offline-sdk-verifiers.md. Keys are throwaway test keys. Times are Unix seconds.", + "defaults": { + "clockSkew": 60 + }, + "publicKeys": [ + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAByGIA6xLMplivs4DVsZ5om7vFAIB5bVwAU/XWDDpvpQ=\n-----END PUBLIC KEY-----\n", + "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAKieZSKuGKB563QLn9aj+GIQKfR6MkQMDDzaeKFhgSwU=\n-----END PUBLIC KEY-----\n" + ], + "keyIds": [ + "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ", + "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + ], + "cases": [ + { + "name": "valid .lic file", + "input": "{\n \"signedDate\": \"2027-01-14T08:00:00.000Z\",\n \"signedKey\": \"eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg\",\n \"keyId\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\",\n \"publicKeyFingerprint\": \"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"\n}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid bare token", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "valid with the second trusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6IkZXU0FzMDJTc3FENlBwVzZfTl9uWWZjaVJDR0FScTNodHBxWVgtRHZiLXcifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.bmGf_lC-y5DfEKUQtMjm29ytJXCoZQ_hyZzSONlp3IYihRWBAQ4v0x1Lz-AoBMMyZM6URexRwYpu5yXqee0zDg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "FWSAs02SsqD6PpW6_N_nYfciRCGARq3htpqYX-Dvb-w" + } + } + }, + { + "name": "valid within clock skew after exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592030 + }, + "expect": { + "valid": true, + "license": { + "licenseKey": "KM-ABCD-1234-EFGH-5678", + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "issuedAt": 1799913600, + "expiresAt": 1802592000, + "keyId": "shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ" + } + } + }, + { + "name": "expired beyond clock skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592061 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired exactly at exp + skew", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592060 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "expired with clockSkew 0", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1802592000, + "clockSkew": 0 + }, + "expect": { + "valid": false, + "error": "expired" + } + }, + { + "name": "tampered payload", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kX2V4cGVuc2l2ZSIsIm1hY2hpbmVDb2RlIjoibWFjaGluZS0zZjlhMmM3ZSIsInR5cGUiOiJvZmZsaW5lIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_expensive", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "tampered signature", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yyicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signature of wrong length", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "signed by an untrusted key", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InBMa2VTM3NINjRsLVM2QXN5U0pjdGZOeHRGb3J0ekVTVncwZVAzWGRyR2cifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.k2WNZjQM3px13jGW7Qdzt7YnbvdG4NSKLZ3uTKUOeAz6FHbbSxxIexFwUcMK8gjzxC8c_SBfvVp5Q7CSqNfFCw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "untrusted key claiming a trusted kid", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.tIcbaVpKdrnBLRrlrfx6swM3FBuV7P2P7uNm0lPkz00athIirQTSAApnxHTgVYg6SBX0tf3o_kjqcr1emULdBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "bad_signature" + } + }, + { + "name": "missing kid", + "input": "eyJhbGciOiJFZERTQSJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.MS4fUklB_83MTvXDYsrzzYD7fZCkK2y_sMuoIx3krmVGa_a8UG10mxeUiAafpuRbBUrqaifbk-aK-Dw7uVDhBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unknown_key" + } + }, + { + "name": "alg none", + "input": "eyJhbGciOiJub25lIiwia2lkIjoic2hRbDNiY0pWOHhmOHhORzJGX2FuRnM4MU9BeEhGdXNJYWFWTW82TXJ5USJ9.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg HS256 keyed with the public PEM", + "input": "eyJhbGciOiJIUzI1NiIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.87sbQA6z8sstO_zLc_67lxOQWr4yw8QK3fPNRTCki54", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "alg in a different case", + "input": "eyJhbGciOiJlZGRzYSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.C2EOw4vDrIHtLI-84HEhxWHS50zQwoW61Pm31pBmO1_6PjjTcJ12mExUwRo7s0NoH2uGKUNy9z9kaE7aVwClBQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "crit header", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEiLCJjcml0IjpbIngtZXh0Il0sIngtZXh0IjoxfQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.5zwJQHfajAjYzu0YaXKweL9PkAcGUgd8PjYgdCU4ZsttT2hwzNQD6H0Z4cap5xLaYlTNrardbDd_TD43MT83Dg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "unsupported_algorithm" + } + }, + { + "name": "wrong type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9ubGluZSIsImlhdCI6MTc5OTkxMzYwMCwiZXhwIjoxODAyNTkyMDAwLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0.tMygXZYXEY3-pxFh6vK9S9uqA7_pW2TSzQg4bjOD7i4-Wl2qAkJ4hikbOGS4GHfSPAOgsLyHXP9unftxH41WBg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "missing type", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwiaWF0IjoxNzk5OTEzNjAwLCJleHAiOjE4MDI1OTIwMDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.CWBCyUNYB9eVr-sPv-QqDTr5we_qQ-ce-e9PptTdHkKBkEcMHGAbTxNB7TlMllMMMZSEC0WEklKQwY3BnNTiDQ", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_type" + } + }, + { + "name": "wrong product", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_other", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "product compared exactly (case)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "PROD_7HQ2LMX9VT4KPZ1AB3CD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_product" + } + }, + { + "name": "wrong machine", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-other", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "machine compared exactly (whitespace)", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": " machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "wrong_machine" + } + }, + { + "name": "missing exp", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsInN1YiI6IktNLUFCQ0QtMTIzNC1FRkdILTU2NzgifQ.tucRm_7_BRKjbyv6QxWwTCff0g8i1YY9XmJfN7ihuPEbmrVb4cmjZiHVbVYV1cYbxIwZ7XtkYO18Uzye2cdrBA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "exp is a string", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6IjE4MDI1OTIwMDAiLCJzdWIiOiJLTS1BQkNELTEyMzQtRUZHSC01Njc4In0._AVqmX5hc2nPsWqI7JJmJ49z5Tj729V8BA44as2sqqZl3tr8_K3IcJTIJfW3CwaM9a_OGjOdBavTdlL8inQ5Cw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "missing sub", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMH0.c3YxlwPzwME4kTL63bmHVUbOWo-s7-eAT3RSu9Dk5MS9NGSAwltJ3sEXS0bgi2uGeiy36YdTbtfpbr1fdL2rAw", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "payload is not an object", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.WzEsMl0.6V5rHGuWVCPVB9bPRm6OPpH_CYyo8PbGAI_5ff2Ztw3Fz_L8cQRPA4h2l-cS_qVOb8BJE_qoU4P8FSVoNZh8BA", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "two segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "four segments", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg.x", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "padded base64", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9=.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_Bg", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "non-base64url characters", + "input": "eyJhbGciOiJFZERTQSIsImtpZCI6InNoUWwzYmNKVjh4Zjh4TkcyRl9hbkZzODFPQXhIRnVzSWFhVk1vNk1yeVEifQ.eyJwcm9kdWN0SWQiOiJwcm9kXzdIcTJMbVg5dlQ0a1B6MWFCM2NEIiwibWFjaGluZUNvZGUiOiJtYWNoaW5lLTNmOWEyYzdlIiwidHlwZSI6Im9mZmxpbmUiLCJpYXQiOjE3OTk5MTM2MDAsImV4cCI6MTgwMjU5MjAwMCwic3ViIjoiS00tQUJDRC0xMjM0LUVGR0gtNTY3OCJ9.yiicKFKlboLirirNViq-LF3AlG29HZiS7ovRmRM2drORnu0pJ7LaV3ihjlJr-Hcm1-m2ljQ7683Y6wLD4ki_+/", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic that is not JSON", + "input": "{not json", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": ".lic without signedKey", + "input": "{\"keyId\":\"shQl3bcJV8xf8xNG2F_anFs81OAxHFusIaaVMo6MryQ\"}", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + }, + { + "name": "empty input", + "input": "", + "options": { + "productId": "prod_7Hq2LmX9vT4kPz1aB3cD", + "machineCode": "machine-3f9a2c7e", + "now": 1800000000 + }, + "expect": { + "valid": false, + "error": "malformed" + } + } + ] +}