feat(licenses): implement machine ID binding for license validation

- Added support for machine ID binding in license certificates, ensuring that if a machine ID is specified, it must match the current runtime machine ID for the license to be valid.
- Introduced new tests to verify behavior for empty, matching, and mismatching machine IDs in certificates, enhancing the robustness of license validation.
- Updated LicenseInfo struct to include MachineID field, reflecting the new binding requirement.
This commit is contained in:
Max 2026-03-27 17:18:17 +08:00
parent ac8cdaeda9
commit 6e003875b4
7 changed files with 200 additions and 5 deletions

View file

@ -1,6 +1,7 @@
package commercial
import (
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"fmt"
@ -150,6 +151,17 @@ func verify(pemData []byte, product string) (*LicenseInfo, error) {
return info, nil
}
// Machine ID binding: if the certificate specifies a machine ID,
// it must match the current runtime machine ID.
// Empty machine_id means no binding — runs on any machine.
if info.MachineID != "" {
if got := currentMachineID(); got != info.MachineID {
info.Valid = false
info.Error = "certificate machine_id does not match this host"
return info, nil
}
}
info.Valid = true
return info, nil
}
@ -195,6 +207,8 @@ func parseExtensions(cert *x509.Certificate, info *LicenseInfo) {
info.Domain = val
case ext.Id.Equal(OIDAppID):
info.AppID = val
case ext.Id.Equal(OIDMachineID):
info.MachineID = val
// Quota
case ext.Id.Equal(OIDMaxUsers):
@ -260,3 +274,23 @@ func toBool(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
return s == "true" || s == "1" || s == "yes"
}
// currentMachineID returns a deterministic identifier for the current host,
// using the same algorithm as tai/machine.ID():
// - macOS: IOPlatformUUID via ioreg
// - Linux: /etc/machine-id
// - Windows: HKLM MachineGuid registry value
// - fallback: sha256("tai-fallback:" + hostname)[:16]
//
// Implemented via platform-specific machine_{os}.go files in this package.
func currentMachineID() string {
if id := platformMachineID(); id != "" {
return id
}
hostname, err := os.Hostname()
if err != nil || hostname == "" {
hostname = "unknown"
}
h := sha256.Sum256([]byte("tai-fallback:" + hostname))
return fmt.Sprintf("%x", h[:16])
}

View file

@ -506,3 +506,82 @@ func TestCertsSubdirectoryFallback(t *testing.T) {
t.Fatalf("expected Source=file, got %s", License.Source)
}
}
func TestMachineIDEmpty(t *testing.T) {
// No machine_id in cert → valid on any machine
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
// defaultLicenseOpts has no OIDMachineID → empty
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
Load(dir, "yao")
if !License.Valid {
t.Fatalf("expected Valid=true when machine_id is empty, got: %s", License.Error)
}
}
func TestMachineIDMatch(t *testing.T) {
// machine_id in cert matches current machine → valid
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
thisID := currentMachineID()
opts := defaultLicenseOpts()
opts.Extensions = append(opts.Extensions, ExtensionValue{OID: OIDMachineID, Value: thisID})
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
Load(dir, "yao")
if !License.Valid {
t.Fatalf("expected Valid=true when machine_id matches, got: %s", License.Error)
}
if License.MachineID != thisID {
t.Fatalf("expected MachineID=%s, got %s", thisID, License.MachineID)
}
}
func TestMachineIDMismatch(t *testing.T) {
// machine_id in cert does not match current machine → invalid
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
opts.Extensions = append(opts.Extensions, ExtensionValue{OID: OIDMachineID, Value: "000000000000000000000000deadbeef"})
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
Load(dir, "yao")
if License.Valid {
t.Fatal("expected Valid=false when machine_id does not match")
}
if License.Error == "" {
t.Fatal("expected Error to be set")
}
}

View file

@ -0,0 +1,27 @@
//go:build darwin
package commercial
import (
"os/exec"
"strings"
)
func platformMachineID() string {
out, err := exec.Command("ioreg", "-rd1", "-c", "IOPlatformExpertDevice").Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if strings.Contains(line, "IOPlatformUUID") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
uuid := strings.Trim(strings.TrimSpace(parts[1]), "\"")
if uuid != "" {
return strings.TrimSpace(strings.ToLower(uuid))
}
}
}
}
return ""
}

View file

@ -0,0 +1,23 @@
//go:build linux
package commercial
import (
"os"
"strings"
)
func platformMachineID() string {
data, err := os.ReadFile("/etc/machine-id")
if err != nil {
data, err = os.ReadFile("/var/lib/dbus/machine-id")
if err != nil {
return ""
}
}
id := strings.TrimSpace(strings.ToLower(string(data)))
if id == "" {
return ""
}
return id
}

View file

@ -0,0 +1,28 @@
//go:build windows
package commercial
import (
"os/exec"
"strings"
)
func platformMachineID() string {
out, err := exec.Command("reg", "query",
`HKLM\SOFTWARE\Microsoft\Cryptography`,
"/v", "MachineGuid",
).Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.Contains(line, "MachineGuid") {
fields := strings.Fields(line)
if len(fields) >= 3 {
return strings.TrimSpace(strings.ToLower(fields[len(fields)-1]))
}
}
}
return ""
}

View file

@ -31,6 +31,9 @@ var (
OIDAllowSSO = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 6}
OIDSupportLevel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 7}
// Binding (optional — if present, must match at runtime)
OIDMachineID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 5, 1}
// Issuance (internal tracking)
OIDIssuerID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 1}
OIDOrderID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 2}

View file

@ -30,6 +30,7 @@ type LicenseInfo struct {
Env []string `json:"env,omitempty"`
Domain string `json:"domain,omitempty"`
AppID string `json:"app_id,omitempty"`
MachineID string `json:"machine_id,omitempty"` // if set, must match runtime machine ID
// Quota (0 = unlimited)
MaxUsers int `json:"max_users"`