Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions internal/datagen/domain.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package datagen

import (
"fmt"
"math/rand"
"strings"
"time"
)

// DomainIdentity represents a simulated Active Directory domain.
type DomainIdentity struct {
Name string // "contoso.com"
NetBIOSName string // "CONTOSO"
ForestName string // "contoso.com" (single-domain forest)
DomainSID string // "S-1-5-21-xxxxxxxxxx-xxxxxxxxxx-xxxxxxxxxx"
FunctionalLevel string // "2016"
Sites []string // ["Default-First-Site-Name", "Branch-Office-01"]
CA *CertAuthority // Enterprise root CA
}

// CertAuthority represents a certificate authority for the domain.
type CertAuthority struct {
CommonName string // "contoso-ROOT-CA"
Thumbprint string // SHA1 hex (40 chars)
SerialNumber string // Hex serial
ValidFrom time.Time // 5 years before the supplied "now"
ValidTo time.Time // 5 years after the supplied "now"
CRLDistPoint string // "ldap:///CN=contoso-ROOT-CA,...,DC=contoso,DC=com"
}

// GenerateDomainIdentity creates a deterministic domain identity from (seed, now).
// If domainName is empty, defaults to "blitz.local". The "now" parameter pins
// the CertAuthority validity window so that the function is reproducible from
// (seed, now) alone — callers that want a stable test fixture pass a fixed
// timestamp; callers that want a currently-valid cert pass the wall clock.
func GenerateDomainIdentity(seed int64, domainName string, now time.Time) *DomainIdentity {
r := rand.New(rand.NewSource(seed)) // #nosec G404

if domainName == "" {
domainName = "blitz.local"
}

// Extract NetBIOS name (first label, uppercased)
netbios := strings.ToUpper(strings.Split(domainName, ".")[0])

// Generate domain SID
sid := fmt.Sprintf("S-1-5-21-%d-%d-%d",
r.Int31n(2000000000)+1000000000, // #nosec G404
r.Int31n(2000000000)+1000000000, // #nosec G404
r.Int31n(2000000000)+1000000000) // #nosec G404

// Generate sites
sites := []string{"Default-First-Site-Name"}
if r.Float64() > 0.3 { // #nosec G404
sites = append(sites, "Branch-Office-01")
}

// Generate CA
ca := generateCertAuthority(r, netbios, domainName, now)

return &DomainIdentity{
Name: domainName,
NetBIOSName: netbios,
ForestName: domainName,
DomainSID: sid,
FunctionalLevel: "2016",
Sites: sites,
CA: ca,
}
}

// generateCertAuthority creates a deterministic root CA for the domain.
// The validity window is pinned to the supplied "now" so the function is
// reproducible from its inputs alone.
func generateCertAuthority(r *rand.Rand, netbios, domainName string, now time.Time) *CertAuthority {
caName := fmt.Sprintf("%s-ROOT-CA", strings.ToLower(netbios))

// Thumbprint: 40 hex chars
thumbprint := randomHex(r, 20)

// Serial number: 16 hex chars
serial := randomHex(r, 8)

validFrom := now.AddDate(-5, 0, 0) // 5 years ago
validTo := now.AddDate(5, 0, 0) // 5 years from now

crl := fmt.Sprintf("ldap:///CN=%s,CN=AIA,CN=Public Key Services,CN=Services,CN=Configuration,%s",
caName, domainToDC(domainName))

return &CertAuthority{
CommonName: caName,
Thumbprint: thumbprint,
SerialNumber: serial,
ValidFrom: validFrom,
ValidTo: validTo,
CRLDistPoint: crl,
}
}

// domainToDC converts a domain name like "contoso.com" to its
// LDAP-distinguished-name suffix "DC=contoso,DC=com".
func domainToDC(name string) string {
parts := strings.Split(name, ".")
dcParts := make([]string, len(parts))
for i, p := range parts {
dcParts[i] = "DC=" + p
}
return strings.Join(dcParts, ",")
}

// randomHex generates a hex string of nBytes length (2 hex chars per byte).
func randomHex(r *rand.Rand, nBytes int) string {
var sb strings.Builder
sb.Grow(nBytes * 2)
for i := 0; i < nBytes; i++ {
fmt.Fprintf(&sb, "%02x", r.Intn(256)) // #nosec G404
}
return sb.String()
}
134 changes: 134 additions & 0 deletions internal/datagen/domain_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package datagen

import (
"strings"
"testing"
"time"
)

// fixedNow is the test reference timestamp. Pinned for reproducible CA validity windows.
var fixedNow = time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)

func TestGenerateDomainIdentity(t *testing.T) {
t.Run("default domain name", func(t *testing.T) {
d := GenerateDomainIdentity(42, "", fixedNow)
if d.Name != "blitz.local" {
t.Errorf("expected default domain name 'blitz.local', got %q", d.Name)
}
if d.NetBIOSName != "BLITZ" {
t.Errorf("expected NetBIOS name 'BLITZ', got %q", d.NetBIOSName)
}
})

t.Run("custom domain name", func(t *testing.T) {
d := GenerateDomainIdentity(42, "contoso.com", fixedNow)
if d.Name != "contoso.com" {
t.Errorf("expected domain name 'contoso.com', got %q", d.Name)
}
if d.NetBIOSName != "CONTOSO" {
t.Errorf("expected NetBIOS name 'CONTOSO', got %q", d.NetBIOSName)
}
})

t.Run("forest name equals domain name", func(t *testing.T) {
d := GenerateDomainIdentity(42, "example.org", fixedNow)
if d.ForestName != d.Name {
t.Errorf("ForestName %q should equal Name %q", d.ForestName, d.Name)
}
})

t.Run("domain SID format", func(t *testing.T) {
d := GenerateDomainIdentity(42, "", fixedNow)
if !strings.HasPrefix(d.DomainSID, "S-1-5-21-") {
t.Errorf("DomainSID %q should start with S-1-5-21-", d.DomainSID)
}
})

t.Run("has sites", func(t *testing.T) {
d := GenerateDomainIdentity(42, "", fixedNow)
if len(d.Sites) < 1 {
t.Error("expected at least 1 site")
}
if d.Sites[0] != "Default-First-Site-Name" {
t.Errorf("first site should be 'Default-First-Site-Name', got %q", d.Sites[0])
}
})

t.Run("has CA", func(t *testing.T) {
d := GenerateDomainIdentity(42, "contoso.com", fixedNow)
if d.CA == nil {
t.Fatal("expected CA to be set")
}
if !strings.Contains(d.CA.CommonName, "contoso") {
t.Errorf("CA CommonName %q should contain 'contoso'", d.CA.CommonName)
}
if d.CA.ValidTo.Before(d.CA.ValidFrom) {
t.Error("CA ValidTo should be after ValidFrom")
}
if len(d.CA.Thumbprint) != 40 {
t.Errorf("CA Thumbprint should be 40 hex chars, got %d", len(d.CA.Thumbprint))
}
if len(d.CA.SerialNumber) == 0 {
t.Error("CA SerialNumber should not be empty")
}
})

t.Run("CRL distribution point includes full DC chain", func(t *testing.T) {
d := GenerateDomainIdentity(42, "contoso.com", fixedNow)
want := "DC=contoso,DC=com"
if !strings.Contains(d.CA.CRLDistPoint, want) {
t.Errorf("CRLDistPoint %q should contain %q", d.CA.CRLDistPoint, want)
}
})

t.Run("CA validity window pinned to supplied now", func(t *testing.T) {
d := GenerateDomainIdentity(42, "contoso.com", fixedNow)
wantFrom := fixedNow.AddDate(-5, 0, 0)
wantTo := fixedNow.AddDate(5, 0, 0)
if !d.CA.ValidFrom.Equal(wantFrom) {
t.Errorf("ValidFrom = %v, want %v", d.CA.ValidFrom, wantFrom)
}
if !d.CA.ValidTo.Equal(wantTo) {
t.Errorf("ValidTo = %v, want %v", d.CA.ValidTo, wantTo)
}
})

t.Run("deterministic from seed and now", func(t *testing.T) {
d1 := GenerateDomainIdentity(99, "test.local", fixedNow)
d2 := GenerateDomainIdentity(99, "test.local", fixedNow)
if d1.DomainSID != d2.DomainSID {
t.Error("same seed should produce same DomainSID")
}
if d1.CA.Thumbprint != d2.CA.Thumbprint {
t.Error("same seed should produce same CA Thumbprint")
}
if !d1.CA.ValidFrom.Equal(d2.CA.ValidFrom) {
t.Error("same now should produce same CA ValidFrom")
}
})
}

func TestCertAuthorityDates(t *testing.T) {
d := GenerateDomainIdentity(42, "", fixedNow)
if d.CA.ValidFrom.After(fixedNow) {
t.Error("CA ValidFrom should be in the past relative to now")
}
if d.CA.ValidTo.Before(fixedNow) {
t.Error("CA ValidTo should be in the future relative to now")
}
}

func TestDomainToDC(t *testing.T) {
cases := map[string]string{
"contoso.com": "DC=contoso,DC=com",
"sub.contoso.com": "DC=sub,DC=contoso,DC=com",
"blitz.local": "DC=blitz,DC=local",
"single": "DC=single",
"a.b.c.d.example.org": "DC=a,DC=b,DC=c,DC=d,DC=example,DC=org",
}
for input, want := range cases {
if got := domainToDC(input); got != want {
t.Errorf("domainToDC(%q) = %q, want %q", input, got, want)
}
}
}
6 changes: 6 additions & 0 deletions internal/datagen/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const (
IdentityServices IdentityType = "services"
IdentityApplications IdentityType = "applications"
IdentityNetworks IdentityType = "networks"
IdentityDomains IdentityType = "domains"
)

// SeedConfig controls deterministic generation across all identity types.
Expand All @@ -40,6 +41,7 @@ type SeedConfig struct {
Services int64
Applications int64
Networks int64
Domains int64
}

// NewSeedConfig returns a SeedConfig with every field set to -1 so that an
Expand All @@ -54,6 +56,7 @@ func NewSeedConfig() *SeedConfig {
Services: -1,
Applications: -1,
Networks: -1,
Domains: -1,
}
}

Expand All @@ -76,6 +79,8 @@ func (s *SeedConfig) ResolveSeed(identityType IdentityType) int64 {
override = s.Applications
case IdentityNetworks:
override = s.Networks
case IdentityDomains:
override = s.Domains
}
if override >= 0 {
return override
Expand All @@ -98,5 +103,6 @@ func (s *SeedConfig) Init(logger *zap.Logger) {
zap.Int64("services", s.ResolveSeed(IdentityServices)),
zap.Int64("applications", s.ResolveSeed(IdentityApplications)),
zap.Int64("networks", s.ResolveSeed(IdentityNetworks)),
zap.Int64("domains", s.ResolveSeed(IdentityDomains)),
)
}
Loading