From 6fbe6d9516f673f09d49839f7276e9f4e6afa4c0 Mon Sep 17 00:00:00 2001 From: catsika Date: Mon, 26 Oct 2026 02:27:39 +0000 Subject: [PATCH] feat: verify offline licenses locally (VerifyOfflineLicense) Add VerifyOfflineLicense, which checks a signed .lic file (or bare token) against trusted Ed25519 public key PEMs with the standard library only. ProductID and MachineCode are required, so a license for one product cannot unlock another under the shared org signing key (L-04). Failures return *OfflineLicenseError with one of eight reason codes; bad options wrap ErrInvalidOfflineOptions. Also adds OfflineKeyID, re-exports from the deprecated /src package, the shared 34-case fixture file with a table test, a live check of the signed file when KEYMINT_TEST_PUBLIC_KEY is set, and a README section. --- .github/workflows/ci.yml | 5 +- Offline.go | 304 +++++++++++++++ README.md | 80 ++++ live_test.go | 13 + offline_test.go | 214 +++++++++++ src/compat.go | 19 + testdata/offline-license-fixtures.json | 487 +++++++++++++++++++++++++ 7 files changed, 1121 insertions(+), 1 deletion(-) create mode 100644 Offline.go create mode 100644 offline_test.go create mode 100644 testdata/offline-license-fixtures.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19f368e..8fb5b3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,8 @@ jobs: c, _ := keymint.New("key", "") _, _ = c.ActivateKey(keymint.ActivateKeyParams{HostID: keymint.String("host")}) _, _ = legacy.New("key", "") + _, _ = keymint.VerifyOfflineLicense("", keymint.OfflineVerifyOptions{}) + _, _ = legacy.VerifyOfflineLicense("", legacy.OfflineVerifyOptions{}) } GO go mod init consumer @@ -60,7 +62,7 @@ jobs: go build ./... - name: Run contract tests - run: go test ./... -run 'TestSignKey|TestGetKey|TestActivateKey|TestCreateKey|TestFloating|TestApiErrors' -v + run: go test ./... -run 'TestSignKey|TestGetKey|TestActivateKey|TestCreateKey|TestFloating|TestApiErrors|TestVerifyOfflineLicense' -v live: name: Live API Validation @@ -84,4 +86,5 @@ 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 }} run: go test . -run TestLiveLicenseWorkflowsEndToEnd -v diff --git a/Offline.go b/Offline.go new file mode 100644 index 0000000..00cfd62 --- /dev/null +++ b/Offline.go @@ -0,0 +1,304 @@ +package keymint + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" + "time" +) + +// OfflineLicenseErrorCode is the reason an offline license failed verification. +type OfflineLicenseErrorCode string + +// Reason codes returned in OfflineLicenseError.Code. Checks run in the order +// listed; the first one that fails decides the code. +const ( + // OfflineMalformed: the input is not a .lic file or a well-formed token, + // or the signed claims are missing exp or sub. + OfflineMalformed OfflineLicenseErrorCode = "malformed" + // OfflineUnsupportedAlgorithm: the token header's alg is not EdDSA, or it + // carries a crit header. + OfflineUnsupportedAlgorithm OfflineLicenseErrorCode = "unsupported_algorithm" + // OfflineUnknownKey: the token was not issued under any of the trusted + // public keys. + OfflineUnknownKey OfflineLicenseErrorCode = "unknown_key" + // OfflineBadSignature: the signature does not verify (the file was + // altered or signed by a different key). + OfflineBadSignature OfflineLicenseErrorCode = "bad_signature" + // OfflineWrongType: the token is not an offline license. + OfflineWrongType OfflineLicenseErrorCode = "wrong_type" + // OfflineWrongProduct: the license was issued for a different product. + OfflineWrongProduct OfflineLicenseErrorCode = "wrong_product" + // OfflineWrongMachine: the license was issued for a different machine. + OfflineWrongMachine OfflineLicenseErrorCode = "wrong_machine" + // OfflineExpired: the license has expired; sign a new one online. + OfflineExpired OfflineLicenseErrorCode = "expired" +) + +// OfflineLicenseError is returned by VerifyOfflineLicense when the license +// itself fails verification. Use errors.As to read the Code. The message never +// contains the license key. +type OfflineLicenseError struct { + Code OfflineLicenseErrorCode + Message string +} + +func (e *OfflineLicenseError) Error() string { + return fmt.Sprintf("offline license %s: %s", e.Code, e.Message) +} + +// ErrInvalidOfflineOptions is wrapped by the error VerifyOfflineLicense +// returns when OfflineVerifyOptions are invalid (no public keys, a key that is +// not an Ed25519 SPKI PEM, an empty ProductID or MachineCode, or a ClockSkew +// outside 0..1h). This is a programming error, not a verification result. +var ErrInvalidOfflineOptions = errors.New("keymint: invalid offline verify options") + +// OfflineVerifyOptions configures VerifyOfflineLicense. +type OfflineVerifyOptions struct { + // PublicKeys are the trusted SPKI PEM public keys, from Developer → Public + // Keys in the dashboard. At least one is required. + PublicKeys []string + // ProductID must equal the product the license was signed for. Required: + // one signing key covers every product in an organization. + ProductID string + // MachineCode must equal the machine code (host ID) the license was + // signed for. Required. + MachineCode string + // Now is the time to check expiry against. Zero means time.Now(). To + // guard against clock rollback, pass the later of the system time and a + // last-trusted time your app stores. + Now time.Time + // ClockSkew is the leeway allowed after the expiry time. Nil means 60s; + // an explicit 0 means none. Must be between 0 and 1h. + ClockSkew *time.Duration +} + +// OfflineLicense is a verified offline license. +type OfflineLicense struct { + // LicenseKey is the license key the file was signed for. + LicenseKey string + ProductID string + MachineCode string + // KeyID identifies the public key that verified the signature. + KeyID string + // IssuedAt is when the license was signed, or nil if the token has no iat. + IssuedAt *time.Time + // ExpiresAt is when the license expires. + ExpiresAt time.Time +} + +const ( + defaultOfflineClockSkew = 60 * time.Second + maxOfflineClockSkew = time.Hour +) + +var offlineSegment = regexp.MustCompile(`^[A-Za-z0-9_-]*$`) + +// OfflineKeyID returns the key ID Keymint uses for an Ed25519 SPKI PEM public +// key: base64url (no padding) of SHA-256 over the DER-encoded key. +func OfflineKeyID(publicKeyPEM string) (string, error) { + _, kid, err := parseOfflinePublicKey(publicKeyPEM) + return kid, err +} + +func parseOfflinePublicKey(publicKeyPEM string) (ed25519.PublicKey, string, error) { + block, _ := pem.Decode([]byte(publicKeyPEM)) + if block == nil { + return nil, "", fmt.Errorf("%w: public key is not a PEM block", ErrInvalidOfflineOptions) + } + parsed, err := x509.ParsePKIXPublicKey(block.Bytes) + if err != nil { + return nil, "", fmt.Errorf("%w: public key is not an SPKI public key", ErrInvalidOfflineOptions) + } + key, ok := parsed.(ed25519.PublicKey) + if !ok { + return nil, "", fmt.Errorf("%w: public key is not an Ed25519 key", ErrInvalidOfflineOptions) + } + sum := sha256.Sum256(block.Bytes) + return key, base64.RawURLEncoding.EncodeToString(sum[:]), nil +} + +// decodeOfflineSegment decodes a base64url JSON segment that must be an +// object. Numbers are kept as json.Number so their type can be checked. +func decodeOfflineSegment(segment string) (map[string]any, bool) { + raw, err := base64.RawURLEncoding.DecodeString(segment) + if err != nil { + return nil, false + } + return decodeOfflineObject(raw) +} + +func decodeOfflineObject(raw []byte) (map[string]any, bool) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + return nil, false + } + // Reject anything but whitespace after the value, as JSON.parse does. + if len(bytes.Trim(raw[dec.InputOffset():], " \t\r\n")) != 0 { + return nil, false + } + obj, ok := value.(map[string]any) + return obj, ok +} + +// offlineNumber returns v as a finite float64 if it is a JSON number. +func offlineNumber(v any) (float64, bool) { + n, ok := v.(json.Number) + if !ok { + return 0, false + } + f, err := strconv.ParseFloat(string(n), 64) + if err != nil || math.IsInf(f, 0) || math.IsNaN(f) { + return 0, false + } + return f, true +} + +func unixSecondsToTime(sec float64) time.Time { + // Clamp to what int64 seconds can hold; signed licenses are capped at + // 10 years, so this only matters for absurd values. + const limit = float64(math.MaxInt64 / 2) + sec = math.Max(-limit, math.Min(limit, sec)) + whole, frac := math.Modf(sec) + return time.Unix(int64(whole), int64(frac*1e9)) +} + +// VerifyOfflineLicense verifies an offline license locally, with no network +// call. license is the contents of the .lic file from SignKey or the +// dashboard, or the bare signed token. It returns the verified license, an +// *OfflineLicenseError if the license fails verification, or an error +// wrapping ErrInvalidOfflineOptions if opts are invalid. +func VerifyOfflineLicense(license string, opts OfflineVerifyOptions) (*OfflineLicense, error) { + if len(opts.PublicKeys) == 0 { + return nil, fmt.Errorf("%w: PublicKeys must contain at least one key", ErrInvalidOfflineOptions) + } + if opts.ProductID == "" { + return nil, fmt.Errorf("%w: ProductID is required", ErrInvalidOfflineOptions) + } + if opts.MachineCode == "" { + return nil, fmt.Errorf("%w: MachineCode is required", ErrInvalidOfflineOptions) + } + clockSkew := defaultOfflineClockSkew + if opts.ClockSkew != nil { + clockSkew = *opts.ClockSkew + } + if clockSkew < 0 || clockSkew > maxOfflineClockSkew { + return nil, fmt.Errorf("%w: ClockSkew must be between 0 and 1h", ErrInvalidOfflineOptions) + } + keys := make(map[string]ed25519.PublicKey, len(opts.PublicKeys)) + for _, p := range opts.PublicKeys { + key, kid, err := parseOfflinePublicKey(p) + if err != nil { + return nil, err + } + keys[kid] = key + } + fail := func(code OfflineLicenseErrorCode, msg string) (*OfflineLicense, error) { + return nil, &OfflineLicenseError{Code: code, Message: msg} + } + + // 1. Parse: a .lic file (JSON) or a bare token. + token := strings.TrimSpace(license) + if strings.HasPrefix(token, "{") { + file, ok := decodeOfflineObject([]byte(token)) + if !ok { + return fail(OfflineMalformed, "license file is not a JSON object") + } + signed, ok := file["signedKey"].(string) + if !ok { + return fail(OfflineMalformed, "license file has no signedKey") + } + token = signed + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fail(OfflineMalformed, "token must have three segments") + } + for _, p := range parts { + if !offlineSegment.MatchString(p) { + return fail(OfflineMalformed, "token segment is not base64url") + } + } + headerB64, payloadB64, signatureB64 := parts[0], parts[1], parts[2] + header, okHeader := decodeOfflineSegment(headerB64) + payload, okPayload := decodeOfflineSegment(payloadB64) + if !okHeader || !okPayload { + return fail(OfflineMalformed, "token header or payload is not a JSON object") + } + + // 2. Algorithm: exactly EdDSA, no critical extensions. + if alg, _ := header["alg"].(string); alg != "EdDSA" { + return fail(OfflineUnsupportedAlgorithm, "token algorithm must be EdDSA") + } + if _, hasCrit := header["crit"]; hasCrit { + return fail(OfflineUnsupportedAlgorithm, "token has unsupported critical header") + } + + // 3. Key: the kid must name a trusted key. + kid, _ := header["kid"].(string) + key, ok := keys[kid] + if !ok { + return fail(OfflineUnknownKey, "token was not signed by a trusted key") + } + + // 4. Signature over the segments exactly as received. + signature, err := base64.RawURLEncoding.DecodeString(signatureB64) + if err != nil || len(signature) != ed25519.SignatureSize || + !ed25519.Verify(key, []byte(headerB64+"."+payloadB64), signature) { + return fail(OfflineBadSignature, "signature is invalid") + } + + // 5-7. Claims, compared exactly. + if typ, _ := payload["type"].(string); typ != "offline" { + return fail(OfflineWrongType, "token is not an offline license") + } + if productID, ok := payload["productId"].(string); !ok || productID != opts.ProductID { + return fail(OfflineWrongProduct, "license is for a different product") + } + if machineCode, ok := payload["machineCode"].(string); !ok || machineCode != opts.MachineCode { + return fail(OfflineWrongMachine, "license is for a different machine") + } + + // 8. Shape of the remaining claims, then 9. expiry. + exp, ok := offlineNumber(payload["exp"]) + if !ok { + return fail(OfflineMalformed, "license has no numeric exp") + } + sub, ok := payload["sub"].(string) + if !ok || sub == "" { + return fail(OfflineMalformed, "license has no sub") + } + now := opts.Now + if now.IsZero() { + now = time.Now() + } + nowSeconds := float64(now.Unix()) + float64(now.Nanosecond())/1e9 + if !(nowSeconds < exp+clockSkew.Seconds()) { + return fail(OfflineExpired, "license has expired") + } + + result := &OfflineLicense{ + LicenseKey: sub, + ProductID: opts.ProductID, + MachineCode: opts.MachineCode, + KeyID: kid, + ExpiresAt: unixSecondsToTime(exp), + } + if iat, ok := offlineNumber(payload["iat"]); ok { + issuedAt := unixSecondsToTime(iat) + result.IssuedAt = &issuedAt + } + return result, nil +} diff --git a/README.md b/README.md index e68e4d4..62334dc 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,79 @@ activation with the same generic error as an invalid key, so the stored date lets your app tell the user the license expired on that date rather than calling the key invalid. +## Offline Licenses + +An offline license is a signed `.lic` file your app can check with no network +call. `VerifyOfflineLicense` verifies it locally with the standard library +(Ed25519); no extra dependencies. + +1. **Get the `.lic` file.** Sign it from the dashboard's Offline License + modal, or call `SignKey` while the machine is online: + + ```go + signed, err := client.SignKey(keymint.SignKeyParams{ + ProductID: productId, + LicenseKey: licenseKey, + HostID: hostId, // the machine code the license is bound to + }) + // signed.File holds the .lic contents; save it next to your app. + ``` + +2. **Bundle your public key.** Copy the PEM from **Developer → Public Keys** + in the dashboard and ship it with your app (it is public; never ship the + API key). + +3. **Verify on start-up**, passing the product ID and this machine's code: + + ```go + //go:embed keymint-public-key.pem + var publicKeyPEM string + + lic, err := keymint.VerifyOfflineLicense(licFileContents, keymint.OfflineVerifyOptions{ + PublicKeys: []string{publicKeyPEM}, + ProductID: productId, + MachineCode: hostId, + Now: trustedNow(), // optional, see below + }) + var licErr *keymint.OfflineLicenseError + switch { + case err == nil: + fmt.Printf("Licensed %s until %s\n", lic.ProductID, lic.ExpiresAt.Format("2006-01-02")) + case errors.As(err, &licErr): + switch licErr.Code { + case keymint.OfflineExpired: + // Ask the user to go online and re-sign (SignKey) to renew. + case keymint.OfflineWrongMachine: + // The file was signed for another machine. + case keymint.OfflineWrongProduct: + // The file is for a different product. + default: + // OfflineMalformed, OfflineUnsupportedAlgorithm, OfflineUnknownKey, + // OfflineBadSignature, OfflineWrongType: not a valid license file. + } + case errors.Is(err, keymint.ErrInvalidOfflineOptions): + // Programming error: missing/invalid public key, empty ProductID or + // MachineCode, or ClockSkew outside 0..1h. + panic(err) + } + ``` + +`ProductID` is required because one signing key covers every product in your +organization: without the check, a license for one product would unlock +another. Comparisons are exact (no trimming or case folding). + +`license` may be the `.lic` file contents or the bare signed token. Pass more +than one key in `PublicKeys` to accept licenses across a key rotation. +`ClockSkew` (default 60s, max 1h; pass a pointer to 0 for none) is the leeway +allowed after expiry. + +**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 the later of `time.Now()` and that stored time as `Now`. + +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: @@ -139,6 +212,13 @@ Keymint provides utilities to uniquely identify machines for node-locking: |-------------------------|--------------------------------------------------| | `VerifyWebhookSignature`| Verifies the signature of a webhook request payload. | +### Offline Verification + +| Function | Description | +|-------------------------|--------------------------------------------------| +| `VerifyOfflineLicense` | Verifies a signed `.lic` file locally, with no network call. | +| `OfflineKeyID` | Returns the key ID Keymint computes for a public key PEM. | + ## Idempotency All mutating SDK methods support idempotency keys to safely retry requests in case of network drops. Pass a pointer to a `keymint.RequestOptions` struct as the optional variadic argument: diff --git a/live_test.go b/live_test.go index 7f800bb..d07abcd 100644 --- a/live_test.go +++ b/live_test.go @@ -176,6 +176,19 @@ func TestLiveLicenseWorkflowsEndToEnd(t *testing.T) { if !ok || !strings.Contains(fileStr, "signedKey") || !strings.Contains(fileStr, "keyId") { t.Fatalf("unexpected signed file: %#v", signed.File) } + if publicKey := os.Getenv("KEYMINT_TEST_PUBLIC_KEY"); publicKey != "" { + verified, err := VerifyOfflineLicense(fileStr, OfflineVerifyOptions{ + PublicKeys: []string{publicKey}, ProductID: productID, MachineCode: nodeHost, + }) + if err != nil { + t.Fatalf("verify signed file: %v", err) + } + if verified.LicenseKey != nodeKey || verified.ProductID != productID || verified.MachineCode != nodeHost { + t.Fatal("verified offline license does not match the signed key, product or machine") + } + } else { + t.Log("KEYMINT_TEST_PUBLIC_KEY not set; skipping offline verification of the signed file") + } email := "ci-go-" + runID + "@example.com" createdCustomer := live(t, "create customer", func() (*CreateCustomerResponse, error) { diff --git a/offline_test.go b/offline_test.go new file mode 100644 index 0000000..e20a279 --- /dev/null +++ b/offline_test.go @@ -0,0 +1,214 @@ +package keymint + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/json" + "encoding/pem" + "errors" + "os" + "strings" + "testing" + "time" +) + +type offlineFixtureLicense struct { + LicenseKey string `json:"licenseKey"` + ProductID string `json:"productId"` + MachineCode string `json:"machineCode"` + IssuedAt *int64 `json:"issuedAt"` + ExpiresAt int64 `json:"expiresAt"` + KeyID string `json:"keyId"` +} + +type offlineFixtures struct { + PublicKeys []string `json:"publicKeys"` + KeyIDs []string `json:"keyIds"` + Cases []struct { + Name string `json:"name"` + Input string `json:"input"` + Options struct { + ProductID string `json:"productId"` + MachineCode string `json:"machineCode"` + Now int64 `json:"now"` + ClockSkew *float64 `json:"clockSkew"` + } `json:"options"` + Expect struct { + Valid bool `json:"valid"` + Error string `json:"error"` + License *offlineFixtureLicense `json:"license"` + } `json:"expect"` + } `json:"cases"` +} + +func loadOfflineFixtures(t *testing.T) offlineFixtures { + t.Helper() + raw, err := os.ReadFile("testdata/offline-license-fixtures.json") + if err != nil { + t.Fatalf("read fixtures: %v", err) + } + var f offlineFixtures + if err := json.Unmarshal(raw, &f); err != nil { + t.Fatalf("parse fixtures: %v", err) + } + return f +} + +func TestVerifyOfflineLicenseKeyIDs(t *testing.T) { + f := loadOfflineFixtures(t) + for i, pemKey := range f.PublicKeys { + kid, err := OfflineKeyID(pemKey) + if err != nil { + t.Fatalf("OfflineKeyID(%d): %v", i, err) + } + if kid != f.KeyIDs[i] { + t.Errorf("key %d: kid %q, want %q", i, kid, f.KeyIDs[i]) + } + } +} + +func TestVerifyOfflineLicenseFixtures(t *testing.T) { + f := loadOfflineFixtures(t) + if len(f.Cases) != 34 { + t.Fatalf("expected 34 fixture cases, got %d", len(f.Cases)) + } + for _, tc := range f.Cases { + t.Run(tc.Name, func(t *testing.T) { + opts := OfflineVerifyOptions{ + PublicKeys: f.PublicKeys, + ProductID: tc.Options.ProductID, + MachineCode: tc.Options.MachineCode, + Now: time.Unix(tc.Options.Now, 0), + } + if tc.Options.ClockSkew != nil { + skew := time.Duration(*tc.Options.ClockSkew * float64(time.Second)) + opts.ClockSkew = &skew + } + got, err := VerifyOfflineLicense(tc.Input, opts) + + if !tc.Expect.Valid { + var licErr *OfflineLicenseError + if !errors.As(err, &licErr) { + t.Fatalf("expected OfflineLicenseError %q, got license=%+v err=%v", tc.Expect.Error, got, err) + } + if string(licErr.Code) != tc.Expect.Error { + t.Fatalf("code %q, want %q", licErr.Code, tc.Expect.Error) + } + if got != nil { + t.Fatalf("expected nil license on failure") + } + if strings.Contains(licErr.Error(), "KM-ABCD-1234-EFGH-5678") { + t.Fatalf("error message leaks the license key: %q", licErr.Error()) + } + return + } + + if err != nil { + t.Fatalf("expected valid, got %v", err) + } + want := tc.Expect.License + if got.LicenseKey != want.LicenseKey || got.ProductID != want.ProductID || + got.MachineCode != want.MachineCode || got.KeyID != want.KeyID { + t.Fatalf("license %+v, want %+v", got, want) + } + if got.ExpiresAt.Unix() != want.ExpiresAt { + t.Fatalf("ExpiresAt %d, want %d", got.ExpiresAt.Unix(), want.ExpiresAt) + } + switch { + case want.IssuedAt == nil && got.IssuedAt != nil: + t.Fatalf("IssuedAt %v, want nil", got.IssuedAt) + case want.IssuedAt != nil && (got.IssuedAt == nil || got.IssuedAt.Unix() != *want.IssuedAt): + t.Fatalf("IssuedAt %v, want %d", got.IssuedAt, *want.IssuedAt) + } + }) + } +} + +func TestVerifyOfflineLicenseDefaultsNowToSystemClock(t *testing.T) { + f := loadOfflineFixtures(t) + // The fixture tokens expire in 2027; with the system clock well before + // that, a valid fixture must verify without Now. Skip once that is false. + valid := f.Cases[0] + if time.Now().Unix() >= valid.Options.Now { + t.Skip("system clock is past the fixture's fixed time") + } + _, err := VerifyOfflineLicense(valid.Input, OfflineVerifyOptions{ + PublicKeys: f.PublicKeys, + ProductID: valid.Options.ProductID, + MachineCode: valid.Options.MachineCode, + }) + if err != nil { + t.Fatalf("expected valid with default Now, got %v", err) + } +} + +func TestVerifyOfflineLicenseOptionErrors(t *testing.T) { + f := loadOfflineFixtures(t) + valid := f.Cases[0] + + ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + ecDER, err := x509.MarshalPKIXPublicKey(&ecKey.PublicKey) + if err != nil { + t.Fatal(err) + } + ecPEM := string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: ecDER})) + + dur := func(d time.Duration) *time.Duration { return &d } + base := func() OfflineVerifyOptions { + return OfflineVerifyOptions{ + PublicKeys: f.PublicKeys, + ProductID: valid.Options.ProductID, + MachineCode: valid.Options.MachineCode, + Now: time.Unix(valid.Options.Now, 0), + } + } + + cases := []struct { + name string + mutate func(*OfflineVerifyOptions) + }{ + {"no public keys", func(o *OfflineVerifyOptions) { o.PublicKeys = nil }}, + {"empty public keys", func(o *OfflineVerifyOptions) { o.PublicKeys = []string{} }}, + {"garbage PEM", func(o *OfflineVerifyOptions) { o.PublicKeys = []string{"not a pem"} }}, + {"PEM with garbage body", func(o *OfflineVerifyOptions) { + o.PublicKeys = []string{"-----BEGIN PUBLIC KEY-----\nAAAA\n-----END PUBLIC KEY-----\n"} + }}, + {"non-Ed25519 key", func(o *OfflineVerifyOptions) { o.PublicKeys = []string{ecPEM} }}, + {"one bad key among good", func(o *OfflineVerifyOptions) { o.PublicKeys = append([]string{ecPEM}, f.PublicKeys...) }}, + {"empty product", func(o *OfflineVerifyOptions) { o.ProductID = "" }}, + {"empty machine", func(o *OfflineVerifyOptions) { o.MachineCode = "" }}, + {"negative skew", func(o *OfflineVerifyOptions) { o.ClockSkew = dur(-time.Second) }}, + {"skew over 1h", func(o *OfflineVerifyOptions) { o.ClockSkew = dur(time.Hour + time.Second) }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := base() + tc.mutate(&opts) + got, err := VerifyOfflineLicense(valid.Input, opts) + if !errors.Is(err, ErrInvalidOfflineOptions) { + t.Fatalf("expected ErrInvalidOfflineOptions, got license=%v err=%v", got, err) + } + var licErr *OfflineLicenseError + if errors.As(err, &licErr) { + t.Fatalf("option error must not be an OfflineLicenseError") + } + }) + } + + for _, skew := range []time.Duration{0, time.Hour} { + opts := base() + opts.ClockSkew = dur(skew) + if _, err := VerifyOfflineLicense(valid.Input, opts); err != nil { + t.Errorf("skew %v should be accepted, got %v", skew, err) + } + } + + if _, err := OfflineKeyID(ecPEM); !errors.Is(err, ErrInvalidOfflineOptions) { + t.Errorf("OfflineKeyID(non-Ed25519) = %v, want ErrInvalidOfflineOptions", err) + } +} diff --git a/src/compat.go b/src/compat.go index 8bb739c..a250f96 100644 --- a/src/compat.go +++ b/src/compat.go @@ -56,6 +56,22 @@ type ( SignKeyParams = km.SignKeyParams SignKeyResponse = km.SignKeyResponse RequestOptions = km.RequestOptions + OfflineVerifyOptions = km.OfflineVerifyOptions + OfflineLicense = km.OfflineLicense + OfflineLicenseError = km.OfflineLicenseError + OfflineLicenseErrorCode = km.OfflineLicenseErrorCode +) + +// Offline license reason codes. +const ( + OfflineMalformed = km.OfflineMalformed + OfflineUnsupportedAlgorithm = km.OfflineUnsupportedAlgorithm + OfflineUnknownKey = km.OfflineUnknownKey + OfflineBadSignature = km.OfflineBadSignature + OfflineWrongType = km.OfflineWrongType + OfflineWrongProduct = km.OfflineWrongProduct + OfflineWrongMachine = km.OfflineWrongMachine + OfflineExpired = km.OfflineExpired ) // Functions. @@ -68,4 +84,7 @@ var ( String = km.String Int = km.Int Bool = km.Bool + VerifyOfflineLicense = km.VerifyOfflineLicense + OfflineKeyID = km.OfflineKeyID + ErrInvalidOfflineOptions = km.ErrInvalidOfflineOptions ) diff --git a/testdata/offline-license-fixtures.json b/testdata/offline-license-fixtures.json new file mode 100644 index 0000000..63cf758 --- /dev/null +++ b/testdata/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" + } + } + ] +}