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
|
package commercial
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/asn1"
|
"encoding/asn1"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
@ -150,6 +151,17 @@ func verify(pemData []byte, product string) (*LicenseInfo, error) {
|
||||||
return info, nil
|
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
|
info.Valid = true
|
||||||
return info, nil
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
@ -195,6 +207,8 @@ func parseExtensions(cert *x509.Certificate, info *LicenseInfo) {
|
||||||
info.Domain = val
|
info.Domain = val
|
||||||
case ext.Id.Equal(OIDAppID):
|
case ext.Id.Equal(OIDAppID):
|
||||||
info.AppID = val
|
info.AppID = val
|
||||||
|
case ext.Id.Equal(OIDMachineID):
|
||||||
|
info.MachineID = val
|
||||||
|
|
||||||
// Quota
|
// Quota
|
||||||
case ext.Id.Equal(OIDMaxUsers):
|
case ext.Id.Equal(OIDMaxUsers):
|
||||||
|
|
@ -260,3 +274,23 @@ func toBool(s string) bool {
|
||||||
s = strings.TrimSpace(strings.ToLower(s))
|
s = strings.TrimSpace(strings.ToLower(s))
|
||||||
return s == "true" || s == "1" || s == "yes"
|
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)
|
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}
|
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}
|
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)
|
// Issuance (internal tracking)
|
||||||
OIDIssuerID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 1}
|
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}
|
OIDOrderID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 2}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ type LicenseInfo struct {
|
||||||
Env []string `json:"env,omitempty"`
|
Env []string `json:"env,omitempty"`
|
||||||
Domain string `json:"domain,omitempty"`
|
Domain string `json:"domain,omitempty"`
|
||||||
AppID string `json:"app_id,omitempty"`
|
AppID string `json:"app_id,omitempty"`
|
||||||
|
MachineID string `json:"machine_id,omitempty"` // if set, must match runtime machine ID
|
||||||
|
|
||||||
// Quota (0 = unlimited)
|
// Quota (0 = unlimited)
|
||||||
MaxUsers int `json:"max_users"`
|
MaxUsers int `json:"max_users"`
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue