From 81a621423abaf38849760ec7e2ef5d72fd4f610a Mon Sep 17 00:00:00 2001 From: Grant Date: Fri, 8 May 2026 11:17:46 -0500 Subject: [PATCH] Initial Go SDK for Keysat licensing service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 5 + LICENSE | 21 +++ README.md | 98 ++++++++++++++ crosscheck_test.go | 180 +++++++++++++++++++++++++ examples/offline_verify.go | 34 +++++ go.mod | 3 + keysat.go | 268 +++++++++++++++++++++++++++++++++++++ online.go | 156 +++++++++++++++++++++ 8 files changed, 765 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 crosscheck_test.go create mode 100644 examples/offline_verify.go create mode 100644 go.mod create mode 100644 keysat.go create mode 100644 online.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..39f9a85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Build artifacts +*.test +*.out +/coverage.txt +/dist/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1b63053 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5cdd77 --- /dev/null +++ b/README.md @@ -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`. diff --git a/crosscheck_test.go b/crosscheck_test.go new file mode 100644 index 0000000..a5389d3 --- /dev/null +++ b/crosscheck_test.go @@ -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) + } +} diff --git a/examples/offline_verify.go b/examples/offline_verify.go new file mode 100644 index 0000000..69b1cd8 --- /dev/null +++ b/examples/offline_verify.go @@ -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)") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..74c825a --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/keysat-xyz/keysat-client-go + +go 1.21 diff --git a/keysat.go b/keysat.go new file mode 100644 index 0000000..827b3da --- /dev/null +++ b/keysat.go @@ -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--. 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--", 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") +) diff --git a/online.go b/online.go new file mode 100644 index 0000000..f0897b8 --- /dev/null +++ b/online.go @@ -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 +}