feat(certificates): inject commercial license root certificates into build process

- Added steps in both Linux and macOS workflows to inject commercial license root certificates from GitHub Secrets during the build process.
- Updated the `inspect` and `load` packages to include license information, enhancing the application's licensing capabilities.
- Included license metadata in the OpenAPI response for better visibility of licensing status.
This commit is contained in:
Max 2026-03-27 12:51:30 +08:00
parent c89570f3a7
commit 789cc85996
15 changed files with 1318 additions and 0 deletions

View file

@ -35,6 +35,9 @@ jobs:
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
- name: Build
env:
ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }}
ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }}
run: |
export PATH=$PATH:/github/home/go/bin
@ -48,6 +51,16 @@ jobs:
git clone https://github.com/yaoapp/yao-init.git /app/yao-init
git clone https://github.com/yaoapp/yao.git /app/yao
# Inject commercial license root certificates from GitHub Secrets
if [ -n "$ROOT_CA_1" ]; then
echo "$ROOT_CA_1" | base64 -d > /app/yao/commercial/roots/root-ca-1.pem
echo "Injected root-ca-1.pem"
fi
if [ -n "$ROOT_CA_2" ]; then
echo "$ROOT_CA_2" | base64 -d > /app/yao/commercial/roots/root-ca-2.pem
echo "Injected root-ca-2.pem"
fi
# Extract libv8
files=$(find /app/v8go -name "libv8*.zip")
for file in $files; do

View file

@ -105,6 +105,20 @@ jobs:
rm -f share/const.go.bak
grep 'const VERSION' share/const.go
- name: Inject License Root Certificates
env:
ROOT_CA_1: ${{ secrets.YAO_ROOT_CA_1_PEM }}
ROOT_CA_2: ${{ secrets.YAO_ROOT_CA_2_PEM }}
run: |
if [ -n "$ROOT_CA_1" ]; then
echo "$ROOT_CA_1" | base64 -d > commercial/roots/root-ca-1.pem
echo "Injected root-ca-1.pem"
fi
if [ -n "$ROOT_CA_2" ]; then
echo "$ROOT_CA_2" | base64 -d > commercial/roots/root-ca-2.pem
echo "Injected root-ca-2.pem"
fi
- name: Setup Go
uses: actions/setup-go@v5
with:

View file

@ -4,6 +4,7 @@ import (
"github.com/spf13/cobra"
"github.com/yaoapp/kun/maps"
"github.com/yaoapp/kun/utils"
"github.com/yaoapp/yao/commercial"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/engine"
"github.com/yaoapp/yao/share"
@ -20,6 +21,7 @@ var inspectCmd = &cobra.Command{
"version": share.VERSION,
"config": config.Conf,
}
res["license"] = commercial.License
if share.Tools != nil {
res["tools"] = share.Tools
}

View file

@ -0,0 +1,121 @@
// Command generate-test-cert creates a test root CA and a license certificate
// for local development and testing.
//
// Usage:
//
// go run ./commercial/cmd/generate-test-cert \
// -out-cert /path/to/yao-dev-app/license.pem \
// -out-root-ca ./commercial/roots/root-ca-1.pem
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"flag"
"fmt"
"math/big"
"os"
"time"
"github.com/yaoapp/yao/commercial"
)
func main() {
outCert := flag.String("out-cert", "license.pem", "path to write the license certificate PEM")
outRootCA := flag.String("out-root-ca", "", "path to write the root CA PEM (optional; for injecting into commercial/roots/)")
flag.Parse()
rootKey, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
fmt.Fprintf(os.Stderr, "generate root key: %v\n", err)
os.Exit(1)
}
rootSerial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
rootTmpl := &x509.Certificate{
SerialNumber: rootSerial,
Subject: pkix.Name{
CommonName: "Yao Test Root CA",
Organization: []string{"Infinite Wisdom Software"},
Country: []string{"CN"},
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
rootDER, err := x509.CreateCertificate(rand.Reader, rootTmpl, rootTmpl, &rootKey.PublicKey, rootKey)
if err != nil {
fmt.Fprintf(os.Stderr, "create root cert: %v\n", err)
os.Exit(1)
}
rootCert, _ := x509.ParseCertificate(rootDER)
rootPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: rootDER})
leafKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
fmt.Fprintf(os.Stderr, "generate leaf key: %v\n", err)
os.Exit(1)
}
leafTmpl := &x509.Certificate{
SerialNumber: big.NewInt(20001),
Subject: pkix.Name{
CommonName: "Yao Dev App",
Organization: []string{"Dev Testing"},
Country: []string{"CN"},
},
EmailAddresses: []string{"dev@yaoapps.com"},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
ExtraExtensions: []pkix.Extension{
{Id: commercial.OIDProduct, Value: []byte("yao,tai")},
{Id: commercial.OIDEdition, Value: []byte("enterprise")},
{Id: commercial.OIDMaxUsers, Value: []byte("0")},
{Id: commercial.OIDMaxTaiNodes, Value: []byte("0")},
{Id: commercial.OIDMaxAgents, Value: []byte("0")},
{Id: commercial.OIDMaxSandboxes, Value: []byte("0")},
{Id: commercial.OIDMaxAPIRPM, Value: []byte("0")},
{Id: commercial.OIDMaxStorageGB, Value: []byte("0")},
{Id: commercial.OIDAllowBrandingRemoval, Value: []byte("true")},
{Id: commercial.OIDAllowWhiteLabel, Value: []byte("true")},
{Id: commercial.OIDAllowMultiTenant, Value: []byte("true")},
{Id: commercial.OIDAllowCustomDomain, Value: []byte("true")},
{Id: commercial.OIDAllowHostExec, Value: []byte("true")},
{Id: commercial.OIDAllowSSO, Value: []byte("true")},
{Id: commercial.OIDSupportLevel, Value: []byte("dedicated")},
},
}
leafDER, err := x509.CreateCertificate(rand.Reader, leafTmpl, rootCert, &leafKey.PublicKey, rootKey)
if err != nil {
fmt.Fprintf(os.Stderr, "create leaf cert: %v\n", err)
os.Exit(1)
}
leafPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER})
if err := os.WriteFile(*outCert, leafPEM, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write cert: %v\n", err)
os.Exit(1)
}
fmt.Printf("Wrote license certificate to %s\n", *outCert)
if *outRootCA != "" {
if err := os.WriteFile(*outRootCA, rootPEM, 0644); err != nil {
fmt.Fprintf(os.Stderr, "write root CA: %v\n", err)
os.Exit(1)
}
fmt.Printf("Wrote root CA to %s\n", *outRootCA)
}
}

262
commercial/commercial.go Normal file
View file

@ -0,0 +1,262 @@
package commercial
import (
"crypto/x509"
"encoding/asn1"
"fmt"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
const envLicenseCert = "YAO_LICENSE_CERT"
// Load discovers and verifies the commercial license certificate,
// writing the result into the global License variable.
// It never returns an error — failures degrade to community defaults.
func Load(appRoot, product string) {
License = DefaultLicense()
License.Product = []string{product}
pemData, source := findCert(appRoot)
if pemData == nil {
log.Printf("[License] No license certificate found, running with community defaults")
return
}
info, err := verify(pemData, product)
if err != nil {
License.Source = source
License.Error = err.Error()
log.Printf("[License] %v — running with community defaults", err)
return
}
info.Source = source
info.LoadedAt = time.Now().Unix()
License = *info
if License.Valid {
remaining := time.Until(time.Unix(License.NotAfter, 0))
log.Printf("[License] Loaded: %s (%s) — valid until %s",
License.LicenseeName, License.Edition,
time.Unix(License.NotAfter, 0).UTC().Format("2006-01-02"))
if remaining < 90*24*time.Hour {
log.Printf("[License] WARNING: Certificate expires in %d days", int(remaining.Hours()/24))
}
}
}
// findCert locates the license PEM data.
// Search order:
// 1. YAO_LICENSE_CERT env (PEM content or file path)
// 2. <appRoot>/license.pem
// 3. <appRoot>/certs/license.pem
func findCert(appRoot string) (pemData []byte, source string) {
if v := os.Getenv(envLicenseCert); v != "" {
if strings.HasPrefix(v, "-----BEGIN") {
return []byte(v), "env"
}
data, err := os.ReadFile(v)
if err == nil {
return data, "env"
}
log.Printf("[License] env %s points to unreadable file: %v", envLicenseCert, err)
}
candidates := []string{
filepath.Join(appRoot, "license.pem"),
filepath.Join(appRoot, "certs", "license.pem"),
}
for _, path := range candidates {
data, err := os.ReadFile(path)
if err == nil {
return data, "file"
}
}
return nil, "none"
}
// verify parses PEM data, validates the certificate chain against built-in
// roots, checks revocation, time validity, product scope, and extracts
// custom extension fields.
func verify(pemData []byte, product string) (*LicenseInfo, error) {
certs, err := ParsePEMChain(pemData)
if err != nil {
return nil, fmt.Errorf("parse PEM: %w", err)
}
if len(certs) == 0 {
return nil, fmt.Errorf("no certificates found in PEM data")
}
leaf := certs[0]
pool := RootPool()
if pool == nil {
return nil, fmt.Errorf("no root certificates available (development build)")
}
// Verify the trust chain with a synthetic time within the leaf's validity
// window. This lets us extract structured info from expired/future
// certificates instead of returning an opaque x509 error.
// We use NotAfter-1s (just before expiry) to maximize overlap with CA validity.
opts := x509.VerifyOptions{
Roots: pool,
CurrentTime: leaf.NotAfter.Add(-time.Second),
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if len(certs) > 1 {
intermediates := x509.NewCertPool()
for _, c := range certs[1:] {
intermediates.AddCert(c)
}
opts.Intermediates = intermediates
}
if _, err := leaf.Verify(opts); err != nil {
return nil, fmt.Errorf("certificate verification failed: %w", err)
}
if IsRevoked(leaf.SerialNumber) {
return nil, fmt.Errorf("certificate serial %s has been revoked", leaf.SerialNumber.Text(16))
}
info := extractIdentity(leaf)
parseExtensions(leaf, info)
now := time.Now()
if now.Before(leaf.NotBefore) {
info.Valid = false
info.Error = fmt.Sprintf("certificate not yet valid (starts %s)",
leaf.NotBefore.UTC().Format("2006-01-02"))
return info, nil
}
if now.After(leaf.NotAfter) {
info.Valid = false
info.IsExpired = true
info.Error = fmt.Sprintf("certificate expired on %s",
leaf.NotAfter.UTC().Format("2006-01-02"))
return info, nil
}
if !info.HasProduct(product) {
info.Valid = false
info.Error = fmt.Sprintf("certificate not licensed for product %q (licensed: %v)",
product, info.Product)
return info, nil
}
info.Valid = true
return info, nil
}
func extractIdentity(cert *x509.Certificate) *LicenseInfo {
info := &LicenseInfo{
LicenseeName: cert.Subject.CommonName,
SerialNumber: cert.SerialNumber.Text(16),
NotBefore: cert.NotBefore.Unix(),
NotAfter: cert.NotAfter.Unix(),
Issuer: cert.Issuer.CommonName,
Edition: "community",
Product: []string{},
Permissions: Permissions{SupportLevel: "none"},
}
if len(cert.Subject.Organization) > 0 {
info.LicenseeOrg = cert.Subject.Organization[0]
}
if len(cert.Subject.Country) > 0 {
info.LicenseeCountry = cert.Subject.Country[0]
}
if len(cert.EmailAddresses) > 0 {
info.LicenseeEmail = cert.EmailAddresses[0]
}
return info
}
func parseExtensions(cert *x509.Certificate, info *LicenseInfo) {
for _, ext := range cert.Extensions {
val := string(ext.Value)
switch {
// Scope
case ext.Id.Equal(OIDProduct):
info.Product = splitCSV(val)
case ext.Id.Equal(OIDEdition):
info.Edition = val
case ext.Id.Equal(OIDEnv):
if val != "" {
info.Env = splitCSV(val)
}
case ext.Id.Equal(OIDDomain):
info.Domain = val
case ext.Id.Equal(OIDAppID):
info.AppID = val
// Quota
case ext.Id.Equal(OIDMaxUsers):
info.MaxUsers = atoi(val)
case ext.Id.Equal(OIDMaxTaiNodes):
info.MaxTaiNodes = atoi(val)
case ext.Id.Equal(OIDMaxAgents):
info.MaxAgents = atoi(val)
case ext.Id.Equal(OIDMaxSandboxes):
info.MaxSandboxes = atoi(val)
case ext.Id.Equal(OIDMaxAPIRPM):
info.MaxAPIRPM = atoi(val)
case ext.Id.Equal(OIDMaxStorageGB):
info.MaxStorageGB = atoi(val)
// Permissions
case ext.Id.Equal(OIDAllowBrandingRemoval):
info.Permissions.AllowBrandingRemoval = toBool(val)
case ext.Id.Equal(OIDAllowWhiteLabel):
info.Permissions.AllowWhiteLabel = toBool(val)
case ext.Id.Equal(OIDAllowMultiTenant):
info.Permissions.AllowMultiTenant = toBool(val)
case ext.Id.Equal(OIDAllowCustomDomain):
info.Permissions.AllowCustomDomain = toBool(val)
case ext.Id.Equal(OIDAllowHostExec):
info.Permissions.AllowHostExec = toBool(val)
case ext.Id.Equal(OIDAllowSSO):
info.Permissions.AllowSSO = toBool(val)
case ext.Id.Equal(OIDSupportLevel):
info.Permissions.SupportLevel = val
}
}
}
// MakeExtension creates a pkix.Extension for embedding in a certificate.
func MakeExtension(oid asn1.ObjectIdentifier, value string) ExtensionValue {
return ExtensionValue{OID: oid, Value: value}
}
// ExtensionValue pairs an OID with its string value for certificate generation.
type ExtensionValue struct {
OID asn1.ObjectIdentifier
Value string
}
func splitCSV(s string) []string {
parts := strings.Split(s, ",")
var result []string
for _, p := range parts {
if t := strings.TrimSpace(p); t != "" {
result = append(result, t)
}
}
return result
}
func atoi(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
func toBool(s string) bool {
s = strings.TrimSpace(strings.ToLower(s))
return s == "true" || s == "1" || s == "yes"
}

View file

@ -0,0 +1,446 @@
package commercial
import (
"crypto/x509"
"math/big"
"os"
"path/filepath"
"sync"
"testing"
"time"
)
// withTestRootPool temporarily replaces the root pool and revocation list
// for testing, restoring originals on cleanup.
func withTestRootPool(t *testing.T, ca *testCA, revoked []*big.Int) {
t.Helper()
origPool := rootPool
origOnce := rootPoolOnce
origSerials := revokedSerials
origRevokedOnce := revokedOnce
pool := x509.NewCertPool()
pool.AddCert(ca.Cert)
rootPool = pool
rootPoolOnce = sync.Once{}
rootPoolOnce.Do(func() {}) // mark as done so RootPool() returns our pool
revokedSerials = revoked
revokedOnce = sync.Once{}
revokedOnce.Do(func() {}) // mark as done
t.Cleanup(func() {
rootPool = origPool
rootPoolOnce = origOnce
revokedSerials = origSerials
revokedOnce = origRevokedOnce
})
}
func TestNoCertificate(t *testing.T) {
dir := t.TempDir()
License = DefaultLicense()
Load(dir, "yao")
if License.Valid {
t.Fatal("expected Valid=false with no certificate")
}
if License.Source != "none" {
t.Fatalf("expected Source=none, got %s", License.Source)
}
if License.Edition != "community" {
t.Fatalf("expected Edition=community, got %s", License.Edition)
}
if License.MaxUsers != 100 {
t.Fatalf("expected MaxUsers=100, got %d", License.MaxUsers)
}
}
func TestValidCertificate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644); err != nil {
t.Fatal(err)
}
Load(dir, "yao")
if !License.Valid {
t.Fatalf("expected Valid=true, got error: %s", License.Error)
}
if License.Source != "file" {
t.Fatalf("expected Source=file, got %s", License.Source)
}
if License.Edition != "pro" {
t.Fatalf("expected Edition=pro, got %s", License.Edition)
}
if License.LicenseeName != "Test Corp" {
t.Fatalf("expected LicenseeName=Test Corp, got %s", License.LicenseeName)
}
if License.MaxUsers != 500 {
t.Fatalf("expected MaxUsers=500, got %d", License.MaxUsers)
}
}
func TestExpiredCertificate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
opts.NotBefore = time.Now().Add(-30 * time.Minute)
opts.NotAfter = time.Now().Add(-1 * time.Minute)
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 for expired certificate")
}
if !License.IsExpired {
t.Fatal("expected IsExpired=true")
}
}
func TestNotYetValidCertificate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
opts.NotBefore = time.Now().Add(24 * time.Hour)
opts.NotAfter = time.Now().Add(365 * 24 * time.Hour)
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 for not-yet-valid certificate")
}
if License.IsExpired {
t.Fatal("expected IsExpired=false for future certificate")
}
}
func TestTamperedCertificate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
// Generate with a different root (not in our pool) to simulate tampering
fakeRoot, err := generateRootCA("Fake Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
opts := defaultLicenseOpts()
leaf, err := generateLicenseCert(fakeRoot, 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 for tampered certificate")
}
if License.Error == "" {
t.Fatal("expected Error to be set")
}
}
func TestWrongProduct(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
// Only licensed for "tai", not "yao"
for i, ext := range opts.Extensions {
if ext.OID.Equal(OIDProduct) {
opts.Extensions[i].Value = "tai"
}
}
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 for wrong product")
}
}
func TestCertificateChainWithIntermediate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
intermediate, err := generateIntermediateCA("Test Intermediate CA", 3*365*24*time.Hour, root)
if err != nil {
t.Fatal(err)
}
opts := defaultLicenseOpts()
leaf, err := generateLicenseCert(intermediate, opts)
if err != nil {
t.Fatal(err)
}
// PEM chain: leaf + intermediate
chainPEM := append(leaf.CertPEM, intermediate.CertPEM...)
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "license.pem"), chainPEM, 0644)
Load(dir, "yao")
if !License.Valid {
t.Fatalf("expected Valid=true with intermediate chain, got error: %s", License.Error)
}
}
func TestRevokedCertificate(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
opts := defaultLicenseOpts()
opts.Serial = big.NewInt(99999)
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, []*big.Int{big.NewInt(99999)})
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "license.pem"), leaf.CertPEM, 0644)
Load(dir, "yao")
if License.Valid {
t.Fatal("expected Valid=false for revoked certificate")
}
}
func TestEnvVarLoading(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
// Write cert to a temp file and point env var to it
certFile := filepath.Join(t.TempDir(), "test-license.pem")
os.WriteFile(certFile, leaf.CertPEM, 0644)
t.Setenv(envLicenseCert, certFile)
Load(t.TempDir(), "yao")
if !License.Valid {
t.Fatalf("expected Valid=true via env, got error: %s", License.Error)
}
if License.Source != "env" {
t.Fatalf("expected Source=env, got %s", License.Source)
}
}
func TestAllExtensions(t *testing.T) {
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 = []ExtensionValue{
{OID: OIDProduct, Value: "yao,tai"},
{OID: OIDEdition, Value: "enterprise"},
{OID: OIDEnv, Value: "production,staging"},
{OID: OIDDomain, Value: "*.acme.com"},
{OID: OIDAppID, Value: "acme-crm"},
{OID: OIDMaxUsers, Value: "0"},
{OID: OIDMaxTaiNodes, Value: "0"},
{OID: OIDMaxAgents, Value: "0"},
{OID: OIDMaxSandboxes, Value: "0"},
{OID: OIDMaxAPIRPM, Value: "0"},
{OID: OIDMaxStorageGB, Value: "0"},
{OID: OIDAllowBrandingRemoval, Value: "true"},
{OID: OIDAllowWhiteLabel, Value: "true"},
{OID: OIDAllowMultiTenant, Value: "true"},
{OID: OIDAllowCustomDomain, Value: "true"},
{OID: OIDAllowHostExec, Value: "true"},
{OID: OIDAllowSSO, Value: "true"},
{OID: OIDSupportLevel, Value: "dedicated"},
}
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, got error: %s", License.Error)
}
if License.Edition != "enterprise" {
t.Fatalf("expected enterprise, got %s", License.Edition)
}
if !License.HasProduct("yao") || !License.HasProduct("tai") {
t.Fatalf("expected product yao,tai, got %v", License.Product)
}
if len(License.Env) != 2 {
t.Fatalf("expected 2 envs, got %v", License.Env)
}
if License.Domain != "*.acme.com" {
t.Fatalf("expected domain *.acme.com, got %s", License.Domain)
}
if License.AppID != "acme-crm" {
t.Fatalf("expected app_id acme-crm, got %s", License.AppID)
}
if License.MaxUsers != 0 {
t.Fatalf("expected MaxUsers=0 (unlimited), got %d", License.MaxUsers)
}
if !License.Permissions.AllowBrandingRemoval {
t.Fatal("expected AllowBrandingRemoval=true")
}
if !License.Permissions.AllowWhiteLabel {
t.Fatal("expected AllowWhiteLabel=true")
}
if !License.Permissions.AllowMultiTenant {
t.Fatal("expected AllowMultiTenant=true")
}
if !License.Permissions.AllowSSO {
t.Fatal("expected AllowSSO=true")
}
if License.Permissions.SupportLevel != "dedicated" {
t.Fatalf("expected SupportLevel=dedicated, got %s", License.Permissions.SupportLevel)
}
}
func TestDefaultLicenseAndHelpers(t *testing.T) {
def := DefaultLicense()
if def.Valid {
t.Fatal("default should not be Valid")
}
if def.Edition != "community" {
t.Fatalf("expected community, got %s", def.Edition)
}
if !def.IsLevel("community") {
t.Fatal("community should satisfy IsLevel(community)")
}
if def.IsLevel("starter") {
t.Fatal("community should not satisfy IsLevel(starter)")
}
if def.IsLevel("pro") {
t.Fatal("community should not satisfy IsLevel(pro)")
}
pro := LicenseInfo{Edition: "pro"}
if !pro.IsLevel("community") {
t.Fatal("pro should satisfy IsLevel(community)")
}
if !pro.IsLevel("starter") {
t.Fatal("pro should satisfy IsLevel(starter)")
}
if !pro.IsLevel("pro") {
t.Fatal("pro should satisfy IsLevel(pro)")
}
if pro.IsLevel("enterprise") {
t.Fatal("pro should not satisfy IsLevel(enterprise)")
}
multi := LicenseInfo{Product: []string{"yao", "tai"}}
if !multi.HasProduct("yao") {
t.Fatal("expected HasProduct(yao)=true")
}
if !multi.HasProduct("tai") {
t.Fatal("expected HasProduct(tai)=true")
}
if multi.HasProduct("other") {
t.Fatal("expected HasProduct(other)=false")
}
}
func TestCertsSubdirectoryFallback(t *testing.T) {
root, err := generateRootCA("Test Root CA", 10*365*24*time.Hour)
if err != nil {
t.Fatal(err)
}
withTestRootPool(t, root, nil)
opts := defaultLicenseOpts()
leaf, err := generateLicenseCert(root, opts)
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
certsDir := filepath.Join(dir, "certs")
os.MkdirAll(certsDir, 0755)
os.WriteFile(filepath.Join(certsDir, "license.pem"), leaf.CertPEM, 0644)
Load(dir, "yao")
if !License.Valid {
t.Fatalf("expected Valid=true from certs/ fallback, got error: %s", License.Error)
}
if License.Source != "file" {
t.Fatalf("expected Source=file, got %s", License.Source)
}
}

38
commercial/oid.go Normal file
View file

@ -0,0 +1,38 @@
package commercial
import "encoding/asn1"
// OID prefix: 1.3.6.1.4.1.15099.1
// 15099 is Yao's internal port number, used as a recognizable enterprise number
// for this closed-loop certificate system. Not registered with IANA.
var (
// Scope
OIDProduct = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 1}
OIDEdition = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 2}
OIDEnv = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 3}
OIDDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 4}
OIDAppID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 1, 5}
// Quota
OIDMaxUsers = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 1}
OIDMaxTaiNodes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 2}
OIDMaxAgents = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 3}
OIDMaxSandboxes = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 4}
OIDMaxAPIRPM = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 5}
OIDMaxStorageGB = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 2, 6}
// Permissions
OIDAllowBrandingRemoval = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 1}
OIDAllowWhiteLabel = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 2}
OIDAllowMultiTenant = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 3}
OIDAllowCustomDomain = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 4}
OIDAllowHostExec = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 3, 5}
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}
// 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}
OIDNote = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 15099, 1, 4, 3}
)

98
commercial/roots.go Normal file
View file

@ -0,0 +1,98 @@
package commercial
import (
"crypto/x509"
_ "embed"
"encoding/json"
"encoding/pem"
"math/big"
"sync"
)
//go:embed roots/root-ca-1.pem
var rootCA1PEM []byte
//go:embed roots/root-ca-2.pem
var rootCA2PEM []byte
//go:embed roots/revoked.json
var revokedJSON []byte
var (
rootPoolOnce sync.Once
rootPool *x509.CertPool
revokedOnce sync.Once
revokedSerials []*big.Int
)
// RootPool returns the built-in root certificate pool (primary + backup).
// Returns nil if neither root certificate could be parsed (e.g. dev placeholder).
func RootPool() *x509.CertPool {
rootPoolOnce.Do(func() {
pool := x509.NewCertPool()
added := false
for _, pemData := range [][]byte{rootCA1PEM, rootCA2PEM} {
if pool.AppendCertsFromPEM(pemData) {
added = true
}
}
if added {
rootPool = pool
}
})
return rootPool
}
// RevokedSerials returns the list of revoked certificate serial numbers
// embedded in the binary.
func RevokedSerials() []*big.Int {
revokedOnce.Do(func() {
var data struct {
Serials []string `json:"serials"`
}
if err := json.Unmarshal(revokedJSON, &data); err != nil {
return
}
for _, s := range data.Serials {
n := new(big.Int)
if _, ok := n.SetString(s, 0); ok {
revokedSerials = append(revokedSerials, n)
}
}
})
return revokedSerials
}
// IsRevoked checks whether the given serial number is in the revocation list.
func IsRevoked(serial *big.Int) bool {
for _, s := range RevokedSerials() {
if s.Cmp(serial) == 0 {
return true
}
}
return false
}
// ParsePEMChain parses a PEM bundle into a list of x509 certificates.
// The first certificate is treated as the leaf; remaining are intermediates.
func ParsePEMChain(pemData []byte) ([]*x509.Certificate, error) {
var certs []*x509.Certificate
rest := pemData
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
break
}
if block.Type != "CERTIFICATE" {
continue
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, err
}
certs = append(certs, cert)
}
return certs, nil
}

View file

@ -0,0 +1 @@
{"serials": []}

View file

@ -0,0 +1,14 @@
-----BEGIN CERTIFICATE-----
MIICFzCCAZygAwIBAgIQYJYI6zEyHfyDlfFW2p4NHDAKBggqhkjOPQQDAzBLMQsw
CQYDVQQGEwJDTjEhMB8GA1UEChMYSW5maW5pdGUgV2lzZG9tIFNvZnR3YXJlMRkw
FwYDVQQDExBZYW8gVGVzdCBSb290IENBMB4XDTI2MDMyNzAzMzYxMVoXDTM2MDMy
NDA0MzYxMVowSzELMAkGA1UEBhMCQ04xITAfBgNVBAoTGEluZmluaXRlIFdpc2Rv
bSBTb2Z0d2FyZTEZMBcGA1UEAxMQWWFvIFRlc3QgUm9vdCBDQTB2MBAGByqGSM49
AgEGBSuBBAAiA2IABNe4Y3nl17lCgK3dZaGFYPdeutdm/hdprjwynJPfGBnw84oE
UBIgm7Nd5hpM57xZak9oH4ol58sOTxjWL492JWX+jU9pRUYn042HA4imSmmrxzP3
83m8Kn9VcYSEKPJzmKNFMEMwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYB
Af8CAQEwHQYDVR0OBBYEFGh4E/o55y4xR3qa7ZUSIVoxpHdlMAoGCCqGSM49BAMD
A2kAMGYCMQDLhwvg8SDmMiwHCejwtLBozFLcdkys/MoFVnueUE1RAQo1G0CitB/y
UJaYwCnV3j0CMQDe09TLfSA/69XDsv4ueNq9UtY9+ysEzkD2zxupOL1MYSPGNN0a
/ct7CLnLDOnRsN8=
-----END CERTIFICATE-----

View file

@ -0,0 +1,4 @@
-----BEGIN PLACEHOLDER-----
This is a development placeholder. The real backup root certificate will be
injected by the release workflow via GitHub Secrets before compilation.
-----END PLACEHOLDER-----

View file

@ -0,0 +1,177 @@
package commercial
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"time"
)
// testCA holds a generated CA certificate and its private key.
type testCA struct {
Cert *x509.Certificate
Key *ecdsa.PrivateKey
CertPEM []byte
}
// testLicenseCert holds a generated leaf (license) certificate.
type testLicenseCert struct {
Cert *x509.Certificate
CertPEM []byte
}
// generateRootCA creates a self-signed ECDSA P-384 root CA for testing.
func generateRootCA(cn string, validity time.Duration) (*testCA, error) {
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: cn,
Organization: []string{"Infinite Wisdom Software"},
Country: []string{"CN"},
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil
}
// generateIntermediateCA creates an intermediate CA signed by the given parent.
func generateIntermediateCA(cn string, validity time.Duration, parent *testCA) (*testCA, error) {
key, err := ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
if err != nil {
return nil, err
}
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
tmpl := &x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{
CommonName: cn,
Organization: []string{"Test Partner Inc."},
Country: []string{"US"},
},
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(validity),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 0,
MaxPathLenZero: true,
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, parent.Cert, &key.PublicKey, parent.Key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
return &testCA{Cert: cert, Key: key, CertPEM: certPEM}, nil
}
// licenseOpts configures a test license certificate.
type licenseOpts struct {
CN string
Org string
Country string
Email string
NotBefore time.Time
NotAfter time.Time
Serial *big.Int
Extensions []ExtensionValue
}
func defaultLicenseOpts() licenseOpts {
return licenseOpts{
CN: "Test Corp",
Org: "Test Inc.",
Country: "CN",
Email: "test@example.com",
NotBefore: time.Now().Add(-1 * time.Hour),
NotAfter: time.Now().Add(365 * 24 * time.Hour),
Serial: big.NewInt(10042),
Extensions: []ExtensionValue{
{OID: OIDProduct, Value: "yao"},
{OID: OIDEdition, Value: "pro"},
{OID: OIDMaxUsers, Value: "500"},
{OID: OIDMaxTaiNodes, Value: "10"},
{OID: OIDMaxAgents, Value: "50"},
{OID: OIDMaxSandboxes, Value: "20"},
{OID: OIDMaxAPIRPM, Value: "10000"},
{OID: OIDMaxStorageGB, Value: "100"},
{OID: OIDSupportLevel, Value: "priority"},
},
}
}
// generateLicenseCert creates a leaf license certificate signed by the given CA.
func generateLicenseCert(signer *testCA, opts licenseOpts) (*testLicenseCert, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, err
}
tmpl := &x509.Certificate{
SerialNumber: opts.Serial,
Subject: pkix.Name{
CommonName: opts.CN,
Organization: []string{opts.Org},
Country: []string{opts.Country},
},
EmailAddresses: []string{opts.Email},
NotBefore: opts.NotBefore,
NotAfter: opts.NotAfter,
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
for _, ext := range opts.Extensions {
tmpl.ExtraExtensions = append(tmpl.ExtraExtensions, pkix.Extension{
Id: ext.OID,
Value: []byte(ext.Value),
})
}
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, signer.Cert, &key.PublicKey, signer.Key)
if err != nil {
return nil, err
}
cert, err := x509.ParseCertificate(certDER)
if err != nil {
return nil, err
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
return &testLicenseCert{Cert: cert, CertPEM: certPEM}, nil
}

114
commercial/types.go Normal file
View file

@ -0,0 +1,114 @@
package commercial
import "time"
// License is the global commercial license state, populated by Load().
// Read-only after initialization; all modules may read without synchronization.
var License LicenseInfo
// LicenseInfo holds the parsed result of a commercial license certificate.
type LicenseInfo struct {
Valid bool `json:"valid"`
Source string `json:"source"` // "none" | "file" | "env"
LoadedAt int64 `json:"loaded_at"`
Error string `json:"error,omitempty"`
// Identity (from X.509 Subject)
LicenseeName string `json:"licensee_name"`
LicenseeOrg string `json:"licensee_org"`
LicenseeCountry string `json:"licensee_country,omitempty"`
LicenseeEmail string `json:"licensee_email,omitempty"`
SerialNumber string `json:"serial_number"`
NotBefore int64 `json:"not_before"`
NotAfter int64 `json:"not_after"`
IsExpired bool `json:"is_expired"`
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"`
// Quota (0 = unlimited)
MaxUsers int `json:"max_users"`
MaxTaiNodes int `json:"max_tai_nodes"`
MaxAgents int `json:"max_agents"`
MaxSandboxes int `json:"max_sandboxes"`
MaxAPIRPM int `json:"max_api_rpm"`
MaxStorageGB int `json:"max_storage_gb"`
// Permissions
Permissions Permissions `json:"permissions"`
}
// Permissions controls feature switches.
type Permissions struct {
AllowBrandingRemoval bool `json:"allow_branding_removal"`
AllowWhiteLabel bool `json:"allow_white_label"`
AllowMultiTenant bool `json:"allow_multi_tenant"`
AllowCustomDomain bool `json:"allow_custom_domain"`
AllowHostExec bool `json:"allow_host_exec"`
AllowSSO bool `json:"allow_sso"`
SupportLevel string `json:"support_level"` // "none" | "email" | "priority" | "dedicated"
}
// PublicInfo is the subset safe for well-known / public API exposure.
type PublicInfo struct {
Valid bool `json:"valid"`
Edition string `json:"edition"`
NotAfter int64 `json:"not_after,omitempty"`
Product []string `json:"product"`
}
// DefaultLicense returns community-level defaults when no certificate is present.
func DefaultLicense() LicenseInfo {
return LicenseInfo{
Source: "none",
LoadedAt: time.Now().Unix(),
Edition: "community",
Product: []string{"yao"},
MaxUsers: 100,
MaxTaiNodes: 1,
MaxAgents: 3,
MaxSandboxes: 1,
MaxAPIRPM: 1000,
MaxStorageGB: 10,
Permissions: Permissions{
SupportLevel: "none",
},
}
}
// GetPublicInfo returns the public-safe subset of the current license state.
func GetPublicInfo() *PublicInfo {
return &PublicInfo{
Valid: License.Valid,
Edition: License.Edition,
NotAfter: License.NotAfter,
Product: License.Product,
}
}
var editionRank = map[string]int{
"community": 0,
"starter": 1,
"pro": 2,
"enterprise": 3,
}
// IsLevel reports whether the license meets or exceeds the given minimum edition.
func (l LicenseInfo) IsLevel(minEdition string) bool {
return editionRank[l.Edition] >= editionRank[minEdition]
}
// HasProduct reports whether the license covers the given product name.
func (l LicenseInfo) HasProduct(product string) bool {
for _, p := range l.Product {
if p == product {
return true
}
}
return false
}

View file

@ -23,6 +23,7 @@ import (
"github.com/yaoapp/yao/api"
"github.com/yaoapp/yao/attachment"
"github.com/yaoapp/yao/cert"
"github.com/yaoapp/yao/commercial"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/connector"
"github.com/yaoapp/yao/data"
@ -149,6 +150,12 @@ func Load(cfg config.Config, options LoadOption, progressCallback ...func(string
warnings = append(warnings, Warning{Widget: "Registry", Error: err})
}
// Load Commercial License
loadStep("License", func() error {
commercial.Load(cfg.Root, "yao")
return nil
}, callback)
// Load Certs
err = loadStep("Cert", func() error {
return cert.Load(cfg)

View file

@ -8,6 +8,7 @@ import (
"strings"
"github.com/gin-gonic/gin"
"github.com/yaoapp/yao/commercial"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/share"
)
@ -52,6 +53,9 @@ type YaoMetadata struct {
GRPC string `json:"grpc,omitempty"` // gRPC server address (e.g., "127.0.0.1:9099")
Optional map[string]interface{} `json:"optional,omitempty"` // Optional settings
// Commercial license
License *commercial.PublicInfo `json:"license,omitempty"`
// Developer information
Developer *share.Developer `json:"developer,omitempty"`
}
@ -76,6 +80,9 @@ func (openapi *OpenAPI) yaoMetadata(c *gin.Context) {
Optional: share.App.Optional,
}
// Include license info
metadata.License = commercial.GetPublicInfo()
// Include developer info if available
if share.App.Developer.ID != "" || share.App.Developer.Name != "" {
metadata.Developer = &share.App.Developer