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:
parent
ac8cdaeda9
commit
6e003875b4
7 changed files with 200 additions and 5 deletions
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
commercial/machine_darwin.go
Normal file
27
commercial/machine_darwin.go
Normal 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 ""
|
||||
}
|
||||
23
commercial/machine_linux.go
Normal file
23
commercial/machine_linux.go
Normal 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
|
||||
}
|
||||
28
commercial/machine_windows.go
Normal file
28
commercial/machine_windows.go
Normal 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 ""
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue