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:
Grant
2026-05-08 11:17:46 -05:00
commit 81a621423a
8 changed files with 765 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
# Build artifacts
*.test
*.out
/coverage.txt
/dist/
+21
View File
@@ -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.
+98
View File
@@ -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`.
+180
View File
@@ -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)
}
}
+34
View File
@@ -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)")
}
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/keysat-xyz/keysat-client-go
go 1.21
+268
View File
@@ -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")
)
+156
View File
@@ -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
}