From 6e003875b429d965d3c0953453d5d83dbf0783ed Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 27 Mar 2026 17:18:17 +0800 Subject: [PATCH] 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. --- commercial/commercial.go | 34 +++++++++++++++ commercial/commercial_test.go | 79 +++++++++++++++++++++++++++++++++++ commercial/machine_darwin.go | 27 ++++++++++++ commercial/machine_linux.go | 23 ++++++++++ commercial/machine_windows.go | 28 +++++++++++++ commercial/oid.go | 3 ++ commercial/types.go | 11 ++--- 7 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 commercial/machine_darwin.go create mode 100644 commercial/machine_linux.go create mode 100644 commercial/machine_windows.go diff --git a/commercial/commercial.go b/commercial/commercial.go index e320be5b..eb6c0e5f 100644 --- a/commercial/commercial.go +++ b/commercial/commercial.go @@ -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]) +} diff --git a/commercial/commercial_test.go b/commercial/commercial_test.go index 45809303..c981d756 100644 --- a/commercial/commercial_test.go +++ b/commercial/commercial_test.go @@ -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") + } +} diff --git a/commercial/machine_darwin.go b/commercial/machine_darwin.go new file mode 100644 index 00000000..7af237b6 --- /dev/null +++ b/commercial/machine_darwin.go @@ -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 "" +} diff --git a/commercial/machine_linux.go b/commercial/machine_linux.go new file mode 100644 index 00000000..18775e51 --- /dev/null +++ b/commercial/machine_linux.go @@ -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 +} diff --git a/commercial/machine_windows.go b/commercial/machine_windows.go new file mode 100644 index 00000000..19da9916 --- /dev/null +++ b/commercial/machine_windows.go @@ -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 "" +} diff --git a/commercial/oid.go b/commercial/oid.go index 7567f4e8..a6c24788 100644 --- a/commercial/oid.go +++ b/commercial/oid.go @@ -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} diff --git a/commercial/types.go b/commercial/types.go index a3e42a4d..8f383e1d 100644 --- a/commercial/types.go +++ b/commercial/types.go @@ -25,11 +25,12 @@ type LicenseInfo struct { Issuer string `json:"issuer"` // Scope - Product []string `json:"product"` - Edition string `json:"edition"` // "community" | "starter" | "pro" | "enterprise" - Env []string `json:"env,omitempty"` - Domain string `json:"domain,omitempty"` - AppID string `json:"app_id,omitempty"` + Product []string `json:"product"` + Edition string `json:"edition"` // "community" | "starter" | "pro" | "enterprise" + 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"`