From 968408b95538203f9ad935e4f47777dfbde240d7 Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Fri, 24 Jul 2026 14:58:25 +0100 Subject: [PATCH] feat(license): add offline verification --- shared/license/verify.go | 132 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 shared/license/verify.go diff --git a/shared/license/verify.go b/shared/license/verify.go new file mode 100644 index 0000000..5d0c348 --- /dev/null +++ b/shared/license/verify.go @@ -0,0 +1,132 @@ +package license + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/hyperboloide/lk" +) + +type State string + +const ( + StateValid State = "valid" + StateExpired State = "expired" + StateInvalid State = "invalid" +) + +// Reasons a licence is not valid. These are stable identifiers: the API returns +// them and the UI maps them to messages, so do not reword them casually. +const ( + ReasonNoLicense = "no_license" + ReasonBadSignature = "bad_signature" + ReasonDeploymentMismatch = "deployment_mismatch" + ReasonInstanceMismatch = "instance_mismatch" + ReasonExpired = "expired" +) + +// VerifyOpts is what the verifier knows about itself. +type VerifyOpts struct { + InstanceID string // this instance's own ID; required + Deployment string // "cloud" or "self_hosted"; required + Now time.Time // zero means time.Now() +} + +type Result struct { + License License + State State + Reason string + // ClockSkewed is set when IssuedAt is in the future, which usually means + // the host clock is wrong. It does not by itself invalidate the licence. + ClockSkewed bool +} + +// Verify checks a licence blob against this instance. +// +// The checks run in a fixed order and stop at the first failure: +// +// 1. signature against a trusted public key -> bad_signature +// 2. deployment matches this install -> deployment_mismatch +// 3. instance ID matches this instance -> instance_mismatch +// 4. not past ExpiresAt -> expired +// +// The order matters. A blob that is both expired and bound to another instance +// reports instance_mismatch, not expired, because that is the more useful thing +// to tell the person holding it. +// +// No clock tolerance is applied. Terms are a month or a year; a host whose clock +// is wrong by enough to matter has larger problems, and a tolerance window is a +// thing to get wrong. +func Verify(blob string, opts VerifyOpts) Result { + if blob == "" { + return Result{State: StateInvalid, Reason: ReasonNoLicense} + } + + l, err := Parse(blob) + if err != nil { + return Result{State: StateInvalid, Reason: ReasonBadSignature} + } + + res := Result{License: l} + + if l.Deployment != opts.Deployment { + res.State, res.Reason = StateInvalid, ReasonDeploymentMismatch + return res + } + if l.InstanceID != opts.InstanceID { + res.State, res.Reason = StateInvalid, ReasonInstanceMismatch + return res + } + + now := opts.Now + if now.IsZero() { + now = time.Now() + } + res.ClockSkewed = l.IssuedAt.After(now) + + if !now.Before(l.ExpiresAt) { + res.State, res.Reason = StateExpired, ReasonExpired + return res + } + + res.State = StateValid + return res +} + +// Parse verifies the signature only, ignoring binding and expiry. +// +// Used to display a licence and to inspect a blob a customer has emailed in. +// Never use it for enforcement — it does not check who the licence is for. +func Parse(blob string) (License, error) { + parsed, err := lk.LicenseFromB32String(blob) + if err != nil { + return License{}, fmt.Errorf("licence is not readable: %w", err) + } + + keys, err := publicKeys() + if err != nil { + return License{}, err + } + if len(keys) == 0 { + return License{}, fmt.Errorf("this build trusts no licence signing keys") + } + + verified := false + for _, k := range keys { + ok, err := parsed.Verify(k) + if err == nil && ok { + verified = true + break + } + } + if !verified { + return License{}, fmt.Errorf("licence signature does not match any trusted key") + } + + var l License + if err := json.Unmarshal(parsed.Data, &l); err != nil { + return License{}, fmt.Errorf("licence contents are not readable: %w", err) + } + return l, nil +}