Initial Go SDK for Keysat licensing service
Pure-Go, stdlib-only implementation of the LIC1 wire format: - ParseKey + Verify + ParseAndVerify for offline verification - HashFingerprint helper (SHA-256, matching the daemon's contract) - LoadPublicKeyPEM for the standard PKIX-encoded Ed25519 public keys the daemon emits - Client.Validate / Client.PublicKey for online checks against a running Keysat daemon - LicensePayload struct with idiomatic Go getters (IsTrial, IsFingerprintBound, IsExpiredAt, HasEntitlement) Wire-format crosscheck against the shared tests/crosscheck/vector.json (the same file the Rust, TypeScript, Python SDKs and the daemon itself test against). All four fixtures pass — v1 legacy fingerprint-bound, v2 trial with entitlements, v2 perpetual unbound, plus end-to-end PEM-load → ParseAndVerify signature roundtrip. Confirms byte-for-byte agreement across five independent implementations. No third-party dependencies. Module path: github.com/keysat-xyz/keysat-client-go go 1.21
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
# Build artifacts
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
/coverage.txt
|
||||||
|
/dist/
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Keysat
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# keysat-client-go
|
||||||
|
|
||||||
|
Go SDK for [Keysat](https://keysat.xyz) — a self-hosted, Bitcoin-paid software licensing service.
|
||||||
|
|
||||||
|
Verifies LIC1-format license keys offline against an Ed25519 public key, and optionally validates them online against a running Keysat daemon.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go get github.com/keysat-xyz/keysat-client-go
|
||||||
|
```
|
||||||
|
|
||||||
|
Stdlib only — no third-party dependencies.
|
||||||
|
|
||||||
|
## Offline verification
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/keysat-xyz/keysat-client-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Embed the daemon's PEM public key at build time. Get it from your
|
||||||
|
// Keysat admin UI or `curl https://your-keysat.example/v1/pubkey`.
|
||||||
|
const publicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEA...
|
||||||
|
-----END PUBLIC KEY-----`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pub, err := keysat.LoadPublicKeyPEM(publicKeyPEM)
|
||||||
|
if err != nil { log.Fatal(err) }
|
||||||
|
|
||||||
|
licenseKey := readKeyFromUserConfig() // however your app stores it
|
||||||
|
|
||||||
|
payload, err := keysat.ParseAndVerify(licenseKey, pub)
|
||||||
|
if err != nil { log.Fatalf("license invalid: %v", err) }
|
||||||
|
|
||||||
|
if payload.IsExpiredAt(time.Now().Unix()) {
|
||||||
|
log.Fatal("license expired")
|
||||||
|
}
|
||||||
|
if !payload.HasEntitlement("pro") {
|
||||||
|
log.Fatal("license does not include 'pro' tier")
|
||||||
|
}
|
||||||
|
fmt.Println("license OK")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Online validation (revocation, fingerprint binding, machine cap)
|
||||||
|
|
||||||
|
```go
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
c := keysat.NewClient("https://licensing.example.com", nil)
|
||||||
|
resp, err := c.Validate(ctx, keysat.ValidateRequest{
|
||||||
|
Key: licenseKey,
|
||||||
|
ProductSlug: "myapp",
|
||||||
|
Fingerprint: machineUUID,
|
||||||
|
})
|
||||||
|
if err != nil { log.Fatalf("daemon unreachable: %v", err) }
|
||||||
|
if !resp.OK {
|
||||||
|
log.Fatalf("license rejected: %s", resp.Reason)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Validate` returns HTTP 200 in all cases; license failures are conveyed via `resp.OK + resp.Reason` (`bad_signature`, `revoked`, `expired`, `too_many_machines`, etc.).
|
||||||
|
|
||||||
|
## Fingerprint binding
|
||||||
|
|
||||||
|
When a key is fingerprint-bound, the daemon's first successful online validation pins the machine's fingerprint hash to the license row. Subsequent validations from a different machine fail with `fingerprint_mismatch`.
|
||||||
|
|
||||||
|
The SDK exposes `keysat.HashFingerprint` if you need to compute the hash yourself (e.g., to compare against a key's `FingerprintHash` field offline):
|
||||||
|
|
||||||
|
```go
|
||||||
|
h := keysat.HashFingerprint(machineUUID)
|
||||||
|
if h != payload.FingerprintHash {
|
||||||
|
log.Fatal("license does not belong to this machine")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wire format compatibility
|
||||||
|
|
||||||
|
Every SDK + the daemon agree on the LIC1 wire format. Crosscheck tests in this package run against the shared `tests/crosscheck/vector.json` (alongside the daemon repo) — three independently-signed fixtures (v1 legacy, v2 trial with entitlements, v2 perpetual unbound) parse to the same field values across Rust, TypeScript, Python, and Go.
|
||||||
|
|
||||||
|
When fetched standalone via `go get`, the crosscheck test skips gracefully (the vector file isn't bundled into the Go module). The crosscheck only runs from the parent `licensing/` workspace.
|
||||||
|
|
||||||
|
## API stability
|
||||||
|
|
||||||
|
This SDK is alpha alongside Keysat v0.1.0. The wire format itself is stable and won't break compatibility — license keys issued by any v0.1 daemon will keep parsing in any future SDK. The Go API surface (function names, struct fields) may settle further before v1.0; nothing here is wildly out of line with idiomatic Go but expect minor tweaks.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see `LICENSE`.
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Wire-format crosscheck. Each fixture in the shared
|
||||||
|
// tests/crosscheck/vector.json was generated by Python's
|
||||||
|
// reference_signer.py (independent crypto), then is consumed by
|
||||||
|
// every Keysat SDK + the daemon. If this test fails, this Go SDK
|
||||||
|
// has drifted from the wire format the rest of the ecosystem
|
||||||
|
// agrees on.
|
||||||
|
//
|
||||||
|
// The path to vector.json assumes this package is checked out
|
||||||
|
// under the parent licensing/ workspace (next to
|
||||||
|
// licensing-client-rust, etc.). Skip the test gracefully if the
|
||||||
|
// vector isn't reachable — when this SDK is fetched standalone
|
||||||
|
// via `go get`, there's nothing to cross-check against.
|
||||||
|
|
||||||
|
package keysat_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/keysat-xyz/keysat-client-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fixture struct {
|
||||||
|
LicenseKey string `json:"licenseKey"`
|
||||||
|
Expected struct {
|
||||||
|
Version int `json:"version"`
|
||||||
|
ProductUUID string `json:"productUuid"`
|
||||||
|
LicenseUUID string `json:"licenseUuid"`
|
||||||
|
IssuedAt int64 `json:"issuedAt"`
|
||||||
|
ExpiresAt int64 `json:"expiresAt"`
|
||||||
|
Flags int `json:"flags"`
|
||||||
|
IsFingerprintBound bool `json:"isFingerprintBound"`
|
||||||
|
IsTrial bool `json:"isTrial"`
|
||||||
|
Entitlements []string `json:"entitlements"`
|
||||||
|
FingerprintRaw *string `json:"fingerprintRaw"`
|
||||||
|
FingerprintHashHex string `json:"fingerprintHashHex"`
|
||||||
|
} `json:"expected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type vectorFile struct {
|
||||||
|
PublicKeyPEM string `json:"publicKeyPem"`
|
||||||
|
V1 fixture `json:"v1"`
|
||||||
|
V2 fixture `json:"v2"`
|
||||||
|
V2PerpetualUnbound fixture `json:"v2_perpetual_unbound"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadVector(t *testing.T) vectorFile {
|
||||||
|
t.Helper()
|
||||||
|
candidates := []string{
|
||||||
|
"../tests/crosscheck/vector.json", // when this is a sibling of /tests
|
||||||
|
"../../tests/crosscheck/vector.json", // when nested one deeper
|
||||||
|
}
|
||||||
|
for _, c := range candidates {
|
||||||
|
abs, err := filepath.Abs(c)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
raw, err := os.ReadFile(abs)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading %s: %v", abs, err)
|
||||||
|
}
|
||||||
|
var v vectorFile
|
||||||
|
if err := json.Unmarshal(raw, &v); err != nil {
|
||||||
|
t.Fatalf("parsing %s: %v", abs, err)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
t.Skipf("crosscheck vector.json not found alongside this package " +
|
||||||
|
"(expected at ../tests/crosscheck/vector.json); skipping. " +
|
||||||
|
"This is normal when fetched standalone via `go get` — the " +
|
||||||
|
"crosscheck only runs from the parent licensing/ workspace.")
|
||||||
|
return vectorFile{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uuidString(b [16]byte) string {
|
||||||
|
// Render as canonical 8-4-4-4-12 lowercase hex.
|
||||||
|
hexStr := hex.EncodeToString(b[:])
|
||||||
|
return hexStr[0:8] + "-" + hexStr[8:12] + "-" + hexStr[12:16] + "-" +
|
||||||
|
hexStr[16:20] + "-" + hexStr[20:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkFixture(t *testing.T, name string, fx fixture) {
|
||||||
|
t.Helper()
|
||||||
|
payload, _, _, err := keysat.ParseKey(fx.LicenseKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s: ParseKey failed: %v", name, err)
|
||||||
|
}
|
||||||
|
if int(payload.Version) != fx.Expected.Version {
|
||||||
|
t.Errorf("%s: version = %d, want %d", name, payload.Version, fx.Expected.Version)
|
||||||
|
}
|
||||||
|
if uuidString(payload.ProductID) != fx.Expected.ProductUUID {
|
||||||
|
t.Errorf("%s: productID = %s, want %s", name, uuidString(payload.ProductID), fx.Expected.ProductUUID)
|
||||||
|
}
|
||||||
|
if uuidString(payload.LicenseID) != fx.Expected.LicenseUUID {
|
||||||
|
t.Errorf("%s: licenseID = %s, want %s", name, uuidString(payload.LicenseID), fx.Expected.LicenseUUID)
|
||||||
|
}
|
||||||
|
if payload.IssuedAt != fx.Expected.IssuedAt {
|
||||||
|
t.Errorf("%s: issuedAt = %d, want %d", name, payload.IssuedAt, fx.Expected.IssuedAt)
|
||||||
|
}
|
||||||
|
if payload.ExpiresAt != fx.Expected.ExpiresAt {
|
||||||
|
t.Errorf("%s: expiresAt = %d, want %d", name, payload.ExpiresAt, fx.Expected.ExpiresAt)
|
||||||
|
}
|
||||||
|
if int(payload.Flags) != fx.Expected.Flags {
|
||||||
|
t.Errorf("%s: flags = %d, want %d", name, payload.Flags, fx.Expected.Flags)
|
||||||
|
}
|
||||||
|
if payload.IsFingerprintBound() != fx.Expected.IsFingerprintBound {
|
||||||
|
t.Errorf("%s: IsFingerprintBound = %v, want %v", name, payload.IsFingerprintBound(), fx.Expected.IsFingerprintBound)
|
||||||
|
}
|
||||||
|
if payload.IsTrial() != fx.Expected.IsTrial {
|
||||||
|
t.Errorf("%s: IsTrial = %v, want %v", name, payload.IsTrial(), fx.Expected.IsTrial)
|
||||||
|
}
|
||||||
|
got := payload.Entitlements
|
||||||
|
if got == nil {
|
||||||
|
got = []string{}
|
||||||
|
}
|
||||||
|
want := fx.Expected.Entitlements
|
||||||
|
if want == nil {
|
||||||
|
want = []string{}
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Errorf("%s: entitlements len = %d, want %d", name, len(got), len(want))
|
||||||
|
} else {
|
||||||
|
for i := range got {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("%s: entitlements[%d] = %q, want %q", name, i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gotFP := hex.EncodeToString(payload.FingerprintHash[:])
|
||||||
|
if gotFP != fx.Expected.FingerprintHashHex {
|
||||||
|
t.Errorf("%s: fingerprintHash = %s, want %s", name, gotFP, fx.Expected.FingerprintHashHex)
|
||||||
|
}
|
||||||
|
// If a raw fingerprint is supplied, verify HashFingerprint reproduces the wire bytes.
|
||||||
|
if fx.Expected.FingerprintRaw != nil && fx.Expected.IsFingerprintBound {
|
||||||
|
h := keysat.HashFingerprint(*fx.Expected.FingerprintRaw)
|
||||||
|
if hex.EncodeToString(h[:]) != fx.Expected.FingerprintHashHex {
|
||||||
|
t.Errorf("%s: HashFingerprint(raw) does not match wire hash", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrosscheck_V1(t *testing.T) {
|
||||||
|
v := loadVector(t)
|
||||||
|
checkFixture(t, "v1", v.V1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrosscheck_V2_TrialWithEntitlements(t *testing.T) {
|
||||||
|
v := loadVector(t)
|
||||||
|
checkFixture(t, "v2", v.V2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCrosscheck_V2_PerpetualUnbound(t *testing.T) {
|
||||||
|
v := loadVector(t)
|
||||||
|
checkFixture(t, "v2_perpetual_unbound", v.V2PerpetualUnbound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exercise signature verification end-to-end: load the vector's
|
||||||
|
// public key, parse the v2 fixture, verify. Locks in the
|
||||||
|
// PEM → ed25519.PublicKey path on top of the parser.
|
||||||
|
func TestCrosscheck_V2_SignatureVerifies(t *testing.T) {
|
||||||
|
v := loadVector(t)
|
||||||
|
pub, err := keysat.LoadPublicKeyPEM(v.PublicKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadPublicKeyPEM: %v", err)
|
||||||
|
}
|
||||||
|
payload, err := keysat.ParseAndVerify(v.V2.LicenseKey, pub)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseAndVerify: %v", err)
|
||||||
|
}
|
||||||
|
if payload.Version != keysat.KeyVersionV2 {
|
||||||
|
t.Errorf("unexpected version: %d", payload.Version)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// Offline verification example — `go run examples/offline_verify.go`
|
||||||
|
// from the package root. Replace the embedded pubkey + license key
|
||||||
|
// with your own.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/keysat-xyz/keysat-client-go"
|
||||||
|
)
|
||||||
|
|
||||||
|
const publicKeyPEM = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MCowBQYDK2VwAyEAA6EHv/POEL4dcN0Y50vAmWfk1jCbpQ1fHdyGZBJVMbg=
|
||||||
|
-----END PUBLIC KEY-----`
|
||||||
|
|
||||||
|
const licenseKey = `LIC1-AIBW6RVE6YGS6SRIW2VD5D57N4UPBKVKVKVLXO6MZTO533XO53XO53QAAAAAAZKT6EAAAAAAABYT7MYA2NCGD73DC4G6MM5VVISRFTROCWWBECY4GJNM3LNGPQBOLFF2HM6QEA3QOJXQY3LVNR2GSLLEMV3GSY3F-QPSJIDYL6Y5TFCKXQ2SN43EDJIZIRJZCEROM2I4MJHODT6KO4KDPW6AJ3HMYJERYPD34CF2Z46PXPYFKSRZS7BDZKVKWE57UBJSTEBI`
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
pub, err := keysat.LoadPublicKeyPEM(publicKeyPEM)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("loading public key: %v", err)
|
||||||
|
}
|
||||||
|
payload, err := keysat.ParseAndVerify(licenseKey, pub)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("license invalid: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("OK — version=%d trial=%v fingerprint_bound=%v entitlements=%v\n",
|
||||||
|
payload.Version, payload.IsTrial(), payload.IsFingerprintBound(), payload.Entitlements)
|
||||||
|
if payload.IsExpiredAt(time.Now().Unix()) {
|
||||||
|
fmt.Println("(expired)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
// Package keysat is the Go SDK for Keysat — a self-hosted, Bitcoin-paid
|
||||||
|
// software licensing service. It parses and verifies LIC1-format
|
||||||
|
// license keys against an Ed25519 public key, and optionally validates
|
||||||
|
// them online against a running Keysat daemon.
|
||||||
|
//
|
||||||
|
// # Wire format
|
||||||
|
//
|
||||||
|
// A key string looks like LIC1-<payload_b32>-<signature_b32>. Both halves
|
||||||
|
// are RFC 4648 base32 (uppercase, no padding) of the raw bytes.
|
||||||
|
//
|
||||||
|
// # Versions
|
||||||
|
//
|
||||||
|
// v1 is the legacy 74-byte fixed payload. New keys are issued as v2,
|
||||||
|
// which adds expires_at and variable-length entitlement slugs. Both
|
||||||
|
// versions are accepted; clients should treat v1 keys as perpetual
|
||||||
|
// with no entitlements.
|
||||||
|
//
|
||||||
|
// Do not edit one SDK without the others — the wire format is
|
||||||
|
// crosscheck-tested across all four implementations (the daemon,
|
||||||
|
// the Rust SDK, the TS SDK, and this one) using the shared
|
||||||
|
// vectors at tests/crosscheck/vector.json in the parent licensing
|
||||||
|
// repo.
|
||||||
|
package keysat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ed25519"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/base32"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/pem"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wire-format identifiers. v1 is legacy; new keys are issued as v2.
|
||||||
|
const (
|
||||||
|
KeyPrefix = "LIC1"
|
||||||
|
KeyVersionV1 byte = 1
|
||||||
|
KeyVersionV2 byte = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// Flag bits in the payload's second byte.
|
||||||
|
const (
|
||||||
|
FlagFingerprintBound byte = 0b0000_0001
|
||||||
|
FlagTrial byte = 0b0000_0010
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed lengths.
|
||||||
|
const (
|
||||||
|
signatureLen = 64
|
||||||
|
payloadV1Len = 74
|
||||||
|
payloadV2HeadLen = 83
|
||||||
|
)
|
||||||
|
|
||||||
|
// b32 is RFC 4648 base32, uppercase, no padding — the alphabet used by
|
||||||
|
// every Keysat SDK and the daemon. Defined once so callers can't pick
|
||||||
|
// a slightly-different variant by mistake.
|
||||||
|
var b32 = base32.StdEncoding.WithPadding(base32.NoPadding)
|
||||||
|
|
||||||
|
// LicensePayload is the parsed contents of a license key, version-
|
||||||
|
// independent. v1 keys parse with ExpiresAt=0 and Entitlements=nil so
|
||||||
|
// callers don't need to branch on Version.
|
||||||
|
type LicensePayload struct {
|
||||||
|
Version byte
|
||||||
|
Flags byte
|
||||||
|
ProductID [16]byte
|
||||||
|
LicenseID [16]byte
|
||||||
|
IssuedAt int64
|
||||||
|
ExpiresAt int64 // 0 = perpetual; always 0 on v1
|
||||||
|
FingerprintHash [32]byte
|
||||||
|
Entitlements []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsFingerprintBound reports whether the key was issued bound to a
|
||||||
|
// machine fingerprint hash (FlagFingerprintBound is set).
|
||||||
|
func (p *LicensePayload) IsFingerprintBound() bool {
|
||||||
|
return p.Flags&FlagFingerprintBound != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTrial reports whether the key represents a trial (FlagTrial is set).
|
||||||
|
func (p *LicensePayload) IsTrial() bool {
|
||||||
|
return p.Flags&FlagTrial != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsExpiredAt reports whether the key has expired at the given Unix
|
||||||
|
// time. Perpetual keys (ExpiresAt == 0) always return false.
|
||||||
|
func (p *LicensePayload) IsExpiredAt(nowUnix int64) bool {
|
||||||
|
return p.ExpiresAt != 0 && nowUnix >= p.ExpiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasEntitlement reports whether the key grants the named entitlement.
|
||||||
|
// Comparison is case-sensitive; callers should pick a canonical casing.
|
||||||
|
func (p *LicensePayload) HasEntitlement(slug string) bool {
|
||||||
|
for _, e := range p.Entitlements {
|
||||||
|
if e == slug {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashFingerprint computes SHA-256 of the supplied raw fingerprint
|
||||||
|
// string, returning the 32 raw hash bytes. Used to compare a
|
||||||
|
// machine's fingerprint against a license's bound hash without ever
|
||||||
|
// transmitting the raw fingerprint to the daemon.
|
||||||
|
//
|
||||||
|
// Mirrors keysat::crypto::hash_fingerprint in the daemon, so the
|
||||||
|
// crosscheck vectors round-trip identically.
|
||||||
|
func HashFingerprint(rawFingerprint string) [32]byte {
|
||||||
|
return sha256.Sum256([]byte(rawFingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseKey decodes a LIC1-format key string into its payload, the raw
|
||||||
|
// signature bytes, and the canonical signed-bytes prefix that the
|
||||||
|
// signature covers. Callers typically pass (payload, sig, signed) to
|
||||||
|
// Verify next.
|
||||||
|
//
|
||||||
|
// Returns an error wrapping ErrBadFormat for any structural problem
|
||||||
|
// (wrong prefix, bad base32, truncated payload, unknown version).
|
||||||
|
func ParseKey(s string) (LicensePayload, []byte, []byte, error) {
|
||||||
|
parts := strings.Split(s, "-")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: expected LIC1-<payload>-<sig>", ErrBadFormat)
|
||||||
|
}
|
||||||
|
if parts[0] != KeyPrefix {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: prefix is %q, expected %q", ErrBadFormat, parts[0], KeyPrefix)
|
||||||
|
}
|
||||||
|
payloadBytes, err := b32.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: payload base32: %v", ErrBadFormat, err)
|
||||||
|
}
|
||||||
|
sigBytes, err := b32.DecodeString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: signature base32: %v", ErrBadFormat, err)
|
||||||
|
}
|
||||||
|
if len(sigBytes) != signatureLen {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: signature is %d bytes, expected %d", ErrBadFormat, len(sigBytes), signatureLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(payloadBytes) < 1 {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: empty payload", ErrBadFormat)
|
||||||
|
}
|
||||||
|
version := payloadBytes[0]
|
||||||
|
|
||||||
|
var p LicensePayload
|
||||||
|
switch version {
|
||||||
|
case KeyVersionV1:
|
||||||
|
if len(payloadBytes) != payloadV1Len {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: v1 payload is %d bytes, expected %d", ErrBadFormat, len(payloadBytes), payloadV1Len)
|
||||||
|
}
|
||||||
|
p = LicensePayload{
|
||||||
|
Version: KeyVersionV1,
|
||||||
|
Flags: payloadBytes[1],
|
||||||
|
IssuedAt: int64(binary.BigEndian.Uint64(payloadBytes[34:42])),
|
||||||
|
ExpiresAt: 0,
|
||||||
|
}
|
||||||
|
copy(p.ProductID[:], payloadBytes[2:18])
|
||||||
|
copy(p.LicenseID[:], payloadBytes[18:34])
|
||||||
|
copy(p.FingerprintHash[:], payloadBytes[42:74])
|
||||||
|
|
||||||
|
case KeyVersionV2:
|
||||||
|
if len(payloadBytes) < payloadV2HeadLen {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: v2 payload is %d bytes, need at least %d", ErrBadFormat, len(payloadBytes), payloadV2HeadLen)
|
||||||
|
}
|
||||||
|
p = LicensePayload{
|
||||||
|
Version: KeyVersionV2,
|
||||||
|
Flags: payloadBytes[1],
|
||||||
|
IssuedAt: int64(binary.BigEndian.Uint64(payloadBytes[34:42])),
|
||||||
|
ExpiresAt: int64(binary.BigEndian.Uint64(payloadBytes[42:50])),
|
||||||
|
}
|
||||||
|
copy(p.ProductID[:], payloadBytes[2:18])
|
||||||
|
copy(p.LicenseID[:], payloadBytes[18:34])
|
||||||
|
copy(p.FingerprintHash[:], payloadBytes[50:82])
|
||||||
|
|
||||||
|
// Entitlement count + variable-length tail.
|
||||||
|
numEnts := int(payloadBytes[82])
|
||||||
|
off := payloadV2HeadLen
|
||||||
|
for i := 0; i < numEnts; i++ {
|
||||||
|
if off >= len(payloadBytes) {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: entitlement count %d but truncated tail", ErrBadFormat, numEnts)
|
||||||
|
}
|
||||||
|
slugLen := int(payloadBytes[off])
|
||||||
|
off++
|
||||||
|
if off+slugLen > len(payloadBytes) {
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: entitlement %d declares %d bytes but only %d remain", ErrBadFormat, i, slugLen, len(payloadBytes)-off)
|
||||||
|
}
|
||||||
|
p.Entitlements = append(p.Entitlements, string(payloadBytes[off:off+slugLen]))
|
||||||
|
off += slugLen
|
||||||
|
}
|
||||||
|
// We don't error on trailing bytes: a future SDK might append fields,
|
||||||
|
// and this one should still parse the prefix it understands.
|
||||||
|
|
||||||
|
default:
|
||||||
|
return LicensePayload{}, nil, nil, fmt.Errorf("%w: unknown version %d", ErrBadFormat, version)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, sigBytes, payloadBytes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify checks that the signature was made over signedBytes by the
|
||||||
|
// holder of the private key corresponding to pub. signedBytes is what
|
||||||
|
// ParseKey returns as its third value — the raw payload bytes BEFORE
|
||||||
|
// base32 decoding (Ed25519 signs raw bytes, not their base32 form).
|
||||||
|
//
|
||||||
|
// Returns nil if the signature is valid, ErrBadSignature otherwise.
|
||||||
|
func Verify(pub ed25519.PublicKey, signedBytes, signature []byte) error {
|
||||||
|
if len(pub) != ed25519.PublicKeySize {
|
||||||
|
return fmt.Errorf("%w: public key is %d bytes, expected %d", ErrBadSignature, len(pub), ed25519.PublicKeySize)
|
||||||
|
}
|
||||||
|
if len(signature) != ed25519.SignatureSize {
|
||||||
|
return fmt.Errorf("%w: signature is %d bytes, expected %d", ErrBadSignature, len(signature), ed25519.SignatureSize)
|
||||||
|
}
|
||||||
|
if !ed25519.Verify(pub, signedBytes, signature) {
|
||||||
|
return ErrBadSignature
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseAndVerify is a convenience wrapper around ParseKey + Verify
|
||||||
|
// that returns the parsed payload only when the signature is valid.
|
||||||
|
// Most application code should call this rather than the lower-level
|
||||||
|
// pieces.
|
||||||
|
func ParseAndVerify(keyString string, pub ed25519.PublicKey) (LicensePayload, error) {
|
||||||
|
payload, sig, signed, err := ParseKey(keyString)
|
||||||
|
if err != nil {
|
||||||
|
return LicensePayload{}, err
|
||||||
|
}
|
||||||
|
if err := Verify(pub, signed, sig); err != nil {
|
||||||
|
return LicensePayload{}, err
|
||||||
|
}
|
||||||
|
return payload, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadPublicKeyPEM parses a PEM-encoded Ed25519 public key (the format
|
||||||
|
// the daemon emits via /v1/issuer/public-key and embeds in operator-
|
||||||
|
// distributed SDKs). Returns the key ready to pass to Verify or
|
||||||
|
// ParseAndVerify.
|
||||||
|
func LoadPublicKeyPEM(pemData string) (ed25519.PublicKey, error) {
|
||||||
|
block, _ := pem.Decode([]byte(pemData))
|
||||||
|
if block == nil {
|
||||||
|
return nil, fmt.Errorf("no PEM block found")
|
||||||
|
}
|
||||||
|
if block.Type != "PUBLIC KEY" {
|
||||||
|
return nil, fmt.Errorf("expected 'PUBLIC KEY' PEM block, got %q", block.Type)
|
||||||
|
}
|
||||||
|
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse PKIX public key: %w", err)
|
||||||
|
}
|
||||||
|
ed, ok := pub.(ed25519.PublicKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("PEM does not contain an Ed25519 key (got %T)", pub)
|
||||||
|
}
|
||||||
|
return ed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentinel error values. Wrap with fmt.Errorf("%w: ...") to add
|
||||||
|
// context; check with errors.Is.
|
||||||
|
var (
|
||||||
|
// ErrBadFormat is returned when a key string is structurally
|
||||||
|
// invalid — wrong prefix, bad base32, truncated payload, etc.
|
||||||
|
ErrBadFormat = errors.New("bad_format")
|
||||||
|
// ErrBadSignature is returned when the parsed signature does not
|
||||||
|
// match the payload + public key.
|
||||||
|
ErrBadSignature = errors.New("bad_signature")
|
||||||
|
)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Online validation against a running Keysat daemon. Use this when
|
||||||
|
// you want to honour revocation, machine-cap enforcement, or
|
||||||
|
// fingerprint binding — the offline ParseAndVerify path can't see
|
||||||
|
// post-issuance state changes.
|
||||||
|
//
|
||||||
|
// Most software calls Validate at startup (or first use), trusts
|
||||||
|
// the result for some grace period, and falls back to offline
|
||||||
|
// verification if the daemon is unreachable.
|
||||||
|
|
||||||
|
package keysat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client talks to a running Keysat daemon's public API. Construct
|
||||||
|
// with NewClient.
|
||||||
|
type Client struct {
|
||||||
|
BaseURL string
|
||||||
|
HTTP *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient returns a Client pointed at the daemon at baseURL with a
|
||||||
|
// 10-second default HTTP timeout. Pass a nil http.Client to use the
|
||||||
|
// default; pass your own to customise (proxy, custom transport, etc.).
|
||||||
|
func NewClient(baseURL string, httpClient *http.Client) *Client {
|
||||||
|
if httpClient == nil {
|
||||||
|
httpClient = &http.Client{Timeout: 10 * time.Second}
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||||
|
HTTP: httpClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRequest is the body of POST /v1/validate. Only Key is
|
||||||
|
// required; the rest fine-tune the validation.
|
||||||
|
type ValidateRequest struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
// ProductSlug, when non-empty, makes the daemon reject keys
|
||||||
|
// issued for a different product even if otherwise valid.
|
||||||
|
ProductSlug string `json:"product_slug,omitempty"`
|
||||||
|
// Fingerprint, when non-empty, the first successful validation
|
||||||
|
// binds this fingerprint to the license row; later validations
|
||||||
|
// succeed only if it matches. SHA-256 hash is computed
|
||||||
|
// daemon-side, so pass the raw value (machine UUID, etc.).
|
||||||
|
Fingerprint string `json:"fingerprint,omitempty"`
|
||||||
|
// Hostname is an optional human-friendly label stored on the
|
||||||
|
// machines row.
|
||||||
|
Hostname string `json:"hostname,omitempty"`
|
||||||
|
// Platform is an optional descriptor like "linux-x64",
|
||||||
|
// "darwin-arm64", "win-x64".
|
||||||
|
Platform string `json:"platform,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateResponse is the daemon's reply. HTTP is always 200; the
|
||||||
|
// boolean OK + machine-readable Reason field signal success/failure.
|
||||||
|
type ValidateResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
LicenseID string `json:"license_id,omitempty"`
|
||||||
|
ProductID string `json:"product_id,omitempty"`
|
||||||
|
ProductSlug string `json:"product_slug,omitempty"`
|
||||||
|
IssuedAt string `json:"issued_at,omitempty"`
|
||||||
|
ExpiresAt string `json:"expires_at,omitempty"`
|
||||||
|
GraceUntil string `json:"grace_until,omitempty"`
|
||||||
|
InGracePeriod *bool `json:"in_grace_period,omitempty"`
|
||||||
|
IsTrial *bool `json:"is_trial,omitempty"`
|
||||||
|
Entitlements []string `json:"entitlements,omitempty"`
|
||||||
|
Status string `json:"status,omitempty"`
|
||||||
|
MachineID string `json:"machine_id,omitempty"`
|
||||||
|
MaxMachines *int64 `json:"max_machines,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate calls POST /v1/validate. The daemon returns 200 in all
|
||||||
|
// cases; structural HTTP / JSON errors are surfaced here, license
|
||||||
|
// failures are conveyed via ValidateResponse.OK + Reason. Inspect
|
||||||
|
// resp.OK before trusting the rest of the fields.
|
||||||
|
func (c *Client) Validate(ctx context.Context, req ValidateRequest) (ValidateResponse, error) {
|
||||||
|
body, err := json.Marshal(req)
|
||||||
|
if err != nil {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
c.BaseURL+"/v1/validate", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
httpReq.Header.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.HTTP.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("validate request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("read response body: %w", err)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("daemon returned HTTP %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var out ValidateResponse
|
||||||
|
if err := json.Unmarshal(respBody, &out); err != nil {
|
||||||
|
return ValidateResponse{}, fmt.Errorf("decode response: %w (body=%s)", err, string(respBody))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PublicKey fetches the daemon's PEM-encoded Ed25519 public key from
|
||||||
|
// /v1/pubkey. Useful for SDK consumers who want to verify offline
|
||||||
|
// against a daemon they trust to publish the key over HTTPS.
|
||||||
|
//
|
||||||
|
// Production deployments should embed the key at build time rather
|
||||||
|
// than fetching it; this function is primarily for development
|
||||||
|
// convenience.
|
||||||
|
func (c *Client) PublicKey(ctx context.Context) (string, error) {
|
||||||
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/v1/pubkey", nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
resp, err := c.HTTP.Do(httpReq)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("fetch pubkey: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("daemon returned HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
// /v1/pubkey returns JSON like {"public_key_pem": "..."}.
|
||||||
|
var wrap struct {
|
||||||
|
PublicKeyPEM string `json:"public_key_pem"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||||
|
// If it's already raw PEM, return as-is — older daemons did
|
||||||
|
// this and we want to stay compatible.
|
||||||
|
if strings.Contains(string(body), "BEGIN PUBLIC KEY") {
|
||||||
|
return string(body), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("decode pubkey response: %w", err)
|
||||||
|
}
|
||||||
|
return wrap.PublicKeyPEM, nil
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user