Files
keysat-client-go/online.go
T
Grant 81a621423a 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
2026-05-08 11:17:46 -05:00

157 lines
5.5 KiB
Go

// 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
}