From 6328197b33f1617d3a3a1c6a0875d642d3e853c4 Mon Sep 17 00:00:00 2001 From: Dylan Myers Date: Mon, 6 Apr 2026 12:41:57 -0400 Subject: [PATCH] feat(datagen): add DomainIdentity and CertAuthority generation --- internal/datagen/domain.go | 119 ++++++++++++++++++++++++++++ internal/datagen/domain_test.go | 134 ++++++++++++++++++++++++++++++++ internal/datagen/seed.go | 6 ++ 3 files changed, 259 insertions(+) create mode 100644 internal/datagen/domain.go create mode 100644 internal/datagen/domain_test.go diff --git a/internal/datagen/domain.go b/internal/datagen/domain.go new file mode 100644 index 0000000..31619b8 --- /dev/null +++ b/internal/datagen/domain.go @@ -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() +} diff --git a/internal/datagen/domain_test.go b/internal/datagen/domain_test.go new file mode 100644 index 0000000..7ede34e --- /dev/null +++ b/internal/datagen/domain_test.go @@ -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) + } + } +} diff --git a/internal/datagen/seed.go b/internal/datagen/seed.go index bdcd112..9674f9f 100644 --- a/internal/datagen/seed.go +++ b/internal/datagen/seed.go @@ -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. @@ -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 @@ -54,6 +56,7 @@ func NewSeedConfig() *SeedConfig { Services: -1, Applications: -1, Networks: -1, + Domains: -1, } } @@ -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 @@ -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)), ) }