This commit is contained in:
2026-07-20 21:03:05 +02:00
parent dc60551e07
commit c3c85eef21
70 changed files with 8049 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 AI Usage Disclosure contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# Universal License Platform — client snapshot
This directory contains only the runtime verification subset required by the
AI Disclosure Standard. It is compatible with Universal License Platform
v1.0.0 and intentionally excludes key generation, private-key decoding,
license issuance, administrative APIs, storage and server code.
Source protocol: `github.com/b1tsblog/license-platform`
The product imports:
- `pkg/licensekit`: public-key trust-store parsing and token verification only;
- `sdk/go/licenseclient`: offline, hybrid and online runtime checks.
The standalone Universal License Platform remains the sole authority for key
management, license issuance, revocation and lease signing.
+3
View File
@@ -0,0 +1,3 @@
module github.com/b1tsblog/license-platform
go 1.23
@@ -0,0 +1,3 @@
// Package licensekit implements the verification-only token protocol consumed
// by Universal License Platform client applications.
package licensekit
@@ -0,0 +1,408 @@
// Package licensekit contains the verification-only protocol types and
// cryptographic checks used by Universal License Platform clients.
//
// This client snapshot intentionally contains no private-key handling, token
// signing, key generation, license issuance or administrative functionality.
package licensekit
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
)
const (
TokenTypeLicense = "LICENSE"
TokenTypeLease = "LEASE"
AlgorithmEdDSA = "EdDSA"
SchemaVersion = 1
)
type VerificationMode string
const (
ModeOffline VerificationMode = "offline"
ModeHybrid VerificationMode = "hybrid"
ModeOnline VerificationMode = "online"
)
type Header struct {
Algorithm string `json:"alg"`
Type string `json:"typ"`
KeyID string `json:"kid"`
Version int `json:"v"`
}
type VerificationPolicy struct {
Mode VerificationMode `json:"mode"`
LeaseTTLSeconds int64 `json:"leaseTtlSeconds,omitempty"`
OfflineGraceSeconds int64 `json:"offlineGraceSeconds,omitempty"`
ServerURL string `json:"serverUrl,omitempty"`
}
type Claims struct {
Version int `json:"version"`
LicenseID string `json:"licenseId"`
Issuer string `json:"issuer"`
Customer string `json:"customer"`
Product string `json:"product"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Limits map[string]int64 `json:"limits,omitempty"`
Domains []string `json:"domains,omitempty"`
InstanceIDs []string `json:"instanceIds,omitempty"`
IssuedAt int64 `json:"issuedAt"`
NotBefore int64 `json:"notBefore,omitempty"`
ExpiresAt int64 `json:"expiresAt"`
Verification VerificationPolicy `json:"verification"`
Metadata map[string]string `json:"metadata,omitempty"`
}
type LeaseClaims struct {
Version int `json:"version"`
LeaseID string `json:"leaseId"`
LicenseID string `json:"licenseId"`
Product string `json:"product"`
Customer string `json:"customer"`
Edition string `json:"edition"`
Features []string `json:"features,omitempty"`
Host string `json:"host,omitempty"`
InstanceID string `json:"instanceId,omitempty"`
IssuedAt int64 `json:"issuedAt"`
ExpiresAt int64 `json:"expiresAt"`
}
type TrustStore struct {
LicenseKeys map[string]string `json:"licenseKeys"`
LeaseKeys map[string]string `json:"leaseKeys"`
}
type VerifiedLicense struct {
Header Header
Claims Claims
}
type VerifiedLease struct {
Header Header
Claims LeaseClaims
}
func NewTrustStore() TrustStore {
return TrustStore{LicenseKeys: map[string]string{}, LeaseKeys: map[string]string{}}
}
func ParseTrustStore(data []byte) (TrustStore, error) {
var store TrustStore
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(&store); err != nil {
return TrustStore{}, fmt.Errorf("decode trust store: %w", err)
}
if store.LicenseKeys == nil {
store.LicenseKeys = map[string]string{}
}
if store.LeaseKeys == nil {
store.LeaseKeys = map[string]string{}
}
for kid, encoded := range store.LicenseKeys {
if strings.TrimSpace(kid) == "" {
return TrustStore{}, errors.New("license trust store contains an empty key id")
}
if _, err := DecodePublicKey(encoded); err != nil {
return TrustStore{}, fmt.Errorf("license key %q: %w", kid, err)
}
}
for kid, encoded := range store.LeaseKeys {
if strings.TrimSpace(kid) == "" {
return TrustStore{}, errors.New("lease trust store contains an empty key id")
}
if _, err := DecodePublicKey(encoded); err != nil {
return TrustStore{}, fmt.Errorf("lease key %q: %w", kid, err)
}
}
return store, nil
}
func VerifyLicense(store TrustStore, token string, now time.Time) (VerifiedLicense, error) {
header, payload, err := verifyToken(store.LicenseKeys, TokenTypeLicense, token)
if err != nil {
return VerifiedLicense{}, err
}
var claims Claims
if err := decodeStrict(payload, &claims); err != nil {
return VerifiedLicense{}, fmt.Errorf("decode license payload: %w", err)
}
if err := validateLicenseClaims(claims, now); err != nil {
return VerifiedLicense{}, err
}
claims.Features = UniqueSorted(claims.Features)
claims.Domains = UniqueSorted(claims.Domains)
claims.InstanceIDs = UniqueSorted(claims.InstanceIDs)
return VerifiedLicense{Header: header, Claims: claims}, nil
}
func VerifyLease(store TrustStore, token string, now time.Time, allowGrace time.Duration) (VerifiedLease, error) {
header, payload, err := verifyToken(store.LeaseKeys, TokenTypeLease, token)
if err != nil {
return VerifiedLease{}, err
}
var claims LeaseClaims
if err := decodeStrict(payload, &claims); err != nil {
return VerifiedLease{}, fmt.Errorf("decode lease payload: %w", err)
}
if err := validateLeaseClaims(claims, now); err != nil {
return VerifiedLease{}, err
}
if now.Unix() >= claims.ExpiresAt+int64(allowGrace.Seconds()) {
return VerifiedLease{}, errors.New("lease has expired")
}
claims.Features = UniqueSorted(claims.Features)
return VerifiedLease{Header: header, Claims: claims}, nil
}
func verifyToken(keys map[string]string, expectedType, token string) (Header, []byte, error) {
parts := strings.Split(strings.TrimSpace(token), ".")
if len(parts) != 3 {
return Header{}, nil, errors.New("token has invalid format")
}
headerBytes, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return Header{}, nil, errors.New("token header is not valid base64url")
}
var header Header
if err := decodeStrict(headerBytes, &header); err != nil {
return Header{}, nil, fmt.Errorf("decode token header: %w", err)
}
if header.Algorithm != AlgorithmEdDSA || header.Type != expectedType || header.Version != SchemaVersion {
return Header{}, nil, errors.New("unsupported token header")
}
encodedKey, ok := keys[header.KeyID]
if !ok {
return Header{}, nil, fmt.Errorf("token is signed by unknown key %q", header.KeyID)
}
publicKey, err := DecodePublicKey(encodedKey)
if err != nil {
return Header{}, nil, fmt.Errorf("decode trusted key %q: %w", header.KeyID, err)
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
return Header{}, nil, errors.New("token signature is not valid base64url")
}
signingInput := parts[0] + "." + parts[1]
if !ed25519.Verify(publicKey, []byte(signingInput), signature) {
return Header{}, nil, errors.New("token signature verification failed")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Header{}, nil, errors.New("token payload is not valid base64url")
}
return header, payload, nil
}
func ValidateLicenseContext(claims Claims, product, baseURL, instanceID string) error {
if strings.TrimSpace(product) == "" {
return errors.New("client product id is required")
}
if claims.Product != product {
return fmt.Errorf("license is for product %q, not %q", claims.Product, product)
}
if err := ValidateDomain(claims.Domains, baseURL); err != nil {
return err
}
if len(claims.InstanceIDs) > 0 {
instanceID = strings.TrimSpace(instanceID)
if instanceID == "" {
return errors.New("license requires an instance id")
}
allowed := false
for _, candidate := range claims.InstanceIDs {
if candidate == "*" || candidate == instanceID {
allowed = true
break
}
}
if !allowed {
return fmt.Errorf("instance %q is not covered by the license", instanceID)
}
}
return nil
}
func ValidateLeaseContext(claims LeaseClaims, license Claims, product, baseURL, instanceID string) error {
if claims.LicenseID != license.LicenseID {
return errors.New("lease does not belong to the configured license")
}
if claims.Product != product || claims.Product != license.Product {
return errors.New("lease product does not match")
}
host, err := HostFromBaseURL(baseURL)
if err != nil {
return err
}
if claims.Host != "" && !strings.EqualFold(claims.Host, host) {
return errors.New("lease host does not match")
}
if claims.InstanceID != "" && claims.InstanceID != instanceID {
return errors.New("lease instance does not match")
}
return nil
}
func ValidateDomain(domains []string, baseURL string) error {
if len(domains) == 0 {
return nil
}
host, err := HostFromBaseURL(baseURL)
if err != nil {
return err
}
for _, allowed := range domains {
allowed = strings.ToLower(strings.TrimSpace(allowed))
if allowed == "*" || host == allowed {
return nil
}
if strings.HasPrefix(allowed, "*.") {
root := strings.TrimPrefix(allowed, "*.")
if host != root && strings.HasSuffix(host, "."+root) {
return nil
}
}
}
return fmt.Errorf("host %q is not covered by the license", host)
}
func HostFromBaseURL(baseURL string) (string, error) {
u, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil || u.Hostname() == "" {
return "", errors.New("base URL has no valid host")
}
return strings.ToLower(u.Hostname()), nil
}
func StricterMode(a, b VerificationMode) VerificationMode {
rank := map[VerificationMode]int{ModeOffline: 0, ModeHybrid: 1, ModeOnline: 2}
if rank[b] > rank[a] {
return b
}
if _, ok := rank[a]; !ok {
return ModeOffline
}
return a
}
func ParseMode(value string) (VerificationMode, error) {
mode := VerificationMode(strings.ToLower(strings.TrimSpace(value)))
switch mode {
case "", ModeOffline:
return ModeOffline, nil
case ModeHybrid, ModeOnline:
return mode, nil
default:
return "", fmt.Errorf("unknown verification mode %q", value)
}
}
func DecodePublicKey(encoded string) (ed25519.PublicKey, error) {
b, err := decodeKey(encoded)
if err != nil {
return nil, err
}
if len(b) != ed25519.PublicKeySize {
return nil, errors.New("public key must contain an Ed25519 public key")
}
return ed25519.PublicKey(b), nil
}
func decodeKey(value string) ([]byte, error) {
value = strings.TrimSpace(value)
if b, err := base64.RawURLEncoding.DecodeString(value); err == nil {
return b, nil
}
if b, err := base64.StdEncoding.DecodeString(value); err == nil {
return b, nil
}
return nil, errors.New("key is not valid base64")
}
func UniqueSorted(values []string) []string {
seen := map[string]bool{}
out := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" && !seen[value] {
seen[value] = true
out = append(out, value)
}
}
sort.Strings(out)
return out
}
func validateLicenseClaims(c Claims, now time.Time) error {
if c.Version != SchemaVersion {
return errors.New("unsupported license version")
}
if strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Issuer) == "" {
return errors.New("license id and issuer are required")
}
if strings.TrimSpace(c.Customer) == "" || strings.TrimSpace(c.Product) == "" || strings.TrimSpace(c.Edition) == "" {
return errors.New("customer, product and edition are required")
}
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
return errors.New("license timestamps are invalid")
}
notBefore := c.NotBefore
if notBefore == 0 {
notBefore = c.IssuedAt
}
if now.Unix() < notBefore-300 {
return errors.New("license is not active yet")
}
if now.Unix() >= c.ExpiresAt {
return errors.New("license has expired")
}
if _, err := ParseMode(string(c.Verification.Mode)); err != nil {
return err
}
if c.Verification.LeaseTTLSeconds < 0 || c.Verification.OfflineGraceSeconds < 0 {
return errors.New("verification durations cannot be negative")
}
if raw := strings.TrimSpace(c.Verification.ServerURL); raw != "" {
parsed, err := url.Parse(raw)
if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return errors.New("verification server URL must be an absolute HTTP(S) URL")
}
}
return nil
}
func validateLeaseClaims(c LeaseClaims, now time.Time) error {
if c.Version != SchemaVersion {
return errors.New("unsupported lease version")
}
if strings.TrimSpace(c.LeaseID) == "" || strings.TrimSpace(c.LicenseID) == "" || strings.TrimSpace(c.Product) == "" {
return errors.New("lease id, license id and product are required")
}
if c.IssuedAt <= 0 || c.ExpiresAt <= c.IssuedAt {
return errors.New("lease timestamps are invalid")
}
if now.Unix() < c.IssuedAt-300 {
return errors.New("lease is not active yet")
}
return nil
}
func decodeStrict(data []byte, target any) error {
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return err
}
return nil
}
@@ -0,0 +1,35 @@
package licensekit
import (
"strings"
"testing"
"time"
)
const (
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
)
func TestVerifyUniversalPlatformToken(t *testing.T) {
store := NewTrustStore()
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
verified, err := VerifyLicense(store, fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if verified.Claims.Product != "ai-disclosure-standard" || verified.Claims.Edition != "pro" {
t.Fatalf("unexpected claims: %#v", verified.Claims)
}
if err := ValidateLicenseContext(verified.Claims, "ai-disclosure-standard", "https://any-domain.example", ""); err != nil {
t.Fatalf("global domain wildcard should be accepted: %v", err)
}
}
func TestUnknownIssuerKeyIsRejected(t *testing.T) {
_, err := VerifyLicense(NewTrustStore(), fixtureOfflineToken, time.Date(2026, 7, 20, 0, 0, 0, 0, time.UTC))
if err == nil || !strings.Contains(err.Error(), "unknown key") {
t.Fatalf("expected unknown-key error, got %v", err)
}
}
@@ -0,0 +1,439 @@
package licenseclient
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
type Status struct {
Edition string `json:"edition"`
Licensed bool `json:"licensed"`
LicenseID string `json:"licenseId,omitempty"`
Customer string `json:"customer,omitempty"`
Product string `json:"product,omitempty"`
Features []string `json:"features"`
Limits map[string]int64 `json:"limits,omitempty"`
ExpiresAt string `json:"expiresAt,omitempty"`
Mode string `json:"mode,omitempty"`
Source string `json:"source,omitempty"`
LastChecked string `json:"lastChecked,omitempty"`
LeaseExpires string `json:"leaseExpiresAt,omitempty"`
Reason string `json:"reason,omitempty"`
ServerURL string `json:"serverUrl,omitempty"`
}
type Config struct {
Product string
Token string
TrustStore licensekit.TrustStore
BaseURL string
InstanceID string
Mode licensekit.VerificationMode
ServerURL string
CacheFile string
RefreshEvery time.Duration
RequestTimeout time.Duration
ClientVersion string
HTTPClient *http.Client
}
type Client struct {
cfg Config
mu sync.RWMutex
status Status
claims licensekit.Claims
features map[string]bool
stopOnce sync.Once
stop chan struct{}
}
type introspectRequest struct {
Token string `json:"token"`
Product string `json:"product"`
BaseURL string `json:"baseUrl"`
Host string `json:"host"`
InstanceID string `json:"instanceId,omitempty"`
ClientVersion string `json:"clientVersion,omitempty"`
}
type introspectResponse struct {
Valid bool `json:"valid"`
LeaseToken string `json:"leaseToken,omitempty"`
Reason string `json:"reason,omitempty"`
}
type cacheDocument struct {
LicenseID string `json:"licenseId"`
Lease string `json:"lease"`
SavedAt int64 `json:"savedAt"`
}
func New(ctx context.Context, cfg Config) *Client {
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(cfg.ServerURL), "/")
if cfg.ServerURL == "" {
cfg.ServerURL = strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/")
}
if cfg.RefreshEvery <= 0 {
cfg.RefreshEvery = 15 * time.Minute
}
if cfg.RequestTimeout <= 0 {
cfg.RequestTimeout = 5 * time.Second
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: cfg.RequestTimeout}
}
c := &Client{cfg: cfg, stop: make(chan struct{}), status: communityStatus(), features: map[string]bool{}}
c.refresh(ctx)
return c
}
func (c *Client) Start(ctx context.Context) {
c.mu.RLock()
mode := c.status.Mode
c.mu.RUnlock()
if mode == string(licensekit.ModeOffline) || strings.TrimSpace(c.cfg.Token) == "" {
return
}
go func() {
ticker := time.NewTicker(c.cfg.RefreshEvery)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-c.stop:
return
case <-ticker.C:
refreshCtx, cancel := context.WithTimeout(context.Background(), c.cfg.RequestTimeout)
c.refresh(refreshCtx)
cancel()
}
}
}()
}
func (c *Client) Close() { c.stopOnce.Do(func() { close(c.stop) }) }
func (c *Client) Refresh(ctx context.Context) Status {
c.refresh(ctx)
return c.Status()
}
func (c *Client) Has(feature string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.isCurrentlyLicensedLocked(time.Now().UTC()) && c.features[feature]
}
func (c *Client) Limit(name string) (int64, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
return 0, false
}
value, ok := c.status.Limits[name]
return value, ok
}
func (c *Client) Status() Status {
c.mu.RLock()
defer c.mu.RUnlock()
out := c.status
if out.Licensed && !c.isCurrentlyLicensedLocked(time.Now().UTC()) {
out.Licensed = false
out.Edition = "community"
if out.Reason == "" {
out.Reason = "license or online lease is no longer valid"
}
}
out.Features = append([]string{}, c.status.Features...)
if c.status.Limits != nil {
out.Limits = make(map[string]int64, len(c.status.Limits))
for key, value := range c.status.Limits {
out.Limits[key] = value
}
}
return out
}
func (c *Client) isCurrentlyLicensedLocked(now time.Time) bool {
if !c.status.Licensed {
return false
}
if c.claims.ExpiresAt > 0 && now.Unix() >= c.claims.ExpiresAt {
return false
}
mode := licensekit.VerificationMode(c.status.Mode)
if mode == licensekit.ModeOffline || c.status.LeaseExpires == "" {
return true
}
leaseExpiry, err := time.Parse(time.RFC3339, c.status.LeaseExpires)
if err != nil {
return false
}
if mode == licensekit.ModeHybrid {
leaseExpiry = leaseExpiry.Add(time.Duration(c.claims.Verification.OfflineGraceSeconds) * time.Second)
}
return now.Before(leaseExpiry)
}
func (c *Client) refresh(ctx context.Context) {
now := time.Now().UTC()
if strings.TrimSpace(c.cfg.Token) == "" {
c.apply(communityStatus(), licensekit.Claims{})
return
}
verified, err := licensekit.VerifyLicense(c.cfg.TrustStore, c.cfg.Token, now)
if err != nil {
c.apply(failedStatus(err.Error(), now), licensekit.Claims{})
return
}
claims := verified.Claims
if err := licensekit.ValidateLicenseContext(claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
c.apply(failedStatus(err.Error(), now), claims)
return
}
mode := licensekit.StricterMode(claims.Verification.Mode, c.cfg.Mode)
base := statusFromClaims(claims, mode, now)
if mode == licensekit.ModeOffline {
base.Licensed = true
base.Source = "offline"
c.apply(base, claims)
return
}
serverURL := c.resolveServerURL(ctx, claims)
base.ServerURL = serverURL
if serverURL == "" {
base.Edition = "community"
base.Reason = "online verification is required but no license server URL could be resolved"
c.apply(base, claims)
return
}
lease, source, err := c.obtainLease(ctx, claims, mode, now, serverURL)
if err != nil {
base.Edition = "community"
base.Reason = err.Error()
c.apply(base, claims)
return
}
base.Licensed = true
base.Source = source
base.LeaseExpires = time.Unix(lease.ExpiresAt, 0).UTC().Format(time.RFC3339)
base.Features = licensekit.UniqueSorted(intersection(claims.Features, lease.Features))
c.apply(base, claims)
}
func (c *Client) obtainLease(ctx context.Context, claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time, serverURL string) (licensekit.LeaseClaims, string, error) {
leaseToken, err := c.requestLease(ctx, serverURL)
if err == nil {
lease, verifyErr := licensekit.VerifyLease(c.cfg.TrustStore, leaseToken, now, 0)
if verifyErr != nil {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online lease verification failed: %w", verifyErr)
}
if verifyErr = licensekit.ValidateLeaseContext(lease.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); verifyErr != nil {
return licensekit.LeaseClaims{}, "", verifyErr
}
_ = c.writeCache(claims.LicenseID, leaseToken)
return lease.Claims, "online", nil
}
if mode == licensekit.ModeOnline {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed: %w", err)
}
cached, cacheErr := c.readCache(claims, now)
if cacheErr != nil {
return licensekit.LeaseClaims{}, "", fmt.Errorf("online verification failed (%v) and no usable cached lease exists (%v)", err, cacheErr)
}
return cached, "cached-lease", nil
}
func (c *Client) resolveServerURL(ctx context.Context, claims licensekit.Claims) string {
if value := strings.TrimRight(strings.TrimSpace(c.cfg.ServerURL), "/"); value != "" {
return value
}
if value := strings.TrimRight(strings.TrimSpace(os.Getenv("LICENSE_SERVER_URL")), "/"); value != "" {
return value
}
if value := strings.TrimRight(strings.TrimSpace(claims.Verification.ServerURL), "/"); value != "" {
return value
}
base := strings.TrimRight(strings.TrimSpace(c.cfg.BaseURL), "/")
if base == "" {
return ""
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, base+"/.well-known/license-server", nil)
if err != nil {
return ""
}
req.Header.Set("Accept", "application/json")
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
var discovery struct {
ServerURL string `json:"serverUrl"`
}
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&discovery); err != nil {
return ""
}
return strings.TrimRight(strings.TrimSpace(discovery.ServerURL), "/")
}
func (c *Client) requestLease(ctx context.Context, serverURL string) (string, error) {
host, err := licensekit.HostFromBaseURL(c.cfg.BaseURL)
if err != nil {
return "", err
}
body, err := json.Marshal(introspectRequest{Token: c.cfg.Token, Product: c.cfg.Product, BaseURL: c.cfg.BaseURL, Host: host, InstanceID: c.cfg.InstanceID, ClientVersion: c.cfg.ClientVersion})
if err != nil {
return "", err
}
paths := []string{"/api/v1/licenses/validate", "/v1/introspect"}
var lastErr error
for index, path := range paths {
token, status, err := c.requestLeaseAt(ctx, strings.TrimRight(serverURL, "/")+path, body)
if err == nil {
return token, nil
}
lastErr = err
if index == 0 && status == http.StatusNotFound {
continue
}
break
}
return "", lastErr
}
func (c *Client) requestLeaseAt(ctx context.Context, endpoint string, body []byte) (string, int, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.cfg.HTTPClient.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", resp.StatusCode, err
}
var result introspectResponse
if err := json.Unmarshal(payload, &result); err != nil {
return "", resp.StatusCode, fmt.Errorf("decode verification response: %w", err)
}
if resp.StatusCode != http.StatusOK || !result.Valid || result.LeaseToken == "" {
if result.Reason == "" {
result.Reason = resp.Status
}
return "", resp.StatusCode, errors.New(result.Reason)
}
return result.LeaseToken, resp.StatusCode, nil
}
func (c *Client) readCache(claims licensekit.Claims, now time.Time) (licensekit.LeaseClaims, error) {
if strings.TrimSpace(c.cfg.CacheFile) == "" {
return licensekit.LeaseClaims{}, errors.New("cache file is not configured")
}
data, err := os.ReadFile(c.cfg.CacheFile)
if err != nil {
return licensekit.LeaseClaims{}, err
}
var doc cacheDocument
if err := json.Unmarshal(data, &doc); err != nil {
return licensekit.LeaseClaims{}, err
}
if doc.LicenseID != claims.LicenseID {
return licensekit.LeaseClaims{}, errors.New("cached lease belongs to another license")
}
grace := time.Duration(claims.Verification.OfflineGraceSeconds) * time.Second
verified, err := licensekit.VerifyLease(c.cfg.TrustStore, doc.Lease, now, grace)
if err != nil {
return licensekit.LeaseClaims{}, err
}
if err := licensekit.ValidateLeaseContext(verified.Claims, claims, c.cfg.Product, c.cfg.BaseURL, c.cfg.InstanceID); err != nil {
return licensekit.LeaseClaims{}, err
}
return verified.Claims, nil
}
func (c *Client) writeCache(licenseID, lease string) error {
if strings.TrimSpace(c.cfg.CacheFile) == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(c.cfg.CacheFile), 0o700); err != nil && filepath.Dir(c.cfg.CacheFile) != "." {
return err
}
data, err := json.Marshal(cacheDocument{LicenseID: licenseID, Lease: lease, SavedAt: time.Now().UTC().Unix()})
if err != nil {
return err
}
temp := c.cfg.CacheFile + ".tmp"
if err := os.WriteFile(temp, data, 0o600); err != nil {
return err
}
return os.Rename(temp, c.cfg.CacheFile)
}
func (c *Client) apply(status Status, claims licensekit.Claims) {
status.Features = licensekit.UniqueSorted(status.Features)
c.mu.Lock()
defer c.mu.Unlock()
c.status = status
c.claims = claims
c.features = make(map[string]bool, len(status.Features))
for _, feature := range status.Features {
c.features[feature] = true
}
}
func communityStatus() Status {
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}}
}
func failedStatus(reason string, now time.Time) Status {
return Status{Edition: "community", Features: []string{}, Limits: map[string]int64{}, Reason: reason, LastChecked: now.Format(time.RFC3339)}
}
func statusFromClaims(claims licensekit.Claims, mode licensekit.VerificationMode, now time.Time) Status {
limits := map[string]int64{}
for key, value := range claims.Limits {
limits[key] = value
}
return Status{
Edition: claims.Edition, LicenseID: claims.LicenseID, Customer: claims.Customer, Product: claims.Product,
Features: append([]string(nil), claims.Features...), Limits: limits,
ExpiresAt: time.Unix(claims.ExpiresAt, 0).UTC().Format(time.RFC3339), Mode: string(mode), LastChecked: now.Format(time.RFC3339),
}
}
func intersection(a, b []string) []string {
allowed := make(map[string]bool, len(b))
for _, value := range b {
allowed[value] = true
}
out := make([]string, 0, len(a))
for _, value := range a {
if allowed[value] {
out = append(out, value)
}
}
return out
}
@@ -0,0 +1,64 @@
package licenseclient
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/b1tsblog/license-platform/pkg/licensekit"
)
const (
fixtureIssuerPublic = "ebVWLo_mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ"
fixtureLeasePublic = "5_FioQvsVZr-oZXk3OhLaVaNXSywlj60RsBoXisX8vA"
fixtureOfflineToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfb2ZmbGluZV9maXh0dXJlIiwiaXNzdWVyIjoidW5pdmVyc2FsLWxpY2Vuc2UtcGxhdGZvcm0iLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJwcm9kdWN0IjoiYWktZGlzY2xvc3VyZS1zdGFuZGFyZCIsImVkaXRpb24iOiJwcm8iLCJmZWF0dXJlcyI6WyJjdXN0b21fdGV4dCIsImN1c3RvbV9iYWRnZSJdLCJsaW1pdHMiOnsic2l0ZXMiOjEwfSwiZG9tYWlucyI6WyIqIl0sImlzc3VlZEF0IjoxNzY3MjI1NjAwLCJleHBpcmVzQXQiOjI1MjQ2MDgwMDAsInZlcmlmaWNhdGlvbiI6eyJtb2RlIjoib2ZmbGluZSJ9fQ.xrJiWqCjUQuiu9DWQrmyx-f96rkz3wvbau6IN-z2EyxZwqXVtuICGpEcUN4JEpU4uDMagQpr89boeGMvGs38Cg"
fixtureHybridToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxJQ0VOU0UiLCJraWQiOiJpc3N1ZXItZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsaWNlbnNlSWQiOiJsaWNfcGxhdGZvcm1fZml4dHVyZSIsImlzc3VlciI6InVuaXZlcnNhbC1saWNlbnNlLXBsYXRmb3JtIiwiY3VzdG9tZXIiOiJGaXh0dXJlIEN1c3RvbWVyIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwibGltaXRzIjp7InNpdGVzIjoxMH0sImRvbWFpbnMiOlsiKiJdLCJpc3N1ZWRBdCI6MTc2NzIyNTYwMCwiZXhwaXJlc0F0IjoyNTI0NjA4MDAwLCJ2ZXJpZmljYXRpb24iOnsibW9kZSI6Imh5YnJpZCIsImxlYXNlVHRsU2Vjb25kcyI6MzYwMCwib2ZmbGluZUdyYWNlU2Vjb25kcyI6NzIwMCwic2VydmVyVXJsIjoiaHR0cHM6Ly9saWNlbnNlcy5leGFtcGxlLnRlc3QifX0.Jvb6NPFnZZgH0KxpEsjsVyn6kjBkk3cce1kOWrNPZmD5qA35_9Prgt1f_2RmtOtaXTp7j6ISNeYGAetpsHIQDw"
fixtureLeaseToken = "eyJhbGciOiJFZERTQSIsInR5cCI6IkxFQVNFIiwia2lkIjoibGVhc2UtZml4dHVyZS0xIiwidiI6MX0.eyJ2ZXJzaW9uIjoxLCJsZWFzZUlkIjoibGVhc2VfcGxhdGZvcm1fZml4dHVyZSIsImxpY2Vuc2VJZCI6ImxpY19wbGF0Zm9ybV9maXh0dXJlIiwicHJvZHVjdCI6ImFpLWRpc2Nsb3N1cmUtc3RhbmRhcmQiLCJjdXN0b21lciI6IkZpeHR1cmUgQ3VzdG9tZXIiLCJlZGl0aW9uIjoicHJvIiwiZmVhdHVyZXMiOlsiY3VzdG9tX3RleHQiLCJjdXN0b21fYmFkZ2UiXSwiaG9zdCI6ImV4YW1wbGUub3JnIiwiaXNzdWVkQXQiOjE3NjcyMjU2MDAsImV4cGlyZXNBdCI6MjUyNDYwODAwMH0.NwWSLoJVwY1X9WGobZu2edPAhmUhjHq8QZkCwpN2CTCqxaqWQjs1suC3mFxsjd5EG2wODr-PQh2duZ4qN4mdCA"
)
func fixtureTrustStore() licensekit.TrustStore {
store := licensekit.NewTrustStore()
store.LicenseKeys["issuer-fixture-1"] = fixtureIssuerPublic
store.LeaseKeys["lease-fixture-1"] = fixtureLeasePublic
return store
}
func TestOfflinePlatformLicense(t *testing.T) {
client := New(context.Background(), Config{
Product: "ai-disclosure-standard",
Token: fixtureOfflineToken,
TrustStore: fixtureTrustStore(),
BaseURL: "https://example.org",
Mode: licensekit.ModeOffline,
})
if !client.Status().Licensed || !client.Has("custom_text") {
t.Fatalf("unexpected status: %#v", client.Status())
}
}
func TestHybridUsesPlatformValidateEndpoint(t *testing.T) {
var requestedPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"valid": true, "leaseToken": fixtureLeaseToken})
}))
defer server.Close()
client := New(context.Background(), Config{
Product: "ai-disclosure-standard",
Token: fixtureHybridToken,
TrustStore: fixtureTrustStore(),
BaseURL: "https://example.org",
Mode: licensekit.ModeHybrid,
ServerURL: server.URL,
})
if !client.Status().Licensed || client.Status().Source != "online" {
t.Fatalf("unexpected status: %#v", client.Status())
}
if requestedPath != "/api/v1/licenses/validate" {
t.Fatalf("unexpected endpoint %q", requestedPath)
}
}
@@ -0,0 +1,4 @@
// Package licenseclient implements the runtime side of product licensing. It
// supports offline verification, hybrid signed-lease caching and mandatory
// online introspection without allowing customers to replace trusted keys.
package licenseclient