fleet/server/datastore/mysql/host_certificates_test.go
Magnus Jensen 183c102b0d
DCSW: Allow Windows profiles to hit SCEP Proxy (#35041)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Resolves #35042

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
2025-11-06 11:14:49 -03:00

856 lines
37 KiB
Go

package mysql
import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
mathrand "math/rand/v2"
"strings"
"testing"
"time"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/ptr"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHostCertificates(t *testing.T) {
ds := CreateMySQLDS(t)
cases := []struct {
name string
fn func(t *testing.T, ds *Datastore)
}{
{"UpdateAndList", testUpdateAndListHostCertificates},
{"Update with host_mdm_managed_certificates to update", testUpdatingHostMDMManagedCertificates},
{"Update certificate sources isolation", testUpdateHostCertificatesSourcesIsolation},
{"Create certificates with long country code", testHostCertificateWithInvalidCountryCode},
{"Truncate long certificate fields", testTruncateLongCertificateFields},
{"Count matches main query", testListHostCertificatesCountMatches},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer TruncateTables(t, ds)
c.fn(t, ds)
})
}
}
func testUpdateAndListHostCertificates(t *testing.T, ds *Datastore) {
ctx := t.Context()
createX509Cert := func(commonName string, notAfter time.Duration) x509.Certificate {
return x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: commonName,
Organization: []string{"Org"},
OrganizationalUnit: []string{"Engineering"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(notAfter).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
}
expected1 := createX509Cert("test.example.com", 24*time.Hour)
expected2 := createX509Cert("another.test.example.com", 48*time.Hour)
payload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecord(t, 1, &expected1),
generateTestHostCertificateRecord(t, 1, &expected2),
}
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload))
// verify that we saved the records correctly
certs, meta, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", IncludeMetadata: true})
require.NoError(t, err)
require.Len(t, certs, 2)
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[0].Source)
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[1].Source)
require.EqualValues(t, 2, meta.TotalResults)
// order by not_valid_after descending
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "not_valid_after", OrderDirection: fleet.OrderAscending})
require.NoError(t, err)
require.Len(t, certs, 2)
require.Equal(t, expected1.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, expected2.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[1].SubjectCommonName)
// simulate removal of a certificate
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1]}))
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
require.NoError(t, err)
require.Len(t, certs, 1)
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName)
// re-add first certificate but as a "user" source
payload[0].Source = fleet.UserHostCertificate
payload[0].Username = "A"
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[0], payload[1]}))
certs, _, err = ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
require.NoError(t, err)
require.Len(t, certs, 2)
require.Equal(t, expected2.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[0].Source)
require.Equal(t, "", certs[0].Username)
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
require.Equal(t, fleet.UserHostCertificate, certs[1].Source)
require.Equal(t, "A", certs[1].Username)
hostCert1SrcUserA := payload[0]
hostCert2SrcSys := payload[1]
expected3 := createX509Cert("multi.test.example.com", 24*time.Hour)
hostCert3SrcUserB := generateTestHostCertificateRecord(t, 1, &expected3)
hostCert3SrcUserB.Source = fleet.UserHostCertificate
hostCert3SrcUserB.Username = "B"
cloneC := *hostCert3SrcUserB // copy to create a new record
hostCert3SrcUserC := &cloneC
hostCert3SrcUserC.Source = fleet.UserHostCertificate
hostCert3SrcUserC.Username = "C"
cloneD := *hostCert3SrcUserB // copy to create a new record
hostCert3SrcUserD := &cloneD
hostCert3SrcUserD.Source = fleet.UserHostCertificate
hostCert3SrcUserD.Username = "D"
cases := []struct {
desc string
ingest []*fleet.HostCertificateRecord
}{
{desc: "nil slice", ingest: nil},
{desc: "cert 1 and 2", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert1SrcUserA}},
{desc: "cert 2 and 3 (B, C)", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert3SrcUserB, hostCert3SrcUserC}},
{desc: "no change", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert3SrcUserB, hostCert3SrcUserC}},
{desc: "added cert 3 source (D)", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert3SrcUserB, hostCert3SrcUserC, hostCert3SrcUserD}},
{desc: "removed cert3 source (B)", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert3SrcUserC, hostCert3SrcUserD}},
{desc: "removed cert3 source (C)", ingest: []*fleet.HostCertificateRecord{hostCert2SrcSys, hostCert3SrcUserB, hostCert3SrcUserD}},
{desc: "cleared, added cert 1", ingest: []*fleet.HostCertificateRecord{hostCert1SrcUserA}},
{desc: "all cleared", ingest: []*fleet.HostCertificateRecord{}},
}
for _, c := range cases {
t.Log(c.desc)
err := ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", c.ingest)
require.NoError(t, err)
certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name", TestSecondaryOrderKey: "username"})
require.NoError(t, err)
require.Len(t, certs, len(c.ingest))
for i, cert := range certs {
require.Equal(t, c.ingest[i].CommonName, cert.CommonName, "index %d", i)
require.Equal(t, c.ingest[i].Source, cert.Source, "index %d", i)
require.Equal(t, c.ingest[i].Username, cert.Username, "index %d", i)
}
}
}
func testUpdatingHostMDMManagedCertificates(t *testing.T, ds *Datastore) {
// test that we can update the host_mdm_managed_certificates table when
// ingesting the associated certificate from the host
ctx := context.Background()
initialCPs := storeDummyConfigProfilesForTest(t, ds, 2)
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host0-osquery-id"),
NodeKey: ptr.String("host0-node-key"),
UUID: "host0-test-mdm-profiles",
Hostname: "hostname0",
})
require.NoError(t, err)
err = ds.BulkUpsertMDMAppleHostProfiles(ctx, []*fleet.MDMAppleBulkUpsertHostProfilePayload{
{
ProfileUUID: initialCPs[0].ProfileUUID,
ProfileIdentifier: initialCPs[0].Identifier,
ProfileName: initialCPs[0].Name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "command-uuid-1",
Checksum: []byte("checksum1"),
Scope: fleet.PayloadScopeSystem,
},
{
ProfileUUID: initialCPs[1].ProfileUUID,
ProfileIdentifier: initialCPs[1].Identifier,
ProfileName: initialCPs[1].Name,
HostUUID: host.UUID,
Status: &fleet.MDMDeliveryPending,
OperationType: fleet.MDMOperationTypeInstall,
CommandUUID: "command-uuid-2",
Checksum: []byte("checksum2"),
Scope: fleet.PayloadScopeSystem,
},
},
)
require.NoError(t, err)
// Initial certificate state where a host has been requested to install but we have no metadata
challengeRetrievedAt := time.Now().Add(-time.Hour).UTC().Round(time.Microsecond)
err = ds.BulkUpsertMDMManagedCertificates(ctx, []*fleet.MDMManagedCertificate{
{
HostUUID: host.UUID,
ProfileUUID: initialCPs[0].ProfileUUID,
Type: fleet.CAConfigCustomSCEPProxy,
CAName: "custom-ca",
},
{
HostUUID: host.UUID,
ProfileUUID: initialCPs[1].ProfileUUID,
ChallengeRetrievedAt: &challengeRetrievedAt,
Type: fleet.CAConfigSmallstep,
CAName: "step-ca",
},
})
require.NoError(t, err)
expected1 := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "MYHWSERIAL fleet-" + initialCPs[0].ProfileUUID,
Organization: []string{"Org 1"},
OrganizationalUnit: []string{"Engineering"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer 1"},
},
SerialNumber: big.NewInt(1337),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
expected2 := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "MYOTHERHWSERIAL",
Organization: []string{"Org 2"},
OrganizationalUnit: []string{"Engineering"},
},
SerialNumber: big.NewInt(31337),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(48 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
expected3 := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "MYHWSERIAL 2",
Organization: []string{"Org 1"},
OrganizationalUnit: []string{"fleet-" + initialCPs[1].ProfileUUID},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer 1"},
},
SerialNumber: big.NewInt(1338),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
payload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecord(t, host.ID, &expected1),
generateTestHostCertificateRecord(t, host.ID, &expected2),
generateTestHostCertificateRecord(t, host.ID, &expected3),
}
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, host.UUID, payload))
// verify that we saved the records correctly
certs, _, err := ds.ListHostCertificates(context.Background(), 1, fleet.ListOptions{OrderKey: "common_name"})
require.NoError(t, err)
require.Len(t, certs, 3)
require.Equal(t, expected3.Subject.CommonName, certs[0].CommonName)
require.Equal(t, expected3.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].CommonName)
require.Equal(t, expected1.Subject.CommonName, certs[1].SubjectCommonName)
require.Equal(t, expected2.Subject.CommonName, certs[2].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs[2].SubjectCommonName)
// Check that the managed certificate details were updated correctly
profile, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, initialCPs[0].ProfileUUID, "custom-ca")
require.NoError(t, err)
require.NotNil(t, profile)
assert.Equal(t, host.UUID, profile.HostUUID)
assert.Equal(t, initialCPs[0].ProfileUUID, profile.ProfileUUID)
require.Nil(t, profile.ChallengeRetrievedAt)
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, profile.Type)
require.NotNil(t, profile.Serial)
assert.Equal(t, fmt.Sprintf("%040s", expected1.SerialNumber.Text(16)), *profile.Serial)
require.NotNil(t, profile.NotValidBefore)
assert.Equal(t, expected1.NotBefore, *profile.NotValidBefore)
require.NotNil(t, profile.NotValidAfter)
assert.Equal(t, expected1.NotAfter, *profile.NotValidAfter)
assert.Equal(t, "custom-ca", profile.CAName)
// Check that the managed certificate details were updated correctly
profile2, err := ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, initialCPs[1].ProfileUUID, "step-ca")
require.NoError(t, err)
require.NotNil(t, profile2)
assert.Equal(t, host.UUID, profile2.HostUUID)
assert.Equal(t, initialCPs[1].ProfileUUID, profile2.ProfileUUID)
require.NotNil(t, profile2.ChallengeRetrievedAt)
assert.Equal(t, &challengeRetrievedAt, profile2.ChallengeRetrievedAt)
assert.Equal(t, fleet.CAConfigSmallstep, profile2.Type)
require.NotNil(t, profile2.Serial)
assert.Equal(t, fmt.Sprintf("%040s", expected3.SerialNumber.Text(16)), *profile2.Serial)
require.NotNil(t, profile2.NotValidBefore)
assert.Equal(t, expected3.NotBefore, *profile2.NotValidBefore)
require.NotNil(t, profile2.NotValidAfter)
assert.Equal(t, expected3.NotAfter, *profile2.NotValidAfter)
assert.Equal(t, "step-ca", profile2.CAName)
// simulate removal of a certificate
require.NoError(t, ds.UpdateHostCertificates(context.Background(), host.ID, "95816502-d8c0-462c-882f-39991cc89a0c", []*fleet.HostCertificateRecord{payload[1], payload[2]}))
certs3, _, err := ds.ListHostCertificates(context.Background(), host.ID, fleet.ListOptions{OrderKey: "common_name"})
require.NoError(t, err)
require.Len(t, certs3, 2)
require.Equal(t, expected3.Subject.CommonName, certs3[0].CommonName)
require.Equal(t, expected3.Subject.CommonName, certs3[0].SubjectCommonName)
require.Equal(t, expected2.Subject.CommonName, certs3[1].CommonName)
require.Equal(t, expected2.Subject.CommonName, certs3[1].SubjectCommonName)
// Check that the managed certificate details were not updated
profile, err = ds.GetAppleHostMDMCertificateProfile(ctx, host.UUID, initialCPs[0].ProfileUUID, "custom-ca")
require.NoError(t, err)
require.NotNil(t, profile)
assert.Equal(t, host.UUID, profile.HostUUID)
assert.Equal(t, initialCPs[0].ProfileUUID, profile.ProfileUUID)
require.Nil(t, profile.ChallengeRetrievedAt)
assert.Equal(t, fleet.CAConfigCustomSCEPProxy, profile.Type)
require.NotNil(t, profile.Serial)
assert.Equal(t, fmt.Sprintf("%040s", expected1.SerialNumber.Text(16)), *profile.Serial)
require.NotNil(t, profile.NotValidBefore)
assert.Equal(t, expected1.NotBefore, *profile.NotValidBefore)
require.NotNil(t, profile.NotValidAfter)
assert.Equal(t, expected1.NotAfter, *profile.NotValidAfter)
assert.Equal(t, "custom-ca", profile.CAName)
}
func generateTestHostCertificateRecord(t *testing.T, hostID uint, template *x509.Certificate) *fleet.HostCertificateRecord {
b, _, err := GenerateTestCertBytes(template)
require.NoError(t, err)
block, _ := pem.Decode(b)
parsed, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
require.NotNil(t, parsed)
return fleet.NewHostCertificateRecord(hostID, parsed)
}
// generateTestHostCertificateRecordWithParent creates a certificate signed by a parent certificate
// allowing for different issuer attributes (like country code) than the certificate itself.
// This is useful for testing scenarios where the certificate's subject country differs from
// the issuer's country, which is common in real-world certificate chains.
func generateTestHostCertificateRecordWithParent(t *testing.T, hostID uint, certTemplate, parentTemplate *x509.Certificate) *fleet.HostCertificateRecord {
// Generate parent key pair
parentPriv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Create parent certificate (self-signed)
parentCertBytes, err := x509.CreateCertificate(rand.Reader, parentTemplate, parentTemplate, &parentPriv.PublicKey, parentPriv)
require.NoError(t, err)
parentCert, err := x509.ParseCertificate(parentCertBytes)
require.NoError(t, err)
// Generate certificate key pair
certPriv, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Create certificate signed by parent
certBytes, err := x509.CreateCertificate(rand.Reader, certTemplate, parentCert, &certPriv.PublicKey, parentPriv)
require.NoError(t, err)
parsed, err := x509.ParseCertificate(certBytes)
require.NoError(t, err)
require.NotNil(t, parsed)
return fleet.NewHostCertificateRecord(hostID, parsed)
}
func testUpdateHostCertificatesSourcesIsolation(t *testing.T, ds *Datastore) {
// regression test for #30574
ctx := context.Background()
host1, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host1-osquery-id"),
NodeKey: ptr.String("host1-node-key"),
UUID: "host1-uuid",
Hostname: "host1",
})
require.NoError(t, err)
host2, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("host2-osquery-id"),
NodeKey: ptr.String("host2-node-key"),
UUID: "host2-uuid",
Hostname: "host2",
})
require.NoError(t, err)
// Create identical certificates for both hosts (same SHA1 sum)
// This simulates the real-world scenario where multiple hosts have the same certificate
// installed (e.g., a company root CA certificate)
sharedCert := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "shared.example.com",
Organization: []string{"Shared Org"},
OrganizationalUnit: []string{"Engineering"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(12345),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
// Generate certificate records for both hosts using the same certificate data
// We need to create the certificate bytes once and reuse them to ensure same SHA1
certBytes, _, err := GenerateTestCertBytes(&sharedCert)
require.NoError(t, err)
block, _ := pem.Decode(certBytes)
parsed, err := x509.ParseCertificate(block.Bytes)
require.NoError(t, err)
host1Cert := fleet.NewHostCertificateRecord(host1.ID, parsed)
host1Cert.Source = fleet.UserHostCertificate
host1Cert.Username = "jdoe"
host2Cert := fleet.NewHostCertificateRecord(host2.ID, parsed)
host2Cert.Source = fleet.UserHostCertificate
host2Cert.Username = "jsmith"
// Add the same certificate to both hosts
require.NoError(t, ds.UpdateHostCertificates(ctx, host1.ID, host1.UUID, []*fleet.HostCertificateRecord{host1Cert}))
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2Cert}))
// Verify both hosts have the correct certs, with the correct sources
host1Certs, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host1Certs, 1)
require.Equal(t, fleet.UserHostCertificate, host1Certs[0].Source)
require.Equal(t, "jdoe", host1Certs[0].Username)
host2Certs, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host2Certs, 1)
require.Equal(t, fleet.UserHostCertificate, host2Certs[0].Source)
require.Equal(t, "jsmith", host2Certs[0].Username)
// Trigger a change in host 2's cert sources to force delete/recreate of source records. Prior to the fix this
// wouldn't modify the correct certs because the DB query would return host 1's cert for the given hash.
host2CertUpdated := fleet.NewHostCertificateRecord(host2.ID, parsed)
host2CertUpdated.Source = fleet.UserHostCertificate
host2CertUpdated.Username = "janesmith"
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated}))
// Verify host1's certificate source was *not* updated
host1CertsAfter, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host1CertsAfter, 1)
require.Equal(t, "jdoe", host1CertsAfter[0].Username)
// Verify host2's certificate source *was* updated
host2CertsAfter, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host2CertsAfter, 1)
require.Equal(t, "janesmith", host2CertsAfter[0].Username)
// Verify no-op case
err = ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated})
require.NoError(t, err)
// Verify host2's certificate source was updated
host2CertsNoop, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host2CertsNoop, 1)
require.Equal(t, fleet.UserHostCertificate, host2CertsNoop[0].Source)
require.Equal(t, "janesmith", host2CertsNoop[0].Username)
// Confirm that adding the cert to the system store gets picked up properly
systemCertOnHost2 := fleet.NewHostCertificateRecord(host2.ID, parsed)
systemCertOnHost2.Source = fleet.SystemHostCertificate
require.NoError(t, ds.UpdateHostCertificates(ctx, host2.ID, host2.UUID, []*fleet.HostCertificateRecord{host2CertUpdated, systemCertOnHost2}))
// Verify host2 now has the certificate with both sources
host2CertsMultiSource, _, err := ds.ListHostCertificates(ctx, host2.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host2CertsMultiSource, 2)
var hasUserCert, hasSystemCert bool
for _, cert := range host2CertsMultiSource {
if cert.Source == fleet.UserHostCertificate {
require.Equal(t, "janesmith", cert.Username)
hasUserCert = true
} else {
require.Empty(t, cert.Username)
require.Equal(t, fleet.SystemHostCertificate, cert.Source)
hasSystemCert = true
}
}
require.True(t, hasUserCert)
require.True(t, hasSystemCert)
// Verify host1 still has only its original certificate source
host1CertsMultiSource, _, err := ds.ListHostCertificates(ctx, host1.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, host1CertsMultiSource, 1)
require.Equal(t, fleet.UserHostCertificate, host1CertsMultiSource[0].Source)
require.Equal(t, "jdoe", host1CertsMultiSource[0].Username)
}
// testHostCertificateWithInvalidCountryCode tests that a certificate with a country code longer than the standard 2 letters works
func testHostCertificateWithInvalidCountryCode(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create certificate templates for the actual certificates
certWithLongSubjectCountryTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"Internet"},
CommonName: "long.example.com",
Organization: []string{"Org"},
OrganizationalUnit: []string{"Engineering"},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
parentWithNormalCountryTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
IsCA: true,
BasicConstraintsValid: true,
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second).UTC(),
}
certWithNormalCountryTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "another.long.example.com",
Organization: []string{"Org"},
OrganizationalUnit: []string{"Engineering"},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(48 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
parentWithLongIssuerCountryTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"Internet"},
CommonName: "issuer.test.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
IsCA: true,
BasicConstraintsValid: true,
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second).UTC(),
}
payload := []*fleet.HostCertificateRecord{
generateTestHostCertificateRecordWithParent(t, 1, &certWithLongSubjectCountryTemplate, &parentWithNormalCountryTemplate),
generateTestHostCertificateRecordWithParent(t, 1, &certWithNormalCountryTemplate, &parentWithLongIssuerCountryTemplate),
}
// Manually override the country codes to preserve the full length for testing
// (they get truncated to 2 characters by the database VARCHAR(2) constraint)
payload[0].SubjectCountry = certWithLongSubjectCountryTemplate.Subject.Country[0]
payload[0].IssuerCountry = parentWithNormalCountryTemplate.Subject.Country[0]
payload[1].SubjectCountry = certWithNormalCountryTemplate.Subject.Country[0]
payload[1].IssuerCountry = parentWithLongIssuerCountryTemplate.Subject.Country[0]
require.NoError(t, ds.UpdateHostCertificates(ctx, 1, "95816502-d8c0-462c-882f-39991cc89a0c", payload))
// verify that we saved the records correctly
certs, _, err := ds.ListHostCertificates(ctx, 1, fleet.ListOptions{OrderKey: "common_name"})
require.NoError(t, err)
require.Len(t, certs, 2)
// First certificate (another.long.example.com) - cert with normal country, issuer with long country
assert.Equal(t, []string{certWithNormalCountryTemplate.Subject.Country[0]}, []string{certs[0].SubjectCountry})
assert.Equal(t, []string{parentWithLongIssuerCountryTemplate.Subject.Country[0]}, []string{certs[0].IssuerCountry})
require.Equal(t, certWithNormalCountryTemplate.Subject.CommonName, certs[0].CommonName)
require.Equal(t, certWithNormalCountryTemplate.Subject.CommonName, certs[0].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[0].Source)
// Second certificate (long.example.com) - cert with long subject country, issuer with normal country
assert.Equal(t, []string{certWithLongSubjectCountryTemplate.Subject.Country[0]}, []string{certs[1].SubjectCountry})
assert.Equal(t, []string{parentWithNormalCountryTemplate.Subject.Country[0]}, []string{certs[1].IssuerCountry})
require.Equal(t, certWithLongSubjectCountryTemplate.Subject.CommonName, certs[1].CommonName)
require.Equal(t, certWithLongSubjectCountryTemplate.Subject.CommonName, certs[1].SubjectCommonName)
require.Equal(t, fleet.SystemHostCertificate, certs[1].Source)
}
// testTruncateLongCertificateFields tests that all string fields in certificates are properly truncated
// when they exceed the database column limits
func testTruncateLongCertificateFields(t *testing.T, ds *Datastore) {
ctx := t.Context()
// Create strings that exceed database limits
longString256 := strings.Repeat("a", 256) // Exceeds varchar(255)
longString300 := strings.Repeat("b", 300) // Exceeds varchar(255)
longCountry33 := strings.Repeat("c", 33) // Exceeds varchar(32)
longCountry50 := strings.Repeat("d", 50) // Exceeds varchar(32)
longUsername260 := strings.Repeat("u", 260) // Exceeds varchar(255)
// Expected truncated values
expectedString255 := strings.Repeat("a", 255)
expectedString255B := strings.Repeat("b", 255)
expectedCountry32 := strings.Repeat("c", 32)
expectedCountry32D := strings.Repeat("d", 32)
expectedUsername255 := strings.Repeat("u", 255)
// Create a certificate template with all fields exceeding limits
certTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{longCountry33},
CommonName: longString256,
Organization: []string{longString300},
OrganizationalUnit: []string{longString256},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
// Create a parent certificate for signing (with long fields)
parentTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{longCountry50},
CommonName: longString300,
Organization: []string{longString256},
OrganizationalUnit: []string{longString300},
},
SerialNumber: big.NewInt(mathrand.Int64()), // nolint:gosec
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
IsCA: true,
BasicConstraintsValid: true,
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-2 * time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(365 * 24 * time.Hour).Truncate(time.Second).UTC(),
}
// Generate the certificate
cert := generateTestHostCertificateRecordWithParent(t, 1, &certTemplate, &parentTemplate)
// Override all string fields with long values to test truncation
cert.CommonName = longString256
cert.KeyAlgorithm = longString300
cert.KeyUsage = longString256
cert.Serial = longString300
cert.SigningAlgorithm = longString256
cert.SubjectCountry = longCountry33
cert.SubjectOrganization = longString300
cert.SubjectOrganizationalUnit = longString256
cert.SubjectCommonName = longString300
cert.IssuerCountry = longCountry50
cert.IssuerOrganization = longString256
cert.IssuerOrganizationalUnit = longString300
cert.IssuerCommonName = longString256
cert.Username = longUsername260
cert.Source = fleet.UserHostCertificate
// Create a host for testing
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("test-truncate-host-osquery-id"),
NodeKey: ptr.String("test-truncate-host-node-key"),
UUID: "test-truncate-host-uuid",
Hostname: "test-truncate-host",
})
require.NoError(t, err)
// Update certificates - this should trigger truncation
err = ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{cert})
require.NoError(t, err)
// Retrieve the certificate and verify all fields were truncated
certs, _, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{})
require.NoError(t, err)
require.Len(t, certs, 1)
savedCert := certs[0]
// Verify all varchar(255) fields were truncated to 255 characters
assert.Equal(t, expectedString255, savedCert.CommonName, "CommonName should be truncated to 255 chars")
assert.Equal(t, expectedString255B, savedCert.KeyAlgorithm, "KeyAlgorithm should be truncated to 255 chars")
assert.Equal(t, expectedString255, savedCert.KeyUsage, "KeyUsage should be truncated to 255 chars")
assert.Equal(t, expectedString255B, savedCert.Serial, "Serial should be truncated to 255 chars")
assert.Equal(t, expectedString255, savedCert.SigningAlgorithm, "SigningAlgorithm should be truncated to 255 chars")
assert.Equal(t, expectedString255B, savedCert.SubjectOrganization, "SubjectOrganization should be truncated to 255 chars")
assert.Equal(t, expectedString255, savedCert.SubjectOrganizationalUnit, "SubjectOrganizationalUnit should be truncated to 255 chars")
assert.Equal(t, expectedString255B, savedCert.SubjectCommonName, "SubjectCommonName should be truncated to 255 chars")
assert.Equal(t, expectedString255, savedCert.IssuerOrganization, "IssuerOrganization should be truncated to 255 chars")
assert.Equal(t, expectedString255B, savedCert.IssuerOrganizationalUnit, "IssuerOrganizationalUnit should be truncated to 255 chars")
assert.Equal(t, expectedString255, savedCert.IssuerCommonName, "IssuerCommonName should be truncated to 255 chars")
assert.Equal(t, expectedUsername255, savedCert.Username, "Username should be truncated to 255 chars")
// Verify varchar(32) country fields were truncated to 32 characters
assert.Equal(t, expectedCountry32, savedCert.SubjectCountry, "SubjectCountry should be truncated to 32 chars")
assert.Equal(t, expectedCountry32D, savedCert.IssuerCountry, "IssuerCountry should be truncated to 32 chars")
// Verify non-string fields remain unchanged
assert.Equal(t, fleet.UserHostCertificate, savedCert.Source, "Source should not be changed")
assert.Equal(t, host.ID, savedCert.HostID, "HostID should not be changed")
}
func testListHostCertificatesCountMatches(t *testing.T, ds *Datastore) {
ctx := context.Background()
// create host
host, err := ds.NewHost(ctx, &fleet.Host{
DetailUpdatedAt: time.Now(),
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String("count-mismatch-host-osquery-id"),
NodeKey: ptr.String("count-mismatch-host-node-key"),
UUID: "count-mismatch-host-uuid",
Hostname: "count-mismatch-host",
})
require.NoError(t, err)
// create a cert template and record
certTemplate := x509.Certificate{
Subject: pkix.Name{
Country: []string{"US"},
CommonName: "count.example.com",
Organization: []string{"Org"},
OrganizationalUnit: []string{"Eng"},
},
Issuer: pkix.Name{
Country: []string{"US"},
CommonName: "issuer.example.com",
Organization: []string{"Issuer"},
},
SerialNumber: big.NewInt(424242),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
SignatureAlgorithm: x509.SHA256WithRSA,
NotBefore: time.Now().Add(-time.Hour).Truncate(time.Second).UTC(),
NotAfter: time.Now().Add(24 * time.Hour).Truncate(time.Second).UTC(),
BasicConstraintsValid: true,
}
certRec := generateTestHostCertificateRecord(t, host.ID, &certTemplate)
// Update using ds.UpdateHostCertificates with two sources: system and user
certSys := *certRec
certSys.Source = fleet.SystemHostCertificate
certSys.Username = ""
certUser := *certRec
certUser.Source = fleet.UserHostCertificate
certUser.Username = "alice"
require.NoError(t, ds.UpdateHostCertificates(ctx, host.ID, host.UUID, []*fleet.HostCertificateRecord{&certSys, &certUser}))
// Now list with metadata
certs, meta, err := ds.ListHostCertificates(ctx, host.ID, fleet.ListOptions{IncludeMetadata: true})
require.NoError(t, err)
require.NotNil(t, meta)
// We expect two returned rows (one per source)
require.Len(t, certs, 2)
require.Equal(t, uint(len(certs)), meta.TotalResults, "expected total results to match returned rows")
}