mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
ACME MDM -> main (#42926)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or remove if NA --> **Related issue:** The entire ACME feature branch merge # Checklist for submitter If some of the following don't apply, delete the relevant line. - [x] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. - [x] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements), JS inline code is prevented especially for url redirects, and untrusted data interpolated into shell scripts/commands is validated against shell metacharacters. - [x] Timeouts are implemented and retries are limited to avoid infinite loops ## Testing - [x] Added/updated automated tests - [x] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [x] QA'd all new/changed functionality manually --------- Co-authored-by: Jordan Montgomery <elijah.jordan.montgomery@gmail.com> Co-authored-by: Martin Angers <martin.n.angers@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Gabriel Hernandez <ghernandez345@gmail.com> Co-authored-by: Sarah Gillespie <73313222+gillespi314@users.noreply.github.com>
This commit is contained in:
parent
4c573f13d0
commit
d4f48b6f9c
123 changed files with 9396 additions and 498 deletions
1
changes/31289-acme-for-mdm-protocol
Normal file
1
changes/31289-acme-for-mdm-protocol
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Implemented ACME for MDM protocol communication, and hardware device attestation.
|
||||
|
|
@ -23,6 +23,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm"
|
||||
acme_api "github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/android"
|
||||
android_svc "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
|
|
@ -957,6 +958,7 @@ func newCleanupsAndAggregationSchedule(
|
|||
softwareTitleIconStore fleet.SoftwareTitleIconStore,
|
||||
androidSvc android.Service,
|
||||
activitySvc activity_api.Service,
|
||||
acmeSvc acme_api.Service,
|
||||
) (*schedule.Schedule, error) {
|
||||
const (
|
||||
name = string(fleet.CronCleanupsThenAggregation)
|
||||
|
|
@ -1093,7 +1095,7 @@ func newCleanupsAndAggregationSchedule(
|
|||
schedule.WithJob(
|
||||
"renew_scep_certificates",
|
||||
func(ctx context.Context) error {
|
||||
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander)
|
||||
return service.RenewSCEPCertificates(ctx, logger, ds, config, commander, acmeSvc)
|
||||
},
|
||||
),
|
||||
schedule.WithJob("renew_host_mdm_managed_certificates", func(ctx context.Context) error {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
|
@ -35,6 +36,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/pkg/scripts"
|
||||
"github.com/fleetdm/fleet/v4/pkg/str"
|
||||
"github.com/fleetdm/fleet/v4/server"
|
||||
"github.com/fleetdm/fleet/v4/server/acl/acmeacl"
|
||||
"github.com/fleetdm/fleet/v4/server/acl/activityacl"
|
||||
activity_api "github.com/fleetdm/fleet/v4/server/activity/api"
|
||||
activity_bootstrap "github.com/fleetdm/fleet/v4/server/activity/bootstrap"
|
||||
|
|
@ -59,6 +61,9 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/live_query"
|
||||
"github.com/fleetdm/fleet/v4/server/logging"
|
||||
"github.com/fleetdm/fleet/v4/server/mail"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
acme_api "github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
acme_bootstrap "github.com/fleetdm/fleet/v4/server/mdm/acme/bootstrap"
|
||||
android_service "github.com/fleetdm/fleet/v4/server/mdm/android/service"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/apple/apple_apps"
|
||||
|
|
@ -67,6 +72,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/buford"
|
||||
nanomdm_pushsvc "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push/service"
|
||||
scepdepot "github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
"github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
||||
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
||||
platform_logging "github.com/fleetdm/fleet/v4/server/platform/logging"
|
||||
|
|
@ -76,6 +82,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/service/async"
|
||||
"github.com/fleetdm/fleet/v4/server/service/conditional_access_microsoft_proxy"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/auth"
|
||||
"github.com/fleetdm/fleet/v4/server/service/middleware/log"
|
||||
otelmw "github.com/fleetdm/fleet/v4/server/service/middleware/otel"
|
||||
"github.com/fleetdm/fleet/v4/server/service/redis_key_value"
|
||||
"github.com/fleetdm/fleet/v4/server/service/redis_lock"
|
||||
|
|
@ -1098,6 +1105,12 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
// Inject the activity bounded context into the main service
|
||||
svc.SetActivityService(activitySvc)
|
||||
|
||||
// Bootstrap ACME service module
|
||||
acmeSigner := &acmeCSRSigner{signer: scepdepot.NewSigner(scepStorage, scepdepot.WithValidityDays(config.MDM.AppleSCEPSignerValidityDays), scepdepot.WithAllowRenewalDays(14))}
|
||||
acmeSvc, acmeRoutes := createACMEServiceModule(ds, dbConns, redisPool, logger, acmeSigner)
|
||||
// Inject the ACME service module into the main service
|
||||
svc.SetACMEService(acmeSvc)
|
||||
|
||||
// Perform a cleanup of cron_stats outside of the cronSchedules because the
|
||||
// schedule package uses cron_stats entries to decide whether a schedule will
|
||||
// run or not (see https://github.com/fleetdm/fleet/issues/9486).
|
||||
|
|
@ -1157,7 +1170,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
func() (fleet.CronSchedule, error) {
|
||||
commander := apple_mdm.NewMDMAppleCommander(mdmStorage, mdmPushService)
|
||||
return newCleanupsAndAggregationSchedule(
|
||||
ctx, instanceID, ds, svc, logger, redisWrapperDS, &config, commander, softwareInstallStore, bootstrapPackageStore, softwareTitleIconStore, androidSvc, activitySvc,
|
||||
ctx, instanceID, ds, svc, logger, redisWrapperDS, &config, commander, softwareInstallStore, bootstrapPackageStore, softwareTitleIconStore, androidSvc, activitySvc, acmeSvc,
|
||||
)
|
||||
},
|
||||
); err != nil {
|
||||
|
|
@ -1468,7 +1481,7 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
extra = append(extra, service.WithHTTPSigVerifier(httpSigVerifier))
|
||||
|
||||
apiHandler = service.MakeHandler(svc, config, httpLogger, limiterStore, redisPool, carveStore,
|
||||
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes}, extra...)
|
||||
[]endpointer.HandlerRoutesFunc{android_service.GetRoutes(svc, androidSvc), activityRoutes, acmeRoutes}, extra...)
|
||||
|
||||
if serveCSP {
|
||||
// Only injecting this if CSP is turned on since the default security headers add some overhead to each request
|
||||
|
|
@ -1871,6 +1884,22 @@ func runServeCmd(cmd *cobra.Command, configManager configpkg.Manager, debug, dev
|
|||
logger.InfoContext(ctx, "terminated", "err", <-errs)
|
||||
}
|
||||
|
||||
// acmeCSRSigner adapts a depot.Signer to the acme.CSRSigner interface.
|
||||
type acmeCSRSigner struct {
|
||||
signer *scepdepot.Signer
|
||||
}
|
||||
|
||||
func (a *acmeCSRSigner) SignCSR(_ context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
|
||||
return a.signer.Signx509CSR(csr)
|
||||
}
|
||||
|
||||
func createACMEServiceModule(ds fleet.Datastore, dbConns *common_mysql.DBConnections, redisPool fleet.RedisPool, logger *slog.Logger, csrSigner acme.CSRSigner) (acme_api.Service, endpointer.HandlerRoutesFunc) {
|
||||
providers := acmeacl.NewFleetDatastoreAdapter(ds, csrSigner)
|
||||
acmeSvc, acmeRoutesFn := acme_bootstrap.New(dbConns, redisPool, providers, logger)
|
||||
acmeRoutes := acmeRoutesFn(log.Logged)
|
||||
return acmeSvc, acmeRoutes
|
||||
}
|
||||
|
||||
func createActivityBoundedContext(svc fleet.Service, dbConns *common_mysql.DBConnections, logger *slog.Logger) (activity_api.Service, endpointer.HandlerRoutesFunc) {
|
||||
legacyAuthorizer, err := authz.NewAuthorizer()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -1320,6 +1320,7 @@ func (cmd *GenerateGitopsCommand) generateControls(teamId *uint, teamName string
|
|||
if cmd.AppConfig.MDM.WindowsEnabledAndConfigured && len(cmd.AppConfig.MDM.WindowsEntraTenantIDs.Value) > 0 {
|
||||
result[jsonFieldName(mdmT, "WindowsEntraTenantIDs")] = cmd.AppConfig.MDM.WindowsEntraTenantIDs.Value
|
||||
}
|
||||
result[jsonFieldName(mdmT, "AppleRequireHardwareAttestation")] = cmd.AppConfig.MDM.AppleRequireHardwareAttestation
|
||||
}
|
||||
if cmd.AppConfig.MDM.WindowsEnabledAndConfigured {
|
||||
result["windows_enabled_and_configured"] = cmd.AppConfig.MDM.WindowsEnabledAndConfigured
|
||||
|
|
|
|||
|
|
@ -263,8 +263,8 @@ func TestMDMRunCommand(t *testing.T) {
|
|||
}
|
||||
return res, nil
|
||||
}
|
||||
ds.GetNanoMDMEnrollmentTimesFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
|
||||
return nil, nil, nil
|
||||
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
ds.IsHostDiskEncryptionKeyArchivedFunc = func(ctx context.Context, hostID uint) (bool, error) {
|
||||
return false, nil
|
||||
|
|
@ -1401,8 +1401,8 @@ func setupDSMocks(ds *mock.Store, hostByUUID map[string]testhost, hostsByID map[
|
|||
ds.GetMDMWindowsBitLockerStatusFunc = func(ctx context.Context, host *fleet.Host) (*fleet.HostMDMDiskEncryption, error) {
|
||||
return nil, nil
|
||||
}
|
||||
ds.GetNanoMDMEnrollmentTimesFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
|
||||
return nil, nil, nil
|
||||
ds.GetNanoMDMEnrollmentDetailsFunc = func(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
ds.GetHostMDMFunc = func(ctx context.Context, hostID uint) (*fleet.HostMDM, error) {
|
||||
h, ok := hostsByID[hostID]
|
||||
|
|
|
|||
|
|
@ -141,6 +141,7 @@
|
|||
"enable_turn_on_windows_mdm_manually": false,
|
||||
"windows_entra_tenant_ids": null,
|
||||
"windows_require_bitlocker_pin": null,
|
||||
"apple_require_hardware_attestation": false,
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
"windows_migration_enabled": false,
|
||||
"enable_turn_on_windows_mdm_manually": false,
|
||||
"windows_entra_tenant_ids": null,
|
||||
"apple_require_hardware_attestation": false,
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ spec:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: null
|
||||
apple_require_hardware_attestation: false
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ spec:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: null
|
||||
apple_require_hardware_attestation: false
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@
|
|||
},
|
||||
"windows_migration_enabled": false,
|
||||
"enable_turn_on_windows_mdm_manually": false,
|
||||
"apple_require_hardware_attestation": false,
|
||||
"macos_migration": {
|
||||
"enable": false,
|
||||
"mode": "",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ spec:
|
|||
windows_require_bitlocker_pin: null
|
||||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
apple_require_hardware_attestation: false
|
||||
windows_entra_tenant_ids: null
|
||||
macos_migration:
|
||||
enable: false
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@
|
|||
"pending_action": "",
|
||||
"server_url": null
|
||||
},
|
||||
"mdm_enrollment_hardware_attested": false,
|
||||
"memory": 0,
|
||||
"orbit_version": null,
|
||||
"os_version": "",
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ spec:
|
|||
name: ""
|
||||
pending_action: ""
|
||||
server_url: null
|
||||
|
||||
mdm_enrollment_hardware_attested: false
|
||||
memory: 0
|
||||
orbit_version: null
|
||||
os_version: ""
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@
|
|||
"5b84b6dd-d257-415e-b8b4-0240666ba4d4",
|
||||
"9d30f55f-d117-4574-acf0-ff593e3e06e3"
|
||||
],
|
||||
"apple_require_hardware_attestation": false,
|
||||
"end_user_authentication": {
|
||||
"entity_id": "some-mdm-entity-id.com",
|
||||
"issuer_uri": "https://some-mdm-issuer-uri.com",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ windows_entra_tenant_ids:
|
|||
windows_migration_enabled: true
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_require_bitlocker_pin: false
|
||||
apple_require_hardware_attestation: false
|
||||
android_enabled_and_configured: true
|
||||
enable_disk_encryption: true
|
||||
enable_recovery_lock_password: false
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
controls:
|
||||
android_enabled_and_configured: true
|
||||
apple_require_hardware_attestation: false
|
||||
enable_disk_encryption: true
|
||||
enable_recovery_lock_password: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ controls:
|
|||
windows_enabled_and_configured: false
|
||||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ controls:
|
|||
windows_enabled_and_configured: false
|
||||
windows_migration_enabled: true
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ controls:
|
|||
windows_enabled_and_configured: true
|
||||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ controls:
|
|||
windows_enabled_and_configured: true
|
||||
windows_migration_enabled: true
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ spec:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: null
|
||||
apple_require_hardware_attestation: false
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ spec:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: null
|
||||
apple_require_hardware_attestation: false
|
||||
macos_migration:
|
||||
enable: false
|
||||
mode: ""
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const DEFAULT_CONFIG_MDM_MOCK: IMdmConfig = {
|
|||
apple_bm_terms_expired: false,
|
||||
enabled_and_configured: true,
|
||||
android_enabled_and_configured: false,
|
||||
apple_require_hardware_attestation: false,
|
||||
macos_updates: {
|
||||
minimum_version: "",
|
||||
deadline: "",
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ export interface IMdmConfig {
|
|||
enable_turn_on_windows_mdm_manually: boolean;
|
||||
windows_migration_enabled: boolean;
|
||||
android_enabled_and_configured: boolean;
|
||||
apple_require_hardware_attestation: boolean;
|
||||
end_user_authentication: IEndUserAuthentication;
|
||||
macos_updates: IAppleDeviceUpdates;
|
||||
ios_updates: IAppleDeviceUpdates;
|
||||
|
|
|
|||
|
|
@ -351,6 +351,7 @@ export interface IHost {
|
|||
/** There will be at most 1 end user */
|
||||
end_users?: IHostEndUser[];
|
||||
conditional_access_bypassed: boolean;
|
||||
mdm_enrollment_hardware_attested?: boolean;
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ interface IAdvancedConfigFormData {
|
|||
disableScripts: boolean;
|
||||
disableAIFeatures: boolean;
|
||||
disableQueryReports: boolean;
|
||||
requireHardwareAttestation: boolean;
|
||||
}
|
||||
|
||||
interface IAdvancedConfigFormErrors {
|
||||
|
|
@ -105,6 +106,8 @@ const Advanced = ({
|
|||
disableAIFeatures: appConfig.server_settings.ai_features_disabled || false,
|
||||
disableQueryReports:
|
||||
appConfig.server_settings.query_reports_disabled || false,
|
||||
requireHardwareAttestation:
|
||||
appConfig.mdm?.apple_require_hardware_attestation || false,
|
||||
});
|
||||
|
||||
const {
|
||||
|
|
@ -121,6 +124,7 @@ const Advanced = ({
|
|||
disableScripts,
|
||||
disableAIFeatures,
|
||||
disableQueryReports,
|
||||
requireHardwareAttestation,
|
||||
} = formData;
|
||||
|
||||
const [formErrors, setFormErrors] = useState<IAdvancedConfigFormErrors>({});
|
||||
|
|
@ -190,6 +194,7 @@ const Advanced = ({
|
|||
},
|
||||
mdm: {
|
||||
apple_server_url: mdmAppleServerURL,
|
||||
apple_require_hardware_attestation: requireHardwareAttestation,
|
||||
},
|
||||
sso_settings: {
|
||||
sso_server_url: ssoUserURL,
|
||||
|
|
@ -517,6 +522,21 @@ const Advanced = ({
|
|||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
<GitOpsModeTooltipWrapper
|
||||
position="left"
|
||||
renderChildren={(disableChildren) => (
|
||||
<Checkbox
|
||||
disabled={disableChildren}
|
||||
onChange={onInputChange}
|
||||
name="requireHardwareAttestation"
|
||||
value={requireHardwareAttestation}
|
||||
parseTarget
|
||||
helpText="Enabling this setting will require macOS hosts with Apple Silicon that automatically enroll (DEP) to use ACME with Managed Device Attestation"
|
||||
>
|
||||
Require hardware attestation
|
||||
</Checkbox>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -240,6 +240,41 @@ describe("Vitals Card component", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("MDM attestation", () => {
|
||||
it("renders MDM attestation when mdm_enrollment_hardware_attested is true", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
mdm_enrollment_hardware_attested: true,
|
||||
});
|
||||
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.getByText("MDM attestation")).toBeInTheDocument();
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render MDM attestation when mdm_enrollment_hardware_attested is false", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
mdm_enrollment_hardware_attested: false,
|
||||
});
|
||||
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.queryByText("MDM attestation")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render MDM attestation when mdm_enrollment_hardware_attested is undefined", () => {
|
||||
const mockHost = createMockHost({
|
||||
platform: "darwin",
|
||||
});
|
||||
|
||||
render(<Vitals vitalsData={mockHost} mdm={mockHost.mdm} />);
|
||||
|
||||
expect(screen.queryByText("MDM attestation")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Disk encryption data", () => {
|
||||
it("renders 'On' for macOS when enabled", () => {
|
||||
const mockHost = createMockHost({
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ const Vitals = ({
|
|||
const {
|
||||
platform,
|
||||
os_version,
|
||||
mdm_enrollment_hardware_attested,
|
||||
disk_encryption_enabled: diskEncryptionEnabled,
|
||||
} = vitalsData;
|
||||
|
||||
|
|
@ -386,6 +387,24 @@ const Vitals = ({
|
|||
});
|
||||
}
|
||||
|
||||
// MDM attestation
|
||||
if (mdm_enrollment_hardware_attested) {
|
||||
vitals.push({
|
||||
sortKey: "MDM attestation",
|
||||
element: (
|
||||
<DataSet
|
||||
key="mdm-attestation"
|
||||
title="MDM attestation"
|
||||
value={
|
||||
<TooltipWrapper tipContent="Host provided a Managed Device Attestation signed by Apple at enrollment.">
|
||||
Yes
|
||||
</TooltipWrapper>
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// MDM
|
||||
if (mdm?.enrollment_status) {
|
||||
vitals.push(
|
||||
|
|
|
|||
|
|
@ -457,6 +457,7 @@ export const HOST_VITALS_DATA = [
|
|||
"cpu_type",
|
||||
"os_version",
|
||||
"timezone",
|
||||
"mdm_enrollment_hardware_attested",
|
||||
"primary_mac",
|
||||
];
|
||||
|
||||
|
|
|
|||
65
go.mod
65
go.mod
|
|
@ -3,7 +3,7 @@ module github.com/fleetdm/fleet/v4
|
|||
go 1.26.1
|
||||
|
||||
require (
|
||||
cloud.google.com/go/pubsub v1.49.0
|
||||
cloud.google.com/go/pubsub v1.50.1
|
||||
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d
|
||||
github.com/AbGuthrie/goquery/v2 v2.0.1
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358
|
||||
|
|
@ -18,9 +18,9 @@ require (
|
|||
github.com/agnivade/levenshtein v1.2.1
|
||||
github.com/andygrunwald/go-jira v1.16.0
|
||||
github.com/antchfx/xmlquery v1.3.14
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12
|
||||
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3
|
||||
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81
|
||||
|
|
@ -30,8 +30,8 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/s3 v1.81.0
|
||||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9
|
||||
github.com/aws/smithy-go v1.24.2
|
||||
github.com/beevik/etree v1.6.0
|
||||
github.com/beevik/ntp v0.3.0
|
||||
github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb
|
||||
|
|
@ -58,6 +58,7 @@ require (
|
|||
github.com/facebookincubator/flog v0.0.0-20190930132826-d2511d0ce33c
|
||||
github.com/fatih/color v1.16.0
|
||||
github.com/foxboron/go-tpm-keyfiles v0.0.0-20250520203025-c3c3a4ec1653
|
||||
github.com/fxamacker/cbor/v2 v2.9.1
|
||||
github.com/getsentry/sentry-go v0.18.0
|
||||
github.com/ghodss/yaml v1.0.0
|
||||
github.com/go-git/go-git/v5 v5.17.1
|
||||
|
|
@ -163,18 +164,19 @@ require (
|
|||
go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0
|
||||
go.opentelemetry.io/otel/trace v1.40.0
|
||||
go.step.sm/crypto v0.77.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56
|
||||
golang.org/x/image v0.38.0
|
||||
golang.org/x/mod v0.33.0
|
||||
golang.org/x/net v0.51.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/oauth2 v0.35.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/sys v0.42.0
|
||||
golang.org/x/term v0.41.0
|
||||
golang.org/x/text v0.35.0
|
||||
golang.org/x/tools v0.42.0
|
||||
google.golang.org/api v0.256.0
|
||||
google.golang.org/api v0.269.0
|
||||
google.golang.org/grpc v1.79.3
|
||||
gopkg.in/guregu/null.v3 v3.5.0
|
||||
gopkg.in/ini.v1 v1.67.0
|
||||
|
|
@ -185,14 +187,15 @@ require (
|
|||
)
|
||||
|
||||
require (
|
||||
cloud.google.com/go v0.120.0 // indirect
|
||||
cloud.google.com/go/auth v0.17.0 // indirect
|
||||
cloud.google.com/go v0.123.0 // indirect
|
||||
cloud.google.com/go/auth v0.18.2 // indirect
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
|
||||
cloud.google.com/go/compute/metadata v0.9.0 // indirect
|
||||
cloud.google.com/go/iam v1.5.2 // indirect
|
||||
cloud.google.com/go/iam v1.5.3 // indirect
|
||||
cloud.google.com/go/pubsub/v2 v2.0.0 // indirect
|
||||
cyphar.com/go-pathrs v0.2.1 // indirect
|
||||
dario.cat/mergo v1.0.0 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
|
||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect
|
||||
github.com/AlekSi/pointer v1.2.0 // indirect
|
||||
|
|
@ -209,18 +212,18 @@ require (
|
|||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||
github.com/armon/go-radix v1.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/c-bata/go-prompt v0.2.3 // indirect
|
||||
github.com/cavaliergopher/cpio v1.0.1 // indirect
|
||||
|
|
@ -260,6 +263,7 @@ require (
|
|||
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/go-git/go-billy/v5 v5.8.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-kit/log v0.2.1 // indirect
|
||||
github.com/go-logfmt/logfmt v0.5.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
|
|
@ -271,10 +275,10 @@ require (
|
|||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm-tools v0.4.5 // indirect
|
||||
github.com/google/go-tpm-tools v0.4.7 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 // indirect
|
||||
github.com/goreleaser/chglog v0.4.2 // indirect
|
||||
github.com/goreleaser/fileglob v1.3.0 // indirect
|
||||
github.com/gorilla/schema v1.4.1 // indirect
|
||||
|
|
@ -283,7 +287,7 @@ require (
|
|||
github.com/hashicorp/go-hclog v1.6.3 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/huandu/xstrings v1.5.0 // indirect
|
||||
github.com/imdario/mergo v0.3.15 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
|
|
@ -344,6 +348,7 @@ require (
|
|||
github.com/tklauser/go-sysconf v0.3.16 // indirect
|
||||
github.com/tklauser/numcpus v0.11.0 // indirect
|
||||
github.com/trivago/tgo v1.0.7 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
||||
|
|
@ -359,9 +364,9 @@ require (
|
|||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
|
|
|||
160
go.sum
160
go.sum
|
|
@ -1,26 +1,28 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
|
||||
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
|
||||
cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4=
|
||||
cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ=
|
||||
cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE=
|
||||
cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU=
|
||||
cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM=
|
||||
cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
|
||||
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
|
||||
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
|
||||
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
|
||||
cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8=
|
||||
cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE=
|
||||
cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk=
|
||||
cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8=
|
||||
cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE=
|
||||
cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY=
|
||||
cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo=
|
||||
cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY=
|
||||
cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc=
|
||||
cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU=
|
||||
cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU=
|
||||
cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58=
|
||||
cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8=
|
||||
cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk=
|
||||
cloud.google.com/go/pubsub v1.50.1 h1:fzbXpPyJnSGvWXF1jabhQeXyxdbCIkXTpjXHy7xviBM=
|
||||
cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk=
|
||||
cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0=
|
||||
cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E=
|
||||
cyphar.com/go-pathrs v0.2.1 h1:9nx1vOgwVvX1mNBWDu93+vaceedpbsDqo+XuBGL40b8=
|
||||
cyphar.com/go-pathrs v0.2.1/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
|
||||
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
|
||||
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d h1:NjHwOOuOgGswUOPzDlsEDJOqKdjOjwL8Vi1mj9qx9+o=
|
||||
fyne.io/systray v1.10.1-0.20240111184411-11c585fff98d/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
|
||||
github.com/AbGuthrie/goquery/v2 v2.0.1 h1:h0tIhmeRroyqYjT9zxXPXOrheNp1xqNTV+XFWuDI+eA=
|
||||
|
|
@ -109,38 +111,38 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
|||
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 h1:12SpdwU8Djs+YGklkinSSlcrPyj3H4VifVsKf78KbwA=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11/go.mod h1:dd+Lkp6YmMryke+qxW/VnKyhMBDTYP41Q2Bb+6gNZgY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3 h1:/d7ZHq/2m+1Uzw4mnizCZbTAWB/dJ3CPy0N1qUpUpI0=
|
||||
github.com/aws/aws-sdk-go-v2/feature/cloudfront/sign v1.8.3/go.mod h1:xWMYk6dLhV33jy2YrbOsv2l3fZTDMWE1yIIbvnD13gU=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE=
|
||||
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16 h1:LFB4eCU2S9wpFAkEnSqtP8CgdOk0cjMIzuXas1+rbWM=
|
||||
github.com/aws/aws-sdk-go-v2/feature/rds/auth v1.6.16/go.mod h1:Q7hjCcQzFZ9QgZ+xeJhO4X1rv7uKAl4aoBEjab6MS8k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81 h1:E5ff1vZlAudg24j5lF6F6/gBpln2LjWxGdQDBSLfVe4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.81/go.mod h1:hHBLCuhHI4Aokvs5vdVoCDBzmFy86yxs5J7LEPQwQEM=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36 h1:GMYy2EOWfzdP3wfVAGXBNKY5vK4K8vMET4sYOYltmqs=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.36/go.mod h1:gDhdAV6wL3PmPqBhiPbnlS447GoWs8HTTOYef9/9Inw=
|
||||
github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7 h1:rDNxf0CQboBMqzm6WmhGL58pYpKMjU6Qs3/BfY3Em4Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/firehose v1.37.7/go.mod h1:E1yDRkUMwlVGmDYcu5UJuwfznGNuVW29sjr2xxM2Y0w=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4 h1:nAP2GYbfh8dd2zGZqFRSMlq+/F6cMPBUuCsGAMkN074=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.4/go.mod h1:LT10DsiGjLWh4GbjInf9LQejkYEhBgBCjLG5+lvk4EE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17 h1:qcLWgdhq45sDM9na4cvXax9dyLitn8EYBRl8Ak4XtG4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.17/go.mod h1:M+jkjBFZ2J6DJrjMv2+vkBbuht6kxJYtJiwoVgX4p4U=
|
||||
github.com/aws/aws-sdk-go-v2/service/kinesis v1.35.3 h1:aAi9YBNpYMEX52Z9qy1YP2t3RhDqMcP67Ep/C4q5RiQ=
|
||||
|
|
@ -153,16 +155,16 @@ github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8 h1:HD6R8K10gPbN9CNqR
|
|||
github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.35.8/go.mod h1:x66GdH8qjYTr6Kb4ik38Ewl6moLsg8igbceNsmxVxeA=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4 h1:VT+yYtHKQiDJrNAsvoO2ExMUN3KxWsFRt+S5j1MdFGk=
|
||||
github.com/aws/aws-sdk-go-v2/service/ses v1.30.4/go.mod h1:Zftob00wu8O9xWSN1pdczm1U+E6yXk9znf+4lkt+3aQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk=
|
||||
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
|
||||
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A=
|
||||
github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE=
|
||||
github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc=
|
||||
|
|
@ -347,6 +349,8 @@ github.com/freddierice/go-losetup/v2 v2.0.1/go.mod h1:TEyBrvlOelsPEhfWD5rutNXDmU
|
|||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17 h1:GOfMz6cRgTJ9jWV0qAezv642OhPnKEG7gtUjJSdStHE=
|
||||
github.com/garyburd/go-oauth v0.0.0-20180319155456-bca2e7f09a17/go.mod h1:HfkOCN6fkKKaPSAeNq/er3xObxTW4VLeY6UUK895gLQ=
|
||||
github.com/getsentry/sentry-go v0.18.0 h1:MtBW5H9QgdcJabtZcuJG80BMOwaBpkRDZkxRkNC1sN0=
|
||||
|
|
@ -367,6 +371,8 @@ github.com/go-git/go-git/v5 v5.17.1 h1:WnljyxIzSj9BRRUlnmAU35ohDsjRK0EKmL0evDqi5
|
|||
github.com/go-git/go-git/v5 v5.17.1/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8 h1:o8UqXPI6SVwQt04RGsqKp3qqmbOfTNMqDrWsc4O47kk=
|
||||
github.com/go-json-experiment/json v0.0.0-20250517221953-25912455fbc8/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
|
||||
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
|
|
@ -466,14 +472,14 @@ github.com/google/go-github/v37 v37.0.0/go.mod h1:LM7in3NmXDrX58GbEHy7FtNLbI2Jij
|
|||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-sev-guest v0.12.1 h1:H4rFYnPIn8HtqEsNTmh56Zxcf9BI9n48ZSYCnpYLYvc=
|
||||
github.com/google/go-sev-guest v0.12.1/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
|
||||
github.com/google/go-sev-guest v0.14.0 h1:dCb4F3YrHTtrDX3cYIPTifEDz7XagZmXQioxRBW4wOo=
|
||||
github.com/google/go-sev-guest v0.14.0/go.mod h1:SK9vW+uyfuzYdVN0m8BShL3OQCtXZe/JPF7ZkpD3760=
|
||||
github.com/google/go-tdx-guest v0.3.2-0.20241009005452-097ee70d0843 h1:+MoPobRN9HrDhGyn6HnF5NYo4uMBKaiFqAtf/D/OB4A=
|
||||
github.com/google/go-tdx-guest v0.3.2-0.20241009005452-097ee70d0843/go.mod h1:g/n8sKITIT9xRivBUbizo34DTsUm2nN2uU3A662h09g=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.4.5 h1:3fhthtyMDbIZFR5/0y1hvUoZ1Kf4i1eZ7C73R4Pvd+k=
|
||||
github.com/google/go-tpm-tools v0.4.5/go.mod h1:ktjTNq8yZFD6TzdBFefUfen96rF3NpYwpSb2d8bc+Y8=
|
||||
github.com/google/go-tpm-tools v0.4.7 h1:J3ycC8umYxM9A4eF73EofRZu4BxY0jjQnUnkhIBbvws=
|
||||
github.com/google/go-tpm-tools v0.4.7/go.mod h1:gSyXTZHe3fgbzb6WEGd90QucmsnT1SRdlye82gH8QjQ=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/logger v1.1.1 h1:+6Z2geNxc9G+4D4oDO9njjjn2d0wN5d7uOo0vOIW1NQ=
|
||||
|
|
@ -484,10 +490,10 @@ github.com/google/uuid v0.0.0-20161128191214-064e2069ce9c/go.mod h1:TIyPZe4Mgqvf
|
|||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
|
||||
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ=
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc=
|
||||
github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY=
|
||||
github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g=
|
||||
github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k=
|
||||
github.com/goreleaser/chglog v0.4.2 h1:afmbT1d7lX/q+GF8wv3a1Dofs2j/Y9YkiCpGemWR6mI=
|
||||
|
|
@ -534,8 +540,8 @@ github.com/hectane/go-acl v0.0.0-20190604041725-da78bae5fc95/go.mod h1:QiyDdbZLa
|
|||
github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f h1:es0IoL1/OOoGYUuvRtSzbtG3STd7Fm5LIniUWsfzMHE=
|
||||
github.com/hillu/go-ntdll v0.0.0-20220801201350-0d23f057ef1f/go.mod h1:cHjYsnAnSckPDx8/H01Y+owD1hf2adLA6VRiw4guEbA=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
|
||||
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/igm/sockjs-go/v3 v3.0.2 h1:2m0k53w0DBiGozeQUIEPR6snZFmpFpYvVsGnfLPNXbE=
|
||||
github.com/igm/sockjs-go/v3 v3.0.2/go.mod h1:UqchsOjeagIBFHvd+RZpLaVRbCwGilEC08EDHsD1jYE=
|
||||
github.com/imdario/mergo v0.3.15 h1:M8XP7IuFNsqUx6VPK2P9OSmsYsI/YFaGil0uD21V3dM=
|
||||
|
|
@ -873,6 +879,8 @@ github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0o
|
|||
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
|
||||
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
|
||||
github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
||||
|
|
@ -898,8 +906,8 @@ github.com/ziutek/mymysql v1.5.4 h1:GB0qdRGsTwQSBVYuVShFBKaXSnSnYYC2d9knnE1LHFs=
|
|||
github.com/ziutek/mymysql v1.5.4/go.mod h1:LMSpPZ6DbqWFxNCHW77HeMg9I646SAhApZ/wKdgO/C0=
|
||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7 h1:8/dO6WWG+98PMhlZowt/YjuiKhqhGlOCwlIV8SqqGh8=
|
||||
gitlab.com/digitalxero/go-conventional-commit v1.0.7/go.mod h1:05Xc2BFsSyC5tKhK0y+P3bs0AwUtNuTp+mTpbCU/DZ0=
|
||||
go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ=
|
||||
go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg=
|
||||
go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps=
|
||||
go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ=
|
||||
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2 h1:/myBx0D/JiwTUjFkVFG3zXmDfGPfQjP/cg27qcBbdfU=
|
||||
go.elastic.co/apm/module/apmgorilla/v2 v2.6.2/go.mod h1:uONZzSIh/cKjQ2rZmINR1VXVOJDq5eWOzKrCY+bu00w=
|
||||
go.elastic.co/apm/module/apmhttp/v2 v2.7.1-0.20250407084155-22ab1be21948 h1:FS1GGVsZoIxezIGL2N3ExjQJzBA3Ne9hxp6HKvUhcRo=
|
||||
|
|
@ -955,6 +963,8 @@ go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZY
|
|||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.step.sm/crypto v0.77.1 h1:4EEqfKdv0egQ1lqz2RhnU8Jv6QgXZfrgoxWMqJF9aDs=
|
||||
go.step.sm/crypto v0.77.1/go.mod h1:U/SsmEm80mNnfD5WIkbhuW/B1eFp3fgFvdXyDLpU1AQ=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
|
|
@ -969,6 +979,7 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
|
|
@ -983,6 +994,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
|||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -1007,11 +1019,13 @@ golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qx
|
|||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
|
|
@ -1020,6 +1034,7 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
|
@ -1063,8 +1078,11 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
|
|
@ -1072,6 +1090,9 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
|
|||
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
|
|
@ -1081,6 +1102,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
|||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
|
|
@ -1097,6 +1120,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
|
|||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
|
@ -1105,8 +1129,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
|
|||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/api v0.256.0 h1:u6Khm8+F9sxbCTYNoBHg6/Hwv0N/i+V94MvkOSor6oI=
|
||||
google.golang.org/api v0.256.0/go.mod h1:KIgPhksXADEKJlnEoRa9qAII4rXcy40vfI8HRqcU964=
|
||||
google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg=
|
||||
google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
|
||||
|
|
@ -1114,12 +1138,12 @@ google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoA
|
|||
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
|
||||
google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM=
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20 h1:7ei4lp52gK1uSejlA8AZl5AJjeLUOHBQscRQZUgAcu0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260203192932-546029d2fa20/go.mod h1:ZdbssH/1SOVnjnDlXzxDHK2MCidiqXtbYccJNzNYPEE=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package mdmtest
|
|||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/tls"
|
||||
|
|
@ -27,6 +29,7 @@ import (
|
|||
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/dev_mode"
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/testhelpers"
|
||||
apple_mdm "github.com/fleetdm/fleet/v4/server/mdm/apple"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/scep/depot"
|
||||
|
|
@ -38,6 +41,7 @@ import (
|
|||
"github.com/micromdm/plist"
|
||||
"github.com/smallstep/pkcs7"
|
||||
"github.com/smallstep/scep"
|
||||
"golang.org/x/crypto/acme"
|
||||
)
|
||||
|
||||
// TestAppleMDMClient simulates a macOS MDM client.
|
||||
|
|
@ -48,6 +52,8 @@ type TestAppleMDMClient struct {
|
|||
SerialNumber string
|
||||
// Model is the model of the simulated device.
|
||||
Model string
|
||||
// OSVersion is the version of the operating system of the simulated device.
|
||||
OSVersion string
|
||||
|
||||
// EnrollInfo holds the information necessary to enroll to an MDM server.
|
||||
EnrollInfo AppleEnrollInfo
|
||||
|
|
@ -116,11 +122,24 @@ type TestAppleMDMClient struct {
|
|||
// SCEP enrollment process.
|
||||
scepKey *rsa.PrivateKey
|
||||
|
||||
acmeCertCA *x509.Certificate
|
||||
acmeCertCAKey *ecdsa.PrivateKey
|
||||
|
||||
acmeCert *x509.Certificate
|
||||
acmeKey *ecdsa.PrivateKey
|
||||
|
||||
// ACME client used to enroll via ACME if enrollment profile is ACME based.
|
||||
acmeClient *acme.Client
|
||||
|
||||
// legacyIDeviceEnrollRef is an optional enroll reference that will be added to the MDMURL after the
|
||||
// client fetches the enrollment profile but prior to attempting SCEP enrollment. Note that this
|
||||
// is not a full simulation of legacy enrollments (especially, as it related to IdP). Rather it
|
||||
// is enough to test certain SCEP renewal scenarios for iOS/IPadOS devices
|
||||
legacyIDeviceEnrollRef string
|
||||
|
||||
// skipParseEnrollProf, when set to true, will skip parsing the enrollment profile after
|
||||
// fetching it. Instead, the raw profile bytes will still be stored in enrollProfBytes.
|
||||
skipParseEnrollProf bool
|
||||
}
|
||||
|
||||
// TestMDMAppleClientOption allows configuring a TestMDMClient.
|
||||
|
|
@ -147,6 +166,20 @@ func WithOTAIdpUUID(idpUUID string) TestMDMAppleClientOption {
|
|||
}
|
||||
}
|
||||
|
||||
func WithSkipParseEnrollProf(skip bool) TestMDMAppleClientOption {
|
||||
return func(c *TestAppleMDMClient) {
|
||||
c.skipParseEnrollProf = skip
|
||||
}
|
||||
}
|
||||
|
||||
// Will set ACME CA certs, which is required if the device enrolls via the ACME flow
|
||||
func WithACMECerts(certCA *x509.Certificate, certKey *ecdsa.PrivateKey) TestMDMAppleClientOption {
|
||||
return func(c *TestAppleMDMClient) {
|
||||
c.acmeCertCA = certCA
|
||||
c.acmeCertCAKey = certKey
|
||||
}
|
||||
}
|
||||
|
||||
// Will add the specified reference as a query parameter to the MDMURL after the
|
||||
// client fetches the enrollment profile but prior to attempting SCEP enrollment. Note that this
|
||||
// is not a full simulation of legacy enrollments (especially, as it relates to IdP). Rather it
|
||||
|
|
@ -168,6 +201,15 @@ type AppleEnrollInfo struct {
|
|||
// AssignedManagedAppleID is the Assigned Managed Apple account for the device. Only used for
|
||||
// account driven enrollment flows, so it will not always be available.
|
||||
AssignedManagedAppleID string
|
||||
// ACMEURL is the optional URL that will be used for ACME enrollment instead of the SCEP.
|
||||
// Currently, this is only used for certain enrollment scenarios when
|
||||
// config.mdm.apple_require_hardware_attestation is true.
|
||||
ACMEURL string
|
||||
|
||||
// RawProfile contains the raw bytes of the enrollment profile. This is useful for tests that
|
||||
// want to inspect the actual profile content. This field is populated regardless of the value
|
||||
// of skipParseEnrollProf.
|
||||
RawProfile []byte
|
||||
}
|
||||
|
||||
// NewTestMDMClientAppleDesktopManual will create a simulated device that will fetch
|
||||
|
|
@ -344,8 +386,15 @@ func (c *TestAppleMDMClient) enrollDevice(awaitingConfiguration bool) error {
|
|||
c.EnrollInfo.MDMURL = parsedMDMURL.String()
|
||||
}
|
||||
|
||||
if err := c.SCEPEnroll(); err != nil {
|
||||
return fmt.Errorf("scep enroll: %w", err)
|
||||
if c.acmeClient != nil {
|
||||
// Do ACME enrollment
|
||||
if err := c.ACMEEnroll(); err != nil {
|
||||
return fmt.Errorf("ACME enroll: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := c.SCEPEnroll(); err != nil {
|
||||
return fmt.Errorf("scep enroll: %w", err)
|
||||
}
|
||||
}
|
||||
if err := c.Authenticate(); err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
|
|
@ -419,8 +468,10 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDesktopURL() error {
|
|||
|
||||
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
|
||||
di, err := EncodeDeviceInfo(fleet.MDMAppleMachineInfo{
|
||||
Serial: c.SerialNumber,
|
||||
UDID: c.UUID,
|
||||
Serial: c.SerialNumber,
|
||||
UDID: c.UUID,
|
||||
Product: c.Model,
|
||||
OSVersion: c.OSVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("test client: encoding device info: %w", err)
|
||||
|
|
@ -432,8 +483,10 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURL() error {
|
|||
|
||||
func (c *TestAppleMDMClient) fetchEnrollmentProfileFromDEPURLUsingPost() error {
|
||||
buf, err := MachineInfoAsPKCS7(fleet.MDMAppleMachineInfo{
|
||||
Serial: c.SerialNumber,
|
||||
UDID: c.UUID,
|
||||
Serial: c.SerialNumber,
|
||||
UDID: c.UUID,
|
||||
Product: c.Model,
|
||||
OSVersion: c.OSVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("test client: encoding device info: %w", err)
|
||||
|
|
@ -619,10 +672,15 @@ func (c *TestAppleMDMClient) fetchOTAProfile(url string) error {
|
|||
if err != nil {
|
||||
return fmt.Errorf("verifying enrollment profile: %w", err)
|
||||
}
|
||||
if c.skipParseEnrollProf {
|
||||
c.EnrollInfo.RawProfile = p7.Content
|
||||
return nil
|
||||
}
|
||||
enrollInfo, err := ParseEnrollmentProfile(p7.Content)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse OTA SCEP profile: %w", err)
|
||||
}
|
||||
enrollInfo.RawProfile = p7.Content
|
||||
c.EnrollInfo = *enrollInfo
|
||||
return nil
|
||||
}
|
||||
|
|
@ -678,13 +736,28 @@ func (c *TestAppleMDMClient) fetchEnrollmentProfile(path string, body []byte) (e
|
|||
|
||||
rawProfile = p7.Content
|
||||
}
|
||||
|
||||
if c.skipParseEnrollProf {
|
||||
c.EnrollInfo.RawProfile = rawProfile
|
||||
return nil
|
||||
}
|
||||
enrollInfo, err := ParseEnrollmentProfile(rawProfile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse enrollment profile: %w", err)
|
||||
}
|
||||
enrollInfo.RawProfile = rawProfile
|
||||
c.EnrollInfo = *enrollInfo
|
||||
|
||||
if enrollInfo.ACMEURL != "" {
|
||||
if c.acmeCertCA == nil || c.acmeCertCAKey == nil {
|
||||
return errors.New("ACME enrollment requested but no cert/key provided")
|
||||
}
|
||||
c.acmeClient = &acme.Client{
|
||||
Key: c.acmeCertCAKey,
|
||||
DirectoryURL: enrollInfo.ACMEURL,
|
||||
HTTPClient: fleethttp.NewClient(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -830,6 +903,96 @@ func (c *TestAppleMDMClient) SCEPEnroll() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (c *TestAppleMDMClient) ACMEEnroll() error {
|
||||
if c.acmeClient == nil {
|
||||
return errors.New("ACME URL not set in enrollment profile")
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err := c.acmeClient.Register(ctx, &acme.Account{}, func(tosURL string) bool { return true })
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME register account: %w", err)
|
||||
}
|
||||
|
||||
order, err := c.acmeClient.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "permanent-identifier", Value: c.SerialNumber}})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME authorize order: %w", err)
|
||||
}
|
||||
|
||||
if len(order.AuthzURLs) != 1 {
|
||||
// We only create on authz for an order
|
||||
return fmt.Errorf("expected 1 authz URL, got %d", len(order.AuthzURLs))
|
||||
}
|
||||
|
||||
authz, err := c.acmeClient.GetAuthorization(ctx, order.AuthzURLs[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME get authorization: %w", err)
|
||||
}
|
||||
|
||||
if len(authz.Challenges) != 1 {
|
||||
// We only create one challenge for an authz
|
||||
return fmt.Errorf("expected 1 challenge, got %d", len(authz.Challenges))
|
||||
}
|
||||
|
||||
if authz.Challenges[0].Type != "device-attest-01" {
|
||||
return fmt.Errorf("expected challenge type device-attest-01, got %s", authz.Challenges[0].Type)
|
||||
}
|
||||
|
||||
challenge := authz.Challenges[0]
|
||||
leafCert, err := testhelpers.BuildAttestationLeafCert(c.acmeCertCA, c.acmeCertCAKey, c.SerialNumber, challenge.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build attestation leaf cert: %w", err)
|
||||
}
|
||||
payload, err := testhelpers.BuildAppleDeviceAttestationPayload(leafCert, c.acmeCertCA)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build Apple device attestation payload: %w", err)
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
challenge.Payload = jsonPayload
|
||||
challenge, err = c.acmeClient.Accept(ctx, challenge)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME accept challenge: %w", err)
|
||||
}
|
||||
|
||||
if challenge.Status != "valid" {
|
||||
return fmt.Errorf("challenge not valid after acceptance, status: %s", challenge.Status)
|
||||
}
|
||||
|
||||
encoded, acmeKey, err := testhelpers.GenerateCSRDER(c.SerialNumber)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate CSR DER: %w", err)
|
||||
}
|
||||
|
||||
// crypto/acme lib base64encodes inside the method, so we have to decode it again here
|
||||
decoded, err := base64.RawURLEncoding.DecodeString(encoded)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode CSR DER: %w", err)
|
||||
}
|
||||
|
||||
der, _, err := c.acmeClient.CreateOrderCert(ctx, order.FinalizeURL, decoded, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ACME create order cert and fetch cert: %w", err)
|
||||
}
|
||||
|
||||
if len(der) != 1 {
|
||||
// Since we don't bundle in CreateOrderCert, we only expect the leaf cert that we can sign requests with.
|
||||
return fmt.Errorf("expected 1 certificate in ACME response, got %d", len(der))
|
||||
}
|
||||
|
||||
acmeCert, err := x509.ParseCertificate(der[0])
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse x509 ACME certificate: %w", err)
|
||||
}
|
||||
|
||||
c.acmeCert = acmeCert
|
||||
// We can reuse the same key we used for the CSR since it's the one that matches the cert
|
||||
c.acmeKey = acmeKey
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticate sends the Authenticate message to the MDM server (Check In protocol).
|
||||
func (c *TestAppleMDMClient) Authenticate() error {
|
||||
payload := map[string]any{
|
||||
|
|
@ -1188,6 +1351,16 @@ func (c *TestAppleMDMClient) sendAndDecodeCommandResponse(payload map[string]any
|
|||
return &p, nil
|
||||
}
|
||||
|
||||
func (c *TestAppleMDMClient) getSignerCertAndKey() (*x509.Certificate, crypto.PrivateKey, error) {
|
||||
if c.scepCert != nil && c.scepKey != nil {
|
||||
return c.scepCert, c.scepKey, nil
|
||||
}
|
||||
if c.acmeCert != nil && c.acmeKey != nil {
|
||||
return c.acmeCert, c.acmeKey, nil
|
||||
}
|
||||
return nil, nil, errors.New("no signer certificate and key available")
|
||||
}
|
||||
|
||||
func (c *TestAppleMDMClient) request(contentType string, payload map[string]any) (*http.Response, error) {
|
||||
body, err := plist.Marshal(payload)
|
||||
if err != nil {
|
||||
|
|
@ -1198,7 +1371,11 @@ func (c *TestAppleMDMClient) request(contentType string, payload map[string]any)
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("create signed data: %w", err)
|
||||
}
|
||||
err = signedData.AddSigner(c.scepCert, c.scepKey, pkcs7.SignerInfoConfig{})
|
||||
cert, key, err := c.getSignerCertAndKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get signer certificate and key: %w", err)
|
||||
}
|
||||
err = signedData.AddSigner(cert, key, pkcs7.SignerInfoConfig{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add signer: %w", err)
|
||||
}
|
||||
|
|
@ -1237,21 +1414,12 @@ func (c *TestAppleMDMClient) request(contentType string, payload map[string]any)
|
|||
// ParseEnrollmentProfile parses the enrollment profile and returns the parsed information as EnrollInfo.
|
||||
func ParseEnrollmentProfile(mobileConfig []byte) (*AppleEnrollInfo, error) {
|
||||
var enrollmentProfile struct {
|
||||
PayloadContent []map[string]interface{} `plist:"PayloadContent"`
|
||||
PayloadContent []map[string]any `plist:"PayloadContent"`
|
||||
}
|
||||
if err := plist.Unmarshal(mobileConfig, &enrollmentProfile); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal enrollment profile: %w", err)
|
||||
}
|
||||
payloadContent := enrollmentProfile.PayloadContent[0]["PayloadContent"].(map[string]interface{})
|
||||
|
||||
scepChallenge, ok := payloadContent["Challenge"].(string)
|
||||
if !ok || scepChallenge == "" {
|
||||
return nil, errors.New("SCEP Challenge field not found")
|
||||
}
|
||||
scepURL, ok := payloadContent["URL"].(string)
|
||||
if !ok || scepURL == "" {
|
||||
return nil, errors.New("SCEP URL field not found")
|
||||
}
|
||||
mdmURL, ok := enrollmentProfile.PayloadContent[1]["ServerURL"].(string)
|
||||
if !ok || mdmURL == "" {
|
||||
return nil, errors.New("MDM ServerURL field not found")
|
||||
|
|
@ -1269,12 +1437,77 @@ func ParseEnrollmentProfile(mobileConfig []byte) (*AppleEnrollInfo, error) {
|
|||
assignedManagedAppleID = assignedManagedAppleIDVal.(string)
|
||||
}
|
||||
|
||||
return &AppleEnrollInfo{
|
||||
SCEPChallenge: scepChallenge,
|
||||
SCEPURL: scepURL,
|
||||
enrollInfo := &AppleEnrollInfo{
|
||||
MDMURL: mdmURL,
|
||||
AssignedManagedAppleID: assignedManagedAppleID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
var err error
|
||||
payloadContent, ok := enrollmentProfile.PayloadContent[0]["PayloadContent"].(map[string]any)
|
||||
if ok {
|
||||
enrollInfo, err = parseSCEPEnrollmentPayload(*enrollInfo, payloadContent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// Check for ACME
|
||||
_, ok := enrollmentProfile.PayloadContent[0]["DirectoryURL"].(string)
|
||||
_, ok2 := enrollmentProfile.PayloadContent[0]["HardwareBound"].(bool)
|
||||
_, ok3 := enrollmentProfile.PayloadContent[0]["Attest"].(bool)
|
||||
if !ok || !ok2 || !ok3 {
|
||||
// One of ACME fields are not present (we don't care about the value, but they need to be present)
|
||||
return nil, errors.New("not a valid ACME or SCEP enrollment profile")
|
||||
}
|
||||
|
||||
enrollInfo, err = parseACMEEnrollmentPayload(*enrollInfo, enrollmentProfile.PayloadContent[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return enrollInfo, nil
|
||||
}
|
||||
|
||||
func parseSCEPEnrollmentPayload(enrollInfo AppleEnrollInfo, payloadContent map[string]any) (*AppleEnrollInfo, error) {
|
||||
scepChallenge, ok := payloadContent["Challenge"].(string)
|
||||
if !ok || scepChallenge == "" {
|
||||
return nil, errors.New("SCEP Challenge field not found")
|
||||
}
|
||||
scepURL, ok := payloadContent["URL"].(string)
|
||||
if !ok || scepURL == "" {
|
||||
return nil, errors.New("SCEP URL field not found")
|
||||
}
|
||||
|
||||
enrollInfo.SCEPChallenge = scepChallenge
|
||||
enrollInfo.SCEPURL = scepURL
|
||||
return &enrollInfo, nil
|
||||
}
|
||||
|
||||
func parseACMEEnrollmentPayload(enrollInfo AppleEnrollInfo, payloadContent map[string]any) (*AppleEnrollInfo, error) {
|
||||
directoryURL, ok := payloadContent["DirectoryURL"].(string)
|
||||
if !ok || directoryURL == "" {
|
||||
return nil, errors.New("ACME DirectoryURL field not found")
|
||||
}
|
||||
|
||||
// TODO CLEAN UP
|
||||
/*
|
||||
PayloadIdentifier: BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1
|
||||
PayloadUUID: BCA53F9D-5DD2-494D-98D3-0D0F20FF6BA1
|
||||
ClientIdentifier: 1c0a81b9-1f30-4393-aa5d-0d4064640233
|
||||
DirectoryURL: http://127.0.0.1:63768/api/mdm/acme/0639af7d-7009-4ac2-9b73-839984323b68/directory
|
||||
PayloadDisplayName: Fleet Identity ACME
|
||||
PayloadType: com.apple.security.acme
|
||||
HardwareBound: true
|
||||
KeySize: 384
|
||||
PayloadVersion: 1
|
||||
Subject: [[[CN %SerialNumber%]]]
|
||||
KeyType: ECSECPrimeRandom
|
||||
Attest: true
|
||||
*/
|
||||
|
||||
// TODO: Directory URL or just base URL with identifier
|
||||
enrollInfo.ACMEURL = directoryURL
|
||||
return &enrollInfo, nil
|
||||
}
|
||||
|
||||
// numbers plus capital letters without I, L, O for readability
|
||||
|
|
|
|||
|
|
@ -184,6 +184,8 @@ type GitOpsControls struct {
|
|||
AndroidEnabledAndConfigured any `json:"android_enabled_and_configured"`
|
||||
AndroidSettings any `json:"android_settings"`
|
||||
|
||||
AppleRequireHardwareAttestation any `json:"apple_require_hardware_attestation"`
|
||||
|
||||
EnableDiskEncryption any `json:"enable_disk_encryption"`
|
||||
EnableRecoveryLockPassword any `json:"enable_recovery_lock_password"`
|
||||
RequireBitLockerPIN any `json:"windows_require_bitlocker_pin,omitempty"`
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ func TestValidGitOpsYaml(t *testing.T) {
|
|||
assert.True(t, ok, "windows_entra_tenant_ids not found")
|
||||
_, ok = gitops.Controls.WindowsUpdates.(map[string]interface{})
|
||||
assert.True(t, ok, "windows_updates not found")
|
||||
_, ok = gitops.Controls.AppleRequireHardwareAttestation.(bool)
|
||||
assert.True(t, ok, "apple_require_hardware_attestation not found")
|
||||
assert.Equal(t, "fleet_secret", gitops.FleetSecrets["FLEET_SECRET_FLEET_SECRET_"])
|
||||
assert.Equal(t, "secret_name", gitops.FleetSecrets["FLEET_SECRET_NAME"])
|
||||
assert.Equal(t, "10", gitops.FleetSecrets["FLEET_SECRET_LENGTH"])
|
||||
|
|
|
|||
1
pkg/spec/testdata/controls.yml
vendored
1
pkg/spec/testdata/controls.yml
vendored
|
|
@ -29,6 +29,7 @@ windows_enabled_and_configured: true
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: []
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
1
pkg/spec/testdata/controls_new_names.yml
vendored
1
pkg/spec/testdata/controls_new_names.yml
vendored
|
|
@ -30,6 +30,7 @@ windows_enabled_and_configured: true
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: []
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
1
pkg/spec/testdata/global_config_no_paths.yml
vendored
1
pkg/spec/testdata/global_config_no_paths.yml
vendored
|
|
@ -31,6 +31,7 @@ controls: # Controls added to "No team"
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: []
|
||||
apple_require_hardware_attestation: false
|
||||
windows_updates:
|
||||
deadline_days: null
|
||||
grace_period_days: null
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ controls:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: []
|
||||
apple_require_hardware_attestation: false
|
||||
software:
|
||||
app_store_apps:
|
||||
- app_store_id: "123456"
|
||||
|
|
|
|||
1
pkg/spec/testdata/team_config_no_paths.yml
vendored
1
pkg/spec/testdata/team_config_no_paths.yml
vendored
|
|
@ -65,6 +65,7 @@ controls:
|
|||
windows_migration_enabled: false
|
||||
enable_turn_on_windows_mdm_manually: false
|
||||
windows_entra_tenant_ids: []
|
||||
apple_require_hardware_attestation: false
|
||||
labels:
|
||||
- name: a
|
||||
description: A cool global label
|
||||
|
|
|
|||
57
server/acl/acmeacl/fleet_adapter.go
Normal file
57
server/acl/acmeacl/fleet_adapter.go
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
// Package acmeacl provides the anti-corruption layer between the ACME
|
||||
// bounded context and legacy Fleet code.
|
||||
//
|
||||
// This package is the ONLY place that imports both ACME types and fleet types.
|
||||
// It translates between them, allowing the ACME context to remain decoupled
|
||||
// from legacy code.
|
||||
package acmeacl
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
)
|
||||
|
||||
// FleetDatastoreAdapter adapts fleet.Datastore to the narrow
|
||||
// acme.DataProviders interface that the ACME bounded context requires.
|
||||
type FleetDatastoreAdapter struct {
|
||||
ds fleet.Datastore
|
||||
signer acme.CSRSigner
|
||||
}
|
||||
|
||||
// NewFleetDatastoreAdapter creates a new adapter for the Fleet datastore.
|
||||
func NewFleetDatastoreAdapter(ds fleet.Datastore, signer acme.CSRSigner) *FleetDatastoreAdapter {
|
||||
return &FleetDatastoreAdapter{ds: ds, signer: signer}
|
||||
}
|
||||
|
||||
// Ensure FleetDatastoreAdapter implements acme.DataProviders
|
||||
var _ acme.DataProviders = (*FleetDatastoreAdapter)(nil)
|
||||
|
||||
func (a *FleetDatastoreAdapter) ServerURL(ctx context.Context) (string, error) {
|
||||
appCfg, err := a.ds.AppConfig(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return appCfg.MDMUrl(), nil
|
||||
}
|
||||
|
||||
func (a *FleetDatastoreAdapter) GetCACertificatePEM(ctx context.Context) ([]byte, error) {
|
||||
assets, err := a.ds.GetAllMDMConfigAssetsByName(ctx, []fleet.MDMAssetName{fleet.MDMAssetCACert}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assets[fleet.MDMAssetCACert].Value, nil
|
||||
}
|
||||
|
||||
func (a *FleetDatastoreAdapter) CSRSigner(_ context.Context) (acme.CSRSigner, error) {
|
||||
return a.signer, nil
|
||||
}
|
||||
|
||||
func (a *FleetDatastoreAdapter) IsDEPEnrolled(ctx context.Context, serial string) (bool, error) {
|
||||
assignments, err := a.ds.GetHostDEPAssignmentsBySerial(ctx, serial)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return len(assignments) > 0, nil
|
||||
}
|
||||
|
|
@ -1740,13 +1740,14 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
|
|||
}
|
||||
|
||||
stmt := `
|
||||
INSERT INTO host_dep_assignments (host_id, abm_token_id, mdm_migration_deadline)
|
||||
INSERT INTO host_dep_assignments (host_id, abm_token_id, mdm_migration_deadline, hardware_serial)
|
||||
VALUES %s
|
||||
ON DUPLICATE KEY UPDATE
|
||||
added_at = CURRENT_TIMESTAMP,
|
||||
deleted_at = NULL,
|
||||
abm_token_id = VALUES(abm_token_id),
|
||||
mdm_migration_deadline = VALUES(mdm_migration_deadline)`
|
||||
added_at = CURRENT_TIMESTAMP,
|
||||
deleted_at = NULL,
|
||||
abm_token_id = VALUES(abm_token_id),
|
||||
mdm_migration_deadline = VALUES(mdm_migration_deadline),
|
||||
hardware_serial = VALUES(hardware_serial)`
|
||||
|
||||
args := []interface{}{}
|
||||
values := []string{}
|
||||
|
|
@ -1755,8 +1756,8 @@ func upsertHostDEPAssignmentsDB(ctx context.Context, tx sqlx.ExtContext, hosts [
|
|||
if d, ok := migrationDeadlinesByHostID[host.ID]; ok {
|
||||
deadline = &d
|
||||
}
|
||||
args = append(args, host.ID, abmTokenID, deadline)
|
||||
values = append(values, "(?, ?, ?)")
|
||||
args = append(args, host.ID, abmTokenID, deadline, host.HardwareSerial)
|
||||
values = append(values, "(?, ?, ?, ?)")
|
||||
}
|
||||
|
||||
_, err := tx.ExecContext(ctx, fmt.Sprintf(stmt, strings.Join(values, ",")), args...)
|
||||
|
|
@ -2038,6 +2039,18 @@ func (ds *Datastore) GetHostDEPAssignment(ctx context.Context, hostID uint) (*fl
|
|||
return &res, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetHostDEPAssignmentsBySerial(ctx context.Context, serial string) ([]*fleet.HostDEPAssignment, error) {
|
||||
var res []*fleet.HostDEPAssignment
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, `
|
||||
SELECT host_id, added_at, deleted_at, abm_token_id, mdm_migration_deadline, mdm_migration_completed
|
||||
FROM host_dep_assignments hdep
|
||||
WHERE hdep.hardware_serial = ? AND hdep.deleted_at IS NULL`, serial)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrapf(ctx, err, "getting host dep assignments by serial")
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetHostMDMMigrationCompleted(ctx context.Context, hostID uint) error {
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
UPDATE host_dep_assignments
|
||||
|
|
@ -4548,7 +4561,7 @@ func (ds *Datastore) ListMDMAppleDEPSerialsInTeam(ctx context.Context, teamID *u
|
|||
|
||||
stmt := fmt.Sprintf(`
|
||||
SELECT
|
||||
hardware_serial
|
||||
h.hardware_serial
|
||||
FROM
|
||||
hosts h
|
||||
JOIN host_dep_assignments hda ON hda.host_id = h.id
|
||||
|
|
@ -4573,7 +4586,7 @@ func (ds *Datastore) ListMDMAppleDEPSerialsInHostIDs(ctx context.Context, hostID
|
|||
|
||||
stmt := `
|
||||
SELECT
|
||||
hardware_serial
|
||||
h.hardware_serial
|
||||
FROM
|
||||
hosts h
|
||||
JOIN host_dep_assignments hda ON hda.host_id = h.id
|
||||
|
|
@ -4749,7 +4762,7 @@ SET
|
|||
response_updated_at = CURRENT_TIMESTAMP,
|
||||
retry_job_id = 0
|
||||
WHERE
|
||||
hardware_serial IN (?)
|
||||
hosts.hardware_serial IN (?)
|
||||
`, setABMTokenID)
|
||||
var args []interface{}
|
||||
var err error
|
||||
|
|
@ -4848,7 +4861,7 @@ func (ds *Datastore) GetDEPAssignProfileExpiredCooldowns(ctx context.Context) (m
|
|||
const stmt = `
|
||||
SELECT
|
||||
COALESCE(team_id, 0) AS team_id,
|
||||
hardware_serial
|
||||
h.hardware_serial
|
||||
FROM
|
||||
host_dep_assignments
|
||||
JOIN hosts h ON h.id = host_id
|
||||
|
|
@ -4893,7 +4906,7 @@ JOIN
|
|||
SET
|
||||
retry_job_id = ?
|
||||
WHERE
|
||||
hardware_serial IN (?)`
|
||||
hosts.hardware_serial IN (?)`
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, jobID, serials)
|
||||
if err != nil {
|
||||
|
|
@ -6334,16 +6347,16 @@ func (ds *Datastore) ListIOSAndIPadOSToRefetch(ctx context.Context, interval tim
|
|||
err error,
|
||||
) {
|
||||
hostsStmt := `
|
||||
SELECT
|
||||
h.id as host_id,
|
||||
h.uuid as uuid,
|
||||
SELECT
|
||||
h.id as host_id,
|
||||
h.uuid as uuid,
|
||||
hmdm.installed_from_dep,
|
||||
JSON_ARRAYAGG(hmc.command_type) as commands_already_sent
|
||||
FROM hosts h
|
||||
INNER JOIN host_mdm hmdm ON hmdm.host_id = h.id
|
||||
INNER JOIN nano_enrollments ne ON ne.id = h.uuid
|
||||
LEFT JOIN host_mdm_commands hmc ON hmc.host_id = h.id AND hmc.command_type IN (?)
|
||||
WHERE
|
||||
WHERE
|
||||
(h.platform = 'ios' OR h.platform = 'ipados')
|
||||
AND TRIM(h.uuid) != ''
|
||||
AND TIMESTAMPDIFF(SECOND, h.detail_updated_at, NOW()) > ?
|
||||
|
|
@ -6980,7 +6993,7 @@ FROM
|
|||
JOIN
|
||||
host_dep_assignments hdep ON h.id = host_id
|
||||
WHERE
|
||||
hardware_serial = ? AND deleted_at IS NULL
|
||||
h.hardware_serial = ? AND deleted_at IS NULL
|
||||
LIMIT 1`
|
||||
|
||||
var dest struct {
|
||||
|
|
@ -7173,10 +7186,11 @@ LIMIT ?
|
|||
return res, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error) {
|
||||
func (ds *Datastore) GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error) {
|
||||
res := []struct {
|
||||
LastMDMEnrollmentTime *time.Time `db:"authenticate_at"`
|
||||
LastMDMSeenTime *time.Time `db:"last_seen_at"`
|
||||
HardwareAttested bool `db:"hardware_attested"`
|
||||
}{}
|
||||
// We are specifically only looking at the singular device enrollment row and not the
|
||||
// potentially many user enrollment rows that will exist for a given device. The device
|
||||
|
|
@ -7184,19 +7198,19 @@ func (ds *Datastore) GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID str
|
|||
// those same lines authenticate_at gets updated only at the authenticate step during the
|
||||
// enroll process and as such is a good indicator of the last enrollment or reenrollment.
|
||||
query := `
|
||||
SELECT nd.authenticate_at, ne.last_seen_at
|
||||
SELECT nd.authenticate_at, ne.last_seen_at, ne.hardware_attested
|
||||
FROM nano_devices nd
|
||||
INNER JOIN nano_enrollments ne ON ne.id = nd.id
|
||||
WHERE ne.type IN ('Device', 'User Enrollment (Device)') AND nd.id = ?`
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &res, query, hostUUID)
|
||||
|
||||
if err == sql.ErrNoRows || len(res) == 0 {
|
||||
return nil, nil, nil
|
||||
return nil, nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
|
||||
return nil, nil, false, ctxerr.Wrap(ctx, err, "get mdm enrollment times")
|
||||
}
|
||||
return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, nil
|
||||
return res[0].LastMDMEnrollmentTime, res[0].LastMDMSeenTime, res[0].HardwareAttested, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) AssociateHostMDMIdPAccount(ctx context.Context, hostUUID, idpAcctUUID string) error {
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ func TestMDMApple(t *testing.T) {
|
|||
{"AggregateMacOSSettingsAllPlatforms", testAggregateMacOSSettingsAllPlatforms},
|
||||
{"GetMDMAppleEnrolledDeviceDeletedFromFleet", testGetMDMAppleEnrolledDeviceDeletedFromFleet},
|
||||
{"SetMDMAppleProfilesWithVariables", testSetMDMAppleProfilesWithVariables},
|
||||
{"GetNanoMDMEnrollmentTimes", testGetNanoMDMEnrollmentTimes},
|
||||
{"GetNanoMDMEnrollmentDetails", testGetNanoMDMEnrollmentDetails},
|
||||
{"GetNanoMDMUserEnrollment", testGetNanoMDMUserEnrollment},
|
||||
{"TestDeleteMDMAppleDeclarationWithPendingInstalls", testDeleteMDMAppleDeclarationWithPendingInstalls},
|
||||
{"TestUpdateNanoMDMUserEnrollmentUsername", testUpdateNanoMDMUserEnrollmentUsername},
|
||||
|
|
@ -5420,10 +5420,10 @@ func testMDMAppleResetEnrollment(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
// add a record of the host DEP assignment
|
||||
_, err = ds.writer(ctx).Exec(`
|
||||
INSERT INTO host_dep_assignments (host_id)
|
||||
VALUES (?)
|
||||
INSERT INTO host_dep_assignments (host_id, hardware_serial)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE added_at = CURRENT_TIMESTAMP, deleted_at = NULL
|
||||
`, host.ID)
|
||||
`, host.ID, host.HardwareSerial)
|
||||
require.NoError(t, err)
|
||||
cmd, err := ds.GetHostBootstrapPackageCommand(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
|
|
@ -5502,7 +5502,7 @@ func testMDMAppleDeleteHostDEPAssignments(t *testing.T, ds *Datastore) {
|
|||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
return sqlx.SelectContext(
|
||||
ctx, q, &got,
|
||||
`SELECT hardware_serial FROM hosts h
|
||||
`SELECT h.hardware_serial FROM hosts h
|
||||
JOIN host_dep_assignments hda ON hda.host_id = h.id
|
||||
WHERE hda.deleted_at IS NULL`,
|
||||
)
|
||||
|
|
@ -9090,7 +9090,7 @@ func testAppleMDMSetBatchAsyncLastSeenAt(t *testing.T, ds *Datastore) {
|
|||
require.True(t, ts2b.After(ts2))
|
||||
}
|
||||
|
||||
func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
|
||||
func testGetNanoMDMEnrollmentDetails(t *testing.T, ds *Datastore) {
|
||||
ctx := t.Context()
|
||||
host, err := ds.NewHost(ctx, &fleet.Host{
|
||||
Hostname: "test-host1-name",
|
||||
|
|
@ -9102,7 +9102,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err := ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, lastMDMEnrolledAt)
|
||||
require.Nil(t, lastMDMSeenAt)
|
||||
|
|
@ -9111,7 +9111,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
|
|||
// returned yet
|
||||
nanoEnroll(t, ds, host, true)
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
|
||||
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
|
||||
|
|
@ -9128,7 +9128,7 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
|
|||
require.NoError(t, err)
|
||||
nanoEnrollUserDevice(t, ds, byodHost)
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, byodHost.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, lastMDMEnrolledAt) // defaults to current time on creation
|
||||
require.NotNil(t, lastMDMSeenAt) // defaults to time 0 value
|
||||
|
|
@ -9164,14 +9164,14 @@ func testGetNanoMDMEnrollmentTimes(t *testing.T, ds *Datastore) {
|
|||
return nil
|
||||
})
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, lastMDMEnrolledAt)
|
||||
assert.Equal(t, authenticateTime, *lastMDMEnrolledAt)
|
||||
require.NotNil(t, lastMDMSeenAt)
|
||||
assert.Equal(t, deviceEnrollTime, *lastMDMSeenAt)
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err = ds.GetNanoMDMEnrollmentTimes(ctx, byodHost.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err = ds.GetNanoMDMEnrollmentDetails(ctx, byodHost.UUID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, lastMDMEnrolledAt)
|
||||
assert.Equal(t, byodDeviceAuthenticateTime, *lastMDMEnrolledAt)
|
||||
|
|
@ -9202,7 +9202,7 @@ func testGetNanoMDMUserEnrollment(t *testing.T, ds *Datastore) {
|
|||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, err := ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
|
||||
lastMDMEnrolledAt, lastMDMSeenAt, _, err := ds.GetNanoMDMEnrollmentDetails(ctx, host.UUID)
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, lastMDMEnrolledAt)
|
||||
require.Nil(t, lastMDMSeenAt)
|
||||
|
|
@ -9924,7 +9924,7 @@ func testGetDEPAssignProfileExpiredCooldowns(t *testing.T, ds *Datastore) {
|
|||
host := newTestHostWithPlatform(t, ds, "macos", "macos", nil)
|
||||
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid) VALUES (?, ?)`, host.ID, uuid.NewString())
|
||||
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, hardware_serial) VALUES (?, ?, ?)`, host.ID, uuid.NewString(), host.HardwareSerial)
|
||||
return err
|
||||
})
|
||||
|
||||
|
|
@ -9954,7 +9954,7 @@ func testGetDEPAssignProfileExpiredCooldowns(t *testing.T, ds *Datastore) {
|
|||
for i := range 200 {
|
||||
h := newTestHostWithPlatform(t, ds, fmt.Sprintf("host-%d", i), "macos", nil)
|
||||
ExecAdhocSQL(t, ds, func(q sqlx.ExtContext) error {
|
||||
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, assign_profile_response, response_updated_at, retry_job_id) VALUES (?, ?, ?, DATE_SUB(NOW(), INTERVAL ? SECOND), 0)`, h.ID, uuid.NewString(), fleet.DEPAssignProfileResponseFailed, depFailedCooldownPeriod.Seconds()+10)
|
||||
_, err := q.ExecContext(ctx, `INSERT INTO host_dep_assignments (host_id, profile_uuid, assign_profile_response, response_updated_at, retry_job_id, hardware_serial) VALUES (?, ?, ?, DATE_SUB(NOW(), INTERVAL ? SECOND), 0, ?)`, h.ID, uuid.NewString(), fleet.DEPAssignProfileResponseFailed, depFailedCooldownPeriod.Seconds()+10, h.HardwareSerial)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,14 +70,14 @@ func (ds *Datastore) GetHostIdentityCertByName(ctx context.Context, name string)
|
|||
// certificate-based authentication on the My Device page.
|
||||
//
|
||||
// This query uses the nano_cert_auth_associations table which maps device IDs to
|
||||
// certificate hashes. The serial number lookup in scep_certificates provides
|
||||
// certificate hashes. The serial number lookup in identity_certificates provides
|
||||
// the raw certificate data, but we need the nanomdm association to get the device UUID.
|
||||
func (ds *Datastore) GetMDMSCEPCertBySerial(ctx context.Context, serialNumber uint64) (deviceUUID string, err error) {
|
||||
// First get the certificate by serial
|
||||
var certPEM string
|
||||
err = sqlx.GetContext(ctx, ds.reader(ctx), &certPEM, `
|
||||
SELECT certificate_pem
|
||||
FROM scep_certificates
|
||||
FROM identity_certificates
|
||||
WHERE serial = ?
|
||||
AND not_valid_after > NOW()
|
||||
AND revoked = 0`, serialNumber)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ var (
|
|||
)
|
||||
|
||||
var (
|
||||
hostSearchColumns = []string{"hostname", "computer_name", "uuid", "hardware_serial", "primary_ip"}
|
||||
hostSearchColumns = []string{"hostname", "computer_name", "uuid", "h.hardware_serial", "primary_ip"}
|
||||
wildCardableHostSearchColumns = []string{"hostname", "computer_name"}
|
||||
)
|
||||
|
||||
|
|
@ -6085,7 +6085,7 @@ func (ds *Datastore) GetMatchingHostSerialsMarkedDeleted(ctx context.Context, se
|
|||
|
||||
stmt := `
|
||||
SELECT
|
||||
hardware_serial
|
||||
h.hardware_serial
|
||||
FROM
|
||||
hosts h
|
||||
JOIN host_dep_assignments hdep ON hdep.host_id = h.id
|
||||
|
|
|
|||
|
|
@ -9011,7 +9011,7 @@ func testHostsDeleteHosts(t *testing.T, ds *Datastore) {
|
|||
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_software_installed_paths (host_id, software_id, installed_path) VALUES (?, ?, ?)`, host.ID, 1, "some_path")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, host.ID)
|
||||
_, err = ds.writer(context.Background()).Exec(`INSERT INTO host_dep_assignments (host_id, hardware_serial) VALUES (?, ?)`, host.ID, host.HardwareSerial)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = ds.writer(context.Background()).Exec(`
|
||||
|
|
|
|||
|
|
@ -1763,6 +1763,42 @@ LIMIT ?`, expiryDays, limit)
|
|||
return uuids, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetDeviceInfoForACMERenewal(ctx context.Context, hostUUIDs []string) ([]fleet.DeviceInfoForACMERenewal, error) {
|
||||
if len(hostUUIDs) == 0 {
|
||||
return []fleet.DeviceInfoForACMERenewal{}, nil
|
||||
}
|
||||
|
||||
// TODO(mna): anyone knows what those TODOs (from Sarah's PRs) were for?
|
||||
// TODO: refactor this to use hw model from host_dep_assignments once we have that fully in place
|
||||
// TODO: confirm we can rely on host_operating_system and operating_systems tables for accurate OS version information
|
||||
stmt := `
|
||||
SELECT
|
||||
h.uuid AS host_uuid,
|
||||
h.hardware_serial AS hardware_serial,
|
||||
h.hardware_model AS hardware_model,
|
||||
os.version AS os_version
|
||||
FROM
|
||||
hosts h
|
||||
JOIN host_dep_assignments hda ON hda.host_id = h.id
|
||||
JOIN host_operating_system hos ON hos.host_id = h.id
|
||||
JOIN operating_systems os ON os.id = hos.os_id
|
||||
WHERE
|
||||
h.uuid IN(?)
|
||||
AND hda.deleted_at IS NULL
|
||||
AND os.name = 'macOS'`
|
||||
|
||||
stmt, args, err := sqlx.In(stmt, hostUUIDs)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "building sqlx.In query for ACME hardware attestation")
|
||||
}
|
||||
|
||||
var dest []fleet.DeviceInfoForACMERenewal
|
||||
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &dest, stmt, args...); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get host details for ACME hardware attestation")
|
||||
}
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []fleet.SCEPIdentityAssociation, cmdUUID string) error {
|
||||
if len(assocs) == 0 {
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -0,0 +1,129 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260401153000, Down_20260401153000)
|
||||
}
|
||||
|
||||
func Up_20260401153000(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE nano_enrollments
|
||||
ADD COLUMN hardware_attested TINYINT(1) NOT NULL DEFAULT 0
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "add nano_enrollments hardware_attested column")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE scep_serials RENAME TO identity_serials
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "rename scep_serials to identity_serials")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
ALTER TABLE scep_certificates RENAME TO identity_certificates
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "rename scep_certificates to identity_certificates")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_enrollments (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
path_identifier VARCHAR(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
host_identifier VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
not_valid_after DATETIME DEFAULT NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT '0',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY idx_path_identifier (path_identifier)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create acme_enrollments table")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_accounts (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
acme_enrollment_id INT UNSIGNED NOT NULL,
|
||||
json_web_key json NOT NULL,
|
||||
json_web_key_thumbprint VARCHAR(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
revoked TINYINT(1) NOT NULL DEFAULT '0',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (acme_enrollment_id) REFERENCES acme_enrollments(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
UNIQUE KEY idx_enrollment_id_thumbprint (acme_enrollment_id, json_web_key_thumbprint)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create acme_accounts table")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_orders (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
acme_account_id INT UNSIGNED NOT NULL,
|
||||
finalized TINYINT(1) NOT NULL DEFAULT '0',
|
||||
certificate_signing_request TEXT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
identifiers json NOT NULL,
|
||||
status enum('pending', 'ready', 'processing', 'valid', 'invalid') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
|
||||
issued_certificate_serial BIGINT DEFAULT NULL,
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (acme_account_id) REFERENCES acme_accounts(id) ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
UNIQUE KEY idx_issued_certificate_serial (issued_certificate_serial)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create acme_orders table")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_authorizations (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
identifier_type varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
identifier_value varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
acme_order_id INT UNSIGNED NOT NULL,
|
||||
status enum('pending', 'valid', 'invalid', 'deactivated', 'expired', 'revoked') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (acme_order_id) REFERENCES acme_orders(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create acme_authorizations table")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
CREATE TABLE acme_challenges (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
challenge_type varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
token varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||
acme_authorization_id INT UNSIGNED NOT NULL,
|
||||
status enum('pending', 'valid', 'invalid', 'processing') CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'pending',
|
||||
created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
FOREIGN KEY (acme_authorization_id) REFERENCES acme_authorizations(id) ON DELETE CASCADE ON UPDATE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "create acme_challenges table")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260401153000(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/fleet"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260401153001, Down_20260401153001)
|
||||
}
|
||||
|
||||
func Up_20260401153001(tx *sql.Tx) error {
|
||||
err := updateAppConfigJSON(tx, func(config *fleet.AppConfig) error {
|
||||
if config != nil {
|
||||
config.MDM.AppleRequireHardwareAttestation = false
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "set AppleRequireHardwareAttestation in AppConfig")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260401153001(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func init() {
|
||||
MigrationClient.AddMigration(Up_20260401153503, Down_20260401153503)
|
||||
}
|
||||
|
||||
func Up_20260401153503(tx *sql.Tx) error {
|
||||
_, err := tx.Exec(`
|
||||
ALTER TABLE host_dep_assignments
|
||||
ADD COLUMN hardware_serial varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||
ADD INDEX idx_hdep_hardware_serial (hardware_serial)
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "add host_dep_assignments.hardware_serial column")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(`
|
||||
UPDATE host_dep_assignments hda
|
||||
JOIN hosts h ON h.id = hda.host_id
|
||||
SET hda.hardware_serial = h.hardware_serial
|
||||
WHERE hda.deleted_at IS NULL
|
||||
`)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "backfill host_dep_assignments.hardware_serial column")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func Down_20260401153503(tx *sql.Tx) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package tables
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUp_20260401153503_SomeAssignments(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// create a dozen hosts each for macOS, Windows and Linux
|
||||
macIDs, _, _, _ := insertHosts(t, db, 12, 12, 12)
|
||||
require.Len(t, macIDs, 12)
|
||||
|
||||
// load the serials for the mac hosts
|
||||
type host struct {
|
||||
ID uint `db:"id"`
|
||||
HardwareSerial string `db:"hardware_serial"`
|
||||
}
|
||||
var hosts []host
|
||||
stmt, args, err := sqlx.In(`SELECT id, hardware_serial FROM hosts WHERE id IN (?)`, macIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Select(&hosts, stmt, args...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hosts, 12)
|
||||
|
||||
idToSerial := make(map[uint]string)
|
||||
for _, h := range hosts {
|
||||
idToSerial[h.ID] = h.HardwareSerial
|
||||
}
|
||||
|
||||
// create DEP assignments for a few mac hosts
|
||||
for _, id := range macIDs[:3] {
|
||||
_, err := db.Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, id)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// make macIDs[2] a deleted assignment
|
||||
_, err = db.Exec(`UPDATE host_dep_assignments SET deleted_at = NOW() WHERE host_id = ?`, macIDs[2]) //nolint:nilaway
|
||||
require.NoError(t, err)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
// load the assignments and verify that it has the expected hardware serials for non-deleted assignments
|
||||
var assignments []struct {
|
||||
HostID uint `db:"host_id"`
|
||||
HardwareSerial string `db:"hardware_serial"`
|
||||
DeletedAt *time.Time `db:"deleted_at"`
|
||||
}
|
||||
err = db.Select(&assignments, `SELECT host_id, hardware_serial, deleted_at FROM host_dep_assignments`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignments, 3)
|
||||
|
||||
for _, a := range assignments {
|
||||
switch a.HostID {
|
||||
case macIDs[0], macIDs[1]:
|
||||
require.Nil(t, a.DeletedAt)
|
||||
require.Equal(t, idToSerial[a.HostID], a.HardwareSerial)
|
||||
case macIDs[2]:
|
||||
require.Empty(t, a.HardwareSerial)
|
||||
require.NotNil(t, a.DeletedAt)
|
||||
default:
|
||||
t.Fatalf("unexpected host_id %d in host_dep_assignments", a.HostID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUp_20260401153503_NoAssignment(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
var count int
|
||||
err := db.Get(&count, `SELECT COUNT(*) FROM host_dep_assignments`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestUp_20260401153503_ManyAssignments(t *testing.T) {
|
||||
db := applyUpToPrev(t)
|
||||
|
||||
// create a thousand macOS hosts and a few other
|
||||
macIDs, _, _, _ := insertHosts(t, db, 1000, 10, 10)
|
||||
require.Len(t, macIDs, 1000)
|
||||
|
||||
// load the serials for the mac hosts
|
||||
type host struct {
|
||||
ID uint `db:"id"`
|
||||
HardwareSerial string `db:"hardware_serial"`
|
||||
}
|
||||
var hosts []host
|
||||
stmt, args, err := sqlx.In(`SELECT id, hardware_serial FROM hosts WHERE id IN (?)`, macIDs)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Select(&hosts, stmt, args...)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, hosts, len(macIDs))
|
||||
|
||||
idToSerial := make(map[uint]string)
|
||||
for _, h := range hosts {
|
||||
idToSerial[h.ID] = h.HardwareSerial
|
||||
}
|
||||
|
||||
// create DEP assignments for all mac hosts
|
||||
for _, id := range macIDs {
|
||||
_, err := db.Exec(`INSERT INTO host_dep_assignments (host_id) VALUES (?)`, id)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Apply current migration.
|
||||
applyNext(t, db)
|
||||
|
||||
// load the assignments and verify that it has the expected hardware serials for non-deleted assignments
|
||||
var assignments []struct {
|
||||
HostID uint `db:"host_id"`
|
||||
HardwareSerial string `db:"hardware_serial"`
|
||||
}
|
||||
err = db.Select(&assignments, `SELECT host_id, hardware_serial FROM host_dep_assignments`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, assignments, len(macIDs))
|
||||
|
||||
for _, a := range assignments {
|
||||
require.Equal(t, idToSerial[a.HostID], a.HardwareSerial)
|
||||
}
|
||||
}
|
||||
|
|
@ -54,7 +54,7 @@ func (d *SCEPDepot) CA(_ []byte) ([]*x509.Certificate, *rsa.PrivateKey, error) {
|
|||
|
||||
// Serial allocates and returns a new (increasing) serial number.
|
||||
func (d *SCEPDepot) Serial() (*big.Int, error) {
|
||||
result, err := d.db.Exec(`INSERT INTO scep_serials () VALUES ();`)
|
||||
result, err := d.db.Exec(`INSERT INTO identity_serials () VALUES ();`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -72,7 +72,7 @@ func (d *SCEPDepot) Serial() (*big.Int, error) {
|
|||
// - revokeOldCertificate specifies whether to revoke the old certificate once renewed.
|
||||
func (d *SCEPDepot) HasCN(cn string, allowTime int, cert *x509.Certificate, revokeOldCertificate bool) (bool, error) {
|
||||
var ct int
|
||||
row := d.db.QueryRow(`SELECT COUNT(*) FROM scep_certificates WHERE name = ?`, cn)
|
||||
row := d.db.QueryRow(`SELECT COUNT(*) FROM identity_certificates WHERE name = ?`, cn)
|
||||
if err := row.Scan(&ct); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ func (d *SCEPDepot) Put(name string, crt *x509.Certificate) error {
|
|||
}
|
||||
certPEM := certificate.EncodeCertPEM(crt)
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO scep_certificates
|
||||
INSERT INTO identity_certificates
|
||||
(serial, name, not_valid_before, not_valid_after, certificate_pem)
|
||||
VALUES
|
||||
(?, ?, ?, ?, ?)`,
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -133,11 +133,16 @@ func ReadOnlyConn(pool fleet.RedisPool, conn redis.Conn) redis.Conn {
|
|||
return conn
|
||||
}
|
||||
|
||||
// Simplified interface for the ConfigureDoer function.
|
||||
type getPool interface {
|
||||
Get() redis.Conn
|
||||
}
|
||||
|
||||
// ConfigureDoer configures conn to follow redirections if the redis
|
||||
// configuration requested it and the pool is a Redis Cluster pool. If the conn
|
||||
// is already in error, or if it is not a redisc cluster connection, it is
|
||||
// returned unaltered.
|
||||
func ConfigureDoer(pool fleet.RedisPool, conn redis.Conn) redis.Conn {
|
||||
func ConfigureDoer(pool getPool, conn redis.Conn) redis.Conn {
|
||||
if p, isCluster := pool.(*clusterPool); isCluster {
|
||||
if err := conn.Err(); err == nil && p.followRedirs {
|
||||
rc, err := redisc.RetryConn(conn, 3, 300*time.Millisecond)
|
||||
|
|
|
|||
11
server/fleet/acme.go
Normal file
11
server/fleet/acme.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package fleet
|
||||
|
||||
import "context"
|
||||
|
||||
// ACMEWriteService is the subset of the ACME service module service
|
||||
// used by the legacy service layer for write operations.
|
||||
type ACMEWriteService interface {
|
||||
// NewACMEEnrollment creates a new enrollment in the acme_enrollments table with the specified
|
||||
// host_uuid and returns a new path_identifier for the created row.
|
||||
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
|
||||
}
|
||||
|
|
@ -238,6 +238,10 @@ type MDM struct {
|
|||
EnableTurnOnWindowsMDMManually bool `json:"enable_turn_on_windows_mdm_manually"`
|
||||
EndUserAuthentication MDMEndUserAuthentication `json:"end_user_authentication"`
|
||||
|
||||
// AppleRequireHardwareAttestation indicates whether to require Managed Device Attestation via ACME(including hardware bound keys) for
|
||||
// certain Apple MDM enrollments.
|
||||
AppleRequireHardwareAttestation bool `json:"apple_require_hardware_attestation"`
|
||||
|
||||
WindowsEntraTenantIDs optjson.Slice[string] `json:"windows_entra_tenant_ids"`
|
||||
|
||||
// WindowsEnabledAndConfigured indicates if Fleet MDM is enabled for Windows.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -676,6 +677,13 @@ type SCEPIdentityAssociation struct {
|
|||
EnrollmentType string `db:"type"`
|
||||
}
|
||||
|
||||
type DeviceInfoForACMERenewal struct {
|
||||
HostUUID string `db:"host_uuid"`
|
||||
HardwareSerial string `db:"hardware_serial"`
|
||||
HardwareModel string `db:"hardware_model"`
|
||||
OSVersion string `db:"os_version"`
|
||||
}
|
||||
|
||||
// MDMAppleDeclaration represents a DDM JSON declaration.
|
||||
type MDMAppleDeclaration struct {
|
||||
// DeclarationUUID is the unique identifier of the declaration in
|
||||
|
|
@ -1039,6 +1047,66 @@ type MDMAppleMachineInfo struct {
|
|||
Version string `plist:"VERSION"`
|
||||
}
|
||||
|
||||
// macProductRe matches a macOS model identifier such as "MacBookPro18,3", capturing the
|
||||
// alphabetic family prefix (group 1) and the numeric major version (group 2).
|
||||
var macProductRe = regexp.MustCompile(`^([A-Za-z]+)(\d+),\d+$`)
|
||||
|
||||
// appleSiliconMajorThreshold maps each traditional Mac product family to the first major
|
||||
// version number that corresponds to an Apple Silicon model. Any major version equal to or
|
||||
// greater than the threshold is Apple Silicon; lower versions are x86.
|
||||
var appleSiliconMajorThreshold = map[string]int{
|
||||
// MacBookAir10,1 was the first Apple Silicon MacBook Air (M1, Late 2020).
|
||||
"MacBookAir": 10,
|
||||
// MacBookPro17,1 was the first Apple Silicon MacBook Pro (M1, Late 2020).
|
||||
"MacBookPro": 17,
|
||||
// Macmini9,1 was the first Apple Silicon Mac mini (M1, Late 2020).
|
||||
"Macmini": 9,
|
||||
// iMac21,1 was the first Apple Silicon iMac (M1, Early 2021).
|
||||
"iMac": 21,
|
||||
}
|
||||
|
||||
// IsMacAppleSilicon determines whether the device is an Apple Silicon Mac. If the model identifier
|
||||
// starts with iPhone, iPod, or iPad, it returns false with no error; however, other non-Mac Apple
|
||||
// devices like AppleTV will return an error.
|
||||
func IsMacAppleSilicon(modelIdentifier string) (bool, error) {
|
||||
if strings.HasPrefix(modelIdentifier, "iPhone") ||
|
||||
strings.HasPrefix(modelIdentifier, "iPod") ||
|
||||
strings.HasPrefix(modelIdentifier, "iPad") {
|
||||
// If the model identifier starts with iPhone, iPod, or iPad, we'll return false with no
|
||||
// error; however, other non-Mac Apple devices like AppleTV will return an error
|
||||
return false, nil
|
||||
}
|
||||
|
||||
matches := macProductRe.FindStringSubmatch(modelIdentifier)
|
||||
if matches == nil {
|
||||
return false, fmt.Errorf("unrecognized product identifier format: %q", modelIdentifier)
|
||||
}
|
||||
|
||||
family := matches[1]
|
||||
major, _ := strconv.Atoi(matches[2])
|
||||
|
||||
// Model identifiers starting with "Mac" immediately followed by a digit (e.g. "Mac13,1")
|
||||
// represent the unified naming scheme Apple adopted for Apple Silicon products such as the
|
||||
// Mac Studio and the M2/M3/M4-era Mac Pro. All such identifiers are Apple Silicon.
|
||||
if family == "Mac" {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// MacBook (no suffix), iMacPro, and MacPro were all discontinued before Apple Silicon
|
||||
// was introduced; every model in these families is x86.
|
||||
switch family {
|
||||
case "MacBook", "iMacPro", "MacPro":
|
||||
return false, nil
|
||||
}
|
||||
|
||||
threshold, ok := appleSiliconMajorThreshold[family]
|
||||
if !ok {
|
||||
return false, fmt.Errorf("unrecognized Mac product family in identifier: %q", modelIdentifier)
|
||||
}
|
||||
|
||||
return major >= threshold, nil
|
||||
}
|
||||
|
||||
// MDMAppleAccountDrivenUserEnrollDeviceInfo is a more minimal version of DeviceInfo sent on Account
|
||||
// Driven User Enrollment requests[1] that only describes the base product attempting enrollment.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -829,3 +829,92 @@ func TestValidateNoSecretsInProfileName(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsMacAppleSilicon(t *testing.T) {
|
||||
cases := []struct {
|
||||
product string
|
||||
wantAS bool
|
||||
wantErr bool
|
||||
}{
|
||||
// --- MacBookPro ---
|
||||
// x86: last Intel model before Apple Silicon transition
|
||||
{product: "MacBookPro16,1", wantAS: false},
|
||||
{product: "MacBookPro16,4", wantAS: false},
|
||||
// Apple Silicon: first AS model (M1, Late 2020) and later
|
||||
{product: "MacBookPro17,1", wantAS: true},
|
||||
{product: "MacBookPro18,3", wantAS: true},
|
||||
{product: "MacBookPro18,4", wantAS: true},
|
||||
|
||||
// --- MacBookAir ---
|
||||
// x86: last Intel model before Apple Silicon transition
|
||||
{product: "MacBookAir9,1", wantAS: false},
|
||||
// Apple Silicon: first AS model (M1, Late 2020) and later
|
||||
{product: "MacBookAir10,1", wantAS: true},
|
||||
{product: "MacBookAir14,2", wantAS: true},
|
||||
|
||||
// --- Macmini ---
|
||||
// x86: last Intel model before Apple Silicon transition
|
||||
{product: "Macmini8,1", wantAS: false},
|
||||
// Apple Silicon: first AS model (M1, Late 2020) and later
|
||||
{product: "Macmini9,1", wantAS: true},
|
||||
{product: "Macmini9,2", wantAS: true},
|
||||
|
||||
// --- iMac ---
|
||||
// x86: last Intel models before Apple Silicon transition
|
||||
{product: "iMac20,1", wantAS: false},
|
||||
{product: "iMac20,2", wantAS: false},
|
||||
// Apple Silicon: first AS model (M1, Early 2021) and later
|
||||
{product: "iMac21,1", wantAS: true},
|
||||
{product: "iMac21,2", wantAS: true},
|
||||
|
||||
// --- MacBook (no suffix) — all x86, line discontinued before Apple Silicon ---
|
||||
{product: "MacBook10,1", wantAS: false},
|
||||
{product: "MacBook9,1", wantAS: false},
|
||||
|
||||
// --- iMacPro — all x86, discontinued before Apple Silicon ---
|
||||
{product: "iMacPro1,1", wantAS: false},
|
||||
|
||||
// --- MacPro — old numbering, all x86 ---
|
||||
// (the AS Mac Pro uses the "Mac" prefix, e.g. Mac14,8)
|
||||
{product: "MacPro7,1", wantAS: false},
|
||||
{product: "MacPro6,1", wantAS: false},
|
||||
|
||||
// --- Mac (bare prefix) — unified Apple Silicon naming ---
|
||||
// Mac Studio (M1 Ultra, 2022)
|
||||
{product: "Mac13,1", wantAS: true},
|
||||
{product: "Mac13,2", wantAS: true},
|
||||
// Mac Pro (M2 Ultra, 2023)
|
||||
{product: "Mac14,8", wantAS: true},
|
||||
// Mac mini (M4, 2024)
|
||||
{product: "Mac16,10", wantAS: true},
|
||||
|
||||
// --- Non-Mac Apple devices — return false without error ---
|
||||
{product: "iPhone15,2", wantAS: false},
|
||||
{product: "iPhone14,3", wantAS: false},
|
||||
{product: "iPad13,18", wantAS: false},
|
||||
{product: "iPodTouch9,1", wantAS: false},
|
||||
|
||||
// --- Error cases ---
|
||||
// Empty string
|
||||
{product: "", wantErr: true},
|
||||
// No comma separator
|
||||
{product: "MacBookPro18", wantErr: true},
|
||||
// Garbage input
|
||||
{product: "not-a-model", wantErr: true},
|
||||
// Non-Mac Apple devices that don't start with iPhone/iPod/iPad return an error
|
||||
{product: "AppleTV6,2", wantErr: true},
|
||||
{product: "AppleTV14,1", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.product, func(t *testing.T) {
|
||||
got, err := IsMacAppleSilicon(tc.product)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.wantAS, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1156,6 +1156,9 @@ type Datastore interface {
|
|||
// progress based on the provided arguments.
|
||||
GetHostCertAssociationsToExpire(ctx context.Context, expiryDays, limit int) ([]SCEPIdentityAssociation, error)
|
||||
|
||||
// GetDeviceInfoForACMERenewal retrieves the device information for ACMERenewal based on the provided host UUIDs.
|
||||
GetDeviceInfoForACMERenewal(ctx context.Context, hostUUIDs []string) ([]DeviceInfoForACMERenewal, error)
|
||||
|
||||
// SetCommandForPendingSCEPRenewal tracks the command used to renew a scep certificate
|
||||
SetCommandForPendingSCEPRenewal(ctx context.Context, assocs []SCEPIdentityAssociation, cmdUUID string) error
|
||||
|
||||
|
|
@ -1437,6 +1440,9 @@ type Datastore interface {
|
|||
// GetHostDEPAssignment returns the DEP assignment for the host.
|
||||
GetHostDEPAssignment(ctx context.Context, hostID uint) (*HostDEPAssignment, error)
|
||||
|
||||
// GetHostDEPAssignmentsBySerial returns the DEP assignment for the host with the specified serial number.
|
||||
GetHostDEPAssignmentsBySerial(ctx context.Context, serial string) ([]*HostDEPAssignment, error)
|
||||
|
||||
// GetNanoMDMEnrollment returns the nano enrollment information for the device id.
|
||||
GetNanoMDMEnrollment(ctx context.Context, id string) (*NanoEnrollment, error)
|
||||
|
||||
|
|
@ -1455,9 +1461,9 @@ type Datastore interface {
|
|||
// overriden by a TokenUpdate but that should provide the latest username
|
||||
UpdateNanoMDMUserEnrollmentUsername(ctx context.Context, deviceID string, userUUID string, username string) error
|
||||
|
||||
// GetNanoMDMEnrollmentTimes returns the time of the most recent enrollment and the most recent
|
||||
// MDM protocol seen time for the host with the given UUID
|
||||
GetNanoMDMEnrollmentTimes(ctx context.Context, hostUUID string) (*time.Time, *time.Time, error)
|
||||
// GetNanoMDMEnrollmentDetails returns the time of the most recent enrollment, the most recent
|
||||
// MDM protocol seen time, and whether the enrollment is hardware attested for the host with the given UUID
|
||||
GetNanoMDMEnrollmentDetails(ctx context.Context, hostUUID string) (*time.Time, *time.Time, bool, error)
|
||||
|
||||
// IncreasePolicyAutomationIteration marks the policy to fire automation again.
|
||||
IncreasePolicyAutomationIteration(ctx context.Context, policyID uint) error
|
||||
|
|
|
|||
|
|
@ -958,6 +958,8 @@ type HostDetail struct {
|
|||
LastMDMEnrolledAt *time.Time `json:"last_mdm_enrolled_at"`
|
||||
LastMDMCheckedInAt *time.Time `json:"last_mdm_checked_in_at"`
|
||||
|
||||
MDMEnrollmentHardwareAttested bool `json:"mdm_enrollment_hardware_attested"`
|
||||
|
||||
ConditionalAccessBypassed bool `json:"conditional_access_bypassed"`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -655,6 +655,14 @@ type Service interface {
|
|||
// This should be called after service creation to inject the activity service dependency.
|
||||
SetActivityService(activitySvc ActivityWriteService)
|
||||
|
||||
// SetACMEService sets the ACME service module for write operations.
|
||||
// This should be called after service creation to inject the ACME service dependency.
|
||||
SetACMEService(acmeSvc ACMEWriteService)
|
||||
|
||||
// NewACMEEnrollment creates a new ACME enrollment using the ACME service module. It returns the
|
||||
// ACME identifier for the new enrollment, which is used to track the enrollment process and link it to a host.
|
||||
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
|
||||
|
||||
// NewActivity creates the given activity on the datastore.
|
||||
//
|
||||
// What we call "Activities" are administrative operations,
|
||||
|
|
@ -929,7 +937,7 @@ type Service interface {
|
|||
GetMDMAppleProfilesSummary(ctx context.Context, teamID *uint) (*MDMProfilesSummary, error)
|
||||
|
||||
// GetMDMAppleEnrollmentProfileByToken returns the Apple enrollment from its secret token.
|
||||
GetMDMAppleEnrollmentProfileByToken(ctx context.Context, enrollmentToken string, enrollmentRef string) (profile []byte, err error)
|
||||
GetMDMAppleEnrollmentProfileByToken(ctx context.Context, enrollmentToken string, enrollmentRef string, machineInfo *MDMAppleMachineInfo) (profile []byte, err error)
|
||||
|
||||
// GetMDMAppleEnrollmentProfileByToken returns the Apple account-driven user enrollment profile for a given enrollment reference.
|
||||
GetMDMAppleAccountEnrollmentProfile(ctx context.Context, enrollReference string) (profile []byte, err error)
|
||||
|
|
|
|||
20
server/mdm/acme/api/account_order.go
Normal file
20
server/mdm/acme/api/account_order.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
type AccountService interface {
|
||||
CreateAccount(ctx context.Context, pathIdentifier string, enrollmentID uint, jwk jose.JSONWebKey, onlyReturnExisting bool) (*types.AccountResponse, error)
|
||||
AuthenticateMessageFromAccount(ctx context.Context, message *api_http.JWSRequestContainer, request types.AccountAuthenticatedRequest) error
|
||||
AuthenticateNewAccountMessage(ctx context.Context, message *api_http.JWSRequestContainer, request *api_http.CreateNewAccountRequest) error
|
||||
CreateOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, partialOrder *types.Order) (*types.OrderResponse, error)
|
||||
FinalizeOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint, csr string) (*types.OrderResponse, error)
|
||||
GetOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint) (*types.OrderResponse, error)
|
||||
ListAccountOrders(ctx context.Context, pathIdentifier string, account *types.Account) ([]string, error)
|
||||
GetCertificate(ctx context.Context, accountID, orderID uint) (string, error)
|
||||
}
|
||||
12
server/mdm/acme/api/authorization.go
Normal file
12
server/mdm/acme/api/authorization.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
)
|
||||
|
||||
// AuthorizationService does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
|
||||
type AuthorizationService interface {
|
||||
GetAuthorization(ctx context.Context, enrollment *types.Enrollment, account *types.Account, authorizationID uint) (*types.AuthorizationResponse, error)
|
||||
}
|
||||
11
server/mdm/acme/api/challenge.go
Normal file
11
server/mdm/acme/api/challenge.go
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
)
|
||||
|
||||
type ChallengeService interface {
|
||||
ValidateChallenge(ctx context.Context, enrollment *types.Enrollment, account *types.Account, challengeID uint, payload string) (*types.ChallengeResponse, error)
|
||||
}
|
||||
12
server/mdm/acme/api/directory_nonce.go
Normal file
12
server/mdm/acme/api/directory_nonce.go
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
)
|
||||
|
||||
type DirectoryNonceService interface {
|
||||
NewNonce(ctx context.Context, identifier string) error
|
||||
GetDirectory(ctx context.Context, identifier string) (*types.Directory, error)
|
||||
}
|
||||
10
server/mdm/acme/api/enrollment.go
Normal file
10
server/mdm/acme/api/enrollment.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
package api
|
||||
|
||||
import "context"
|
||||
|
||||
// EnrollmentService stores records in the acme_enrollments table.
|
||||
type EnrollmentService interface {
|
||||
// NewACMEEnrollment creates a new enrollment in the acme_enrollments table with the specified
|
||||
// host identifier and returns a new path_identifier for the created row.
|
||||
NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error)
|
||||
}
|
||||
455
server/mdm/acme/api/http/types.go
Normal file
455
server/mdm/acme/api/http/types.go
Normal file
|
|
@ -0,0 +1,455 @@
|
|||
// Package http provides HTTP request and response types for the ACME service module.
|
||||
// These types are used exclusively by the ACME endpoint handler.
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
func generateAndRenderNonce(ctx context.Context, nonces *redis_nonces_store.RedisNoncesStore, w http.ResponseWriter) error {
|
||||
nonce := types.CreateNonceEncodedForHeader()
|
||||
if err := nonces.Store(ctx, nonce, redis_nonces_store.DefaultNonceExpiration); err != nil {
|
||||
return err
|
||||
}
|
||||
w.Header().Set("Replay-Nonce", nonce)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
return nil
|
||||
}
|
||||
|
||||
type GetNewNonceRequest struct {
|
||||
// HTTPMethod used to make this request, populated by the parse custom URL
|
||||
// tag function of the ACME service module, which is one of the only ways
|
||||
// with our framework to access the *http.Request value.
|
||||
HTTPMethod string `url:"http_method"`
|
||||
Identifier string `url:"identifier"`
|
||||
}
|
||||
|
||||
type GetNewNonceResponse struct {
|
||||
HTTPMethod string `json:"-"`
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r GetNewNonceResponse) Error() error { return r.Err }
|
||||
|
||||
// BeforeRender implements the beforeRenderer interface.
|
||||
func (r *GetNewNonceResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there are no error for this endpoint.
|
||||
if r.Err == nil {
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *GetNewNonceResponse) Status() int {
|
||||
if r.HTTPMethod == http.MethodHead {
|
||||
return http.StatusOK
|
||||
}
|
||||
// for GET/POST-as-GET, return 204
|
||||
return http.StatusNoContent
|
||||
}
|
||||
|
||||
type GetDirectoryRequest struct {
|
||||
Identifier string `url:"identifier"`
|
||||
}
|
||||
|
||||
type GetDirectoryResponse struct {
|
||||
*types.Directory
|
||||
Err error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r GetDirectoryResponse) Error() error { return r.Err }
|
||||
|
||||
type CreateNewAccountRequest struct {
|
||||
Enrollment *types.Enrollment `json:"-"`
|
||||
JSONWebKey *jose.JSONWebKey `json:"-"`
|
||||
|
||||
// OnlyReturnExisting indicates that no new account should be created but the
|
||||
// existing account for this key should be returned if it exists. This is the
|
||||
// only actual parameter read from the payload of the JWS request
|
||||
OnlyReturnExisting bool `json:"onlyReturnExisting"`
|
||||
}
|
||||
|
||||
type CreateNewAccountResponse struct {
|
||||
*types.AccountResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
// BeforeRender implements the beforeRenderer interface.
|
||||
func (r *CreateNewAccountResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
if r.AccountResponse != nil && r.AccountResponse.Location != "" {
|
||||
w.Header().Set("Location", r.AccountResponse.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// Status implements the statuser interface.
|
||||
func (r *CreateNewAccountResponse) Status() int {
|
||||
if r.DidCreate {
|
||||
return http.StatusCreated
|
||||
}
|
||||
return http.StatusOK
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r CreateNewAccountResponse) Error() error { return r.Err }
|
||||
|
||||
type CreateNewOrderRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
Identifiers []types.Identifier `json:"identifiers"`
|
||||
// NotBefore and NotAfter must not be set, we capture them so we can validate
|
||||
// that they were indeed not provided.
|
||||
NotBefore *time.Time `json:"notBefore"`
|
||||
NotAfter *time.Time `json:"notAfter"`
|
||||
}
|
||||
|
||||
type CreateNewOrderResponse struct {
|
||||
*types.OrderResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *CreateNewOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
if r.OrderResponse != nil && r.OrderResponse.Location != "" {
|
||||
w.Header().Set("Location", r.OrderResponse.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r CreateNewOrderResponse) Error() error { return r.Err }
|
||||
|
||||
// Status implements the statuser interface.
|
||||
func (r *CreateNewOrderResponse) Status() int { return http.StatusCreated }
|
||||
|
||||
type GetOrderDecodedRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
OrderID uint `json:"-"`
|
||||
}
|
||||
|
||||
type GetOrderRequest struct {
|
||||
JWSRequestContainer
|
||||
OrderID uint `url:"order_id"`
|
||||
}
|
||||
|
||||
type GetOrderResponse struct {
|
||||
*types.OrderResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *GetOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r GetOrderResponse) Error() error { return r.Err }
|
||||
|
||||
type GetAuthorizationRequest struct {
|
||||
JWSRequestContainer
|
||||
AuthorizationID uint `url:"authorization_id"`
|
||||
}
|
||||
|
||||
type GetAuthorizationDecodedRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
AuthorizationID uint `json:"-"`
|
||||
}
|
||||
|
||||
type GetAuthorizationResponse struct {
|
||||
*types.AuthorizationResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *GetAuthorizationResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
if r.AuthorizationResponse != nil && r.AuthorizationResponse.Location != "" {
|
||||
w.Header().Set("Location", r.AuthorizationResponse.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func (r GetAuthorizationResponse) Error() error { return r.Err }
|
||||
|
||||
type ListOrdersDecodedRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
AccountID uint `json:"-"`
|
||||
}
|
||||
|
||||
type ListOrdersRequest struct {
|
||||
JWSRequestContainer
|
||||
AccountID uint `url:"account_id"`
|
||||
}
|
||||
|
||||
type ListOrdersResponse struct {
|
||||
Orders []string `json:"orders"`
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *ListOrdersResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r ListOrdersResponse) Error() error { return r.Err }
|
||||
|
||||
type GetCertificateDecodedRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
OrderID uint `json:"-"`
|
||||
}
|
||||
|
||||
type GetCertificateRequest struct {
|
||||
JWSRequestContainer
|
||||
OrderID uint `url:"order_id"`
|
||||
}
|
||||
|
||||
type GetCertificateResponse struct {
|
||||
// Certificate is the PEM-encoded certificate chain, will be rendered manually
|
||||
// via HijackRender on success.
|
||||
Certificate string `json:"-"`
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *GetCertificateResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (r *GetCertificateResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "application/pem-certificate-chain")
|
||||
_, _ = w.Write([]byte(r.Certificate))
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r GetCertificateResponse) Error() error { return r.Err }
|
||||
|
||||
type DoChallengeRequest struct {
|
||||
JWSRequestContainer
|
||||
ChallengeID uint `url:"challenge_id"`
|
||||
}
|
||||
|
||||
type DoChallengeDecodedRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
AttestationObject string `json:"attObj"`
|
||||
AttestError string `json:"error"`
|
||||
ChallengeID uint `json:"-"`
|
||||
}
|
||||
|
||||
type DoChallengeResponse struct {
|
||||
*types.ChallengeResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *DoChallengeResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
if r.ChallengeResponse != nil && r.ChallengeResponse.Location != "" {
|
||||
w.Header().Set("Location", r.ChallengeResponse.Location)
|
||||
}
|
||||
}
|
||||
|
||||
func (r DoChallengeResponse) Error() error { return r.Err }
|
||||
|
||||
// JWS Request container is a container for doing basic decoding and validation operations common to all
|
||||
// authenticated ACME requests, which come in the form of a JWS in flattened serialization syntax. This is
|
||||
// parsed into a jose.JSONWebSignature with some basic validation done on it and then the downstream
|
||||
// handler can use the included JWK or KeyID to do further authentication and authorization as needed.
|
||||
type JWSRequestContainer struct {
|
||||
JWS jose.JSONWebSignature
|
||||
JWSHeaderURL string
|
||||
|
||||
Key *jose.JSONWebKey
|
||||
KeyID *string
|
||||
|
||||
// PostAsGet indicates that this POST request is semantically a GET, and as
|
||||
// such should not have any payload in the JWS.
|
||||
PostAsGet bool
|
||||
|
||||
// Fields extracted from the URL path.
|
||||
Identifier string `url:"identifier"`
|
||||
HTTPPath string `url:"http_path"`
|
||||
}
|
||||
|
||||
func (req *JWSRequestContainer) DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error {
|
||||
jwsBytes, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "reading ACME JWS request body")
|
||||
}
|
||||
// Note: req.Identifier is set from the mux path variable by the framework
|
||||
// (via the `url:"identifier"` struct tag), so we don't set it here.
|
||||
jws, err := jose.ParseJWS(string(jwsBytes))
|
||||
if err != nil {
|
||||
err = types.MalformedError("Failed to parse JWS body")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
// The JWS must have exactly one signature because ACME uses the "flattened" JWS JSON serialization
|
||||
if len(jws.Signatures) == 0 {
|
||||
err = types.MalformedError("JWS must have a signature")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
if len(jws.Signatures) > 1 {
|
||||
err = types.MalformedError("JWS must have only one signature")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
// All ACME requests should have either a JWK in the header or a KeyID that points to an account, but never both
|
||||
if jws.Signatures[0].Protected.JSONWebKey == nil && jws.Signatures[0].Protected.KeyID == "" {
|
||||
err = types.MalformedError("JWS must have a key or key ID in the protected header")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
if jws.Signatures[0].Protected.JSONWebKey != nil && jws.Signatures[0].Protected.KeyID != "" {
|
||||
err = types.MalformedError("JWS must not have both a key and key ID in the protected header")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
req.JWS = *jws
|
||||
|
||||
if jws.Signatures[0].Protected.JSONWebKey != nil {
|
||||
req.Key = jws.Signatures[0].Protected.JSONWebKey
|
||||
}
|
||||
// KeyID should be the account URL
|
||||
if jws.Signatures[0].Protected.KeyID != "" {
|
||||
req.KeyID = &jws.Signatures[0].Protected.KeyID
|
||||
}
|
||||
|
||||
// JWS must have a url field in the protected header:
|
||||
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.2
|
||||
headerURL, ok := jws.Signatures[0].Protected.ExtraHeaders["url"].(string)
|
||||
if !ok || headerURL == "" {
|
||||
err = types.MalformedError("JWS must have a url in the protected header")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
req.JWSHeaderURL = headerURL
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type FinalizeOrderRequestContainer struct {
|
||||
JWSRequestContainer
|
||||
OrderID uint `url:"order_id"`
|
||||
}
|
||||
|
||||
type FinalizeOrderRequest struct {
|
||||
types.AccountAuthenticatedRequestBase
|
||||
OrderID uint `json:"-"`
|
||||
CertificateSigningRequest string `json:"csr"`
|
||||
}
|
||||
|
||||
type FinalizeOrderResponse struct {
|
||||
*types.OrderResponse
|
||||
Err error `json:"error,omitempty"`
|
||||
Nonces *redis_nonces_store.RedisNoncesStore `json:"-"`
|
||||
}
|
||||
|
||||
func (r *FinalizeOrderResponse) BeforeRender(ctx context.Context, w http.ResponseWriter) {
|
||||
// only generate a new nonce if there is no error or the error is due to a client error
|
||||
// other than "enrollment not found" (in which case the client has no reason to retry).
|
||||
if r.Err != nil {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(r.Err, &acmeErr) || !acmeErr.ShouldReturnNonce() {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := generateAndRenderNonce(ctx, r.Nonces, w); err != nil {
|
||||
r.Err = err
|
||||
return
|
||||
}
|
||||
if r.OrderResponse != nil && r.OrderResponse.Location != "" {
|
||||
w.Header().Set("Location", r.OrderResponse.Location)
|
||||
}
|
||||
}
|
||||
|
||||
// Error implements the platform_http.Errorer interface.
|
||||
func (r FinalizeOrderResponse) Error() error { return r.Err }
|
||||
16
server/mdm/acme/api/service.go
Normal file
16
server/mdm/acme/api/service.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
// Package api provides the public API for the ACME service module.
|
||||
// External code should use this package to interact with ACME.
|
||||
package api
|
||||
|
||||
import "github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
|
||||
|
||||
// Service is the composite interface for the ACME service module.
|
||||
// It embeds all method-specific interfaces. Bootstrap returns this type.
|
||||
type Service interface {
|
||||
DirectoryNonceService
|
||||
AccountService
|
||||
EnrollmentService
|
||||
AuthorizationService
|
||||
ChallengeService
|
||||
NoncesStore() *redis_nonces_store.RedisNoncesStore
|
||||
}
|
||||
146
server/mdm/acme/arch_test.go
Normal file
146
server/mdm/acme/arch_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package acme_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/archtest"
|
||||
)
|
||||
|
||||
const m = archtest.ModuleName
|
||||
|
||||
var (
|
||||
fleetDeps = regexp.MustCompile(`^github\.com/fleetdm/`)
|
||||
|
||||
// Common allowed dependencies across acme packages
|
||||
acmePkgs = []string{
|
||||
m + "/server/mdm/acme",
|
||||
m + "/server/mdm/acme/api",
|
||||
m + "/server/mdm/acme/api/http",
|
||||
m + "/server/mdm/acme/internal/types",
|
||||
// TODO: redis_nonces_store should not leak through the API layer.
|
||||
// It's here because api.Service exposes NoncesStore() and the HTTP
|
||||
// response types reference it for nonce generation in BeforeRender.
|
||||
m + "/server/mdm/acme/internal/redis_nonces_store",
|
||||
}
|
||||
|
||||
platformPkgs = []string{
|
||||
m + "/server/ptr",
|
||||
m + "/server/platform/...",
|
||||
m + "/server/contexts/...",
|
||||
m + "/server/mdm/internal/commonmdm",
|
||||
m + "/pkg/fleethttp",
|
||||
}
|
||||
)
|
||||
|
||||
// TestACMEPackageDependencies runs architecture tests for all ACME packages.
|
||||
// Each package has specific rules about what dependencies are allowed.
|
||||
func TestACMEPackageDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
pkg string
|
||||
shouldNotDepend []string // defaults to m + "/..." if empty
|
||||
ignoreDeps []string
|
||||
ignoreRecursively []string // for test infra packages whose transitive deps we don't control
|
||||
skip bool // Temp flag to skip tests that will end up using the redis package, and therefore pull in all of server/fleet, we need to move the datastore redis package out, to enable these.
|
||||
}{
|
||||
{
|
||||
name: "root package has no Fleet dependencies",
|
||||
pkg: m + "/server/mdm/acme",
|
||||
},
|
||||
{
|
||||
name: "api package only depends on acme and platform packages",
|
||||
pkg: m + "/server/mdm/acme/api",
|
||||
ignoreDeps: append(acmePkgs, platformPkgs...),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "api/http depends on api and platform",
|
||||
pkg: m + "/server/mdm/acme/api/http",
|
||||
ignoreDeps: append(append([]string{
|
||||
m + "/server/mdm/acme/api",
|
||||
}, acmePkgs...), platformPkgs...),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "internal/types only depends on api",
|
||||
pkg: m + "/server/mdm/acme/internal/types",
|
||||
ignoreDeps: []string{m + "/server/mdm/acme/api"},
|
||||
},
|
||||
{
|
||||
name: "internal/mysql depends on api, types, and platform",
|
||||
pkg: m + "/server/mdm/acme/internal/mysql",
|
||||
ignoreDeps: append([]string{
|
||||
m + "/server/mdm/acme/api",
|
||||
m + "/server/mdm/acme/internal/types",
|
||||
m + "/server/mdm/acme/internal/testutils",
|
||||
}, platformPkgs...),
|
||||
},
|
||||
{
|
||||
name: "internal/service depends on acme and platform packages",
|
||||
pkg: m + "/server/mdm/acme/internal/service",
|
||||
ignoreDeps: append(append([]string{
|
||||
m + "/server/ptr",
|
||||
m + "/server/mdm/acme/internal/redis_nonces_store",
|
||||
}, acmePkgs...), platformPkgs...),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "bootstrap depends on acme and platform packages",
|
||||
pkg: m + "/server/mdm/acme/bootstrap",
|
||||
ignoreDeps: append(append([]string{
|
||||
m + "/server/mdm/acme/internal/mysql",
|
||||
m + "/server/mdm/acme/internal/service",
|
||||
m + "/server/ptr",
|
||||
}, acmePkgs...), platformPkgs...),
|
||||
skip: true,
|
||||
},
|
||||
{
|
||||
name: "all packages only depend on acme and platform",
|
||||
pkg: m + "/server/mdm/acme/...",
|
||||
ignoreDeps: append(append([]string{
|
||||
m + "/server/ptr",
|
||||
m + "/server/mdm/acme/internal/mysql",
|
||||
m + "/server/mdm/acme/internal/service",
|
||||
m + "/server/mdm/acme/internal/testutils",
|
||||
m + "/server/mdm/acme/testhelpers",
|
||||
}, acmePkgs...), platformPkgs...),
|
||||
// Test infrastructure packages whose transitive deps (fleet, etc.) we don't control.
|
||||
ignoreRecursively: []string{
|
||||
m + "/server/datastore/redis/redistest",
|
||||
m + "/server/test",
|
||||
},
|
||||
skip: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if tc.skip {
|
||||
t.Skip("Skipping test, due to pulling in server/datastore/redis, which pulls in server/fleet.")
|
||||
}
|
||||
t.Parallel()
|
||||
|
||||
shouldNotDepend := tc.shouldNotDepend
|
||||
if len(shouldNotDepend) == 0 {
|
||||
shouldNotDepend = []string{m + "/..."}
|
||||
}
|
||||
|
||||
test := archtest.NewPackageTest(t, tc.pkg).
|
||||
OnlyInclude(fleetDeps).
|
||||
ShouldNotDependOn(shouldNotDepend...).
|
||||
WithTests()
|
||||
|
||||
if len(tc.ignoreDeps) > 0 {
|
||||
test.IgnoreDeps(tc.ignoreDeps...)
|
||||
}
|
||||
if len(tc.ignoreRecursively) > 0 {
|
||||
test.IgnoreRecursively(tc.ignoreRecursively...)
|
||||
}
|
||||
|
||||
test.Check()
|
||||
})
|
||||
}
|
||||
}
|
||||
42
server/mdm/acme/bootstrap/bootstrap.go
Normal file
42
server/mdm/acme/bootstrap/bootstrap.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Package bootstrap provides the public entry point for the ACME service module.
|
||||
// It wires together internal components and exposes them for use in serve.go.
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
|
||||
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
||||
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
)
|
||||
|
||||
type ServiceOption = service.ServiceOption
|
||||
|
||||
// New creates a new ACME service module and returns its service and route handler.
|
||||
func New(
|
||||
dbConns *platform_mysql.DBConnections,
|
||||
redisPool acme.RedisPool,
|
||||
providers acme.DataProviders,
|
||||
logger *slog.Logger,
|
||||
opts ...ServiceOption,
|
||||
) (api.Service, func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc) {
|
||||
ds := mysql.NewDatastore(dbConns, logger)
|
||||
svc := service.NewService(ds, redisPool, providers, logger, opts...)
|
||||
|
||||
routesFn := func(authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
|
||||
return service.GetRoutes(svc, authMiddleware)
|
||||
}
|
||||
|
||||
return svc, routesFn
|
||||
}
|
||||
|
||||
func WithTestAppleRootCAs(rootCAs *x509.CertPool) ServiceOption {
|
||||
return func(svc *service.Service) {
|
||||
svc.TestAppleRootCAs = rootCAs
|
||||
}
|
||||
}
|
||||
287
server/mdm/acme/internal/mysql/account_order.go
Normal file
287
server/mdm/acme/internal/mysql/account_order.go
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
const (
|
||||
maxAccountsPerEnrollment = 3
|
||||
maxOrdersPerAccount = 3
|
||||
)
|
||||
|
||||
func (ds *Datastore) CreateAccount(ctx context.Context, account *types.Account, onlyReturnExisting bool) (*types.Account, bool, error) {
|
||||
var didCreate bool
|
||||
err := platform_mysql.WithRetryTxx(ctx, ds.primary, func(tx sqlx.ExtContext) error {
|
||||
// Mark the enrollment as locked to prevent concurrent account creation for
|
||||
// the same enrollment, so we can enforce limits on account creation
|
||||
const lockEnrollmentStmt = `SELECT id FROM acme_enrollments WHERE id = ? FOR UPDATE`
|
||||
var enrollmentID uint
|
||||
err := sqlx.GetContext(ctx, tx, &enrollmentID, lockEnrollmentStmt, account.ACMEEnrollmentID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "lock acme enrollment")
|
||||
}
|
||||
|
||||
thumbprint, err := jose.Thumbprint(&account.JSONWebKey)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "compute jwk thumbprint for new account")
|
||||
}
|
||||
|
||||
// if the account already exists (and is not revoked), we return it
|
||||
const findExistingAccountStmt = `SELECT id, revoked FROM acme_accounts WHERE acme_enrollment_id = ? AND json_web_key_thumbprint = ?`
|
||||
var existingAccount struct {
|
||||
ID uint `db:"id"`
|
||||
Revoked bool `db:"revoked"`
|
||||
}
|
||||
err = sqlx.GetContext(ctx, tx, &existingAccount, findExistingAccountStmt, account.ACMEEnrollmentID, thumbprint)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return ctxerr.Wrap(ctx, err, "check for existing account with same jwk thumbprint")
|
||||
}
|
||||
if err == nil {
|
||||
if existingAccount.Revoked {
|
||||
err = types.AccountRevokedError(fmt.Sprintf("Account %d is revoked", existingAccount.ID))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
account.ID = existingAccount.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we got here we didn't find an existing account so, if requested by the caller, return a notFound
|
||||
if onlyReturnExisting {
|
||||
err = types.AccountDoesNotExistError(fmt.Sprintf("No account exists for enrollment id %d with the provided jwk", account.ACMEEnrollmentID))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// check if maximum number of accounts for this enrollment has been reached before creating a new one
|
||||
const countAccountsStmt = `SELECT COUNT(*) FROM acme_accounts WHERE acme_enrollment_id = ?`
|
||||
var accountCount int
|
||||
err = sqlx.GetContext(ctx, tx, &accountCount, countAccountsStmt, enrollmentID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "count acme accounts for enrollment")
|
||||
}
|
||||
if accountCount >= maxAccountsPerEnrollment {
|
||||
err = types.TooManyAccountsError(fmt.Sprintf("Enrollment id %d already has %d accounts, which is the maximum allowed", enrollmentID, accountCount))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// create the new account
|
||||
jwkSerialized, err := account.JSONWebKey.MarshalJSON()
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "marshal new account jwk")
|
||||
}
|
||||
|
||||
const insertStmt = `INSERT INTO acme_accounts (acme_enrollment_id, json_web_key, json_web_key_thumbprint) VALUES (?, ?, ?)`
|
||||
res, err := tx.ExecContext(ctx, insertStmt, account.ACMEEnrollmentID, jwkSerialized, thumbprint)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert acme account")
|
||||
}
|
||||
lastInsertID, _ := res.LastInsertId() // can never fail with mysql
|
||||
account.ID = uint(lastInsertID)
|
||||
didCreate = true
|
||||
|
||||
// if the acme_enrollment has a NULL not_valid_after it should be set to a value
|
||||
// 24 hours in the future so that now that this enrollment is being used it will expire
|
||||
const updateEnrollmentStmt = `UPDATE acme_enrollments SET not_valid_after = COALESCE(not_valid_after, DATE_ADD(NOW(), INTERVAL 24 HOUR)) WHERE id = ?`
|
||||
_, err = tx.ExecContext(ctx, updateEnrollmentStmt, enrollmentID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update acme enrollment not_valid_after")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, ds.logger)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return account, didCreate, nil
|
||||
}
|
||||
|
||||
// This method specifically requires an enrollment ID because
|
||||
// the caller should know it and have verified it.
|
||||
func (ds *Datastore) GetAccountByID(ctx context.Context, enrollmentID uint, accountID uint) (*types.Account, error) {
|
||||
const stmt = `SELECT id, acme_enrollment_id, json_web_key, json_web_key_thumbprint
|
||||
FROM acme_accounts
|
||||
WHERE acme_enrollment_id = ? AND id = ? AND revoked = false`
|
||||
|
||||
var dbAcc struct {
|
||||
types.Account
|
||||
JSONWebKeyRaw []byte `db:"json_web_key"`
|
||||
}
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbAcc, stmt, enrollmentID, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = types.AccountDoesNotExistError(fmt.Sprintf("No account exists with id %d", accountID))
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "select acme account")
|
||||
}
|
||||
|
||||
var jwk jose.JSONWebKey
|
||||
if err := jwk.UnmarshalJSON(dbAcc.JSONWebKeyRaw); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "unmarshal acme account jwk")
|
||||
}
|
||||
dbAcc.JSONWebKey = jwk
|
||||
|
||||
return &dbAcc.Account, nil
|
||||
}
|
||||
|
||||
// NB: We are leaving it to the caller to set the proper status, token, etc on the order, authorization and challenge
|
||||
func (ds *Datastore) CreateOrder(ctx context.Context, order *types.Order, authorization *types.Authorization, challenge *types.Challenge) (*types.Order, error) {
|
||||
err := platform_mysql.WithRetryTxx(ctx, ds.primary, func(tx sqlx.ExtContext) error {
|
||||
// Mark the account as locked to prevent concurrent order creation for the same account, so we can enforce limits on order creation
|
||||
const lockAccountStmt = `SELECT id FROM acme_accounts WHERE id = ? FOR UPDATE`
|
||||
var accountID uint
|
||||
err := sqlx.GetContext(ctx, tx, &accountID, lockAccountStmt, order.ACMEAccountID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "lock acme account")
|
||||
}
|
||||
|
||||
const countOrdersStmt = `SELECT COUNT(*) FROM acme_orders WHERE acme_account_id = ?`
|
||||
var orderCount int
|
||||
err = sqlx.GetContext(ctx, tx, &orderCount, countOrdersStmt, order.ACMEAccountID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "count acme orders for account")
|
||||
}
|
||||
if orderCount >= maxOrdersPerAccount {
|
||||
err = types.TooManyOrdersError(fmt.Sprintf("Account id %d already has %d orders, which is the maximum allowed", order.ACMEAccountID, orderCount))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
identifiersSerialized, err := json.Marshal(order.Identifiers)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "marshal order identifiers")
|
||||
}
|
||||
|
||||
const insertOrderStmt = `INSERT INTO acme_orders (
|
||||
acme_account_id, finalized, certificate_signing_request, identifiers, status, issued_certificate_serial
|
||||
) VALUES (?, ?, ?, ?, ?, ?)`
|
||||
res, err := tx.ExecContext(ctx, insertOrderStmt,
|
||||
order.ACMEAccountID, order.Finalized, order.CertificateSigningRequest, identifiersSerialized, order.Status, order.IssuedCertificateSerial)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert acme order")
|
||||
}
|
||||
lastInsertID, _ := res.LastInsertId() // can never fail for mysql
|
||||
order.ID = uint(lastInsertID)
|
||||
|
||||
const insertAuthorizationStmt = `INSERT INTO acme_authorizations (acme_order_id, identifier_type, identifier_value, status) VALUES (?, ?, ?, ?)`
|
||||
res, err = tx.ExecContext(ctx, insertAuthorizationStmt, order.ID, authorization.Identifier.Type, authorization.Identifier.Value, authorization.Status)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert acme authorization")
|
||||
}
|
||||
lastInsertID, _ = res.LastInsertId() // can never fail for mysql
|
||||
authorization.ID = uint(lastInsertID)
|
||||
|
||||
const insertChallengeStmt = `INSERT INTO acme_challenges (acme_authorization_id, challenge_type, token, status) VALUES (?, ?, ?, ?)`
|
||||
res, err = tx.ExecContext(ctx, insertChallengeStmt, authorization.ID, challenge.ChallengeType, challenge.Token, challenge.Status)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "insert acme challenge")
|
||||
}
|
||||
lastInsertID, _ = res.LastInsertId() // can never fail for mysql
|
||||
challenge.ID = uint(lastInsertID)
|
||||
challenge.ACMEAuthorizationID = authorization.ID
|
||||
|
||||
return nil
|
||||
}, ds.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) FinalizeOrder(ctx context.Context, orderID uint, csrPEM string, certSerial int64) error {
|
||||
const stmt = `UPDATE acme_orders SET status = ?, finalized=1, certificate_signing_request = ?, issued_certificate_serial = ? WHERE id = ?`
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, stmt, types.OrderStatusValid, csrPEM, certSerial, orderID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "update acme order with signed certificate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetOrderByID(ctx context.Context, accountID, orderID uint) (*types.Order, []*types.Authorization, error) {
|
||||
// condition is on both account and order ids, so that we don't get a match on
|
||||
// just the order id that wouldn't be associated with the validated account from
|
||||
// the request.
|
||||
const getOrderStmt = `SELECT id, acme_account_id, finalized, certificate_signing_request, identifiers, status, issued_certificate_serial
|
||||
FROM acme_orders WHERE acme_account_id = ? AND id = ?`
|
||||
|
||||
var dbOrder struct {
|
||||
types.Order
|
||||
RawIdentifiers []byte `db:"identifiers"`
|
||||
}
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbOrder, getOrderStmt, accountID, orderID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = types.OrderDoesNotExistError(fmt.Sprintf("No order exists with id %d for this account", orderID))
|
||||
return nil, nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "select acme order")
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(dbOrder.RawIdentifiers, &dbOrder.Identifiers); err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal acme order identifiers")
|
||||
}
|
||||
|
||||
const listAuthorizationsStmt = `SELECT id, acme_order_id, identifier_type, identifier_value, status
|
||||
FROM acme_authorizations WHERE acme_order_id = ?`
|
||||
var dbAuthz []struct {
|
||||
types.Authorization
|
||||
IdentifierType string `db:"identifier_type"`
|
||||
IdentifierValue string `db:"identifier_value"`
|
||||
}
|
||||
err = sqlx.SelectContext(ctx, ds.reader(ctx), &dbAuthz, listAuthorizationsStmt, orderID)
|
||||
if err != nil {
|
||||
return nil, nil, ctxerr.Wrap(ctx, err, "select acme authorizations for order")
|
||||
}
|
||||
|
||||
authorizations := make([]*types.Authorization, len(dbAuthz))
|
||||
for i, a := range dbAuthz {
|
||||
authz := a.Authorization
|
||||
authz.Identifier = types.Identifier{
|
||||
Type: a.IdentifierType,
|
||||
Value: a.IdentifierValue,
|
||||
}
|
||||
authorizations[i] = &authz
|
||||
}
|
||||
return &dbOrder.Order, authorizations, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetCertificatePEMByOrderID(ctx context.Context, accountID, orderID uint) (string, error) {
|
||||
const getCertStmt = `SELECT certificate_pem
|
||||
FROM
|
||||
identity_certificates ic
|
||||
JOIN acme_orders o ON ic.serial = o.issued_certificate_serial
|
||||
WHERE
|
||||
o.acme_account_id = ? AND
|
||||
o.id = ? AND
|
||||
ic.revoked IS FALSE`
|
||||
|
||||
var certPEM string
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &certPEM, getCertStmt, accountID, orderID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = types.CertificateDoesNotExistError(fmt.Sprintf("No certificate exists for order id %d for this account", orderID))
|
||||
return "", ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
return "", ctxerr.Wrap(ctx, err, "select certificate PEM for order")
|
||||
}
|
||||
return certPEM, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) ListAccountOrderIDs(ctx context.Context, accountID uint) ([]uint, error) {
|
||||
// must not include orders in status 'invalid'
|
||||
const listOrderIDsStmt = `SELECT id FROM acme_orders WHERE acme_account_id = ? AND status != 'invalid'`
|
||||
var ids []uint
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &ids, listOrderIDsStmt, accountID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "select acme order ids for account")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
544
server/mdm/acme/internal/mysql/account_order_test.go
Normal file
544
server/mdm/acme/internal/mysql/account_order_test.go
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
func generateTestJWK(t *testing.T) jose.JSONWebKey {
|
||||
t.Helper()
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
return jose.JSONWebKey{Key: key.Public()}
|
||||
}
|
||||
|
||||
func TestAccountOrder(t *testing.T) {
|
||||
tdb := testutils.SetupTestDB(t, "acme_account_order")
|
||||
ds := NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
env := &testEnv{TestDB: tdb, ds: ds}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, env *testEnv)
|
||||
}{
|
||||
{"CreateNewAccount", testCreateNewAccount},
|
||||
{"ReturnExistingSameJWK", testReturnExistingSameJWK},
|
||||
{"OnlyReturnExistingFound", testOnlyReturnExistingFound},
|
||||
{"OnlyReturnExistingNotFound", testOnlyReturnExistingNotFound},
|
||||
{"AccountCreationLimit", testAccountCreationLimit},
|
||||
{"AccountRevoked", testAccountRevoked},
|
||||
{"InvalidEnrollmentID", testInvalidEnrollmentID},
|
||||
|
||||
{"CreateNewOrder", testCreateNewOrder},
|
||||
{"OrderCreationLimit", testOrderCreationLimit},
|
||||
{"InvalidAccountID", testInvalidAccountID},
|
||||
{"MultipleOrdersDifferentAccounts", testMultipleOrdersDifferentAccounts},
|
||||
|
||||
{"GetExistingOrder", testGetExistingOrder},
|
||||
{"OrderNotFound", testGetOrderNotFound},
|
||||
{"WrongAccountID", testGetOrderWrongAccountID},
|
||||
|
||||
{"ListOrderIDs", testListOrderIDs},
|
||||
{"ListOrderIDsExcludesInvalid", testListOrderIDsExcludesInvalid},
|
||||
{"ListOrderIDsEmpty", testListOrderIDsEmpty},
|
||||
{"ListOrderIDsInvalidAccount", testListOrderIDsInvalidAccount},
|
||||
|
||||
{"GetCertificatePEM", testGetCertificatePEM},
|
||||
{"GetCertificatePEMNotFound", testGetCertificatePEMNotFound},
|
||||
{"GetCertificatePEMRevoked", testGetCertificatePEMRevoked},
|
||||
{"GetCertificatePEMWrongAccount", testGetCertificatePEMWrongAccount},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer env.TruncateTables(t)
|
||||
c.fn(t, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testCreateNewAccount(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk1 := generateTestJWK(t)
|
||||
account1 := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk1,
|
||||
}
|
||||
|
||||
result1, didCreate, err := env.ds.CreateAccount(t.Context(), account1, false)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, result1.ID)
|
||||
require.Equal(t, enrollment.ID, result1.ACMEEnrollmentID)
|
||||
require.True(t, didCreate)
|
||||
|
||||
// verify enrollment's not_valid_after was set
|
||||
updatedEnrollment1, err := env.ds.GetACMEEnrollment(t.Context(), enrollment.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedEnrollment1.NotValidAfter)
|
||||
require.True(t, updatedEnrollment1.NotValidAfter.After(time.Now()))
|
||||
|
||||
time.Sleep(time.Second) // ensure different timestamp
|
||||
|
||||
// create another account
|
||||
jwk2 := generateTestJWK(t)
|
||||
account2 := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk2,
|
||||
}
|
||||
|
||||
result2, didCreate, err := env.ds.CreateAccount(t.Context(), account2, false)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, result2.ID)
|
||||
require.Equal(t, enrollment.ID, result2.ACMEEnrollmentID)
|
||||
require.NotEqual(t, result1.ID, result2.ID)
|
||||
require.True(t, didCreate)
|
||||
|
||||
// verify enrollment's not_valid_after was not updated as it was already set
|
||||
updatedEnrollment2, err := env.ds.GetACMEEnrollment(t.Context(), enrollment.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedEnrollment1.NotValidAfter)
|
||||
require.True(t, updatedEnrollment1.NotValidAfter.After(time.Now()))
|
||||
require.True(t, updatedEnrollment1.NotValidAfter.Equal(*updatedEnrollment2.NotValidAfter))
|
||||
}
|
||||
|
||||
func testReturnExistingSameJWK(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk := generateTestJWK(t)
|
||||
|
||||
account1 := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
result1, didCreate, err := env.ds.CreateAccount(t.Context(), account1, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result1)
|
||||
require.True(t, didCreate)
|
||||
|
||||
// create again with same JWK
|
||||
account2 := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
result2, didCreate, err := env.ds.CreateAccount(t.Context(), account2, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result2)
|
||||
require.Equal(t, result1.ID, result2.ID)
|
||||
require.False(t, didCreate)
|
||||
}
|
||||
|
||||
func testOnlyReturnExistingFound(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk := generateTestJWK(t)
|
||||
|
||||
// create the account first
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
created, didCreate, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, didCreate)
|
||||
|
||||
// now look it up with onlyReturnExisting=true
|
||||
lookup := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
found, didCreate, err := env.ds.CreateAccount(t.Context(), lookup, true)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, found)
|
||||
require.Equal(t, created.ID, found.ID)
|
||||
require.False(t, didCreate)
|
||||
}
|
||||
|
||||
func testOnlyReturnExistingNotFound(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
|
||||
result, didCreate, err := env.ds.CreateAccount(t.Context(), account, true)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error:accountDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
require.False(t, didCreate)
|
||||
}
|
||||
|
||||
func testAccountCreationLimit(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
// create 3 accounts (the max)
|
||||
for range maxAccountsPerEnrollment {
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
_, _, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// 4th should fail
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
result, _, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/tooManyAccounts") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testAccountRevoked(t *testing.T, env *testEnv) {
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
|
||||
created, didCreate, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, created)
|
||||
require.True(t, didCreate)
|
||||
|
||||
// revoke the account directly in the DB
|
||||
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_accounts SET revoked = 1 WHERE id = ?`, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// try to create again with the same JWK — should get accountRevoked error
|
||||
account2 := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
_, _, err = env.ds.CreateAccount(t.Context(), account2, false)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/accountRevoked") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testInvalidEnrollmentID(t *testing.T, env *testEnv) {
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: 99999,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
|
||||
result, _, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// createTestAccountForOrder is a helper that creates an enrollment and account for order tests.
|
||||
func createTestAccountForOrder(t *testing.T, env *testEnv) (*types.Account, *types.Enrollment) {
|
||||
t.Helper()
|
||||
enrollment := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollment)
|
||||
|
||||
jwk := generateTestJWK(t)
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollment.ID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
created, _, err := env.ds.CreateAccount(t.Context(), account, false)
|
||||
require.NoError(t, err)
|
||||
return created, enrollment
|
||||
}
|
||||
|
||||
func buildTestOrder(accountID uint, identifierValue string) (*types.Order, *types.Authorization, *types.Challenge) {
|
||||
return &types.Order{
|
||||
ACMEAccountID: accountID,
|
||||
Status: types.OrderStatusPending,
|
||||
Identifiers: []types.Identifier{
|
||||
{Type: types.IdentifierTypePermanentIdentifier, Value: identifierValue},
|
||||
},
|
||||
}, &types.Authorization{
|
||||
Identifier: types.Identifier{Type: types.IdentifierTypePermanentIdentifier, Value: identifierValue},
|
||||
Status: types.AuthorizationStatusPending,
|
||||
}, &types.Challenge{
|
||||
ChallengeType: types.DeviceAttestationChallengeType,
|
||||
Token: "test-token",
|
||||
Status: types.ChallengeStatusPending,
|
||||
}
|
||||
}
|
||||
|
||||
func testCreateNewOrder(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, result.ID)
|
||||
require.Equal(t, account.ID, result.ACMEAccountID)
|
||||
require.Equal(t, types.OrderStatusPending, result.Status)
|
||||
|
||||
// authorization and challenge IDs should be set by CreateOrder
|
||||
require.NotZero(t, authorization.ID)
|
||||
require.NotZero(t, challenge.ID)
|
||||
}
|
||||
|
||||
func testOrderCreationLimit(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
// create maxOrdersPerAccount orders (the max)
|
||||
for range maxOrdersPerAccount {
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
_, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// the next order should fail
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/tooManyOrders") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testInvalidAccountID(t *testing.T, env *testEnv) {
|
||||
order, authorization, challenge := buildTestOrder(99999, "serial-123")
|
||||
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func testMultipleOrdersDifferentAccounts(t *testing.T, env *testEnv) {
|
||||
account1, _ := createTestAccountForOrder(t, env)
|
||||
account2, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
// create max orders for account1
|
||||
for range maxOrdersPerAccount {
|
||||
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
|
||||
_, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// account2 should still be able to create orders independently
|
||||
order, authorization, challenge := buildTestOrder(account2.ID, "serial-456")
|
||||
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
require.NotZero(t, result.ID)
|
||||
require.Equal(t, account2.ID, result.ACMEAccountID)
|
||||
}
|
||||
|
||||
func testGetExistingOrder(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotOrder, gotAuths, err := env.ds.GetOrderByID(t.Context(), account.ID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, created.ID, gotOrder.ID)
|
||||
require.Equal(t, account.ID, gotOrder.ACMEAccountID)
|
||||
require.Equal(t, types.OrderStatusPending, gotOrder.Status)
|
||||
require.False(t, gotOrder.Finalized)
|
||||
require.Len(t, gotOrder.Identifiers, 1)
|
||||
require.Equal(t, types.IdentifierTypePermanentIdentifier, gotOrder.Identifiers[0].Type)
|
||||
require.Equal(t, "serial-123", gotOrder.Identifiers[0].Value)
|
||||
|
||||
require.Len(t, gotAuths, 1)
|
||||
require.Equal(t, authorization.ID, gotAuths[0].ID)
|
||||
require.Equal(t, authorization.Identifier, gotAuths[0].Identifier)
|
||||
}
|
||||
|
||||
func testGetOrderNotFound(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
_, _, err := env.ds.GetOrderByID(t.Context(), account.ID, 99999)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "orderDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testGetOrderWrongAccountID(t *testing.T, env *testEnv) {
|
||||
account1, _ := createTestAccountForOrder(t, env)
|
||||
account2, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// try to get the order using account2's ID — should fail
|
||||
_, _, err = env.ds.GetOrderByID(t.Context(), account2.ID, created.ID)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "orderDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testListOrderIDs(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
// create a couple of orders
|
||||
var expectedIDs []uint
|
||||
for range 2 {
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
expectedIDs = append(expectedIDs, created.ID)
|
||||
}
|
||||
|
||||
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedIDs, ids)
|
||||
}
|
||||
|
||||
func testListOrderIDsExcludesInvalid(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
// create two orders
|
||||
allIDs := make([]uint, 0, 2)
|
||||
for range cap(allIDs) {
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
allIDs = append(allIDs, created.ID)
|
||||
}
|
||||
|
||||
// mark the first order as invalid directly in the DB
|
||||
_, err := env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET status = 'invalid' WHERE id = ?`, allIDs[0])
|
||||
require.NoError(t, err)
|
||||
|
||||
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []uint{allIDs[1]}, ids)
|
||||
}
|
||||
|
||||
func testListOrderIDsEmpty(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
ids, err := env.ds.ListAccountOrderIDs(t.Context(), account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ids)
|
||||
}
|
||||
|
||||
func testListOrderIDsInvalidAccount(t *testing.T, env *testEnv) {
|
||||
ids, err := env.ds.ListAccountOrderIDs(t.Context(), 99999)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ids)
|
||||
}
|
||||
|
||||
// insertTestCertificate inserts an identity serial and certificate into the database for testing.
|
||||
func insertTestCertificate(t *testing.T, env *testEnv, serial uint64, certPEM string, revoked bool) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := env.DB.ExecContext(ctx, `INSERT INTO identity_serials (serial) VALUES (?)`, serial)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = env.DB.ExecContext(ctx, `
|
||||
INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem, revoked)
|
||||
VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), ?, ?)
|
||||
`, serial, certPEM, revoked)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func testGetCertificatePEM(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// insert a certificate and link it to the order
|
||||
var certSerial uint64 = 1001
|
||||
expectedPEM := "-----BEGIN CERTIFICATE-----\ntest-cert-pem\n-----END CERTIFICATE-----"
|
||||
insertTestCertificate(t, env, certSerial, expectedPEM, false)
|
||||
|
||||
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotPEM, err := env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedPEM, gotPEM)
|
||||
}
|
||||
|
||||
func testGetCertificatePEMNotFound(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
// create an order without linking a certificate
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testGetCertificatePEMRevoked(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// insert a revoked certificate and link it to the order
|
||||
var certSerial uint64 = 2001
|
||||
insertTestCertificate(t, env, certSerial, "-----BEGIN CERTIFICATE-----\nrevoked\n-----END CERTIFICATE-----", true)
|
||||
|
||||
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account.ID, created.ID)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
|
||||
func testGetCertificatePEMWrongAccount(t *testing.T, env *testEnv) {
|
||||
account1, _ := createTestAccountForOrder(t, env)
|
||||
account2, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
order, authorization, challenge := buildTestOrder(account1.ID, "serial-123")
|
||||
created, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
// insert a certificate and link it to account1's order
|
||||
var certSerial uint64 = 3001
|
||||
insertTestCertificate(t, env, certSerial, "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----", false)
|
||||
|
||||
_, err = env.DB.ExecContext(t.Context(), `UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, created.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// try to get the certificate using account2's ID — should fail
|
||||
_, err = env.ds.GetCertificatePEMByOrderID(t.Context(), account2.ID, created.ID)
|
||||
require.Error(t, err)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "certificateDoesNotExist") // nolint:nilaway // cannot be nil due to previous require
|
||||
}
|
||||
42
server/mdm/acme/internal/mysql/acme.go
Normal file
42
server/mdm/acme/internal/mysql/acme.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Package mysql provides the MySQL datastore implementation for the ACME service module.
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxdb"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"go.opentelemetry.io/otel"
|
||||
)
|
||||
|
||||
// tracer is an OTEL tracer. It has no-op behavior when OTEL is not enabled.
|
||||
var tracer = otel.Tracer("github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql")
|
||||
|
||||
// Datastore is the MySQL implementation of the ACME datastore.
|
||||
type Datastore struct {
|
||||
primary *sqlx.DB
|
||||
replica *sqlx.DB
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDatastore creates a new MySQL datastore for activities.
|
||||
func NewDatastore(conns *platform_mysql.DBConnections, logger *slog.Logger) *Datastore {
|
||||
return &Datastore{primary: conns.Primary, replica: conns.Replica, logger: logger}
|
||||
}
|
||||
|
||||
func (ds *Datastore) reader(ctx context.Context) sqlx.QueryerContext {
|
||||
if ctxdb.IsPrimaryRequired(ctx) {
|
||||
return ds.primary
|
||||
}
|
||||
return ds.replica
|
||||
}
|
||||
|
||||
func (ds *Datastore) writer(_ context.Context) *sqlx.DB {
|
||||
return ds.primary
|
||||
}
|
||||
|
||||
// Ensure Datastore implements types.Datastore
|
||||
var _ types.Datastore = (*Datastore)(nil)
|
||||
9
server/mdm/acme/internal/mysql/acme_test.go
Normal file
9
server/mdm/acme/internal/mysql/acme_test.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package mysql
|
||||
|
||||
import "github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
|
||||
// testEnv holds test dependencies.
|
||||
type testEnv struct {
|
||||
*testutils.TestDB
|
||||
ds *Datastore
|
||||
}
|
||||
62
server/mdm/acme/internal/mysql/authorization.go
Normal file
62
server/mdm/acme/internal/mysql/authorization.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// This file does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
|
||||
|
||||
func (ds *Datastore) GetAuthorizationByID(ctx context.Context, accountID uint, authorizationID uint) (*types.Authorization, error) {
|
||||
if accountID == 0 {
|
||||
return nil, types.MalformedError("invalid account ID")
|
||||
}
|
||||
if authorizationID == 0 {
|
||||
return nil, types.MalformedError("invalid authorization ID")
|
||||
}
|
||||
|
||||
var dbAuthz struct {
|
||||
types.Authorization
|
||||
IdentifierType string `db:"identifier_type"`
|
||||
IdentifierValue string `db:"identifier_value"`
|
||||
}
|
||||
|
||||
const query = `SELECT a.id, a.acme_order_id, a.identifier_type, a.identifier_value, a.status FROM acme_authorizations a
|
||||
INNER JOIN acme_orders o ON a.acme_order_id = o.id
|
||||
INNER JOIN acme_accounts ac ON o.acme_account_id = ac.id
|
||||
WHERE a.id = ? AND ac.id = ?`
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &dbAuthz, query, authorizationID, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, types.AuthorizationDoesNotExistError(fmt.Sprintf("ACME authorization with ID %d not found for account ID %d", authorizationID, accountID))
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &types.Authorization{
|
||||
ID: dbAuthz.ID,
|
||||
ACMEOrderID: dbAuthz.ACMEOrderID,
|
||||
Identifier: types.Identifier{
|
||||
Type: dbAuthz.IdentifierType,
|
||||
Value: dbAuthz.IdentifierValue,
|
||||
},
|
||||
Status: dbAuthz.Status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ds *Datastore) GetAuthorizationsByOrderID(ctx context.Context, orderID uint) ([]*types.Authorization, error) {
|
||||
const stmt = `SELECT id, acme_order_id, identifier_type, identifier_value, status FROM acme_authorizations WHERE acme_order_id = ?`
|
||||
var authorizations []*types.Authorization
|
||||
err := ds.primary.SelectContext(ctx, &authorizations, stmt, orderID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get acme authorizations for order")
|
||||
}
|
||||
|
||||
return authorizations, nil
|
||||
}
|
||||
88
server/mdm/acme/internal/mysql/authorization_test.go
Normal file
88
server/mdm/acme/internal/mysql/authorization_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestACMEAuthorization(t *testing.T) {
|
||||
tdb := testutils.SetupTestDB(t, "acme_authorization")
|
||||
ds := NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
env := &testEnv{TestDB: tdb, ds: ds}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, env *testEnv)
|
||||
}{
|
||||
{"GetValidAuthorization", testGetValidAuthorization},
|
||||
{"GetAuthorizationWithInvalidID", testGetAuthorizationWithInvalidID},
|
||||
{"GetAuthorizationWithInvalidAccountID", testGetAuthorizationWithInvalidAccountID},
|
||||
{"GetAuthorizationWithInvalidInputs", testGetAuthorizationWithInvalidInputs},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer env.TruncateTables(t)
|
||||
c.fn(t, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGetValidAuthorization(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
order, authorization, _ := createTestOrderForAccount(t, account, env)
|
||||
|
||||
authResp, err := env.ds.GetAuthorizationByID(t.Context(), account.ID, authorization.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, authResp)
|
||||
require.Equal(t, types.AuthorizationStatusPending, authResp.Status)
|
||||
require.Equal(t, "permanent-identifier", authResp.Identifier.Type)
|
||||
require.Equal(t, "serial-123", authResp.Identifier.Value)
|
||||
require.Equal(t, order.ID, authResp.ACMEOrderID)
|
||||
require.Equal(t, authorization.ID, authResp.ID)
|
||||
}
|
||||
|
||||
func testGetAuthorizationWithInvalidID(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
authResp, err := env.ds.GetAuthorizationByID(t.Context(), account.ID, 9999) // non-existent ID
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/authorizationDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, authResp)
|
||||
}
|
||||
|
||||
func testGetAuthorizationWithInvalidAccountID(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
_, authorization, _ := createTestOrderForAccount(t, account, env)
|
||||
|
||||
authResp, err := env.ds.GetAuthorizationByID(t.Context(), 9999, authorization.ID) // non-existent account ID
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/authorizationDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, authResp)
|
||||
}
|
||||
|
||||
func testGetAuthorizationWithInvalidInputs(t *testing.T, env *testEnv) {
|
||||
authResp, err := env.ds.GetAuthorizationByID(t.Context(), 0, 0) // zero account ID
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, authResp)
|
||||
|
||||
authResp, err = env.ds.GetAuthorizationByID(t.Context(), 1, 0) // zero authorization ID
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, authResp)
|
||||
}
|
||||
|
||||
func createTestOrderForAccount(t *testing.T, account *types.Account, env *testEnv) (*types.Order, *types.Authorization, *types.Challenge) {
|
||||
order, authorization, challenge := buildTestOrder(account.ID, "serial-123")
|
||||
result, err := env.ds.CreateOrder(t.Context(), order, authorization, challenge)
|
||||
require.NoError(t, err)
|
||||
|
||||
return result, authorization, challenge
|
||||
}
|
||||
107
server/mdm/acme/internal/mysql/challenge.go
Normal file
107
server/mdm/acme/internal/mysql/challenge.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
platform_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func (ds *Datastore) GetChallengesByAuthorizationID(ctx context.Context, authorizationID uint) ([]*types.Challenge, error) {
|
||||
if authorizationID == 0 {
|
||||
return nil, types.MalformedError("invalid authorization ID")
|
||||
}
|
||||
|
||||
const query = `SELECT id, acme_authorization_id, challenge_type, status, token, updated_at FROM acme_challenges WHERE acme_authorization_id = ?`
|
||||
|
||||
var challenges []*types.Challenge
|
||||
err := sqlx.SelectContext(ctx, ds.reader(ctx), &challenges, query, authorizationID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting challenges by authorization ID")
|
||||
}
|
||||
if len(challenges) == 0 {
|
||||
return nil, types.ChallengeDoesNotExistError(fmt.Sprintf("No challenges found for authorization ID %d", authorizationID))
|
||||
}
|
||||
|
||||
return challenges, nil
|
||||
}
|
||||
|
||||
// We require the accountID to validate the challenge belongs to the account trying to validate it
|
||||
func (ds *Datastore) GetChallengeByID(ctx context.Context, accountID, challengeID uint) (*types.Challenge, error) {
|
||||
if challengeID == 0 {
|
||||
return nil, types.MalformedError("invalid challenge ID")
|
||||
}
|
||||
|
||||
const query = `SELECT ac.id, ac.acme_authorization_id, ac.challenge_type, ac.status, ac.token, ac.updated_at FROM acme_challenges ac
|
||||
INNER JOIN acme_authorizations a ON ac.acme_authorization_id = a.id
|
||||
INNER JOIN acme_orders o ON a.acme_order_id = o.id
|
||||
WHERE ac.id = ? AND o.acme_account_id = ?`
|
||||
var challenge types.Challenge
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &challenge, query, challengeID, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, types.ChallengeDoesNotExistError(fmt.Sprintf("Challenge with ID %d not found for account ID %d", challengeID, accountID))
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting challenge by ID")
|
||||
}
|
||||
return &challenge, nil
|
||||
}
|
||||
|
||||
// UpdateChallenge handles updating the challenge status, and the authorization status as well as moving the order status.
|
||||
func (ds *Datastore) UpdateChallenge(ctx context.Context, challenge *types.Challenge) (*types.Challenge, error) {
|
||||
if challenge == nil {
|
||||
return nil, errors.New("Challenge can not be nil for update")
|
||||
}
|
||||
|
||||
err := platform_mysql.WithRetryTxx(ctx, ds.writer(ctx), func(tx sqlx.ExtContext) error {
|
||||
const updateChallengeStmt = `UPDATE acme_challenges SET status = ? WHERE id = ?`
|
||||
_, err := tx.ExecContext(ctx, updateChallengeStmt, challenge.Status, challenge.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating challenge")
|
||||
}
|
||||
|
||||
const updateAuthorizationStatusStmt = `UPDATE acme_authorizations a INNER JOIN acme_challenges c ON a.id = c.acme_authorization_id
|
||||
SET a.status = CASE
|
||||
WHEN c.status = 'valid' THEN 'valid'
|
||||
ELSE 'invalid'
|
||||
END
|
||||
WHERE c.id = ?`
|
||||
_, err = tx.ExecContext(ctx, updateAuthorizationStatusStmt, challenge.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating authorization status based on challenge status")
|
||||
}
|
||||
|
||||
// We can confidently update the order status here based on the challenge and authorization status
|
||||
// since we currently only have one, if we ever add more the state machine should account for all authorizations
|
||||
// to be valid before moving the order to ready
|
||||
const updateOrderStatusStmt = `UPDATE acme_orders o INNER JOIN acme_authorizations a ON o.id = a.acme_order_id
|
||||
SET o.status = CASE
|
||||
WHEN ? = 'valid' THEN 'ready'
|
||||
WHEN ? = 'invalid' THEN 'invalid'
|
||||
ELSE o.status
|
||||
END
|
||||
WHERE a.id = ? AND o.status != 'invalid'`
|
||||
_, err = tx.ExecContext(ctx, updateOrderStatusStmt, challenge.Status, challenge.Status, challenge.ACMEAuthorizationID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "updating order status based on challenge status")
|
||||
}
|
||||
|
||||
const selectQuery = `SELECT id, acme_authorization_id, challenge_type, status, token, updated_at FROM acme_challenges WHERE id = ?`
|
||||
err = sqlx.GetContext(ctx, tx, challenge, selectQuery, challenge.ID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "getting updated challenge")
|
||||
}
|
||||
|
||||
return nil
|
||||
}, ds.logger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return challenge, nil
|
||||
}
|
||||
178
server/mdm/acme/internal/mysql/challenge_test.go
Normal file
178
server/mdm/acme/internal/mysql/challenge_test.go
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestACMEChallenge(t *testing.T) {
|
||||
tdb := testutils.SetupTestDB(t, "acme_challenge")
|
||||
ds := NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
env := &testEnv{TestDB: tdb, ds: ds}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, env *testEnv)
|
||||
}{
|
||||
{"GetValidChallengesForAuthorization", testGetValidChallengesForAuthorization},
|
||||
{"NoChallengesForAuthorization", testGetChallengesWithNoChallengesForAuthorization},
|
||||
{"GetChallengesWithInvalidAuthorizationID", testGetChallengesWithInvalidAuthorizationID},
|
||||
{"GetChallengesWithZeroAuthorizationID", testGetChallengesWithZeroAuthorizationID},
|
||||
|
||||
{"GetChallengeByIDWithValidID", testGetChallengeByIDWithValidID},
|
||||
{"GetChallengeByIDWithInvalidID", testGetChallengeByIDWithInvalidID},
|
||||
{"GetChallengeByIDWithInvalidAccountID", testGetChallengeByIDWithInvalidAccountID},
|
||||
|
||||
{"UpdateChallengeHappyPath", testUpdateChallengeHappyPath},
|
||||
{"UpdateChallengeInvalidStatus", testUpdateChallengeInvalidStatus},
|
||||
{"UpdateChallengeNilChallenge", testUpdateChallengeNilChallenge},
|
||||
{"UpdateChallengeNonExistentID", testUpdateChallengeNonExistentID},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer env.TruncateTables(t)
|
||||
c.fn(t, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGetValidChallengesForAuthorization(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
_, authorization, _ := createTestOrderForAccount(t, account, env)
|
||||
|
||||
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), authorization.ID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, challenges, 1)
|
||||
require.Equal(t, types.ChallengeStatusPending, challenges[0].Status)
|
||||
require.Equal(t, types.DeviceAttestationChallengeType, challenges[0].ChallengeType)
|
||||
require.Equal(t, authorization.ID, challenges[0].ACMEAuthorizationID)
|
||||
}
|
||||
|
||||
func testGetChallengesWithNoChallengesForAuthorization(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
_, authorization, _ := createTestOrderForAccount(t, account, env)
|
||||
|
||||
// Delete the challenge to simulate no challenges for the authorization
|
||||
_, err := env.TestDB.DB.ExecContext(t.Context(), "DELETE FROM acme_challenges WHERE acme_authorization_id = ?", authorization.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), authorization.ID)
|
||||
var acmeError *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeError)
|
||||
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, challenges)
|
||||
}
|
||||
|
||||
func testGetChallengesWithInvalidAuthorizationID(t *testing.T, env *testEnv) {
|
||||
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), 999999) // non-existent ID
|
||||
var acmeError *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeError)
|
||||
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, challenges)
|
||||
}
|
||||
|
||||
func testGetChallengesWithZeroAuthorizationID(t *testing.T, env *testEnv) {
|
||||
challenges, err := env.ds.GetChallengesByAuthorizationID(t.Context(), 0)
|
||||
var acmeError *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeError)
|
||||
require.Contains(t, acmeError.Type, "malformed") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, challenges)
|
||||
}
|
||||
|
||||
func testGetChallengeByIDWithValidID(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
_, authorization, challenge := createTestOrderForAccount(t, account, env)
|
||||
|
||||
challenge, err := env.ds.GetChallengeByID(t.Context(), account.ID, challenge.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, challenge)
|
||||
require.Equal(t, types.ChallengeStatusPending, challenge.Status)
|
||||
require.Equal(t, types.DeviceAttestationChallengeType, challenge.ChallengeType)
|
||||
require.Equal(t, authorization.ID, challenge.ACMEAuthorizationID)
|
||||
}
|
||||
|
||||
func testGetChallengeByIDWithInvalidID(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
|
||||
challenge, err := env.ds.GetChallengeByID(t.Context(), account.ID, 9999) // non-existent ID
|
||||
var acmeError *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeError)
|
||||
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, challenge)
|
||||
}
|
||||
|
||||
func testGetChallengeByIDWithInvalidAccountID(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
_, _, challenge := createTestOrderForAccount(t, account, env)
|
||||
|
||||
challenge, err := env.ds.GetChallengeByID(t.Context(), 9999, challenge.ID) // non-existent account ID
|
||||
var acmeError *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeError)
|
||||
require.Contains(t, acmeError.Type, "error/challengeDoesNotExist") //nolint:nilaway // cannot be null due to previous require
|
||||
require.Nil(t, challenge)
|
||||
}
|
||||
|
||||
func testUpdateChallengeHappyPath(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
order, _, challenge := createTestOrderForAccount(t, account, env)
|
||||
|
||||
challenge.Status = types.ChallengeStatusValid
|
||||
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedChallenge)
|
||||
require.Equal(t, types.ChallengeStatusValid, updatedChallenge.Status)
|
||||
require.Equal(t, challenge.ID, updatedChallenge.ID)
|
||||
|
||||
order, auhtz, err := env.ds.GetOrderByID(t.Context(), account.ID, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, auhtz)
|
||||
for _, auth := range auhtz {
|
||||
require.Equal(t, types.AuthorizationStatusValid, auth.Status)
|
||||
}
|
||||
|
||||
require.Equal(t, types.OrderStatusReady, order.Status)
|
||||
}
|
||||
|
||||
func testUpdateChallengeInvalidStatus(t *testing.T, env *testEnv) {
|
||||
account, _ := createTestAccountForOrder(t, env)
|
||||
order, _, challenge := createTestOrderForAccount(t, account, env)
|
||||
|
||||
challenge.Status = types.ChallengeStatusInvalid
|
||||
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updatedChallenge)
|
||||
require.Equal(t, types.ChallengeStatusInvalid, updatedChallenge.Status)
|
||||
|
||||
order, auhtz, err := env.ds.GetOrderByID(t.Context(), account.ID, order.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, auhtz)
|
||||
for _, auth := range auhtz {
|
||||
require.Equal(t, types.AuthorizationStatusInvalid, auth.Status)
|
||||
}
|
||||
|
||||
require.Equal(t, types.OrderStatusInvalid, order.Status)
|
||||
}
|
||||
|
||||
func testUpdateChallengeNilChallenge(t *testing.T, env *testEnv) {
|
||||
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), nil)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "Challenge can not be nil for update")
|
||||
require.Nil(t, updatedChallenge)
|
||||
}
|
||||
|
||||
func testUpdateChallengeNonExistentID(t *testing.T, env *testEnv) {
|
||||
challenge := &types.Challenge{
|
||||
ID: 999999,
|
||||
ACMEAuthorizationID: 999999,
|
||||
Status: types.ChallengeStatusValid,
|
||||
ChallengeType: types.DeviceAttestationChallengeType,
|
||||
}
|
||||
|
||||
updatedChallenge, err := env.ds.UpdateChallenge(t.Context(), challenge)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, updatedChallenge)
|
||||
}
|
||||
41
server/mdm/acme/internal/mysql/directory_nonce.go
Normal file
41
server/mdm/acme/internal/mysql/directory_nonce.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
func (ds *Datastore) GetACMEEnrollment(ctx context.Context, pathIdentifier string) (*types.Enrollment, error) {
|
||||
ctx, span := tracer.Start(ctx, "acme.mysql.GetACMEEnrollment")
|
||||
defer span.End()
|
||||
|
||||
const stmt = `
|
||||
SELECT
|
||||
id,
|
||||
path_identifier,
|
||||
host_identifier,
|
||||
not_valid_after,
|
||||
revoked
|
||||
FROM
|
||||
acme_enrollments
|
||||
WHERE
|
||||
path_identifier = ?
|
||||
LIMIT 1
|
||||
`
|
||||
var enrollment types.Enrollment
|
||||
err := sqlx.GetContext(ctx, ds.reader(ctx), &enrollment, stmt, pathIdentifier)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = types.EnrollmentNotFoundError(fmt.Sprintf("ACME enrollment with path identifier %s not found", pathIdentifier))
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting ACME enrollment")
|
||||
}
|
||||
return &enrollment, nil
|
||||
}
|
||||
71
server/mdm/acme/internal/mysql/directory_nonce_test.go
Normal file
71
server/mdm/acme/internal/mysql/directory_nonce_test.go
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDirectoryNonce(t *testing.T) {
|
||||
tdb := testutils.SetupTestDB(t, "acme_directory_nonce")
|
||||
ds := NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
env := &testEnv{TestDB: tdb, ds: ds}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
fn func(t *testing.T, env *testEnv)
|
||||
}{
|
||||
{"GetACMEEnrollment", testGetACMEEnrollment},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
defer env.TruncateTables(t)
|
||||
c.fn(t, env)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testGetACMEEnrollment(t *testing.T, env *testEnv) {
|
||||
// non-existing
|
||||
enrollment, err := env.ds.GetACMEEnrollment(t.Context(), "non-existing")
|
||||
require.Nil(t, enrollment)
|
||||
var acmeErr *types.ACMEError
|
||||
require.ErrorAs(t, err, &acmeErr)
|
||||
require.Contains(t, acmeErr.Type, "error/enrollmentNotFound") // nolint:nilaway // cannot be nil due to previous require
|
||||
|
||||
// existing and valid
|
||||
enrollValid := &types.Enrollment{}
|
||||
env.InsertACMEEnrollment(t, enrollValid)
|
||||
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollValid.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, enrollment)
|
||||
require.True(t, enrollment.IsValid())
|
||||
|
||||
// existing and revoked
|
||||
enrollRevoked := &types.Enrollment{Revoked: true}
|
||||
env.InsertACMEEnrollment(t, enrollRevoked)
|
||||
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollRevoked.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, enrollment)
|
||||
require.False(t, enrollment.IsValid())
|
||||
|
||||
// existing and not-valid-after in the future
|
||||
enrollFuture := &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(24 * time.Hour))}
|
||||
env.InsertACMEEnrollment(t, enrollFuture)
|
||||
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollFuture.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, enrollment)
|
||||
require.True(t, enrollment.IsValid())
|
||||
|
||||
// existing and not-valid-after in the past
|
||||
enrollPast := &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(-24 * time.Hour))}
|
||||
env.InsertACMEEnrollment(t, enrollPast)
|
||||
enrollment, err = env.ds.GetACMEEnrollment(t.Context(), enrollPast.PathIdentifier)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, enrollment)
|
||||
require.False(t, enrollment.IsValid())
|
||||
}
|
||||
28
server/mdm/acme/internal/mysql/enrollment.go
Normal file
28
server/mdm/acme/internal/mysql/enrollment.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// NewEnrollment creates a new row in the acme_enrollments table with the given
|
||||
// host_identifier. It generates a new path_identifier for the row and returns
|
||||
// it.
|
||||
func (ds *Datastore) NewEnrollment(ctx context.Context, hostIdentifier string) (string, error) {
|
||||
ctx, span := tracer.Start(ctx, "acme.mysql.NewEnrollment")
|
||||
defer span.End()
|
||||
|
||||
pathIdentifier := uuid.NewString()
|
||||
|
||||
_, err := ds.writer(ctx).ExecContext(ctx, `
|
||||
INSERT INTO acme_enrollments (path_identifier, host_identifier)
|
||||
VALUES (?, ?)
|
||||
`, pathIdentifier, hostIdentifier)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "inserting ACME enrollment")
|
||||
}
|
||||
|
||||
return pathIdentifier, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
package redis_nonces_store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/redis"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
redigo "github.com/gomodule/redigo/redis"
|
||||
)
|
||||
|
||||
const DefaultNonceExpiration = 1 * time.Hour
|
||||
|
||||
// RedisNoncesStore is a store for ACME nonces, implemented using Redis.
|
||||
type RedisNoncesStore struct {
|
||||
pool acme.RedisPool
|
||||
testPrefix string // for tests, the key prefix to use to avoid conflicts
|
||||
}
|
||||
|
||||
// New creates a new RedisNoncesStore store.
|
||||
func New(pool acme.RedisPool) *RedisNoncesStore {
|
||||
return &RedisNoncesStore{pool: pool}
|
||||
}
|
||||
|
||||
// prefix is used to not collide with other key domains (like live queries or calendar locks).
|
||||
const prefix = "acmenonce:"
|
||||
|
||||
// Store creates the key with the given nonce.
|
||||
// Argument expireTime is used to set the expiration of the item.
|
||||
func (r *RedisNoncesStore) Store(ctx context.Context, nonce string, expireTime time.Duration) error {
|
||||
conn := redis.ConfigureDoer(r.pool, r.pool.Get())
|
||||
defer conn.Close()
|
||||
|
||||
// the value of the key is not really important, just that the key exists or not (indicates
|
||||
// that the nonce is valid or not), so we set the value to be the same as the nonce.
|
||||
if _, err := redigo.String(conn.Do("SET", r.testPrefix+prefix+nonce, nonce, "PX", expireTime.Milliseconds())); err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "redis failed to set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Consume validates and consumes the nonce, ensuring it does exist, and then removing
|
||||
// it from the store so it can't be used again.
|
||||
func (r *RedisNoncesStore) Consume(ctx context.Context, nonce string) (ok bool, err error) {
|
||||
// fast path if the nonce is missing
|
||||
if nonce == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
conn := redis.ConfigureDoer(r.pool, r.pool.Get())
|
||||
defer conn.Close()
|
||||
|
||||
n, err := redigo.Int(conn.Do("DEL", r.testPrefix+prefix+nonce))
|
||||
if err != nil {
|
||||
return false, ctxerr.Wrap(ctx, err, "redis failed to delete")
|
||||
}
|
||||
return n > 0, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
package redis_nonces_store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
"github.com/fleetdm/fleet/v4/server/test"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRedisNoncesStore(t *testing.T) {
|
||||
for _, f := range []func(*testing.T, *RedisNoncesStore){
|
||||
testStoreConsume,
|
||||
} {
|
||||
t.Run(test.FunctionName(f), func(t *testing.T) {
|
||||
t.Run("standalone", func(t *testing.T) {
|
||||
kv := setupRedis(t, false, false)
|
||||
f(t, kv)
|
||||
})
|
||||
t.Run("cluster", func(t *testing.T) {
|
||||
kv := setupRedis(t, true, true)
|
||||
f(t, kv)
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupRedis(t testing.TB, cluster, redir bool) *RedisNoncesStore {
|
||||
pool := redistest.SetupRedis(t, t.Name(), cluster, redir, true)
|
||||
return newRedisNoncesStoreForTest(t, pool)
|
||||
}
|
||||
|
||||
type testName interface {
|
||||
Name() string
|
||||
}
|
||||
|
||||
func newRedisNoncesStoreForTest(t testName, pool acme.RedisPool) *RedisNoncesStore {
|
||||
return &RedisNoncesStore{
|
||||
pool: pool,
|
||||
testPrefix: t.Name() + ":",
|
||||
}
|
||||
}
|
||||
|
||||
func testStoreConsume(t *testing.T, store *RedisNoncesStore) {
|
||||
ctx := context.Background()
|
||||
|
||||
err := store.Store(ctx, "foo", time.Millisecond)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.Store(ctx, "bar", 5*time.Second)
|
||||
require.NoError(t, err)
|
||||
|
||||
ok, err := store.Consume(ctx, "bar")
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
ok, err = store.Consume(ctx, "foo")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
|
||||
ok, err = store.Consume(ctx, "no-such")
|
||||
require.NoError(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
297
server/mdm/acme/internal/service/account_order.go
Normal file
297
server/mdm/acme/internal/service/account_order.go
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
func (s *Service) CreateAccount(ctx context.Context, pathIdentifier string, enrollmentID uint, jwk jose.JSONWebKey, onlyReturnExisting bool) (*types.AccountResponse, error) {
|
||||
// authorization is checked in the endpoint implementation for JWS-protected endpoints
|
||||
|
||||
account := &types.Account{
|
||||
ACMEEnrollmentID: enrollmentID,
|
||||
JSONWebKey: jwk,
|
||||
}
|
||||
account, didCreate, err := s.store.CreateAccount(ctx, account, onlyReturnExisting)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "creating account in datastore")
|
||||
}
|
||||
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
ordersURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "accounts", fmt.Sprint(account.ID), "orders")
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing orders URL for account")
|
||||
}
|
||||
acctURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "accounts", fmt.Sprint(account.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing account URL for account")
|
||||
}
|
||||
|
||||
return &types.AccountResponse{
|
||||
CreatedAccount: account,
|
||||
DidCreate: didCreate,
|
||||
Status: "valid", // for now, in our implementation, always valid
|
||||
Orders: ordersURL,
|
||||
Location: acctURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, partialOrder *types.Order) (*types.OrderResponse, error) {
|
||||
// authorization is checked in the endpoint implementation for JWS-protected endpoints
|
||||
|
||||
if err := partialOrder.ValidateOrderCreation(enrollment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
identifiers := []types.Identifier{
|
||||
{Type: partialOrder.Identifiers[0].Type, Value: partialOrder.Identifiers[0].Value},
|
||||
}
|
||||
order := &types.Order{
|
||||
ACMEAccountID: account.ID,
|
||||
Finalized: false,
|
||||
Identifiers: identifiers,
|
||||
Status: types.OrderStatusPending, // always pending at creation
|
||||
}
|
||||
authz := &types.Authorization{
|
||||
Identifier: identifiers[0],
|
||||
Status: types.AuthorizationStatusPending, // always pending at creation
|
||||
}
|
||||
challenge := &types.Challenge{
|
||||
ChallengeType: types.DeviceAttestationChallengeType, // only supported challenge for now
|
||||
Token: types.CreateNonceEncodedForHeader(),
|
||||
Status: types.ChallengeStatusPending, // always pending at creation
|
||||
}
|
||||
order, err := s.store.CreateOrder(ctx, order, authz, challenge)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "creating order in datastore")
|
||||
}
|
||||
return s.createOrderResponse(ctx, enrollment, order, []*types.Authorization{authz})
|
||||
}
|
||||
|
||||
func (s *Service) createOrderResponse(
|
||||
ctx context.Context,
|
||||
enrollment *types.Enrollment,
|
||||
order *types.Order,
|
||||
authorizations []*types.Authorization,
|
||||
) (*types.OrderResponse, error) {
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
orderURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing order URL for account")
|
||||
}
|
||||
|
||||
finalizeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID), "finalize")
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing finalize URL for account")
|
||||
}
|
||||
|
||||
var authzURL string
|
||||
if len(authorizations) == 1 {
|
||||
authzURL, err = s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authorizations[0].ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL for account")
|
||||
}
|
||||
}
|
||||
|
||||
var certURL string
|
||||
if err := order.IsCertificateReady(); err == nil {
|
||||
certURL, err = s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "orders", fmt.Sprint(order.ID), "certificate")
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing certificate URL for account")
|
||||
}
|
||||
}
|
||||
|
||||
return &types.OrderResponse{
|
||||
ID: order.ID,
|
||||
Status: order.Status,
|
||||
Expires: enrollment.NotValidAfter,
|
||||
Identifiers: order.Identifiers,
|
||||
Authorizations: []string{authzURL},
|
||||
Finalize: finalizeURL,
|
||||
Certificate: certURL,
|
||||
Location: orderURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Service) FinalizeOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint, csr string) (*types.OrderResponse, error) {
|
||||
order, authorizations, err := s.store.GetOrderByID(ctx, account.ID, orderID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting order from datastore")
|
||||
}
|
||||
|
||||
if err := order.IsReadyToFinalize(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
for _, authz := range authorizations {
|
||||
authzURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authz.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL for account")
|
||||
}
|
||||
|
||||
if authz.Status != types.AuthorizationStatusValid {
|
||||
return nil, types.OrderNotReadyError(fmt.Sprintf("Order has correct status but authorization %s has status %s.", authzURL, authz.Status))
|
||||
}
|
||||
challenges, err := s.store.GetChallengesByAuthorizationID(ctx, authz.ID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting challenges for authorization from datastore")
|
||||
}
|
||||
hasAValidChallenge := false
|
||||
for _, chlg := range challenges {
|
||||
if chlg.Status == types.ChallengeStatusValid {
|
||||
hasAValidChallenge = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasAValidChallenge {
|
||||
return nil, types.OrderNotReadyError(fmt.Sprintf("Order has correct status but no valid challenges for authorization %s.", authzURL))
|
||||
}
|
||||
}
|
||||
|
||||
// The RFC 7.4 calls out that for the CSR it sends a base64url-encoded DER (so not a full PEM block)
|
||||
parsedCSR, err := parseDERCSR(csr)
|
||||
if err != nil {
|
||||
return nil, types.BadCSRError(fmt.Sprintf("Error parsing DER CSR: %s", err))
|
||||
}
|
||||
if parsedCSR.Subject.CommonName != order.Identifiers[0].Value {
|
||||
return nil, types.BadCSRError("CSR common name does not match identifier value")
|
||||
}
|
||||
// We only support ecdsa CSRs for now since that's what the Apple MDM protocol supports, so if it's not an ECDSA CSR we
|
||||
// return a bad CSR error. We can always add support for more types later if needed. This mirrors the logic we use with
|
||||
// the JWK
|
||||
if parsedCSR.PublicKeyAlgorithm != x509.ECDSA {
|
||||
return nil, types.BadCSRError("Public key is not an Elliptic Curve key as expected")
|
||||
}
|
||||
err = parsedCSR.CheckSignature()
|
||||
if err != nil {
|
||||
return nil, types.BadCSRError("CSR signature is invalid")
|
||||
}
|
||||
// Update the CSR common name and OU to match Fleet-issued SCEP certs
|
||||
parsedCSR.Subject.CommonName = "Fleet Identity"
|
||||
parsedCSR.Subject.OrganizationalUnit = []string{"fleet"}
|
||||
|
||||
signer, err := s.providers.CSRSigner(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting CSR signer")
|
||||
}
|
||||
cert, err := signer.SignCSR(ctx, parsedCSR)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "signing CSR")
|
||||
}
|
||||
|
||||
err = s.store.FinalizeOrder(ctx, orderID, csr, cert.SerialNumber.Int64())
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "finalizing order")
|
||||
}
|
||||
order.Status = types.OrderStatusValid
|
||||
order.Finalized = true
|
||||
|
||||
return s.createOrderResponse(ctx, enrollment, order, authorizations)
|
||||
}
|
||||
|
||||
func parseDERCSR(csr string) (*x509.CertificateRequest, error) {
|
||||
// The CSR is base64 url encoded
|
||||
base64DecodedCSR, err := base64.RawURLEncoding.DecodeString(csr)
|
||||
if err != nil {
|
||||
return nil, types.BadCSRError(fmt.Sprintf("Error decoding base64 CSR: %s", err))
|
||||
}
|
||||
|
||||
parsedCSR, err := x509.ParseCertificateRequest(base64DecodedCSR)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing certificate request: %w", err)
|
||||
}
|
||||
|
||||
return parsedCSR, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetOrder(ctx context.Context, enrollment *types.Enrollment, account *types.Account, orderID uint) (*types.OrderResponse, error) {
|
||||
// authorization is checked in the endpoint implementation for JWS-protected endpoints
|
||||
|
||||
order, authorizations, err := s.store.GetOrderByID(ctx, account.ID, orderID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "get order from datastore")
|
||||
}
|
||||
return s.createOrderResponse(ctx, enrollment, order, authorizations)
|
||||
}
|
||||
|
||||
func (s *Service) ListAccountOrders(ctx context.Context, pathIdentifier string, account *types.Account) ([]string, error) {
|
||||
// authorization is checked in the endpoint implementation for JWS-protected endpoints
|
||||
|
||||
orderIDs, err := s.store.ListAccountOrderIDs(ctx, account.ID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "listing account order IDs from datastore")
|
||||
}
|
||||
|
||||
var orderURLs []string
|
||||
if len(orderIDs) > 0 {
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
orderURLs = make([]string, len(orderIDs))
|
||||
for i, orderID := range orderIDs {
|
||||
orderURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, "orders", fmt.Sprint(orderID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing order URL for account")
|
||||
}
|
||||
orderURLs[i] = orderURL
|
||||
}
|
||||
}
|
||||
return orderURLs, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetCertificate(ctx context.Context, accountID, orderID uint) (string, error) {
|
||||
// authorization is checked in the endpoint implementation for JWS-protected endpoints
|
||||
|
||||
order, _, err := s.store.GetOrderByID(ctx, accountID, orderID)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "get order from datastore")
|
||||
}
|
||||
|
||||
if err := order.IsCertificateReady(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
certPEM, err := s.store.GetCertificatePEMByOrderID(ctx, accountID, orderID)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "get certificate from datastore")
|
||||
}
|
||||
if !strings.HasSuffix(certPEM, "\n") {
|
||||
certPEM += "\n"
|
||||
}
|
||||
|
||||
// retrieve the root certificate
|
||||
rootPEMBytes, err := s.providers.GetCACertificatePEM(ctx)
|
||||
if err != nil {
|
||||
return "", ctxerr.Wrap(ctx, err, "getting Apple SCEP/ACME root certificate")
|
||||
}
|
||||
block, _ := pem.Decode(rootPEMBytes)
|
||||
if block == nil || block.Type != "CERTIFICATE" {
|
||||
return "", ctxerr.New(ctx, "failed to parse PEM block from root SCEP/ACME certificate")
|
||||
}
|
||||
rootPEM := string(pem.EncodeToMemory(block))
|
||||
|
||||
return certPEM + rootPEM, nil
|
||||
}
|
||||
198
server/mdm/acme/internal/service/auth.go
Normal file
198
server/mdm/acme/internal/service/auth.go
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
var acceptableSignatureAlgorithms = [...]string{
|
||||
jose.ES256,
|
||||
jose.ES384,
|
||||
jose.ES512,
|
||||
}
|
||||
|
||||
func (s *Service) authenticateWithACMEEnrollment(ctx context.Context, identifier string) (*types.Enrollment, error) {
|
||||
enrollment, err := s.store.GetACMEEnrollment(ctx, identifier)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !enrollment.IsValid() {
|
||||
err = types.EnrollmentNotFoundError(fmt.Sprintf("ACME enrollment with path identifier %s not found", identifier))
|
||||
return nil, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
return enrollment, nil
|
||||
}
|
||||
|
||||
// common authentication logic for both AuthenticateNewAccountMessage and AuthenticateMessageFromAccount, only
|
||||
// one of createNewAccount or otherRequest must be non-nil.
|
||||
func (s *Service) commonAuthenticateMessage(ctx context.Context, message *api_http.JWSRequestContainer, createNewAccount *api_http.CreateNewAccountRequest, otherRequest types.AccountAuthenticatedRequest) error {
|
||||
var err error
|
||||
|
||||
// consume the nonce as first validation
|
||||
nonce := message.JWS.Signatures[0].Protected.Nonce
|
||||
nonceValid, err := s.nonces.Consume(ctx, nonce)
|
||||
if !nonceValid || err != nil {
|
||||
// if there is an error, it is a Redis/network issue, so keep it as a 500
|
||||
if err == nil {
|
||||
err = types.BadNonceError("")
|
||||
}
|
||||
return ctxerr.Wrapf(ctx, err, "invalid nonce in JWS message for identifier %s", message.Identifier)
|
||||
}
|
||||
|
||||
if createNewAccount != nil {
|
||||
// must have the JWK
|
||||
if message.Key == nil {
|
||||
err = types.UnauthorizedError("missing JWK in JWS message for new account creation")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
// For Apple ACME purposes we only support ECDSA hardware-bound keys so validate the key is of the correct type
|
||||
// and the algorithm is of a proper type for the key (which also ensures it isn't none)
|
||||
_, ok := message.Key.Key.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
err = types.BadPublicKeyError("JWK in JWS message for new account creation is not an ECDSA public key")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
if otherRequest != nil {
|
||||
// must have the kid
|
||||
if message.KeyID == nil || *message.KeyID == "" {
|
||||
err = types.UnauthorizedError("missing kid in JWS message for account-authenticated request")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !slices.Contains(acceptableSignatureAlgorithms[:], message.JWS.Signatures[0].Protected.Algorithm) {
|
||||
err = types.BadSignatureAlgorithmError(fmt.Sprintf("unsupported signature algorithm %s in JWS message", message.JWS.Signatures[0].Protected.Algorithm))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// "url" field validation: https://datatracker.ietf.org/doc/html/rfc8555/#section-6.4.1
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return ctxerr.New(ctx, "get base ACME URL")
|
||||
}
|
||||
expectedURL, err := commonmdm.ResolveURL(baseURL, message.HTTPPath, true)
|
||||
if err != nil {
|
||||
return ctxerr.New(ctx, "get expected ACME URL")
|
||||
}
|
||||
if message.JWSHeaderURL != expectedURL {
|
||||
err = types.UnauthorizedError("invalid url in JWS protected header")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
// authenticate the enrollment identifier from the path
|
||||
enrollment, err := s.authenticateWithACMEEnrollment(ctx, message.Identifier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
webKeyToVerify := message.Key
|
||||
var account *types.Account
|
||||
if otherRequest != nil {
|
||||
accountID, err := s.accountIDFromKeyID(ctx, *message.KeyID, message.Identifier)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "parsing account ID from key ID")
|
||||
}
|
||||
account, err = s.store.GetAccountByID(ctx, enrollment.ID, accountID)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "fetching account by ID")
|
||||
}
|
||||
webKeyToVerify = &account.JSONWebKey
|
||||
}
|
||||
|
||||
payload, err := message.JWS.Verify(webKeyToVerify)
|
||||
if err != nil {
|
||||
err = types.UnauthorizedError(err.Error()) // I think it's safe to return the error as details here?
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
if message.PostAsGet && len(payload) != 0 {
|
||||
err = types.MalformedError("payload must be empty for POST-as-GET requests")
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
var requestPayload any
|
||||
requestPayload = createNewAccount
|
||||
if otherRequest != nil {
|
||||
requestPayload = otherRequest
|
||||
}
|
||||
// From the RFC, for a POST-as-GET request, the payload is an empty string (absent),
|
||||
// which would fail to unmarshal into an object, so we check it explicitly.
|
||||
if len(payload) != 0 {
|
||||
err = json.Unmarshal(payload, requestPayload)
|
||||
if err != nil {
|
||||
err = types.MalformedError(fmt.Sprintf("Failed to unmarshal JWS payload: %v", err))
|
||||
return ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
if createNewAccount != nil {
|
||||
createNewAccount.JSONWebKey = message.Key
|
||||
createNewAccount.Enrollment = enrollment
|
||||
}
|
||||
if otherRequest != nil {
|
||||
otherRequest.SetEnrollmentAndAccount(enrollment, account)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) AuthenticateNewAccountMessage(ctx context.Context, message *api_http.JWSRequestContainer, request *api_http.CreateNewAccountRequest) error {
|
||||
return s.commonAuthenticateMessage(ctx, message, request, nil)
|
||||
}
|
||||
|
||||
func (s *Service) AuthenticateMessageFromAccount(ctx context.Context, message *api_http.JWSRequestContainer, request types.AccountAuthenticatedRequest) error {
|
||||
return s.commonAuthenticateMessage(ctx, message, nil, request)
|
||||
}
|
||||
|
||||
func (s *Service) accountIDFromKeyID(ctx context.Context, keyID, pathIdentifier string) (uint, error) {
|
||||
// The key ID is the account URL, which should be in the format /api/mdm/acme/{identifier}/accounts/{accountID}
|
||||
// We can parse the account ID out of the URL to look up the account in the database
|
||||
urlParsed, err := url.Parse(keyID)
|
||||
if err != nil {
|
||||
err = types.UnauthorizedError("Invalid key ID URL")
|
||||
return 0, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
expectedURL, err := s.getACMEURL(ctx, pathIdentifier, "accounts")
|
||||
if err != nil {
|
||||
// this is not an ACME error, it's a server error
|
||||
return 0, ctxerr.Wrap(ctx, err, "getting expected account URL")
|
||||
}
|
||||
expectedParsed, err := url.Parse(expectedURL)
|
||||
if err != nil {
|
||||
// same here, not an error for a client-provided value
|
||||
return 0, ctxerr.Wrap(ctx, err, "parsing expected account URL")
|
||||
}
|
||||
|
||||
prefix := expectedParsed.Path + "/"
|
||||
if !strings.HasPrefix(urlParsed.Path, prefix) {
|
||||
err = types.UnauthorizedError("Invalid key ID URL")
|
||||
return 0, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
accountIDStr := strings.TrimPrefix(urlParsed.Path, prefix)
|
||||
accountID, err := strconv.ParseUint(accountIDStr, 10, 64)
|
||||
if err != nil {
|
||||
err = types.UnauthorizedError("Invalid key ID URL")
|
||||
return 0, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
if accountID > uint64(math.MaxUint) {
|
||||
err = types.UnauthorizedError("Invalid key ID URL")
|
||||
return 0, ctxerr.Wrap(ctx, err)
|
||||
}
|
||||
|
||||
return uint(accountID), nil
|
||||
}
|
||||
63
server/mdm/acme/internal/service/authorization.go
Normal file
63
server/mdm/acme/internal/service/authorization.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
)
|
||||
|
||||
// This file does not handle normal authentication, but the ACME concept of authorization as part of the protocol.
|
||||
|
||||
func (s *Service) GetAuthorization(ctx context.Context, enrollment *types.Enrollment, account *types.Account, authorizationID uint) (*types.AuthorizationResponse, error) {
|
||||
if authorizationID == 0 {
|
||||
return nil, types.MalformedError("invalid authorization ID")
|
||||
}
|
||||
|
||||
authz, err := s.store.GetAuthorizationByID(ctx, account.ID, authorizationID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting authorization by ID")
|
||||
}
|
||||
|
||||
challenges, err := s.store.GetChallengesByAuthorizationID(ctx, authz.ID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting challenges by authorization ID")
|
||||
}
|
||||
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
var challengeResponses []types.ChallengeResponse
|
||||
for _, c := range challenges {
|
||||
challengeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "challenges", fmt.Sprint(c.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing challenge URL")
|
||||
}
|
||||
|
||||
challengeResponse := types.ChallengeResponse{
|
||||
ChallengeType: c.ChallengeType,
|
||||
Status: c.Status,
|
||||
Token: c.Token,
|
||||
URL: challengeURL,
|
||||
Validated: c.ValidatedAt(),
|
||||
}
|
||||
|
||||
challengeResponses = append(challengeResponses, challengeResponse)
|
||||
}
|
||||
|
||||
authzURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "authorizations", fmt.Sprint(authz.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing authorization URL")
|
||||
}
|
||||
|
||||
return &types.AuthorizationResponse{
|
||||
Status: authz.Status,
|
||||
Expires: enrollment.NotValidAfter,
|
||||
Identifier: authz.Identifier,
|
||||
Challenges: challengeResponses,
|
||||
Location: authzURL,
|
||||
}, nil
|
||||
}
|
||||
205
server/mdm/acme/internal/service/challenge.go
Normal file
205
server/mdm/acme/internal/service/challenge.go
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"crypto/x509"
|
||||
"encoding/asn1"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
)
|
||||
|
||||
const appleEnterpriseAttestationRootCA = `-----BEGIN CERTIFICATE-----
|
||||
MIICJDCCAamgAwIBAgIUQsDCuyxyfFxeq/bxpm8frF15hzcwCgYIKoZIzj0EAwMw
|
||||
UTEtMCsGA1UEAwwkQXBwbGUgRW50ZXJwcmlzZSBBdHRlc3RhdGlvbiBSb290IENB
|
||||
MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yMjAyMTYxOTAx
|
||||
MjRaFw00NzAyMjAwMDAwMDBaMFExLTArBgNVBAMMJEFwcGxlIEVudGVycHJpc2Ug
|
||||
QXR0ZXN0YXRpb24gUm9vdCBDQTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UE
|
||||
BhMCVVMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT6Jigq+Ps9Q4CoT8t8q+UnOe2p
|
||||
oT9nRaUfGhBTbgvqSGXPjVkbYlIWYO+1zPk2Sz9hQ5ozzmLrPmTBgEWRcHjA2/y7
|
||||
7GEicps9wn2tj+G89l3INNDKETdxSPPIZpPj8VmjQjBAMA8GA1UdEwEB/wQFMAMB
|
||||
Af8wHQYDVR0OBBYEFPNqTQGd8muBpV5du+UIbVbi+d66MA4GA1UdDwEB/wQEAwIB
|
||||
BjAKBggqhkjOPQQDAwNpADBmAjEA1xpWmTLSpr1VH4f8Ypk8f3jMUKYz4QPG8mL5
|
||||
8m9sX/b2+eXpTv2pH4RZgJjucnbcAjEA4ZSB6S45FlPuS/u4pTnzoz632rA+xW/T
|
||||
ZwFEh9bhKjJ+5VQ9/Do1os0u3LEkgN/r
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
var (
|
||||
OIDAppleSerialNumber = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 9, 1}
|
||||
OIDAppleNonce = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 11, 1}
|
||||
)
|
||||
|
||||
func (s *Service) ValidateChallenge(ctx context.Context, enrollment *types.Enrollment, account *types.Account, challengeID uint, payload string) (*types.ChallengeResponse, error) {
|
||||
challenge, err := s.store.GetChallengeByID(ctx, account.ID, challengeID)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting challenge by ID")
|
||||
}
|
||||
|
||||
if challenge.Status != types.ChallengeStatusPending {
|
||||
return nil, types.InvalidChallengeStatusError(fmt.Sprintf("Challenge with ID %d is not pending and can not be validated", challengeID))
|
||||
}
|
||||
|
||||
var validationErr error
|
||||
var updatedChallenge *types.Challenge
|
||||
switch challenge.ChallengeType {
|
||||
case types.DeviceAttestationChallengeType:
|
||||
updatedChallenge, validationErr = s.validateDeviceAttestationChallenge(ctx, enrollment, challenge, payload)
|
||||
default:
|
||||
return nil, types.InternalServerError(fmt.Sprintf("unsupported challenge type %s", challenge.ChallengeType))
|
||||
}
|
||||
|
||||
if validationErr != nil && updatedChallenge == nil {
|
||||
return nil, validationErr
|
||||
}
|
||||
|
||||
// We always call UpdateChallenge here since we update it's validity by reference
|
||||
// this internally updates challenge status, authorization status and order status, which we can
|
||||
// confidently do with only one authorization and one challenge per order, if we add more we need to re-work this.
|
||||
if updatedChallenge, err = s.store.UpdateChallenge(ctx, updatedChallenge); err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "updating challenge status")
|
||||
}
|
||||
|
||||
if validationErr != nil {
|
||||
return nil, validationErr
|
||||
}
|
||||
|
||||
challengeResponse := &types.ChallengeResponse{
|
||||
ChallengeType: updatedChallenge.ChallengeType,
|
||||
Status: updatedChallenge.Status,
|
||||
Token: updatedChallenge.Token,
|
||||
Validated: updatedChallenge.ValidatedAt(),
|
||||
}
|
||||
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "getting base URL")
|
||||
}
|
||||
|
||||
challengeURL, err := s.getACMEURLWithBaseURL(ctx, baseURL, enrollment.PathIdentifier, "challenges", fmt.Sprint(updatedChallenge.ID))
|
||||
if err != nil {
|
||||
return nil, ctxerr.Wrap(ctx, err, "constructing challenge URL")
|
||||
}
|
||||
challengeResponse.URL = challengeURL
|
||||
challengeResponse.Location = challengeURL
|
||||
|
||||
return challengeResponse, nil
|
||||
}
|
||||
|
||||
func (s *Service) validateDeviceAttestationChallenge(ctx context.Context, enrollment *types.Enrollment, challenge *types.Challenge, payload string) (*types.Challenge, error) {
|
||||
base64Decoded, err := base64.RawURLEncoding.DecodeString(payload)
|
||||
if err != nil {
|
||||
return nil, types.MalformedError(fmt.Sprintf("Failed to base64 decode payload: %v", err))
|
||||
}
|
||||
|
||||
// Verify the CBOR structure
|
||||
if err := cbor.Wellformed(base64Decoded); err != nil {
|
||||
return nil, types.BadAttestationStatementError(fmt.Sprintf("Device attestation statement is not correctly CBOR formatted: %s", err.Error()))
|
||||
}
|
||||
|
||||
var attestationObject types.AttestationObject
|
||||
if err := cbor.Unmarshal(base64Decoded, &attestationObject); err != nil {
|
||||
return nil, types.BadAttestationStatementError(fmt.Sprintf("Failed to unmarshal CBOR payload into attestation object: %s", err.Error()))
|
||||
}
|
||||
|
||||
switch attestationObject.Format {
|
||||
case "apple":
|
||||
var appleStmt types.AppleDeviceAttestationStatement
|
||||
if err := cbor.Unmarshal(attestationObject.AttestationStatement, &appleStmt); err != nil {
|
||||
return nil, types.BadAttestationStatementError(fmt.Sprintf("Failed to unmarshal CBOR attestation statement into Apple format: %s", err.Error()))
|
||||
}
|
||||
|
||||
return challenge, s.validateAppleDeviceAttestationStatement(ctx, enrollment, challenge, appleStmt)
|
||||
default:
|
||||
return nil, types.BadAttestationStatementError(fmt.Sprintf("Unsupported device attestation format: %s", attestationObject.Format))
|
||||
}
|
||||
}
|
||||
|
||||
// Challenge status is updated by reference
|
||||
func (s *Service) validateAppleDeviceAttestationStatement(ctx context.Context, enrollment *types.Enrollment, challenge *types.Challenge, attStmt types.AppleDeviceAttestationStatement) error {
|
||||
roots := s.TestAppleRootCAs
|
||||
if roots == nil {
|
||||
roots = x509.NewCertPool()
|
||||
rootCABlock, _ := pem.Decode([]byte(appleEnterpriseAttestationRootCA))
|
||||
if rootCABlock == nil {
|
||||
return types.BadAttestationStatementError("Failed to parse Apple Enterprise Attestation Root CA certificate")
|
||||
}
|
||||
rootCA, err := x509.ParseCertificate(rootCABlock.Bytes)
|
||||
if err != nil {
|
||||
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse Apple Enterprise Attestation Root CA certificate: %s", err.Error()))
|
||||
}
|
||||
roots.AddCert(rootCA)
|
||||
}
|
||||
|
||||
if len(attStmt.X5C) < 1 {
|
||||
return types.BadAttestationStatementError("Apple device attestation statement must contain at least one certificate in x5c field")
|
||||
}
|
||||
|
||||
leaf, err := x509.ParseCertificate(attStmt.X5C[0])
|
||||
if err != nil {
|
||||
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse leaf certificate in Apple device attestation statement: %s", err.Error()))
|
||||
}
|
||||
|
||||
intermediates := x509.NewCertPool()
|
||||
for _, certBytes := range attStmt.X5C[1:] {
|
||||
cert, err := x509.ParseCertificate(certBytes)
|
||||
if err != nil {
|
||||
return types.BadAttestationStatementError(fmt.Sprintf("Failed to parse intermediate certificate in Apple device attestation statement: %s", err.Error()))
|
||||
}
|
||||
intermediates.AddCert(cert)
|
||||
}
|
||||
|
||||
if _, err := leaf.Verify(x509.VerifyOptions{
|
||||
Roots: roots,
|
||||
Intermediates: intermediates,
|
||||
CurrentTime: time.Now().Truncate(time.Second),
|
||||
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
}); err != nil {
|
||||
return types.BadAttestationStatementError(fmt.Sprintf("Failed to verify Apple Root CA is part of certificate chain: %s", err.Error()))
|
||||
}
|
||||
|
||||
// TODO: Should we do any validation on leaf.PublicKey? Apple docs on validation calls out "Retain the public key in the attestation leaf certificate for a later validation."
|
||||
// So unsure if we should persist it, or what the later validation might be.
|
||||
appleData := struct {
|
||||
SerialNumber string
|
||||
Nonce []byte
|
||||
}{}
|
||||
|
||||
for _, ext := range leaf.Extensions {
|
||||
if ext.Id.Equal(OIDAppleSerialNumber) {
|
||||
appleData.SerialNumber = string(ext.Value)
|
||||
} else if ext.Id.Equal(OIDAppleNonce) {
|
||||
appleData.Nonce = ext.Value
|
||||
}
|
||||
}
|
||||
|
||||
sha256Token := sha256.Sum256([]byte(challenge.Token))
|
||||
if subtle.ConstantTimeCompare(appleData.Nonce, sha256Token[:]) != 1 {
|
||||
challenge.MarkInvalid()
|
||||
return types.BadAttestationStatementError("Apple freshness nonce does not match challenge token")
|
||||
}
|
||||
|
||||
if appleData.SerialNumber != enrollment.HostIdentifier {
|
||||
challenge.MarkInvalid()
|
||||
return types.BadAttestationStatementError("Serial number in certificate does not match enrollment's host identifier")
|
||||
}
|
||||
|
||||
enrolled, err := s.providers.IsDEPEnrolled(ctx, appleData.SerialNumber)
|
||||
if err != nil {
|
||||
return ctxerr.Wrap(ctx, err, "checking DEP enrollment for serial number in attestation certificate")
|
||||
}
|
||||
|
||||
if !enrolled {
|
||||
challenge.MarkInvalid()
|
||||
return types.BadAttestationStatementError("No DEP assignments found for serial number in certificate")
|
||||
}
|
||||
|
||||
challenge.MarkValid()
|
||||
return nil
|
||||
}
|
||||
45
server/mdm/acme/internal/service/directory_nonce.go
Normal file
45
server/mdm/acme/internal/service/directory_nonce.go
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
)
|
||||
|
||||
func (s *Service) NewNonce(ctx context.Context, identifier string) error {
|
||||
// authentication is via the identifier, that must exist as a valid ACME enrollment
|
||||
if _, err := s.authenticateWithACMEEnrollment(ctx, identifier); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// actual nonce generation happens in the rendering of the response
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) GetDirectory(ctx context.Context, identifier string) (*types.Directory, error) {
|
||||
// authentication is via the identifier, that must exist as a valid ACME enrollment
|
||||
if _, err := s.authenticateWithACMEEnrollment(ctx, identifier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
suffixes := []string{"new_nonce", "new_account", "new_order"}
|
||||
urls := make(map[string]string, len(suffixes))
|
||||
for _, suffix := range suffixes {
|
||||
u, err := s.getACMEURLWithBaseURL(ctx, baseURL, identifier, suffix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
urls[suffix] = u
|
||||
}
|
||||
|
||||
return &types.Directory{
|
||||
NewNonce: urls["new_nonce"],
|
||||
NewAccount: urls["new_account"],
|
||||
NewOrder: urls["new_order"],
|
||||
}, nil
|
||||
}
|
||||
135
server/mdm/acme/internal/service/endpoint_utils.go
Normal file
135
server/mdm/acme/internal/service/endpoint_utils.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
||||
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// encodeResponse encodes the response as JSON.
|
||||
func encodeResponse(ctx context.Context, w http.ResponseWriter, response any) error {
|
||||
return eu.EncodeCommonResponse(ctx, w, response,
|
||||
func(w http.ResponseWriter, response any) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(response)
|
||||
},
|
||||
acmeDomainErrorEncoder,
|
||||
)
|
||||
}
|
||||
|
||||
func acmeErrorEncoder(ctx context.Context, err error, w http.ResponseWriter) {
|
||||
var acmeErr *types.ACMEError
|
||||
if !errors.As(err, &acmeErr) {
|
||||
// TODO: If we can get access to a logger, we can log the details here, to help troubleshoot service errors.
|
||||
// if it's not already an ACME error, it is because it is an internal server
|
||||
// error (or a dev error, for 4xx we should always return ACMEError).
|
||||
acmeErr = types.InternalServerError("") // not passing err.Error() as we don't want to leak internal details
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
statusCode := acmeErr.StatusCode
|
||||
if statusCode == 0 {
|
||||
statusCode = http.StatusInternalServerError
|
||||
}
|
||||
w.WriteHeader(statusCode)
|
||||
// ignoring error as response started being written at that point
|
||||
_ = json.NewEncoder(w).Encode(acmeErr)
|
||||
}
|
||||
|
||||
func acmeDomainErrorEncoder(ctx context.Context, err error, w http.ResponseWriter, enc *json.Encoder, jsonErr *eu.JsonError) (handled bool) {
|
||||
acmeErrorEncoder(ctx, err, w)
|
||||
return true
|
||||
}
|
||||
|
||||
// makeDecoder creates a decoder for the given request type.
|
||||
func makeDecoder(iface any, requestBodySizeLimit int64) kithttp.DecodeRequestFunc {
|
||||
return eu.MakeDecoder(iface, func(body io.Reader, req any) error {
|
||||
return json.NewDecoder(body).Decode(req)
|
||||
}, parseCustomTags, isBodyDecoder, decodeBody, nil, requestBodySizeLimit)
|
||||
}
|
||||
|
||||
// parseCustomTags handles custom URL tag values for acme requests.
|
||||
func parseCustomTags(urlTagValue string, r *http.Request, field reflect.Value) (bool, error) {
|
||||
switch urlTagValue {
|
||||
case "http_method":
|
||||
field.Set(reflect.ValueOf(r.Method))
|
||||
return true, nil
|
||||
case "http_path":
|
||||
field.Set(reflect.ValueOf(r.URL.Path))
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isBodyDecoder(v reflect.Value) bool {
|
||||
_, ok := v.Interface().(bodyDecoder)
|
||||
return ok
|
||||
}
|
||||
|
||||
type bodyDecoder interface {
|
||||
DecodeBody(ctx context.Context, r io.Reader, u url.Values, c []*x509.Certificate) error
|
||||
}
|
||||
|
||||
func decodeBody(ctx context.Context, r *http.Request, v reflect.Value, body io.Reader) error {
|
||||
bd := v.Interface().(bodyDecoder)
|
||||
var certs []*x509.Certificate
|
||||
if (r.TLS != nil) && (r.TLS.PeerCertificates != nil) {
|
||||
certs = r.TLS.PeerCertificates
|
||||
}
|
||||
|
||||
if err := bd.DecodeBody(ctx, body, r.URL.Query(), certs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlerFunc is the handler function type for ACME service endpoints.
|
||||
type handlerFunc func(ctx context.Context, request any, svc api.Service) platform_http.Errorer
|
||||
|
||||
type endpointer struct {
|
||||
svc api.Service
|
||||
}
|
||||
|
||||
func (e *endpointer) CallHandlerFunc(f handlerFunc, ctx context.Context,
|
||||
request any,
|
||||
svc any,
|
||||
) (platform_http.Errorer, error) {
|
||||
return f(ctx, request, svc.(api.Service)), nil
|
||||
}
|
||||
|
||||
func (e *endpointer) Service() any {
|
||||
return e.svc
|
||||
}
|
||||
|
||||
// Compile-time check to ensure endpointer implements Endpointer.
|
||||
var _ eu.Endpointer[handlerFunc] = &endpointer{}
|
||||
|
||||
func newEndpointerWithNoAuth(svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption, r *mux.Router,
|
||||
versions ...string,
|
||||
) *eu.CommonEndpointer[handlerFunc] {
|
||||
return &eu.CommonEndpointer[handlerFunc]{
|
||||
EP: &endpointer{
|
||||
svc: svc,
|
||||
},
|
||||
MakeDecoderFn: makeDecoder,
|
||||
EncodeFn: encodeResponse,
|
||||
Opts: opts,
|
||||
AuthMiddleware: authMiddleware,
|
||||
Router: r,
|
||||
Versions: versions,
|
||||
}
|
||||
}
|
||||
16
server/mdm/acme/internal/service/enrollment.go
Normal file
16
server/mdm/acme/internal/service/enrollment.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
)
|
||||
|
||||
func (s *Service) NewACMEEnrollment(ctx context.Context, hostIdentifier string) (string, error) {
|
||||
// skipauth: No authorization check needed; caller is authenticated via DEP device identity.
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
|
||||
return s.store.NewEnrollment(ctx, hostIdentifier)
|
||||
}
|
||||
260
server/mdm/acme/internal/service/handler.go
Normal file
260
server/mdm/acme/internal/service/handler.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
authz_ctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
eu "github.com/fleetdm/fleet/v4/server/platform/endpointer"
|
||||
platform_http "github.com/fleetdm/fleet/v4/server/platform/http"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
kithttp "github.com/go-kit/kit/transport/http"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// GetRoutes returns a function that registers ACME routes on the router using the provided
|
||||
// authMiddleware.
|
||||
func GetRoutes(svc api.Service, authMiddleware endpoint.Middleware) eu.HandlerRoutesFunc {
|
||||
return func(r *mux.Router, opts []kithttp.ServerOption) {
|
||||
attachFleetAPIRoutes(r, svc, authMiddleware, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func attachFleetAPIRoutes(r *mux.Router, svc api.Service, authMiddleware endpoint.Middleware, opts []kithttp.ServerOption) {
|
||||
opts = append(opts, kithttp.ServerErrorEncoder(acmeErrorEncoder))
|
||||
ae := newEndpointerWithNoAuth(svc, authMiddleware, opts, r)
|
||||
// ACME endpoints use path identifier and JWS authn/z, so we use a middleware to mark
|
||||
// the standard Fleet auth as skipped/done so the endpoints don't return a Forbidden
|
||||
// error due to no standard auth done.
|
||||
ae = ae.WithCustomMiddlewareAfterAuth(skipStandardFleetAuth())
|
||||
|
||||
// must support HEAD, GET and POST-as-GET for new_nonce as per
|
||||
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.3 and
|
||||
// https://datatracker.ietf.org/doc/html/rfc8555/#section-7.2
|
||||
ae.GET("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
|
||||
ae.HEAD("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
|
||||
ae.POST("/api/mdm/acme/{identifier}/new_nonce", getNewNonceEndpoint, api_http.GetNewNonceRequest{})
|
||||
|
||||
// must support GET and POST-as-GET for directory as per
|
||||
// https://datatracker.ietf.org/doc/html/rfc8555/#section-6.3 and
|
||||
// https://datatracker.ietf.org/doc/html/rfc8555/#section-7.1.1
|
||||
ae.GET("/api/mdm/acme/{identifier}/directory", getDirectoryEndpoint, api_http.GetDirectoryRequest{})
|
||||
ae.POST("/api/mdm/acme/{identifier}/directory", getDirectoryEndpoint, api_http.GetDirectoryRequest{})
|
||||
|
||||
ae.POST("/api/mdm/acme/{identifier}/new_account", createAccountEndpoint, api_http.JWSRequestContainer{})
|
||||
ae.POST("/api/mdm/acme/{identifier}/new_order", createOrderEndpoint, api_http.JWSRequestContainer{})
|
||||
|
||||
// POST-as-GET for order endpoint, as per RFC.
|
||||
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}", getOrderEndpoint, api_http.GetOrderRequest{})
|
||||
// POST-as-GET for list orders endpoint, as per RFC.
|
||||
ae.POST("/api/mdm/acme/{identifier}/accounts/{account_id}/orders", listOrdersEndpoint, api_http.ListOrdersRequest{})
|
||||
// POST-as-GET for download certificate endpoint, as per RFC.
|
||||
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}/certificate", getCertificateEndpoint, api_http.GetCertificateRequest{})
|
||||
|
||||
ae.POST("/api/mdm/acme/{identifier}/authorizations/{authorization_id}", getAuthorizationEndpoint, api_http.GetAuthorizationRequest{})
|
||||
ae.POST("/api/mdm/acme/{identifier}/challenges/{challenge_id}", getChallengeEndpoint, api_http.DoChallengeRequest{})
|
||||
ae.POST("/api/mdm/acme/{identifier}/orders/{order_id}/finalize", finalizeOrderEndpoint, api_http.FinalizeOrderRequestContainer{})
|
||||
}
|
||||
|
||||
func skipStandardFleetAuth() endpoint.Middleware {
|
||||
return func(next endpoint.Endpoint) endpoint.Endpoint {
|
||||
return func(ctx context.Context, req any) (any, error) {
|
||||
if az, ok := authz_ctx.FromContext(ctx); ok {
|
||||
az.SetChecked()
|
||||
}
|
||||
return next(ctx, req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getNewNonceEndpoint handles HEAD/GET/POST /api/mdm/acme/{identifier}/new_nonce requests.
|
||||
func getNewNonceEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.GetNewNonceRequest)
|
||||
err := svc.NewNonce(ctx, req.Identifier)
|
||||
if err != nil {
|
||||
return &api_http.GetNewNonceResponse{Err: err}
|
||||
}
|
||||
return &api_http.GetNewNonceResponse{
|
||||
HTTPMethod: req.HTTPMethod,
|
||||
Nonces: svc.NoncesStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// getDirectoryEndpoint handles GET/POST /api/mdm/acme/{identifier}/directory requests.
|
||||
func getDirectoryEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.GetDirectoryRequest)
|
||||
dir, err := svc.GetDirectory(ctx, req.Identifier)
|
||||
if err != nil {
|
||||
return api_http.GetDirectoryResponse{Err: err}
|
||||
}
|
||||
return api_http.GetDirectoryResponse{Directory: dir}
|
||||
}
|
||||
|
||||
// createAccountEndpoint handles POST /api/mdm/acme/{identifier}/new_account requests.
|
||||
func createAccountEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.JWSRequestContainer)
|
||||
newAccountRequest := &api_http.CreateNewAccountRequest{}
|
||||
err := svc.AuthenticateNewAccountMessage(ctx, req, newAccountRequest)
|
||||
if err != nil {
|
||||
return &api_http.CreateNewAccountResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
accountResp, err := svc.CreateAccount(ctx, req.Identifier, newAccountRequest.Enrollment.ID, *newAccountRequest.JSONWebKey, newAccountRequest.OnlyReturnExisting)
|
||||
if err != nil {
|
||||
return &api_http.CreateNewAccountResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
return &api_http.CreateNewAccountResponse{
|
||||
Nonces: svc.NoncesStore(),
|
||||
AccountResponse: accountResp,
|
||||
}
|
||||
}
|
||||
|
||||
// createOrderEndpoint handles POST /api/mdm/acme/{identifier}/new_order requests.
|
||||
func createOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.JWSRequestContainer)
|
||||
newOrderRequest := &api_http.CreateNewOrderRequest{}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, req, newOrderRequest)
|
||||
if err != nil {
|
||||
return &api_http.CreateNewOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
partialOrder := &types.Order{
|
||||
Identifiers: newOrderRequest.Identifiers,
|
||||
NotBefore: newOrderRequest.NotBefore,
|
||||
NotAfter: newOrderRequest.NotAfter,
|
||||
}
|
||||
orderResp, err := svc.CreateOrder(ctx, newOrderRequest.Enrollment, newOrderRequest.Account, partialOrder)
|
||||
if err != nil {
|
||||
return &api_http.CreateNewOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
return &api_http.CreateNewOrderResponse{
|
||||
Nonces: svc.NoncesStore(),
|
||||
OrderResponse: orderResp,
|
||||
}
|
||||
}
|
||||
|
||||
// getOrderEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/orders/{id} requests.
|
||||
func getOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.GetOrderRequest)
|
||||
req.PostAsGet = true
|
||||
orderRequest := &api_http.GetOrderDecodedRequest{OrderID: req.OrderID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, orderRequest)
|
||||
if err != nil {
|
||||
return &api_http.GetOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
orderResp, err := svc.GetOrder(ctx, orderRequest.Enrollment, orderRequest.Account, orderRequest.OrderID)
|
||||
if err != nil {
|
||||
return &api_http.GetOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
return &api_http.GetOrderResponse{
|
||||
Nonces: svc.NoncesStore(),
|
||||
OrderResponse: orderResp,
|
||||
}
|
||||
}
|
||||
|
||||
// listOrdersEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/accounts/{id}/orders requests.
|
||||
func listOrdersEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.ListOrdersRequest)
|
||||
req.PostAsGet = true
|
||||
ordersRequest := &api_http.ListOrdersDecodedRequest{AccountID: req.AccountID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, ordersRequest)
|
||||
if err != nil {
|
||||
return &api_http.ListOrdersResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
urls, err := svc.ListAccountOrders(ctx, req.Identifier, ordersRequest.Account)
|
||||
if err != nil {
|
||||
return &api_http.ListOrdersResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
return &api_http.ListOrdersResponse{
|
||||
Nonces: svc.NoncesStore(),
|
||||
Orders: urls,
|
||||
}
|
||||
}
|
||||
|
||||
// getCertificateEndpoint handles POST-as-GET /api/mdm/acme/{identifier}/orders/{id}/certificate requests.
|
||||
func getCertificateEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.GetCertificateRequest)
|
||||
req.PostAsGet = true
|
||||
certReq := &api_http.GetCertificateDecodedRequest{OrderID: req.OrderID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, certReq)
|
||||
if err != nil {
|
||||
return &api_http.GetCertificateResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
cert, err := svc.GetCertificate(ctx, certReq.Account.ID, certReq.OrderID)
|
||||
if err != nil {
|
||||
return &api_http.GetCertificateResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
return &api_http.GetCertificateResponse{
|
||||
Certificate: cert,
|
||||
Nonces: svc.NoncesStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// getAuthorizationEndpoint handles POST /api/mdm/acme/{identifier}/authz/{authorization} requests.
|
||||
func getAuthorizationEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.GetAuthorizationRequest)
|
||||
req.PostAsGet = true
|
||||
authzReq := &api_http.GetAuthorizationDecodedRequest{AuthorizationID: req.AuthorizationID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, authzReq)
|
||||
if err != nil {
|
||||
return &api_http.GetAuthorizationResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
authzResp, err := svc.GetAuthorization(ctx, authzReq.Enrollment, authzReq.Account, authzReq.AuthorizationID)
|
||||
if err != nil {
|
||||
return &api_http.GetAuthorizationResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
return &api_http.GetAuthorizationResponse{
|
||||
AuthorizationResponse: authzResp,
|
||||
Nonces: svc.NoncesStore(),
|
||||
}
|
||||
}
|
||||
|
||||
func getChallengeEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.DoChallengeRequest)
|
||||
decodedReq := &api_http.DoChallengeDecodedRequest{ChallengeID: req.ChallengeID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, decodedReq)
|
||||
if err != nil {
|
||||
return &api_http.DoChallengeResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
if decodedReq.AttestError != "" {
|
||||
return &api_http.DoChallengeResponse{
|
||||
Err: types.UnauthorizedError(fmt.Sprintf("Attestation failure: %s", decodedReq.AttestError)),
|
||||
Nonces: svc.NoncesStore(),
|
||||
}
|
||||
}
|
||||
|
||||
challengeResp, err := svc.ValidateChallenge(ctx, decodedReq.Enrollment, decodedReq.Account, decodedReq.ChallengeID, decodedReq.AttestationObject)
|
||||
if err != nil {
|
||||
return &api_http.DoChallengeResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
return &api_http.DoChallengeResponse{
|
||||
ChallengeResponse: challengeResp,
|
||||
Nonces: svc.NoncesStore(),
|
||||
}
|
||||
}
|
||||
|
||||
// finalizeOrderEndpoint handles POST /api/mdm/acme/{identifier}/orders/{order_id}/finalize requests.
|
||||
func finalizeOrderEndpoint(ctx context.Context, request any, svc api.Service) platform_http.Errorer {
|
||||
req := request.(*api_http.FinalizeOrderRequestContainer)
|
||||
finalizeOrderRequest := &api_http.FinalizeOrderRequest{OrderID: req.OrderID}
|
||||
err := svc.AuthenticateMessageFromAccount(ctx, &req.JWSRequestContainer, finalizeOrderRequest)
|
||||
if err != nil {
|
||||
return &api_http.FinalizeOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
order, err := svc.FinalizeOrder(ctx, finalizeOrderRequest.Enrollment, finalizeOrderRequest.Account, finalizeOrderRequest.OrderID, finalizeOrderRequest.CertificateSigningRequest)
|
||||
if err != nil {
|
||||
return &api_http.FinalizeOrderResponse{Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
|
||||
return &api_http.FinalizeOrderResponse{OrderResponse: order, Err: err, Nonces: svc.NoncesStore()}
|
||||
}
|
||||
74
server/mdm/acme/internal/service/service.go
Normal file
74
server/mdm/acme/internal/service/service.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// Package service provides the service implementation for the ACME service module.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/api"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/redis_nonces_store"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/internal/commonmdm"
|
||||
)
|
||||
|
||||
// Service is the ACME bounded context service implementation.
|
||||
type Service struct {
|
||||
store types.Datastore
|
||||
nonces *redis_nonces_store.RedisNoncesStore
|
||||
providers acme.DataProviders
|
||||
logger *slog.Logger
|
||||
|
||||
// Field to set for testing, if not set it will use the hardcoded Apple Enterprise Attestation Root CA
|
||||
TestAppleRootCAs *x509.CertPool
|
||||
}
|
||||
|
||||
type ServiceOption func(*Service)
|
||||
|
||||
// NewService creates a new ACME service.
|
||||
func NewService(
|
||||
store types.Datastore,
|
||||
redisPool acme.RedisPool,
|
||||
providers acme.DataProviders,
|
||||
logger *slog.Logger,
|
||||
opts ...ServiceOption,
|
||||
) *Service {
|
||||
noncesStore := redis_nonces_store.New(redisPool)
|
||||
svc := &Service{
|
||||
store: store,
|
||||
nonces: noncesStore,
|
||||
providers: providers,
|
||||
logger: logger,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(svc)
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// Ensure Service implements api.Service
|
||||
var _ api.Service = (*Service)(nil)
|
||||
|
||||
func (s *Service) NoncesStore() *redis_nonces_store.RedisNoncesStore {
|
||||
return s.nonces
|
||||
}
|
||||
|
||||
func (s *Service) getACMEBaseURL(ctx context.Context) (string, error) {
|
||||
return s.providers.ServerURL(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) getACMEURL(ctx context.Context, pathIdentifier string, suffixes ...string) (string, error) {
|
||||
baseURL, err := s.getACMEBaseURL(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return s.getACMEURLWithBaseURL(ctx, baseURL, pathIdentifier, suffixes...)
|
||||
}
|
||||
|
||||
func (s *Service) getACMEURLWithBaseURL(_ context.Context, baseURL, pathIdentifier string, suffixes ...string) (string, error) {
|
||||
return commonmdm.ResolveURL(baseURL, fmt.Sprintf("/api/mdm/acme/%s/%s", pathIdentifier, strings.Join(suffixes, "/")), true)
|
||||
}
|
||||
1822
server/mdm/acme/internal/tests/integration_test.go
Normal file
1822
server/mdm/acme/internal/tests/integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
50
server/mdm/acme/internal/tests/mocks_test.go
Normal file
50
server/mdm/acme/internal/tests/mocks_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
)
|
||||
|
||||
// Mock implementations for dependencies outside the bounded context
|
||||
|
||||
// mockDataProviders implements acme.DataProviders for testing.
|
||||
type mockDataProviders struct {
|
||||
serverURL string
|
||||
assets map[string][]byte // asset name → PEM bytes
|
||||
signer acme.CSRSigner
|
||||
}
|
||||
|
||||
func newMockDataProviders(serverURL string, signer acme.CSRSigner, caCertPEM []byte) *mockDataProviders {
|
||||
return &mockDataProviders{
|
||||
serverURL: serverURL,
|
||||
signer: signer,
|
||||
assets: map[string][]byte{"ca_cert": caCertPEM},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDataProviders) ServerURL(_ context.Context) (string, error) {
|
||||
return m.serverURL, nil
|
||||
}
|
||||
|
||||
func (m *mockDataProviders) GetCACertificatePEM(_ context.Context) ([]byte, error) {
|
||||
if pem, ok := m.assets["ca_cert"]; ok {
|
||||
return pem, nil
|
||||
}
|
||||
return nil, errors.New("ca_cert not found")
|
||||
}
|
||||
|
||||
func (m *mockDataProviders) CSRSigner(_ context.Context) (acme.CSRSigner, error) {
|
||||
return m.signer, nil
|
||||
}
|
||||
|
||||
// Returns true for "valid-serial", error for "error-serial", false otherwise
|
||||
func (m *mockDataProviders) IsDEPEnrolled(_ context.Context, serial string) (bool, error) {
|
||||
if serial == "valid-serial" {
|
||||
return true, nil
|
||||
} else if serial == "error-serial" {
|
||||
return false, errors.New("Mocked error for IsDEPEnrolled")
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
460
server/mdm/acme/internal/tests/suite_test.go
Normal file
460
server/mdm/acme/internal/tests/suite_test.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package tests
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/datastore/redis/redistest"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme"
|
||||
api_http "github.com/fleetdm/fleet/v4/server/mdm/acme/api/http"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/bootstrap"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/mysql"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/testutils"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/testhelpers"
|
||||
"github.com/fleetdm/fleet/v4/server/ptr"
|
||||
"github.com/go-kit/kit/endpoint"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
// integrationTestSuite holds all dependencies for integration tests.
|
||||
type integrationTestSuite struct {
|
||||
*testutils.TestDB
|
||||
ds *mysql.Datastore
|
||||
server *httptest.Server
|
||||
|
||||
attestCA *x509.Certificate
|
||||
attestCAKey *ecdsa.PrivateKey
|
||||
}
|
||||
|
||||
// setupIntegrationTest creates a new test suite with a real database and HTTP server.
|
||||
func setupIntegrationTest(t *testing.T) *integrationTestSuite {
|
||||
t.Helper()
|
||||
|
||||
tdb := testutils.SetupTestDB(t, "acme_integration")
|
||||
pool := redistest.SetupRedis(t, "acme_integration", false, false, false)
|
||||
ds := mysql.NewDatastore(tdb.Conns(), tdb.Logger)
|
||||
cert, key, err := testhelpers.GenerateTestAttestationCA()
|
||||
require.NoError(t, err)
|
||||
rootPool := x509.NewCertPool()
|
||||
rootPool.AddCert(cert)
|
||||
|
||||
// Create mocks
|
||||
providers := newMockDataProviders(
|
||||
"https://example.com", // will update with actual test server URL after it is started
|
||||
acme.CSRSignerFunc(func(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
|
||||
res, err := tdb.DB.DB.Exec(`INSERT INTO identity_serials () VALUES ()`) // insert a row to get an auto-incremented ID for the cert serial number
|
||||
require.NoError(t, err)
|
||||
serialID, err := res.LastInsertId()
|
||||
require.NoError(t, err)
|
||||
_, err = tdb.DB.DB.Exec(`INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem) VALUES (?, NOW(), NOW(), ?)`, serialID, fmt.Appendf(nil, "-----BEGIN CERTIFICATE-----\nmock-cert-%d\n-----END CERTIFICATE-----", serialID))
|
||||
require.NoError(t, err)
|
||||
return &x509.Certificate{
|
||||
SerialNumber: big.NewInt(serialID),
|
||||
Raw: []byte("mock-cert"),
|
||||
}, nil
|
||||
}),
|
||||
[]byte("-----BEGIN CERTIFICATE-----\nroot\n-----END CERTIFICATE-----"),
|
||||
)
|
||||
|
||||
opts := bootstrap.WithTestAppleRootCAs(rootPool)
|
||||
// Create service
|
||||
svc := service.NewService(ds, pool, providers, tdb.Logger, opts)
|
||||
|
||||
// Create router with routes
|
||||
router := mux.NewRouter()
|
||||
authMiddleware := func(next endpoint.Endpoint) endpoint.Endpoint { return next } // no-op auth middleware for testing
|
||||
routesFn := service.GetRoutes(svc, authMiddleware)
|
||||
routesFn(router, nil)
|
||||
|
||||
// Create test server
|
||||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
providers.serverURL = server.URL
|
||||
|
||||
return &integrationTestSuite{
|
||||
TestDB: tdb,
|
||||
ds: ds,
|
||||
server: server,
|
||||
attestCA: cert,
|
||||
attestCAKey: key,
|
||||
}
|
||||
}
|
||||
|
||||
// truncateTables clears all test data between tests.
|
||||
func (s *integrationTestSuite) truncateTables(t *testing.T) {
|
||||
t.Helper()
|
||||
s.TruncateTables(t)
|
||||
}
|
||||
|
||||
func drainAndCloseBody(resp *http.Response) {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
}
|
||||
|
||||
// newNonce makes an HTTP request to new nonce endpoint and returns the parsed response and the raw response.
|
||||
func (s *integrationTestSuite) newNonce(t *testing.T, httpMethod, pathIdentifier string) (*api_http.GetNewNonceResponse, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_nonce", pathIdentifier) //nolint:gosec // test server URL is safe
|
||||
req, err := http.NewRequest(httpMethod, url, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer drainAndCloseBody(resp)
|
||||
|
||||
result := &api_http.GetNewNonceResponse{
|
||||
HTTPMethod: resp.Request.Method,
|
||||
}
|
||||
return result, resp
|
||||
}
|
||||
|
||||
// doACMERequest is a generic helper that makes an HTTP request, decodes the
|
||||
// response into T on success, or into an ACMEError on failure (status >= 300).
|
||||
func doACMERequest[T any](t *testing.T, method, url string, body []byte) (*T, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
|
||||
var bodyReader io.Reader
|
||||
if body != nil {
|
||||
bodyReader = bytes.NewReader(body)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, url, bodyReader) //nolint:gosec // test server URL is safe
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer drainAndCloseBody(resp)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
var acmeErr types.ACMEError
|
||||
if err := json.NewDecoder(resp.Body).Decode(&acmeErr); err == nil && acmeErr.Type != "" {
|
||||
return nil, &acmeErr, resp
|
||||
}
|
||||
return nil, nil, resp
|
||||
}
|
||||
|
||||
var result T
|
||||
err = json.NewDecoder(resp.Body).Decode(&result)
|
||||
require.NoError(t, err)
|
||||
return &result, nil, resp
|
||||
}
|
||||
|
||||
// getDirectory makes an HTTP request to get directory endpoint and returns the parsed response and the raw response.
|
||||
func (s *integrationTestSuite) getDirectory(t *testing.T, httpMethod, pathIdentifier string) (*api_http.GetDirectoryResponse, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/directory", pathIdentifier) //nolint:gosec // test server URL is safe
|
||||
result, _, resp := doACMERequest[api_http.GetDirectoryResponse](t, httpMethod, url, nil)
|
||||
return result, resp
|
||||
}
|
||||
|
||||
// staticNonce implements jose.NonceSource with a fixed nonce value.
|
||||
type staticNonce struct {
|
||||
nonce string
|
||||
}
|
||||
|
||||
func (s staticNonce) Nonce() (string, error) {
|
||||
return s.nonce, nil
|
||||
}
|
||||
|
||||
// getNonce obtains a fresh nonce from the new_nonce endpoint for the given enrollment.
|
||||
func (s *integrationTestSuite) getNonce(t *testing.T, pathIdentifier string) string {
|
||||
t.Helper()
|
||||
_, resp := s.newNonce(t, http.MethodGet, pathIdentifier)
|
||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
nonce := resp.Header.Get("Replay-Nonce")
|
||||
require.NotEmpty(t, nonce)
|
||||
return nonce
|
||||
}
|
||||
|
||||
// buildJWS constructs a JWS in flattened JSON serialization. When accountURL is
|
||||
// empty, the JWK is embedded in the header (for new-account requests). When
|
||||
// accountURL is set, it is used as the KeyID instead (for account-authenticated
|
||||
// requests like new-order).
|
||||
func buildJWS(t *testing.T, privateKey *ecdsa.PrivateKey, nonce, accountURL, endpointURL string, payload any) []byte {
|
||||
t.Helper()
|
||||
|
||||
opts := &jose.SignerOptions{
|
||||
NonceSource: staticNonce{nonce: nonce},
|
||||
ExtraHeaders: map[jose.HeaderKey]any{
|
||||
"url": endpointURL,
|
||||
},
|
||||
}
|
||||
if accountURL == "" {
|
||||
opts.EmbedJWK = true
|
||||
} else {
|
||||
opts.ExtraHeaders["kid"] = accountURL
|
||||
}
|
||||
|
||||
signer, err := jose.NewSigner(
|
||||
jose.SigningKey{Algorithm: jose.ES256, Key: privateKey},
|
||||
opts,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var payloadBytes []byte
|
||||
if payload != nil {
|
||||
payloadBytes, err = json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
// as per the RFC:
|
||||
// > [when doing a POST-as-GET] the "payload" field of the
|
||||
// > JWS object MUST be present and set to the empty string
|
||||
// > [...] a zero-length (and thus non-JSON) payload
|
||||
payloadBytes = []byte("")
|
||||
}
|
||||
|
||||
jws, err := signer.Sign(payloadBytes)
|
||||
require.NoError(t, err)
|
||||
|
||||
return []byte(jws.FullSerialize())
|
||||
}
|
||||
|
||||
// createAccount POSTs a JWS body to the new_account endpoint and returns the
|
||||
// account response or acme error and the raw response.
|
||||
func (s *integrationTestSuite) createAccount(t *testing.T, pathIdentifier string, jwsBody []byte) (*types.AccountResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_account", pathIdentifier) //nolint:gosec // test server URL is safe
|
||||
return doACMERequest[types.AccountResponse](t, http.MethodPost, url, jwsBody)
|
||||
}
|
||||
|
||||
// newAccountURL returns the full URL for the new_account endpoint.
|
||||
func (s *integrationTestSuite) newAccountURL(pathIdentifier string) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/new_account", s.server.URL, pathIdentifier)
|
||||
}
|
||||
|
||||
// newOrderURL returns the full URL for the new_order endpoint.
|
||||
func (s *integrationTestSuite) newOrderURL(pathIdentifier string) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/new_order", s.server.URL, pathIdentifier)
|
||||
}
|
||||
|
||||
// createAccountForOrder is a convenience helper that creates an account for an enrollment,
|
||||
// returning the private key, account URL, and a fresh nonce for subsequent requests.
|
||||
func (s *integrationTestSuite) createAccountForOrder(t *testing.T, enrollment *types.Enrollment) (*ecdsa.PrivateKey, string, string) {
|
||||
t.Helper()
|
||||
privateKey, err := testhelpers.GenerateTestKey()
|
||||
require.NoError(t, err)
|
||||
nonce := s.getNonce(t, enrollment.PathIdentifier)
|
||||
jwsBody := buildJWS(t, privateKey, nonce, "", s.newAccountURL(enrollment.PathIdentifier), nil)
|
||||
_, _, resp := s.createAccount(t, enrollment.PathIdentifier, jwsBody)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
accountURL := resp.Header.Get("Location")
|
||||
require.NotEmpty(t, accountURL)
|
||||
nextNonce := resp.Header.Get("Replay-Nonce")
|
||||
require.NotEmpty(t, nextNonce)
|
||||
return privateKey, accountURL, nextNonce
|
||||
}
|
||||
|
||||
// createOrderForGet is a convenience helper that creates an account and order for an enrollment,
|
||||
// returning the private key, account URL, order response, and a fresh nonce for subsequent requests.
|
||||
func (s *integrationTestSuite) createOrderForGet(t *testing.T, enroll *types.Enrollment) (*ecdsa.PrivateKey, string, *types.OrderResponse, string) {
|
||||
t.Helper()
|
||||
privateKey, accountURL, nonce := s.createAccountForOrder(t, enroll)
|
||||
|
||||
payload := map[string]any{
|
||||
"identifiers": []map[string]string{
|
||||
{"type": "permanent-identifier", "value": enroll.HostIdentifier},
|
||||
},
|
||||
}
|
||||
jwsBody := buildJWS(t, privateKey, nonce, accountURL, s.newOrderURL(enroll.PathIdentifier), payload)
|
||||
orderResp, _, resp := s.createOrder(t, enroll.PathIdentifier, jwsBody)
|
||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||
nextNonce := resp.Header.Get("Replay-Nonce")
|
||||
require.NotEmpty(t, nextNonce)
|
||||
return privateKey, accountURL, orderResp, nextNonce
|
||||
}
|
||||
|
||||
// createOrderForChallenge is a convenience helper that creates account+order+fetches
|
||||
// authorization, returning: privateKey, accountURL, challengeURL, challengeToken, nonce.
|
||||
func (s *integrationTestSuite) createOrderForChallenge(t *testing.T, enroll *types.Enrollment) (privateKey *ecdsa.PrivateKey, accountURL, challengeURL, challengeToken, nonce string) {
|
||||
t.Helper()
|
||||
privateKey, accountURL, orderResp, nonce := s.createOrderForGet(t, enroll)
|
||||
|
||||
require.Len(t, orderResp.Authorizations, 1)
|
||||
authURL := orderResp.Authorizations[0]
|
||||
|
||||
authResp, _, resp := s.getAuthorization(t, authURL, buildJWS(t, privateKey, nonce, accountURL, authURL, nil))
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Len(t, authResp.Challenges, 1)
|
||||
challenge := authResp.Challenges[0]
|
||||
nonce = resp.Header.Get("Replay-Nonce")
|
||||
require.NotEmpty(t, nonce)
|
||||
return privateKey, accountURL, challenge.URL, challenge.Token, nonce
|
||||
}
|
||||
|
||||
// getOrderURL returns the full URL for the get order endpoint.
|
||||
func (s *integrationTestSuite) getOrderURL(pathIdentifier string, orderID uint) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d", s.server.URL, pathIdentifier, orderID)
|
||||
}
|
||||
|
||||
// getOrder POSTs a JWS body to the order endpoint and returns the
|
||||
// order response or acme error and the raw response.
|
||||
func (s *integrationTestSuite) getOrder(t *testing.T, pathIdentifier string, orderID uint, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/orders/%d", pathIdentifier, orderID) //nolint:gosec // test server URL is safe
|
||||
return doACMERequest[types.OrderResponse](t, http.MethodPost, url, jwsBody)
|
||||
}
|
||||
|
||||
// createOrder POSTs a JWS body to the new_order endpoint and returns the
|
||||
// order response or acme error and the raw response.
|
||||
func (s *integrationTestSuite) createOrder(t *testing.T, pathIdentifier string, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.server.URL + fmt.Sprintf("/api/mdm/acme/%s/new_order", pathIdentifier) //nolint:gosec // test server URL is safe
|
||||
return doACMERequest[types.OrderResponse](t, http.MethodPost, url, jwsBody)
|
||||
}
|
||||
|
||||
// listOrdersURL returns the full URL for the list orders endpoint.
|
||||
func (s *integrationTestSuite) listOrdersURL(pathIdentifier string, accountID uint) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/accounts/%d/orders", s.server.URL, pathIdentifier, accountID)
|
||||
}
|
||||
|
||||
// listOrders POSTs a JWS body to the list orders endpoint and returns the
|
||||
// list orders response or acme error and the raw response.
|
||||
func (s *integrationTestSuite) listOrders(t *testing.T, pathIdentifier string, accountID uint, jwsBody []byte) (*api_http.ListOrdersResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.listOrdersURL(pathIdentifier, accountID)
|
||||
return doACMERequest[api_http.ListOrdersResponse](t, http.MethodPost, url, jwsBody)
|
||||
}
|
||||
|
||||
// getCertificateURL returns the full URL for the get certificate endpoint.
|
||||
func (s *integrationTestSuite) getCertificateURL(pathIdentifier string, orderID uint) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d/certificate", s.server.URL, pathIdentifier, orderID)
|
||||
}
|
||||
|
||||
// getCertificate POSTs a JWS body to the certificate endpoint and returns the
|
||||
// PEM certificate chain (on success) or an ACME error (on failure) and the raw response.
|
||||
// Unlike other helpers, the success response is raw PEM, not JSON.
|
||||
func (s *integrationTestSuite) getCertificate(t *testing.T, pathIdentifier string, orderID uint, jwsBody []byte) (string, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
url := s.getCertificateURL(pathIdentifier, orderID)
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(jwsBody)) //nolint:gosec // test server URL is safe
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
var acmeErr types.ACMEError
|
||||
if err := json.Unmarshal(body, &acmeErr); err == nil && acmeErr.Type != "" {
|
||||
return "", &acmeErr, resp
|
||||
}
|
||||
return "", nil, resp
|
||||
}
|
||||
|
||||
return string(body), nil, resp
|
||||
}
|
||||
|
||||
// finalizeOrderWithCert forces an order to finalized+valid state and inserts a
|
||||
// linked certificate in the database. This is useful for setting up the state
|
||||
// needed by the get certificate endpoint and any other test that needs a
|
||||
// finalized order with a valid certificate.
|
||||
func (s *integrationTestSuite) finalizeOrderWithCert(t *testing.T, orderID uint, certSerial uint64, certPEM string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
_, err := s.DB.ExecContext(ctx,
|
||||
`UPDATE acme_orders SET finalized = 1, status = 'valid' WHERE id = ?`, orderID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`INSERT INTO identity_serials (serial) VALUES (?)`, certSerial)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.DB.ExecContext(ctx, `
|
||||
INSERT INTO identity_certificates (serial, not_valid_before, not_valid_after, certificate_pem, revoked)
|
||||
VALUES (?, NOW(), DATE_ADD(NOW(), INTERVAL 1 YEAR), ?, ?)
|
||||
`, certSerial, certPEM, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = s.DB.ExecContext(ctx,
|
||||
`UPDATE acme_orders SET issued_certificate_serial = ? WHERE id = ?`, certSerial, orderID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// parseAccountID extracts the numeric account ID from an account URL like
|
||||
// ".../accounts/123" or ".../accounts/123/orders".
|
||||
func parseAccountID(t *testing.T, accountURL string) uint {
|
||||
t.Helper()
|
||||
// strip trailing "/orders" if present
|
||||
u := strings.TrimSuffix(accountURL, "/orders")
|
||||
parts := strings.Split(u, "/")
|
||||
idStr := parts[len(parts)-1]
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
require.NoError(t, err)
|
||||
return uint(id)
|
||||
}
|
||||
|
||||
// finalizeOrderURL returns the full URL for the finalize endpoint of a given order.
|
||||
func (s *integrationTestSuite) finalizeOrderURL(pathIdentifier string, orderID uint) string {
|
||||
return fmt.Sprintf("%s/api/mdm/acme/%s/orders/%d/finalize", s.server.URL, pathIdentifier, orderID)
|
||||
}
|
||||
|
||||
// finalizeOrder POSTs a JWS body to the finalize endpoint and returns the
|
||||
// order response or acme error and the raw response.
|
||||
func (s *integrationTestSuite) finalizeOrder(t *testing.T, finalizeURL string, jwsBody []byte) (*types.OrderResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
return doACMERequest[types.OrderResponse](t, http.MethodPost, finalizeURL, jwsBody)
|
||||
}
|
||||
|
||||
// createOrderForFinalize is a convenience helper that creates an enrollment, account, and order,
|
||||
// returning everything needed to test the finalize endpoint.
|
||||
func (s *integrationTestSuite) createOrderForFinalize(t *testing.T) (enroll *types.Enrollment, privateKey *ecdsa.PrivateKey, accountURL string, orderResp *types.OrderResponse, nonce string) {
|
||||
t.Helper()
|
||||
enroll = &types.Enrollment{NotValidAfter: ptr.T(time.Now().Add(24 * time.Hour))}
|
||||
s.InsertACMEEnrollment(t, enroll)
|
||||
privateKey, accountURL, nonce = s.createAccountForOrder(t, enroll)
|
||||
|
||||
payload := map[string]any{
|
||||
"identifiers": []map[string]string{
|
||||
{"type": "permanent-identifier", "value": enroll.HostIdentifier},
|
||||
},
|
||||
}
|
||||
jwsBody := buildJWS(t, privateKey, nonce, accountURL, s.newOrderURL(enroll.PathIdentifier), payload)
|
||||
orderResp, acmeErr, resp := s.createOrder(t, enroll.PathIdentifier, jwsBody)
|
||||
require.Nil(t, acmeErr)
|
||||
require.NotNil(t, orderResp)
|
||||
nonce = resp.Header.Get("Replay-Nonce")
|
||||
return enroll, privateKey, accountURL, orderResp, nonce
|
||||
}
|
||||
|
||||
// makeOrderReady transitions the order's authorization and challenge to valid and the order to ready via direct DB updates.
|
||||
func (s *integrationTestSuite) makeOrderReady(t *testing.T, orderID uint) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
_, err := s.DB.ExecContext(ctx, `UPDATE acme_challenges SET status = 'valid' WHERE acme_authorization_id IN (SELECT id FROM acme_authorizations WHERE acme_order_id = ?)`, orderID)
|
||||
require.NoError(t, err)
|
||||
_, err = s.DB.ExecContext(ctx, `UPDATE acme_authorizations SET status = 'valid' WHERE acme_order_id = ?`, orderID)
|
||||
require.NoError(t, err)
|
||||
_, err = s.DB.ExecContext(ctx, `UPDATE acme_orders SET status = 'ready' WHERE id = ?`, orderID)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) getAuthorization(t *testing.T, authUrl string, jwsBody []byte) (*api_http.GetAuthorizationResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
return doACMERequest[api_http.GetAuthorizationResponse](t, http.MethodPost, authUrl, jwsBody)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) doChallenge(t *testing.T, challengeURL string, jwsBody []byte) (*api_http.DoChallengeResponse, *types.ACMEError, *http.Response) {
|
||||
t.Helper()
|
||||
return doACMERequest[api_http.DoChallengeResponse](t, http.MethodPost, challengeURL, jwsBody)
|
||||
}
|
||||
76
server/mdm/acme/internal/testutils/testutils.go
Normal file
76
server/mdm/acme/internal/testutils/testutils.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// Package testutils provides shared test utilities for the ACME service module.
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
common_mysql "github.com/fleetdm/fleet/v4/server/platform/mysql"
|
||||
mysql_testing_utils "github.com/fleetdm/fleet/v4/server/platform/mysql/testing_utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDB holds the database connection for tests.
|
||||
type TestDB struct {
|
||||
DB *sqlx.DB
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// SetupTestDB creates a test database with the Fleet schema loaded.
|
||||
func SetupTestDB(t *testing.T, testNamePrefix string) *TestDB {
|
||||
t.Helper()
|
||||
|
||||
testName, opts := mysql_testing_utils.ProcessOptions(t, &mysql_testing_utils.DatastoreTestOptions{
|
||||
UniqueTestName: testNamePrefix + "_" + t.Name(),
|
||||
})
|
||||
|
||||
mysql_testing_utils.LoadDefaultSchema(t, testName, opts)
|
||||
config := mysql_testing_utils.MysqlTestConfig(testName)
|
||||
db, err := common_mysql.NewDB(config, &common_mysql.DBOptions{}, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { db.Close() })
|
||||
|
||||
return &TestDB{
|
||||
DB: db,
|
||||
Logger: slog.New(slog.DiscardHandler),
|
||||
}
|
||||
}
|
||||
|
||||
// Conns returns DBConnections for creating a datastore.
|
||||
func (tdb *TestDB) Conns() *common_mysql.DBConnections {
|
||||
return &common_mysql.DBConnections{Primary: tdb.DB, Replica: tdb.DB}
|
||||
}
|
||||
|
||||
// TruncateTables clears the tables used by acme bounded context.
|
||||
func (tdb *TestDB) TruncateTables(t *testing.T) {
|
||||
t.Helper()
|
||||
mysql_testing_utils.TruncateTables(t, tdb.DB, tdb.Logger, nil, "acme_enrollments", "acme_accounts", "acme_orders", "acme_authorizations", "acme_challenges", "identity_certificates", "identity_serials")
|
||||
}
|
||||
|
||||
// InsertACMEEnrollment creates an enrollment in the database and updates the enrollment struct
|
||||
// with the generated identifiers (if they were empty) and unique id.
|
||||
func (tdb *TestDB) InsertACMEEnrollment(t *testing.T, enrollment *types.Enrollment) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
|
||||
if enrollment.PathIdentifier == "" {
|
||||
enrollment.PathIdentifier = uuid.NewString()
|
||||
}
|
||||
if enrollment.HostIdentifier == "" {
|
||||
enrollment.HostIdentifier = uuid.NewString()
|
||||
}
|
||||
|
||||
result, err := tdb.DB.ExecContext(ctx, `
|
||||
INSERT INTO acme_enrollments (path_identifier, host_identifier, not_valid_after, revoked)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`, enrollment.PathIdentifier, enrollment.HostIdentifier, enrollment.NotValidAfter, enrollment.Revoked)
|
||||
require.NoError(t, err)
|
||||
|
||||
id, err := result.LastInsertId()
|
||||
require.NoError(t, err)
|
||||
enrollment.ID = uint(id) //nolint:gosec // dismiss G115
|
||||
}
|
||||
268
server/mdm/acme/internal/types/acme.go
Normal file
268
server/mdm/acme/internal/types/acme.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"go.step.sm/crypto/jose"
|
||||
)
|
||||
|
||||
const (
|
||||
OrderStatusPending = "pending"
|
||||
OrderStatusReady = "ready"
|
||||
OrderStatusValid = "valid"
|
||||
OrderStatusInvalid = "invalid"
|
||||
|
||||
AuthorizationStatusPending = "pending"
|
||||
AuthorizationStatusValid = "valid"
|
||||
AuthorizationStatusInvalid = "invalid"
|
||||
|
||||
ChallengeStatusPending = "pending"
|
||||
ChallengeStatusValid = "valid"
|
||||
ChallengeStatusInvalid = "invalid"
|
||||
)
|
||||
|
||||
type Directory struct {
|
||||
NewNonce string `json:"newNonce"`
|
||||
NewAccount string `json:"newAccount"`
|
||||
NewOrder string `json:"newOrder"`
|
||||
NewAuthz string `json:"newAuthz,omitempty"`
|
||||
RevokeCert string `json:"revokeCert,omitempty"`
|
||||
KeyChange string `json:"keyChange,omitempty"`
|
||||
Meta Meta `json:"meta"`
|
||||
}
|
||||
|
||||
type Meta struct {
|
||||
TermsOfService string `json:"termsOfService,omitempty"`
|
||||
Website string `json:"website,omitempty"`
|
||||
CaaIdentities []string `json:"caaIdentities,omitempty"`
|
||||
ExternalAccountRequired bool `json:"externalAccountRequired,omitempty"`
|
||||
}
|
||||
|
||||
type Enrollment struct {
|
||||
ID uint `db:"id"`
|
||||
PathIdentifier string `db:"path_identifier"`
|
||||
HostIdentifier string `db:"host_identifier"`
|
||||
NotValidAfter *time.Time `db:"not_valid_after"`
|
||||
Revoked bool `db:"revoked"`
|
||||
}
|
||||
|
||||
// IsValid returns true if the enrollment is still valid
|
||||
// (not revoked and not expired).
|
||||
func (a *Enrollment) IsValid() bool {
|
||||
if a.NotValidAfter != nil && !a.NotValidAfter.IsZero() && time.Now().After(*a.NotValidAfter) {
|
||||
return false
|
||||
}
|
||||
return !a.Revoked
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
ID uint `db:"id"`
|
||||
ACMEEnrollmentID uint `db:"acme_enrollment_id"`
|
||||
JSONWebKey jose.JSONWebKey `db:"-"`
|
||||
JSONWebKeyThumbprint string `db:"json_web_key_thumbprint"`
|
||||
Revoked bool `db:"revoked"`
|
||||
}
|
||||
|
||||
type AccountResponse struct {
|
||||
CreatedAccount *Account `json:"-"`
|
||||
DidCreate bool `json:"-"`
|
||||
Status string `json:"status"`
|
||||
Contact []string `json:"contact,omitempty"`
|
||||
Orders string `json:"orders"`
|
||||
Location string `json:"-"`
|
||||
}
|
||||
|
||||
type Order struct {
|
||||
ID uint `db:"id"`
|
||||
ACMEAccountID uint `db:"acme_account_id"`
|
||||
Finalized bool `db:"finalized"`
|
||||
CertificateSigningRequest string `db:"certificate_signing_request"`
|
||||
// Identifiers is manually serialized to JSON when inserted, and should do the same
|
||||
// when read (or we could implement sql.Scanner).
|
||||
Identifiers []Identifier `db:"-"`
|
||||
Status string `db:"status"`
|
||||
IssuedCertificateSerial *uint64 `db:"issued_certificate_serial"`
|
||||
|
||||
// NotBefore and NotAfter must not be set, we capture them so we can validate
|
||||
// that they were indeed not provided.
|
||||
NotBefore *time.Time `db:"-"`
|
||||
NotAfter *time.Time `db:"-"`
|
||||
}
|
||||
|
||||
// IsReadyToFinalize returns an error if the order is not in a state where it can be finalized.
|
||||
func (o Order) IsReadyToFinalize() error {
|
||||
if o.Status != OrderStatusReady || o.Finalized {
|
||||
extra := ""
|
||||
if o.Finalized {
|
||||
extra = " and order has already been finalized"
|
||||
}
|
||||
return OrderNotReadyError(fmt.Sprintf("Order is in status %s%s.", o.Status, extra))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsCertificateReady returns an error if the order is not in a state where the certificate can be retrieved.
|
||||
func (o Order) IsCertificateReady() error {
|
||||
if !o.Finalized || o.Status != OrderStatusValid {
|
||||
if o.Status == OrderStatusInvalid {
|
||||
return OrderDoesNotExistError("Order is in invalid state, cannot get certificate")
|
||||
}
|
||||
return OrderNotFinalizedError("Order is not finalized/in valid state, cannot get certificate")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateOrderCreation validates that the order creation request is valid given the enrollment. It returns an error if the request is not valid.
|
||||
func (o Order) ValidateOrderCreation(enrollment *Enrollment) error {
|
||||
// The "identifiers" passed as part of the newOrder request must be an array with a
|
||||
// single member of type "permanent-identifier" matching the serial specified in the
|
||||
// acme_enrollment that this enrollment was created for.
|
||||
if len(o.Identifiers) != 1 || o.Identifiers[0].Type != IdentifierTypePermanentIdentifier {
|
||||
return UnsupportedIdentifierError("A single identifier of type permanent-identifier must be provided in the order request")
|
||||
}
|
||||
if o.Identifiers[0].Value != enrollment.HostIdentifier {
|
||||
return RejectedIdentifierError("The identifier value does not match the host identifier for this enrollment")
|
||||
}
|
||||
|
||||
// notBefore and notAfter, which are optional, must not be set because fleet is going
|
||||
// to control these and the Apple payload doesn't allow specification of them.
|
||||
if o.NotBefore != nil || o.NotAfter != nil {
|
||||
return MalformedError("notBefore and notAfter must not be set in the order request")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type OrderResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Status string `json:"status"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Identifiers []Identifier `json:"identifiers"`
|
||||
Authorizations []string `json:"authorizations"`
|
||||
Finalize string `json:"finalize"`
|
||||
Certificate string `json:"certificate,omitempty"`
|
||||
|
||||
// Location is set in the header, pointing to the created order's URL.
|
||||
Location string `json:"-"`
|
||||
}
|
||||
|
||||
type Authorization struct {
|
||||
ID uint `db:"id"`
|
||||
ACMEOrderID uint `db:"acme_order_id"`
|
||||
Identifier Identifier `db:"-"`
|
||||
Status string `db:"status"`
|
||||
}
|
||||
|
||||
type AuthorizationResponse struct {
|
||||
Status string `json:"status"`
|
||||
Expires *time.Time `json:"expires,omitempty"`
|
||||
Identifier Identifier `json:"identifier"`
|
||||
Challenges []ChallengeResponse `json:"challenges"`
|
||||
|
||||
// Location is set in the header, pointing to the requested authorization's URL.
|
||||
Location string `json:"-"`
|
||||
}
|
||||
|
||||
const (
|
||||
DeviceAttestationChallengeType string = "device-attest-01"
|
||||
)
|
||||
|
||||
type Challenge struct {
|
||||
ID uint `db:"id"`
|
||||
ACMEAuthorizationID uint `db:"acme_authorization_id"`
|
||||
ChallengeType string `db:"challenge_type"`
|
||||
Token string `db:"token"`
|
||||
Status string `db:"status"`
|
||||
// UpdatedAt is used as validated timestamp if the challenge is valid
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}
|
||||
|
||||
// ValidatedAt returns the time that the challenge was validated if it is valid, or nil if it is not valid.
|
||||
func (c Challenge) ValidatedAt() *time.Time {
|
||||
if c.Status == ChallengeStatusValid {
|
||||
return &c.UpdatedAt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Challenge) MarkValid() {
|
||||
c.Status = ChallengeStatusValid
|
||||
}
|
||||
|
||||
func (c *Challenge) MarkInvalid() {
|
||||
c.Status = ChallengeStatusInvalid
|
||||
}
|
||||
|
||||
type ChallengeResponse struct {
|
||||
ChallengeType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
|
||||
// Validated is only set in the response when the challenge is valid and has been validated.
|
||||
Validated *time.Time `json:"validated,omitempty"`
|
||||
|
||||
// Location is set in the header, pointing to the requested challenge's URL.
|
||||
Location string `json:"-"`
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/webauthn-2/#sctn-attestation, but we don't use authData as per the ACME RFC.
|
||||
type AttestationObject struct {
|
||||
Format string `cbor:"fmt"`
|
||||
AttestationStatement cbor.RawMessage `cbor:"attStmt"`
|
||||
}
|
||||
|
||||
// https://www.w3.org/TR/webauthn-2/#sctn-apple-anonymous-attestation
|
||||
type AppleDeviceAttestationStatement struct {
|
||||
X5C [][]byte `cbor:"x5c"`
|
||||
}
|
||||
|
||||
const (
|
||||
IdentifierTypePermanentIdentifier = "permanent-identifier"
|
||||
)
|
||||
|
||||
type AccountAuthenticatedRequest interface {
|
||||
SetEnrollmentAndAccount(enrollment *Enrollment, account *Account)
|
||||
}
|
||||
|
||||
// The base struct for allowing arbitrary types to implement the interface above. It is important that these
|
||||
// members not be serialized to/from JSON as they are meant to be set by the service after authentication and
|
||||
// not by the client.
|
||||
type AccountAuthenticatedRequestBase struct {
|
||||
Enrollment *Enrollment `json:"-"`
|
||||
Account *Account `json:"-"`
|
||||
}
|
||||
|
||||
func (r *AccountAuthenticatedRequestBase) SetEnrollmentAndAccount(enrollment *Enrollment, account *Account) {
|
||||
r.Enrollment = enrollment
|
||||
r.Account = account
|
||||
}
|
||||
|
||||
// Represents acme identifiers (not to be confused with enrollment identifiers)
|
||||
// which, in our usecase, represent identifiers(e.g. serials) that hosts control.
|
||||
type Identifier struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Datastore is the datastore interface for the ACME service module.
|
||||
type Datastore interface {
|
||||
NewEnrollment(ctx context.Context, hostIdentifier string) (string, error)
|
||||
GetACMEEnrollment(ctx context.Context, pathIdentifier string) (*Enrollment, error)
|
||||
GetAccountByID(ctx context.Context, enrollmentID uint, accountID uint) (*Account, error)
|
||||
CreateAccount(ctx context.Context, account *Account, onlyReturnExisting bool) (*Account, bool, error)
|
||||
CreateOrder(ctx context.Context, order *Order, authorization *Authorization, challenge *Challenge) (*Order, error)
|
||||
GetOrderByID(ctx context.Context, accountID, orderID uint) (*Order, []*Authorization, error)
|
||||
ListAccountOrderIDs(ctx context.Context, accountID uint) ([]uint, error)
|
||||
GetAuthorizationByID(ctx context.Context, accountID uint, authorizationID uint) (*Authorization, error)
|
||||
FinalizeOrder(ctx context.Context, orderID uint, csrPEM string, certSerial int64) error
|
||||
GetChallengesByAuthorizationID(ctx context.Context, authorizationID uint) ([]*Challenge, error)
|
||||
GetCertificatePEMByOrderID(ctx context.Context, accountID, orderID uint) (string, error)
|
||||
GetChallengeByID(ctx context.Context, accountID, challengeID uint) (*Challenge, error)
|
||||
// Update challenge handles updating the challenge status, and the authorization status as well as moving the order status.
|
||||
UpdateChallenge(ctx context.Context, challenge *Challenge) (*Challenge, error)
|
||||
}
|
||||
261
server/mdm/acme/internal/types/errors.go
Normal file
261
server/mdm/acme/internal/types/errors.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
acmeErrorsURN = "urn:ietf:params:acme:error:"
|
||||
// NOTE: ideally this would be a valid, dereferenceable link to human-readable documentation,
|
||||
// but it's ok if it's not too. See https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
|
||||
fleetCustomErrorsURI = "https://fleetdm.com/acme/error/"
|
||||
)
|
||||
|
||||
// ACMEError represents an error related to the ACME protocol,
|
||||
// see https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
|
||||
//
|
||||
// It renders as a problem document (https://datatracker.ietf.org/doc/html/rfc7807),
|
||||
// a JSON object with specific fields. In particular for the ACME protocol,
|
||||
// the type field is well-defined and corresponds to a specific error condition.
|
||||
//
|
||||
// This error type is handled by the domain-specific error encoder provided to
|
||||
// encodeResponse.
|
||||
type ACMEError struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
StatusCode int `json:"-"`
|
||||
}
|
||||
|
||||
var (
|
||||
enrollmentNotFound = EnrollmentNotFoundError("")
|
||||
serverInternal = InternalServerError("")
|
||||
)
|
||||
|
||||
func (e *ACMEError) ShouldReturnNonce() bool {
|
||||
if e == nil {
|
||||
return false
|
||||
}
|
||||
switch e.Type {
|
||||
case enrollmentNotFound.Type, serverInternal.Type:
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func EnrollmentNotFoundError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "enrollmentNotFound",
|
||||
Title: "The specified enrollment does not exist",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func AccountDoesNotExistError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "accountDoesNotExist",
|
||||
Title: "The request specified an account that does not exist",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-7.3.1
|
||||
}
|
||||
}
|
||||
|
||||
func AccountRevokedError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "accountRevoked",
|
||||
Title: "The request specified an account that is revoked",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func TooManyAccountsError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "tooManyAccounts",
|
||||
Title: "Too many accounts already exist for this enrollment",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func TooManyOrdersError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "tooManyOrders",
|
||||
Title: "Too many orders already exist for this account",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: surprisingly, the RFC does not document an error and status code for
|
||||
// a POST-as-GET to an order URL with an ID that does not exist, so this is a
|
||||
// Fleet custom error code.
|
||||
func OrderDoesNotExistError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "orderDoesNotExist",
|
||||
Title: "The request specified an order that does not exist",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func OrderNotFinalizedError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "orderNotFinalized",
|
||||
Title: "The request attempted to download a certificate for an order that is not finalized",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func RejectedIdentifierError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "rejectedIdentifier",
|
||||
Title: "The server will not issue certificates for the identifier",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func UnsupportedIdentifierError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "unsupportedIdentifier",
|
||||
Title: "An identifier is of an unsupported type",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func BadNonceError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "badNonce",
|
||||
Title: "The client sent an unacceptable anti-replay nonce",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5
|
||||
}
|
||||
}
|
||||
|
||||
func BadCSRError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "badCSR",
|
||||
Title: "The CSR is unacceptable (e.g., due to a short key)",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func InternalServerError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "serverInternal",
|
||||
Title: "The server experienced an internal error",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
}
|
||||
}
|
||||
|
||||
func BadPublicKeyError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "badPublicKey",
|
||||
Title: "The JWS was signed by a public key the server does not support",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func BadSignatureAlgorithmError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "badSignatureAlgorithm",
|
||||
Title: "The JWS was signed with an algorithm the server does not support",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func UnauthorizedError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "unauthorized",
|
||||
Title: "The client lacks sufficient authorization",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusUnauthorized,
|
||||
}
|
||||
}
|
||||
|
||||
func MalformedError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "malformed",
|
||||
Title: "The request message was malformed",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Fleet error code, as RFC does not document what to return.
|
||||
func AuthorizationDoesNotExistError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "authorizationDoesNotExist",
|
||||
Title: "The specified authorization does not exist for the account",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Fleet error code, as RFC does not document what to return.
|
||||
func ChallengeDoesNotExistError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "challengeDoesNotExist",
|
||||
Title: "The specified challenge does not exist for the authorization",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func CertificateDoesNotExistError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "certificateDoesNotExist",
|
||||
Title: "The order is finalized but the certificate does not exist for the order",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusNotFound,
|
||||
}
|
||||
}
|
||||
|
||||
func OrderNotReadyError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "orderNotReady",
|
||||
Title: "The request attempted to finalize an order that is not ready to be finalized",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusForbidden, // as per RFC https://datatracker.ietf.org/doc/html/rfc8555/#section-6.7
|
||||
}
|
||||
}
|
||||
|
||||
func InvalidChallengeStatusError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: fleetCustomErrorsURI + "invalidChallengeStatus",
|
||||
Title: "The challenge is not in a valid status for the attempted operation",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// Draft ACME device attest RFC https://datatracker.ietf.org/doc/html/draft-acme-device-attest-01#name-new-error-types
|
||||
func BadAttestationStatementError(detail string) *ACMEError {
|
||||
return &ACMEError{
|
||||
Type: acmeErrorsURN + "badAttestationStatement",
|
||||
Title: "The attestation statement provided by the client was unacceptable",
|
||||
Detail: detail,
|
||||
StatusCode: http.StatusBadRequest,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ACMEError) Error() string {
|
||||
s := e.Type
|
||||
if e.Title != "" {
|
||||
s += ": " + e.Title
|
||||
}
|
||||
if e.Detail != "" {
|
||||
s += ": " + e.Detail
|
||||
}
|
||||
return s
|
||||
}
|
||||
28
server/mdm/acme/internal/types/nonce.go
Normal file
28
server/mdm/acme/internal/types/nonce.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
)
|
||||
|
||||
const nonceRawByteSize = 16 // 128 bits
|
||||
|
||||
// per the RFC (https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5):
|
||||
// > The precise method used to generate and track nonces is up to the
|
||||
// > server. For example, the server could generate a random 128-bit
|
||||
// > value
|
||||
func CreateRawNonce() string {
|
||||
nonce := make([]byte, nonceRawByteSize)
|
||||
// as per rand.Read documentation, never returns an error and always fills the entire slice
|
||||
_, _ = rand.Read(nonce)
|
||||
return string(nonce)
|
||||
}
|
||||
|
||||
// per the RFC (https://datatracker.ietf.org/doc/html/rfc8555/#section-6.5.1):
|
||||
// > The value of the Replay-Nonce header field MUST be an octet string
|
||||
// > encoded according to the base64url encoding described in Section 2 of
|
||||
// > [RFC7515].
|
||||
func CreateNonceEncodedForHeader() string {
|
||||
nonce := CreateRawNonce()
|
||||
return base64.RawURLEncoding.EncodeToString([]byte(nonce))
|
||||
}
|
||||
47
server/mdm/acme/providers.go
Normal file
47
server/mdm/acme/providers.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package acme
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
|
||||
redigo "github.com/gomodule/redigo/redis"
|
||||
)
|
||||
|
||||
// RedisPool is the minimal Redis pool interface needed by the ACME bounded context.
|
||||
// fleet.RedisPool satisfies this implicitly via Go's structural typing.
|
||||
type RedisPool interface {
|
||||
Get() redigo.Conn
|
||||
}
|
||||
|
||||
// CSRSigner signs x509 certificate requests. This is the ACME-specific
|
||||
// interface for certificate signing, decoupled from the SCEP protocol types.
|
||||
type CSRSigner interface {
|
||||
SignCSR(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error)
|
||||
}
|
||||
|
||||
// CSRSignerFunc is an adapter to allow use of ordinary functions as CSRSigner.
|
||||
type CSRSignerFunc func(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error)
|
||||
|
||||
func (f CSRSignerFunc) SignCSR(ctx context.Context, csr *x509.CertificateRequest) (*x509.Certificate, error) {
|
||||
return f(ctx, csr)
|
||||
}
|
||||
|
||||
// DataProviders combines all external dependency interfaces for the ACME
|
||||
// bounded context. Methods are narrowed to exactly what ACME needs,
|
||||
// keeping the bounded context decoupled from Fleet-internal types.
|
||||
type DataProviders interface {
|
||||
// ServerURL returns the base URL used to construct ACME endpoint URLs.
|
||||
ServerURL(ctx context.Context) (string, error)
|
||||
|
||||
// GetCACertificatePEM returns the PEM-encoded root CA certificate
|
||||
// used to build the certificate chain in download-certificate responses.
|
||||
GetCACertificatePEM(ctx context.Context) ([]byte, error)
|
||||
|
||||
// CSRSigner returns the signer used to sign certificate requests
|
||||
// during order finalization.
|
||||
CSRSigner(ctx context.Context) (CSRSigner, error)
|
||||
|
||||
// IsDEPEnrolled reports whether the given serial number has an active
|
||||
// DEP assignment, used during device attestation challenge validation.
|
||||
IsDEPEnrolled(ctx context.Context, serial string) (bool, error)
|
||||
}
|
||||
142
server/mdm/acme/testhelpers/helpers.go
Normal file
142
server/mdm/acme/testhelpers/helpers.go
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
package testhelpers
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/service"
|
||||
"github.com/fleetdm/fleet/v4/server/mdm/acme/internal/types"
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
)
|
||||
|
||||
// GenerateTestKey generates an ECDSA P-256 key pair and returns the private key and public JWK.
|
||||
func GenerateTestKey() (*ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
return key, err
|
||||
}
|
||||
|
||||
func GenerateTestAttestationCA() (*x509.Certificate, *ecdsa.PrivateKey, error) {
|
||||
key, err := GenerateTestKey()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to generate test key: %w", err)
|
||||
}
|
||||
template := &x509.Certificate{
|
||||
Subject: pkix.Name{CommonName: "Test Attestation CA"},
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
IsCA: true,
|
||||
BasicConstraintsValid: true,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create test attestation CA certificate: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse test attestation CA certificate: %w", err)
|
||||
}
|
||||
return cert, key, nil
|
||||
}
|
||||
|
||||
func BuildAttestationLeafCert(ca *x509.Certificate, caKey *ecdsa.PrivateKey, serial, token string) (*x509.Certificate, error) {
|
||||
template := &x509.Certificate{
|
||||
Subject: pkix.Name{CommonName: "Test Attestation Leaf"},
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
|
||||
BasicConstraintsValid: true,
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
}
|
||||
|
||||
hashedNonce := sha256.Sum256([]byte(token))
|
||||
|
||||
template.ExtraExtensions = []pkix.Extension{
|
||||
{
|
||||
Id: service.OIDAppleSerialNumber,
|
||||
Value: []byte(serial),
|
||||
},
|
||||
{
|
||||
Id: service.OIDAppleNonce,
|
||||
Value: hashedNonce[:],
|
||||
},
|
||||
}
|
||||
|
||||
key, err := GenerateTestKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key for attestation leaf cert: %w", err)
|
||||
}
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, template, ca, key.Public(), caKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create attestation leaf certificate: %w", err)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(certDER)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse attestation leaf certificate: %w", err)
|
||||
}
|
||||
return cert, nil
|
||||
}
|
||||
|
||||
// The leaf cert should always be the first
|
||||
func BuildAppleDeviceAttestationPayload(certs ...*x509.Certificate) (any, error) {
|
||||
x5c := make([][]byte, len(certs))
|
||||
for i, cert := range certs {
|
||||
x5c[i] = cert.Raw
|
||||
}
|
||||
|
||||
appleAttest := types.AppleDeviceAttestationStatement{
|
||||
X5C: x5c,
|
||||
}
|
||||
|
||||
appleAttestCbor, err := cbor.Marshal(appleAttest)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal Apple device attestation statement to CBOR: %w", err)
|
||||
}
|
||||
|
||||
attObj := types.AttestationObject{
|
||||
Format: "apple",
|
||||
AttestationStatement: appleAttestCbor,
|
||||
}
|
||||
attObjCbor, err := cbor.Marshal(attObj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal attestation object to CBOR: %w", err)
|
||||
}
|
||||
|
||||
base64Encoded := base64.RawURLEncoding.EncodeToString(attObjCbor)
|
||||
|
||||
// finally embed in the top-level json payload
|
||||
return struct {
|
||||
AttObj string `json:"attObj"`
|
||||
}{
|
||||
AttObj: base64Encoded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateCSRDER creates a base64 URL encoded DER-encoded ECDSA CSR with the given common name.
|
||||
func GenerateCSRDER(commonName string) (string, *ecdsa.PrivateKey, error) {
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to generate key for CSR: %w", err)
|
||||
}
|
||||
template := &x509.CertificateRequest{
|
||||
Subject: pkix.Name{
|
||||
CommonName: commonName,
|
||||
},
|
||||
}
|
||||
csrDER, err := x509.CreateCertificateRequest(rand.Reader, template, key)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create CSR: %w", err)
|
||||
}
|
||||
|
||||
// base64 URL encode the DER csr as per the RFC 7.4 spec
|
||||
encoded := base64.RawURLEncoding.EncodeToString(csrDER)
|
||||
return encoded, key, nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue