mirror of
https://github.com/luxfi/license.git
synced 2026-10-30 16:21:14 +00:00
Modules declare go 1.27.1 and the build images follow. go fix applied the modernizers: slices.Contains and range over int. omitzero is not applied, since it changes what the JSON says. Co-authored-by: Hanzo Dev <[email protected]>
261 lines
6.8 KiB
Go
261 lines
6.8 KiB
Go
// Copyright (C) 2020-2026, Lux Industries Inc. All rights reserved.
|
|
// See the file LICENSE for licensing terms.
|
|
|
|
package license
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// Claims is the entitlement set carried inside a license token.
|
|
//
|
|
// Wire layout (big-endian, length-prefixed, deterministic field order):
|
|
//
|
|
// version uint8 (=1)
|
|
// customer_id str (uint16-len-prefixed utf-8)
|
|
// license_id str
|
|
// issued_at int64 (unix seconds)
|
|
// expiry int64 (unix seconds; 0 = never expires)
|
|
// scope_count uint16
|
|
// scope[i] str (sorted ascending, no duplicates)
|
|
// feat_count uint16
|
|
// feat[i].key str (sorted ascending)
|
|
// feat[i].val str (free-form; integers/bools are encoded as decimal/"true"/"false")
|
|
// notes str
|
|
//
|
|
// The encoding is deterministic so that a token round-trips byte-for-byte and
|
|
// can be re-verified against its embedded signature.
|
|
type Claims struct {
|
|
CustomerID string
|
|
LicenseID string
|
|
IssuedAt time.Time
|
|
Expiry time.Time // zero value = no expiry
|
|
Scope []string
|
|
Features map[string]string
|
|
Notes string
|
|
}
|
|
|
|
const claimsVersion uint8 = 1
|
|
|
|
// HasScope returns true if scope is among the claim's authorized scopes.
|
|
func (c *Claims) HasScope(scope string) bool {
|
|
return slices.Contains(c.Scope, scope)
|
|
}
|
|
|
|
// Expired reports whether the claim's Expiry is non-zero and in the past
|
|
// relative to now.
|
|
func (c *Claims) Expired(now time.Time) bool {
|
|
return !c.Expiry.IsZero() && now.After(c.Expiry)
|
|
}
|
|
|
|
// Encode serialises the claims into the deterministic wire format used for
|
|
// signing and verification.
|
|
func (c *Claims) Encode() ([]byte, error) {
|
|
if c.CustomerID == "" {
|
|
return nil, errors.New("license: claims.CustomerID is required")
|
|
}
|
|
if c.LicenseID == "" {
|
|
return nil, errors.New("license: claims.LicenseID is required")
|
|
}
|
|
if c.IssuedAt.IsZero() {
|
|
return nil, errors.New("license: claims.IssuedAt is required")
|
|
}
|
|
|
|
// Sort scopes and features to keep the encoding deterministic.
|
|
scopes := append([]string(nil), c.Scope...)
|
|
sort.Strings(scopes)
|
|
for i := 1; i < len(scopes); i++ {
|
|
if scopes[i] == scopes[i-1] {
|
|
return nil, fmt.Errorf("license: duplicate scope %q", scopes[i])
|
|
}
|
|
}
|
|
|
|
featKeys := make([]string, 0, len(c.Features))
|
|
for k := range c.Features {
|
|
featKeys = append(featKeys, k)
|
|
}
|
|
sort.Strings(featKeys)
|
|
|
|
w := newEncoder()
|
|
w.writeUint8(claimsVersion)
|
|
w.writeString(c.CustomerID)
|
|
w.writeString(c.LicenseID)
|
|
w.writeInt64(c.IssuedAt.Unix())
|
|
if c.Expiry.IsZero() {
|
|
w.writeInt64(0)
|
|
} else {
|
|
w.writeInt64(c.Expiry.Unix())
|
|
}
|
|
|
|
if len(scopes) > 0xFFFF {
|
|
return nil, errors.New("license: too many scopes (max 65535)")
|
|
}
|
|
w.writeUint16(uint16(len(scopes)))
|
|
for _, s := range scopes {
|
|
w.writeString(s)
|
|
}
|
|
|
|
if len(featKeys) > 0xFFFF {
|
|
return nil, errors.New("license: too many features (max 65535)")
|
|
}
|
|
w.writeUint16(uint16(len(featKeys)))
|
|
for _, k := range featKeys {
|
|
w.writeString(k)
|
|
w.writeString(c.Features[k])
|
|
}
|
|
w.writeString(c.Notes)
|
|
|
|
return w.bytes(), nil
|
|
}
|
|
|
|
// DecodeClaims parses the binary claims payload (without the surrounding
|
|
// signature framing). Use ParseToken / VerifyToken for the framed, signed form.
|
|
func DecodeClaims(b []byte) (*Claims, error) {
|
|
r := newDecoder(b)
|
|
v, err := r.readUint8()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read version: %w", err)
|
|
}
|
|
if v != claimsVersion {
|
|
return nil, fmt.Errorf("license: unsupported claims version %d", v)
|
|
}
|
|
|
|
var c Claims
|
|
if c.CustomerID, err = r.readString(); err != nil {
|
|
return nil, fmt.Errorf("license: read customer_id: %w", err)
|
|
}
|
|
if c.LicenseID, err = r.readString(); err != nil {
|
|
return nil, fmt.Errorf("license: read license_id: %w", err)
|
|
}
|
|
iat, err := r.readInt64()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read issued_at: %w", err)
|
|
}
|
|
c.IssuedAt = time.Unix(iat, 0).UTC()
|
|
|
|
exp, err := r.readInt64()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read expiry: %w", err)
|
|
}
|
|
if exp != 0 {
|
|
c.Expiry = time.Unix(exp, 0).UTC()
|
|
}
|
|
|
|
scopeCount, err := r.readUint16()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read scope_count: %w", err)
|
|
}
|
|
c.Scope = make([]string, 0, scopeCount)
|
|
for i := range scopeCount {
|
|
s, err := r.readString()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read scope[%d]: %w", i, err)
|
|
}
|
|
c.Scope = append(c.Scope, s)
|
|
}
|
|
|
|
featCount, err := r.readUint16()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read feat_count: %w", err)
|
|
}
|
|
if featCount > 0 {
|
|
c.Features = make(map[string]string, featCount)
|
|
}
|
|
for i := range featCount {
|
|
k, err := r.readString()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read feat[%d].key: %w", i, err)
|
|
}
|
|
v, err := r.readString()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("license: read feat[%d].val: %w", i, err)
|
|
}
|
|
c.Features[k] = v
|
|
}
|
|
|
|
if c.Notes, err = r.readString(); err != nil {
|
|
return nil, fmt.Errorf("license: read notes: %w", err)
|
|
}
|
|
if r.remaining() != 0 {
|
|
return nil, fmt.Errorf("license: trailing %d bytes after claims", r.remaining())
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// internal encoder / decoder (length-prefixed primitives, big-endian)
|
|
|
|
type encoder struct{ buf []byte }
|
|
|
|
func newEncoder() *encoder { return &encoder{buf: make([]byte, 0, 256)} }
|
|
|
|
func (e *encoder) bytes() []byte { return e.buf }
|
|
|
|
func (e *encoder) writeUint8(v uint8) { e.buf = append(e.buf, v) }
|
|
func (e *encoder) writeUint16(v uint16) { e.buf = binary.BigEndian.AppendUint16(e.buf, v) }
|
|
func (e *encoder) writeInt64(v int64) { e.buf = binary.BigEndian.AppendUint64(e.buf, uint64(v)) }
|
|
|
|
func (e *encoder) writeString(s string) {
|
|
if len(s) > 0xFFFF {
|
|
// Caller responsibility; truncate would be silent corruption.
|
|
panic(fmt.Sprintf("license: string too long (%d bytes, max 65535)", len(s)))
|
|
}
|
|
e.buf = binary.BigEndian.AppendUint16(e.buf, uint16(len(s)))
|
|
e.buf = append(e.buf, s...)
|
|
}
|
|
|
|
type decoder struct {
|
|
buf []byte
|
|
pos int
|
|
}
|
|
|
|
func newDecoder(b []byte) *decoder { return &decoder{buf: b} }
|
|
|
|
func (d *decoder) remaining() int { return len(d.buf) - d.pos }
|
|
|
|
func (d *decoder) readUint8() (uint8, error) {
|
|
if d.remaining() < 1 {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
v := d.buf[d.pos]
|
|
d.pos++
|
|
return v, nil
|
|
}
|
|
|
|
func (d *decoder) readUint16() (uint16, error) {
|
|
if d.remaining() < 2 {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
v := binary.BigEndian.Uint16(d.buf[d.pos:])
|
|
d.pos += 2
|
|
return v, nil
|
|
}
|
|
|
|
func (d *decoder) readInt64() (int64, error) {
|
|
if d.remaining() < 8 {
|
|
return 0, io.ErrUnexpectedEOF
|
|
}
|
|
v := int64(binary.BigEndian.Uint64(d.buf[d.pos:]))
|
|
d.pos += 8
|
|
return v, nil
|
|
}
|
|
|
|
func (d *decoder) readString() (string, error) {
|
|
l, err := d.readUint16()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if d.remaining() < int(l) {
|
|
return "", io.ErrUnexpectedEOF
|
|
}
|
|
s := string(d.buf[d.pos : d.pos+int(l)])
|
|
d.pos += int(l)
|
|
return s, nil
|
|
}
|