From 78eef3123d4f31742f36e75ab66e729a32954814 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 30 Jun 2026 13:28:57 -0400 Subject: [PATCH 1/3] Remove unneeded change --- .../AuthenticationViewController+Shared.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift index 188516aaea8..ef754768d17 100644 --- a/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift +++ b/apps/fleet-desktop-macos/FleetPSSOExtension/AuthenticationViewController+Shared.swift @@ -87,9 +87,8 @@ extension AuthenticationViewController { // Fleet dispatches key_request/key_exchange (the unlock-key flow) at the // token endpoint. The framework needs keyEndpointURL set explicitly to // engage that plumbing — leaving it unset relies on an undocumented - // default. refreshEndpointURL points there too (SSO token renewal). + // default. cfg.keyEndpointURL = pssoEndpointURL(base, "token") - cfg.refreshEndpointURL = pssoEndpointURL(base, "token") self.registrationEndpointURL = pssoEndpointURL(base, "registration") if let encryptionKey = await loginRequestEncryptionKey(jwksURL: pssoEndpointURL(base, "jwks")) { cfg.loginRequestEncryptionPublicKey = encryptionKey From fe1514949a7661ca39a7aedc602f0d4f9d5c300b Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Tue, 30 Jun 2026 15:55:26 -0400 Subject: [PATCH 2/3] Add integration tests + refactor existing --- ee/server/service/apple_psso.go | 64 +- ee/server/service/apple_psso_crypto.go | 496 ++------------ ee/server/service/apple_psso_crypto_test.go | 341 +--------- pkg/mdm/mdmtest/psso.go | 641 ++++++++++++++++++ .../mdm/apple/psso/pssocrypto/pssocrypto.go | 519 ++++++++++++++ .../apple/psso/pssocrypto/pssocrypto_test.go | 297 ++++++++ .../integration_mdm_apple_psso_test.go | 407 +++++++++++ server/service/testing_utils_test.go | 8 +- 8 files changed, 1969 insertions(+), 804 deletions(-) create mode 100644 pkg/mdm/mdmtest/psso.go create mode 100644 server/mdm/apple/psso/pssocrypto/pssocrypto.go create mode 100644 server/mdm/apple/psso/pssocrypto/pssocrypto_test.go create mode 100644 server/service/integration_mdm_apple_psso_test.go diff --git a/ee/server/service/apple_psso.go b/ee/server/service/apple_psso.go index b68bf593683..854c1ad5649 100644 --- a/ee/server/service/apple_psso.go +++ b/ee/server/service/apple_psso.go @@ -24,6 +24,7 @@ import ( "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/pssocrypto" "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/regtoken" jose "github.com/go-jose/go-jose/v3" ) @@ -42,11 +43,6 @@ type pssoServiceState struct { } const ( - pssoSigningAlg = "ES256" - // pssoEncryptionAlg is the JWK `alg` published for the password-encryption - // key and the `alg` Apple uses in the embedded login-assertion JWE. - pssoEncryptionAlg = "ECDH-ES" - // The host app bundle ID is included alongside the extension's just in case; // PSSO validates against the extension, but listing both is harmless and matches // what the IdPs analyzed do. @@ -398,10 +394,10 @@ func (svc *Service) PSSORegisterDevice(ctx context.Context, req fleet.PSSODevice // Reject unparseable key material up front: a bad PEM stored here would // otherwise only surface as opaque verification failures at every // subsequent login. - if _, err := parseECPublicKeyPEM([]byte(req.DeviceSigningKey)); err != nil { + if _, err := pssocrypto.ParseECPublicKeyPEM([]byte(req.DeviceSigningKey)); err != nil { return &fleet.BadRequestError{Message: "psso registration: signing key is not a valid P-256 public key"} } - if _, err := parseECPublicKeyPEM([]byte(req.DeviceEncryptionKey)); err != nil { + if _, err := pssocrypto.ParseECPublicKeyPEM([]byte(req.DeviceEncryptionKey)); err != nil { return &fleet.BadRequestError{Message: "psso registration: encryption key is not a valid P-256 public key"} } @@ -420,12 +416,12 @@ func (svc *Service) PSSORegisterDevice(ctx context.Context, req fleet.PSSODevice // alphabet differences between the extension and Apple's framework. keys := []fleet.PSSOKey{ { - KID: canonicalizeKID(req.SigningKeyID), + KID: pssocrypto.CanonicalizeKID(req.SigningKeyID), KeyType: fleet.PSSOKeyTypeSigning, PEM: req.DeviceSigningKey, }, { - KID: canonicalizeKID(req.EncryptionKeyID), + KID: pssocrypto.CanonicalizeKID(req.EncryptionKeyID), KeyType: fleet.PSSOKeyTypeEncryption, PEM: req.DeviceEncryptionKey, }, @@ -471,9 +467,9 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err // Key requests/exchanges carry a request_type and are dispatched first. switch claims.RequestType { - case pssoRequestKey: + case pssocrypto.RequestKey: return svc.handlePSSOKeyRequest(ctx, signKey.HostUUID, claims) - case pssoRequestExchange: + case pssocrypto.RequestExchange: return svc.handlePSSOKeyExchange(ctx, signKey.HostUUID, claims) } @@ -482,30 +478,12 @@ func (svc *Service) PSSOToken(ctx context.Context, jwtBytes []byte) ([]byte, err // grant and ships the password inside an encrypted embedded assertion. Both // land here and differ only in where handlePSSOPasswordLogin reads the // password from. - if claims.GrantType == pssoGrantTypePassword || claims.GrantType == pssoGrantTypeJWTBearer { + if claims.GrantType == pssocrypto.GrantTypePassword || claims.GrantType == pssocrypto.GrantTypeJWTBearer { return svc.handlePSSOPasswordLogin(ctx, settings, signKey.HostUUID, claims) } return nil, &fleet.BadRequestError{Message: "psso token: unsupported grant_type/request_type"} } -// PSSO grant types in the login request JWT. With plaintext passwords Apple -// sends grant_type=password; when the password is encrypted into the embedded -// assertion it switches to the JWT-bearer grant and the password moves out of -// the top-level claim into the (encrypted) assertion. -const ( - pssoGrantTypePassword = "password" //nolint:gosec // G101 not a credential, a grant type - pssoGrantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer" //nolint:gosec // G101 not a credential, a grant type -) - -// JWE header `typ` media types. The first two are responses Fleet returns; the -// last is the embedded login assertion the device sends when password -// encryption is enabled. -const ( - pssoTypLoginResponse = "platformsso-login-response+jwt" - pssoTypKeyResponse = "platformsso-key-response+jwt" - pssoTypEncryptedLoginAssertion = "platformsso-encrypted-login-assertion+jwt" -) - // pssoDefaultTokenTTL is the id_token / refresh_token lifetime used when the // upstream IdP doesn't return an expires_in. const pssoDefaultTokenTTL = time.Hour @@ -579,7 +557,7 @@ func buildPSSOIDTokenClaims(idpClaims *fleet.PSSOClaims, issuer, audience, nonce // Fleet validates the password against the upstream IdP, then returns the // resulting OIDC claims as a server-signed JWT wrapped in a JWE encrypted per // that recipe. -func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, settings *fleet.PSSOSettings, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, settings *fleet.PSSOSettings, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso password login: missing jwe_crypto recipe"} } @@ -654,7 +632,7 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, settings *fleet return nil, ctxerr.Wrap(ctx, err, "marshal psso login response") } - jwe, err := buildPSSOResponseJWE(payload, recipientPub, claims.JWECrypto.APV, pssoTypLoginResponse) + jwe, err := pssocrypto.BuildPartyInfoJWE(payload, recipientPub, claims.JWECrypto.APV, pssocrypto.TypLoginResponse) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build psso login response jwe") } @@ -666,7 +644,7 @@ func (svc *Service) handlePSSOPasswordLogin(ctx context.Context, settings *fleet // enabled the Password claim is empty and the password lives in the encrypted // embedded assertion, which Fleet decrypts with its PSSO encryption key. The // username is always taken from the (signed) outer JWT, not the assertion. -func (svc *Service) resolvePSSOLoginPassword(ctx context.Context, claims *pssoTokenClaims) (string, error) { +func (svc *Service) resolvePSSOLoginPassword(ctx context.Context, claims *pssocrypto.TokenClaims) (string, error) { if claims.Password != "" { return claims.Password, nil } @@ -677,11 +655,11 @@ func (svc *Service) resolvePSSOLoginPassword(ctx context.Context, claims *pssoTo if err != nil { return "", ctxerr.Wrap(ctx, err, "load psso encryption key") } - plaintext, err := decryptPSSOInboundJWE([]byte(claims.Assertion), encKey) + plaintext, err := pssocrypto.DecryptPartyInfoJWE([]byte(claims.Assertion), encKey, pssocrypto.TypEncryptedLoginAssertion) if err != nil { return "", ctxerr.Wrap(ctx, err, "decrypt psso login assertion") } - password, err := parseEmbeddedAssertionPassword(plaintext) + password, err := pssocrypto.ParseEmbeddedAssertionPassword(plaintext) if err != nil { return "", ctxerr.Wrap(ctx, err, "parse psso login assertion") } @@ -695,7 +673,7 @@ func (svc *Service) resolvePSSOLoginPassword(ctx context.Context, claims *pssoTo // key_context} in a JWE (typ=platformsso-key-response+jwt) encrypted to the // device. key_context carries the provisioned PRIVATE key, sealed under a // server key, so the later key exchange can recover it statelessly. -func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso key request: missing jwe_crypto recipe"} } @@ -737,7 +715,7 @@ func (svc *Service) handlePSSOKeyRequest(ctx context.Context, hostUUID string, c return nil, ctxerr.Wrap(ctx, err, "marshal key_request payload") } - jwe, err := buildPSSOResponseJWE(payload, encPub, claims.JWECrypto.APV, pssoTypKeyResponse) + jwe, err := pssocrypto.BuildPartyInfoJWE(payload, encPub, claims.JWECrypto.APV, pssocrypto.TypKeyResponse) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build key_request JWE") } @@ -779,7 +757,7 @@ func (svc *Service) issuePSSOProvisionedCertificate(ctx context.Context, provisi // provisioned private key from key_context, computes the raw ECDH shared // secret against other_publickey (this is the unlock key), and returns // {iat, exp, key, key_context} in the same JWE envelope. -func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, claims *pssoTokenClaims) ([]byte, error) { +func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, claims *pssocrypto.TokenClaims) ([]byte, error) { if claims.JWECrypto == nil || claims.JWECrypto.APV == "" { return nil, &fleet.BadRequestError{Message: "psso key exchange: missing jwe_crypto recipe"} } @@ -808,11 +786,11 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, return nil, &fleet.BadRequestError{Message: "psso key exchange: unsupported key_context purpose"} } - otherRaw, err := decodeBase64Flexible(claims.OtherPublicKey) + otherRaw, err := pssocrypto.DecodeBase64Flexible(claims.OtherPublicKey) if err != nil { return nil, ctxerr.Wrap(ctx, err, "decode other_publickey") } - shared, err := computeECDHShared(provisioned, otherRaw) + shared, err := pssocrypto.ComputeECDHShared(provisioned, otherRaw) if err != nil { return nil, ctxerr.Wrap(ctx, err, "compute key exchange shared secret") } @@ -833,7 +811,7 @@ func (svc *Service) handlePSSOKeyExchange(ctx context.Context, hostUUID string, return nil, ctxerr.Wrap(ctx, err, "marshal key_exchange payload") } - jwe, err := buildPSSOResponseJWE(payload, encPub, claims.JWECrypto.APV, pssoTypKeyResponse) + jwe, err := pssocrypto.BuildPartyInfoJWE(payload, encPub, claims.JWECrypto.APV, pssocrypto.TypKeyResponse) if err != nil { return nil, ctxerr.Wrap(ctx, err, "build key_exchange JWE") } @@ -869,7 +847,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { { Key: &key.PublicKey, KeyID: kid, - Algorithm: pssoSigningAlg, + Algorithm: pssocrypto.SigningAlg, Use: "sig", }, // The extension sets this key as loginRequestEncryptionPublicKey and @@ -878,7 +856,7 @@ func (svc *Service) PSSOJWKS(ctx context.Context) ([]byte, error) { { Key: &encKey.PublicKey, KeyID: encKID, - Algorithm: pssoEncryptionAlg, + Algorithm: pssocrypto.EncryptionAlg, Use: "enc", }, }} diff --git a/ee/server/service/apple_psso_crypto.go b/ee/server/service/apple_psso_crypto.go index 926b41640dc..be0a48f0d50 100644 --- a/ee/server/service/apple_psso_crypto.go +++ b/ee/server/service/apple_psso_crypto.go @@ -1,121 +1,38 @@ package service -// PSSO crypto helpers. Implemented against Apple's ASAuthorizationProviderExtension* -// protocol surface and standard JOSE primitives. -// -// Cryptographic choices: -// - Inbound JWTs from the Mac extension are ES256 (P-256). We may need to allow more -// algorithms in the future but this is what has been observed today. The kid in the -// header points to a PEM stored in mdm_apple_psso_keys. -// - JWE responses use ECDH-ES with A256GCM, wrapped to the device's registered encryption -// pubkey (resolved from the request's apv). -// - key_context blobs are sealed with A256GCM under a key derived from Fleet's PSSO signing -// key via HKDF-SHA256 — no per-device server state is stored. +// Server-side PSSO crypto. The symmetric JOSE wire-format primitives (party-info +// encoding, the ECDH-ES + A256GCM JWE build/decrypt, kid canonicalization, the +// inbound claim types) live in server/mdm/apple/psso/pssocrypto so the server and +// the PSSO client simulator share one implementation. What remains here is +// server-only: it touches the datastore (resolving a device's registered key by +// kid), Fleet's PSSO signing key, or the opaque key_context Fleet seals under a +// server key and the device round-trips verbatim. import ( "context" "crypto/aes" "crypto/cipher" - "crypto/ecdh" "crypto/ecdsa" - "crypto/elliptic" "crypto/rand" "crypto/sha256" "crypto/x509" "encoding/base64" - "encoding/binary" "encoding/json" - "encoding/pem" - "errors" "fmt" - "strings" - "time" "github.com/fleetdm/fleet/v4/server/contexts/ctxerr" "github.com/fleetdm/fleet/v4/server/fleet" - jose "github.com/go-jose/go-jose/v3" - josecipher "github.com/go-jose/go-jose/v3/cipher" + "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/pssocrypto" jwt "github.com/golang-jwt/jwt/v4" "golang.org/x/crypto/hkdf" ) -// pssoRequestType is the claim discriminator on every inbound token JWT. -type pssoRequestType string - -const ( - pssoRequestKey pssoRequestType = "key_request" - pssoRequestExchange pssoRequestType = "key_exchange" -) - -// pssoTokenClaims models the union of claims an inbound token JWT can -// carry. The PSSO v2 Password login request identifies itself with -// GrantType=="password" and carries a plaintext Password plus a JWECrypto -// recipe describing how the response must be encrypted; key requests and -// key exchanges identify themselves via RequestType instead. -type pssoTokenClaims struct { - jwt.RegisteredClaims - - // PSSO v2 Password login request. - GrantType string `json:"grant_type,omitempty"` - Password string `json:"password,omitempty"` // plaintext; empty when the password is encrypted in Assertion - // Assertion is the embedded login assertion. When the extension sets - // loginRequestEncryptionPublicKey, Apple drops the plaintext Password claim - // and instead places the password in this compact JWE - // (typ platformsso-encrypted-login-assertion+jwt), encrypted to Fleet's - // published encryption key. The outer JWT remains signed by the device key. - Assertion string `json:"assertion,omitempty"` - Username string `json:"username,omitempty"` - Nonce string `json:"nonce,omitempty"` // Apple session nonce, echoed in the response - JWECrypto *pssoJWECrypto `json:"jwe_crypto,omitempty"` // response-encryption recipe - RequestNonce string `json:"request_nonce,omitempty"` // Fleet-issued nonce from /nonce - - // PSSO 2.0 key request / key exchange (request_type "key_request" / - // "key_exchange", used during registration to provision the unlock key). - RequestType pssoRequestType `json:"request_type,omitempty"` - OtherPublicKey string `json:"other_publickey,omitempty"` // device DH public key (key_exchange) - KeyContext string `json:"key_context,omitempty"` // server-sealed provisioned key, echoed back -} - -// pssoJWTLeeway is the clock-skew tolerance applied to inbound JWT time -// claims. The default RegisteredClaims validation allows zero skew, so a Mac -// whose clock runs even a second ahead of the server gets "token used before -// issued" on every login. -const pssoJWTLeeway = time.Minute - -// Valid overrides the embedded RegisteredClaims validation to apply -// pssoJWTLeeway to exp, iat, and nbf. jwt/v4 has no parser-level leeway -// option (that arrived in v5), so the claims type does it. -func (c *pssoTokenClaims) Valid() error { - now := time.Now() - if !c.VerifyExpiresAt(now.Add(-pssoJWTLeeway), false) { - return jwt.ErrTokenExpired - } - if !c.VerifyIssuedAt(now.Add(pssoJWTLeeway), false) { - return jwt.ErrTokenUsedBeforeIssued - } - if !c.VerifyNotBefore(now.Add(pssoJWTLeeway), false) { - return jwt.ErrTokenNotValidYet - } - return nil -} - -// pssoJWECrypto is the jwe_crypto claim the extension sends to tell Fleet how -// to encrypt the login response: ECDH-ES key agreement to the device -// encryption key with A256GCM content encryption, binding the agreed key to -// the apu/apv party-info the device chose. -type pssoJWECrypto struct { - Alg string `json:"alg"` - Enc string `json:"enc"` - APU string `json:"apu,omitempty"` - APV string `json:"apv,omitempty"` -} - // parsePSSOInboundJWT verifies the inbound compact JWS using the device's // signing pubkey (resolved by kid) and returns the parsed claims plus the // signing key row that matched (its HostUUID identifies the device). -func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (*pssoTokenClaims, *fleet.PSSOKey, error) { +func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (*pssocrypto.TokenClaims, *fleet.PSSOKey, error) { // First parse without verification to extract kid. - unverified, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(string(jwtBytes), &pssoTokenClaims{}) + unverified, _, err := jwt.NewParser(jwt.WithoutClaimsValidation()).ParseUnverified(string(jwtBytes), &pssocrypto.TokenClaims{}) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "parse inbound psso jwt header") } @@ -123,7 +40,7 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* if kid == "" { return nil, nil, &fleet.BadRequestError{Message: "psso jwt missing kid header"} } - kid = canonicalizeKID(kid) + kid = pssocrypto.CanonicalizeKID(kid) signKey, err := svc.ds.GetPSSOKey(ctx, kid) if err != nil { @@ -133,7 +50,7 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* return nil, nil, &fleet.BadRequestError{Message: "psso jwt kid does not reference a signing key"} } - pub, err := parseECPublicKeyPEM([]byte(signKey.PEM)) + pub, err := pssocrypto.ParseECPublicKeyPEM([]byte(signKey.PEM)) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "parse device signing pubkey") } @@ -142,16 +59,16 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* // extension signs with) and assert the ECDSA method in the keyfunc. Without // this, a future refactor returning a non-EC key could open an alg-confusion // forgery path even though golang-jwt's type assertions currently prevent it. - tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssoTokenClaims{}, func(t *jwt.Token) (any, error) { + tok, err := jwt.ParseWithClaims(string(jwtBytes), &pssocrypto.TokenClaims{}, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { return nil, fmt.Errorf("psso jwt: unexpected signing method %q", t.Method.Alg()) } return pub, nil - }, jwt.WithValidMethods([]string{pssoSigningAlg})) + }, jwt.WithValidMethods([]string{pssocrypto.SigningAlg})) if err != nil { return nil, nil, ctxerr.Wrap(ctx, err, "verify psso jwt signature") } - claims, ok := tok.Claims.(*pssoTokenClaims) + claims, ok := tok.Claims.(*pssocrypto.TokenClaims) if !ok || !tok.Valid { return nil, nil, &fleet.BadRequestError{Message: "psso jwt claims invalid"} } @@ -160,41 +77,41 @@ func (svc *Service) parsePSSOInboundJWT(ctx context.Context, jwtBytes []byte) (* // resolvePSSOEncryptionKey returns the registered encryption public key the // response JWE must be wrapped to. The device names its encryption key inside -// the request's apv party-info blob ("Apple" || deviceEncKey || nonce), and -// the extension registered that key under kid = base64url(SHA-256(raw key -// bytes)) — so the kid is recomputed from apv and looked up. As a fallback -// against any re-encoding of the key by Apple's framework, the raw point is -// compared against each of the host's registered encryption keys. A key that -// resolves but belongs to a different host, or doesn't resolve at all, is -// rejected: responses are only ever encrypted to keys the host registered. +// the request's apv party-info blob ("Apple" || deviceEncKey || nonce), and the +// extension registered that key under kid = base64url(SHA-256(raw key bytes)) — +// so the kid is recomputed from apv and looked up. As a fallback against any +// re-encoding of the key by Apple's framework, the raw point is compared against +// each of the host's registered encryption keys. A key that resolves but belongs +// to a different host, or doesn't resolve at all, is rejected: responses are only +// ever encrypted to keys the host registered. func (svc *Service) resolvePSSOEncryptionKey(ctx context.Context, hostUUID, apvB64 string) (*ecdsa.PublicKey, error) { - apvRaw, err := decodeJOSEB64(apvB64) + apvRaw, err := pssocrypto.DecodeJOSEB64(apvB64) if err != nil { return nil, ctxerr.Wrap(ctx, err, "decode apv") } - fields, err := parseApplePartyInfo(apvRaw) + fields, err := pssocrypto.ParseApplePartyInfo(apvRaw) if err != nil { return nil, ctxerr.Wrap(ctx, err, "parse apv party-info") } - if len(fields) < 2 || string(fields[0]) != apvPartyLabel { + if len(fields) < 2 || string(fields[0]) != pssocrypto.APVPartyLabel { return nil, &fleet.BadRequestError{Message: "psso: apv is not an Apple party-info blob"} } encKeyRaw := fields[1] sum := sha256.Sum256(encKeyRaw) - kid := canonicalizeKID(base64.RawURLEncoding.EncodeToString(sum[:])) + kid := pssocrypto.CanonicalizeKID(base64.RawURLEncoding.EncodeToString(sum[:])) key, err := svc.ds.GetPSSOKey(ctx, kid) switch { case err == nil: if key.KeyType != fleet.PSSOKeyTypeEncryption || key.HostUUID != hostUUID { return nil, &fleet.BadRequestError{Message: "psso: apv key is not a registered encryption key for this device"} } - return parseECPublicKeyPEM([]byte(key.PEM)) + return pssocrypto.ParseECPublicKeyPEM([]byte(key.PEM)) case !fleet.IsNotFound(err): return nil, ctxerr.Wrap(ctx, err, "look up encryption key by apv kid") } - apvPub, err := parseRawECPoint(encKeyRaw) + apvPub, err := pssocrypto.ParseRawECPoint(encKeyRaw) if err != nil { return nil, ctxerr.Wrap(ctx, err, "parse apv encryption key") } @@ -206,7 +123,7 @@ func (svc *Service) resolvePSSOEncryptionKey(ctx context.Context, hostUUID, apvB if k.KeyType != fleet.PSSOKeyTypeEncryption { continue } - pub, err := parseECPublicKeyPEM([]byte(k.PEM)) + pub, err := pssocrypto.ParseECPublicKeyPEM([]byte(k.PEM)) if err != nil { svc.logger.WarnContext(ctx, "psso: skipping unparseable registered encryption key", "kid", k.KID, "err", err) continue @@ -218,303 +135,8 @@ func (svc *Service) resolvePSSOEncryptionKey(ctx context.Context, hostUUID, apvB return nil, &fleet.BadRequestError{Message: "psso: apv key is not a registered encryption key for this device"} } -// canonicalizeKID normalizes a key ID to a stable comparison form. Apple's -// framework emits the JWT `kid` as base64 with padding (e.g. "…LZE="), while -// the extension registers its key IDs as base64url without padding ("…LZE"). -// Both encode the same SHA-256 bytes, so decode tolerantly (either alphabet, -// optional padding) and re-encode as raw base64url. Both the stored kid and -// the looked-up kid pass through this so the two encodings can't drift apart. -// If the value doesn't decode as base64 it's returned unchanged. -func canonicalizeKID(kid string) string { - t := strings.TrimRight(kid, "=") - t = strings.ReplaceAll(t, "-", "+") - t = strings.ReplaceAll(t, "_", "/") - raw, err := base64.RawStdEncoding.DecodeString(t) - if err != nil { - return kid - } - return base64.RawURLEncoding.EncodeToString(raw) -} - -// parseECPublicKeyPEM decodes a PEM-wrapped P-256 public key. It accepts both -// DER-encoded SubjectPublicKeyInfo (the standard "PUBLIC KEY" body) and a raw -// ANSI X9.63 uncompressed point (0x04 || X || Y). The extension's keys arrive -// in the latter form: macOS SecKeyCopyExternalRepresentation returns the raw -// point for EC keys, which the extension PEM-wraps without converting to SPKI. -func parseECPublicKeyPEM(pemBytes []byte) (*ecdsa.PublicKey, error) { - block, _ := pem.Decode(pemBytes) - if block == nil { - return nil, errors.New("psso: pem decode returned nil block") - } - if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err == nil { - ec, ok := pub.(*ecdsa.PublicKey) - if !ok { - return nil, fmt.Errorf("psso: unexpected pubkey type %T (want *ecdsa.PublicKey)", pub) - } - return ec, nil - } - return parseRawECPoint(block.Bytes) -} - -// parseRawECPoint parses a raw ANSI X9.63 uncompressed P-256 point into an -// ecdsa.PublicKey. crypto/ecdh validates the length and on-curve membership; -// round-tripping through SPKI yields the ecdsa type the JWT verifier and JWE -// encrypter expect without touching the deprecated raw coordinate fields. -func parseRawECPoint(raw []byte) (*ecdsa.PublicKey, error) { - key, err := ecdh.P256().NewPublicKey(raw) - if err != nil { - return nil, fmt.Errorf("psso: parse raw EC point: %w", err) - } - der, err := x509.MarshalPKIXPublicKey(key) - if err != nil { - return nil, fmt.Errorf("psso: marshal raw EC point to SPKI: %w", err) - } - pub, err := x509.ParsePKIXPublicKey(der) - if err != nil { - return nil, fmt.Errorf("psso: parse SPKI from raw point: %w", err) - } - ec, ok := pub.(*ecdsa.PublicKey) - if !ok { - return nil, fmt.Errorf("psso: unexpected pubkey type %T (want *ecdsa.PublicKey)", pub) - } - return ec, nil -} - -// buildAsymmetricJWE encrypts payload to deviceEncPub using JWE -// ECDH-ES + A256GCM via go-jose's stock encrypter (empty apu/apv — see -// buildPSSOResponseJWE for the Apple-party-info variant the handlers use). -func buildAsymmetricJWE(payload []byte, deviceEncPub *ecdsa.PublicKey, kid string) ([]byte, error) { - enc, err := jose.NewEncrypter( - jose.A256GCM, - jose.Recipient{ - Algorithm: jose.ECDH_ES, - Key: deviceEncPub, - KeyID: kid, - }, - (&jose.EncrypterOptions{}).WithContentType("application/platformsso-login-response+jwt"), - ) - if err != nil { - return nil, fmt.Errorf("build asymmetric encrypter: %w", err) - } - jwe, err := enc.Encrypt(payload) - if err != nil { - return nil, fmt.Errorf("encrypt asymmetric jwe: %w", err) - } - compact, err := jwe.CompactSerialize() - if err != nil { - return nil, fmt.Errorf("serialize asymmetric jwe: %w", err) - } - return []byte(compact), nil -} - -// Apple's PSSO ECDH-ES party-info blobs are sequences of 4-byte big-endian -// length-prefixed fields. Per Apple's "Creating a JSON Web Encryption (JWE) -// login response" doc the two differ in both label case and contents: -// - apv (PartyVInfo, the device): "Apple" || deviceEncKey || nonce — echoed -// verbatim from the request. -// - apu (PartyUInfo, the server): "APPLE" || serverEphemeralKey — note the -// uppercase label and the absence of a nonce. -const ( - apuPartyLabel = "APPLE" - apvPartyLabel = "Apple" -) - -// encodeApplePartyInfo serializes fields as Apple's length-prefixed party-info -// blob: each field is a 4-byte big-endian length followed by its bytes. -func encodeApplePartyInfo(fields ...[]byte) []byte { - var b []byte - var l [4]byte - for _, f := range fields { - //nolint:gosec // dismiss G115, party-info fields are small (labels, 65-byte EC points, nonces), never near 2^32 - binary.BigEndian.PutUint32(l[:], uint32(len(f))) - b = append(b, l[:]...) - b = append(b, f...) - } - return b -} - -// parseApplePartyInfo splits an Apple party-info blob back into its -// length-prefixed fields. -func parseApplePartyInfo(raw []byte) ([][]byte, error) { - var fields [][]byte - for i := 0; i < len(raw); { - if i+4 > len(raw) { - return nil, errors.New("psso: truncated party-info length prefix") - } - n := int(binary.BigEndian.Uint32(raw[i:])) - i += 4 - if i+n > len(raw) { - return nil, errors.New("psso: party-info field overruns buffer") - } - fields = append(fields, raw[i:i+n]) - i += n - } - return fields, nil -} - -// buildPSSOResponseJWE encrypts payload to the device's encryption public key -// as a compact JWE using ECDH-ES key agreement + A256GCM. typ is the JWE -// header media type — "platformsso-login-response+jwt" for login, -// "platformsso-key-response+jwt" for key/key-exchange responses. -// -// Apple's framework requires both apu and apv in the protected header and -// validates apu by recomputing it from the epk it sees. apv (PartyVInfo) is -// echoed verbatim from the request. apu (PartyUInfo) is built as -// "APPLE" || serverEphemeralPubKey — uppercase label, no nonce — per Apple's -// JWE login-response doc. -// -// The compact JWE is assembled by hand rather than via jose.NewEncrypter -// because go-jose's ECDH-ES key generator hardcodes empty apu/apv (see -// ecKeyGenerator.genKey) and exposes no way to set them. The Concat KDF itself -// is reused from go-jose's exported cipher package — no PSSO SDK is involved. -func buildPSSOResponseJWE(payload []byte, recipientPub *ecdsa.PublicKey, apvB64, typ string) ([]byte, error) { - apvRaw, err := decodeJOSEB64(apvB64) - if err != nil { - return nil, fmt.Errorf("decode apv: %w", err) - } - - ephemeral, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - if err != nil { - return nil, fmt.Errorf("generate ephemeral key: %w", err) - } - epkECDH, err := ephemeral.PublicKey.ECDH() - if err != nil { - return nil, fmt.Errorf("psso: ephemeral key to ecdh: %w", err) - } - apuRaw := encodeApplePartyInfo([]byte(apuPartyLabel), epkECDH.Bytes()) - - // ECDH-ES direct: the agreed key is the A256GCM content-encryption key, so - // the Concat KDF algorithm ID is the content-encryption alg ("A256GCM"). - cek := josecipher.DeriveECDHES("A256GCM", apuRaw, apvRaw, ephemeral, recipientPub, 32) - - epkJSON, err := json.Marshal(&jose.JSONWebKey{Key: &ephemeral.PublicKey}) - if err != nil { - return nil, fmt.Errorf("marshal epk: %w", err) - } - - // No cty: the decrypted payload is a JSON object (OAuth token response or - // key response), not a nested JWT. - header := map[string]any{ - "alg": "ECDH-ES", - "enc": "A256GCM", - "epk": json.RawMessage(epkJSON), - "typ": typ, - "apu": base64.RawURLEncoding.EncodeToString(apuRaw), - "apv": strings.TrimRight(apvB64, "="), - } - protected, err := json.Marshal(header) - if err != nil { - return nil, fmt.Errorf("marshal protected header: %w", err) - } - protectedB64 := base64.RawURLEncoding.EncodeToString(protected) - - block, err := aes.NewCipher(cek) - if err != nil { - return nil, fmt.Errorf("aes new cipher: %w", err) - } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("aes-gcm: %w", err) - } - iv := make([]byte, gcm.NonceSize()) - if _, err := rand.Read(iv); err != nil { - return nil, fmt.Errorf("rand iv: %w", err) - } - // JWE AAD for compact serialization is the ASCII base64url protected header. - sealed := gcm.Seal(nil, iv, payload, []byte(protectedB64)) - ct := sealed[:len(sealed)-gcm.Overhead()] - tag := sealed[len(sealed)-gcm.Overhead():] - - enc := base64.RawURLEncoding.EncodeToString - // Compact JWE: protected.encrypted_key.iv.ciphertext.tag — encrypted_key - // is empty for ECDH-ES direct key agreement. - compact := protectedB64 + "." + "" + "." + enc(iv) + "." + enc(ct) + "." + enc(tag) - return []byte(compact), nil -} - -// decryptPSSOInboundJWE decrypts a compact JWE the device sent us — the embedded -// login assertion carrying the user's password. It is the inverse direction of -// buildPSSOResponseJWE: here Fleet is the static ECDH-ES recipient -// (recipientPriv) and the device supplied the ephemeral epk in the header. -// go-jose reads epk/apu/apv from the protected header and runs the same Concat -// KDF to recover the A256GCM content-encryption key (the same path the -// login-response round-trip test relies on). alg/enc/typ are pinned to what -// Apple emits for password encryption. -func decryptPSSOInboundJWE(compact []byte, recipientPriv *ecdsa.PrivateKey) ([]byte, error) { - protectedB64, _, ok := strings.Cut(string(compact), ".") - if !ok { - return nil, errors.New("psso inbound jwe: not a compact JWE") - } - protected, err := decodeJOSEB64(protectedB64) - if err != nil { - return nil, fmt.Errorf("psso inbound jwe: decode protected header: %w", err) - } - var hdr struct { - Alg string `json:"alg"` - Enc string `json:"enc"` - Typ string `json:"typ"` - } - if err := json.Unmarshal(protected, &hdr); err != nil { - return nil, fmt.Errorf("psso inbound jwe: parse protected header: %w", err) - } - if hdr.Alg != pssoEncryptionAlg || hdr.Enc != "A256GCM" { - return nil, fmt.Errorf("psso inbound jwe: unsupported alg/enc %q/%q", hdr.Alg, hdr.Enc) - } - if hdr.Typ != pssoTypEncryptedLoginAssertion { - return nil, fmt.Errorf("psso inbound jwe: unexpected typ %q", hdr.Typ) - } - - obj, err := jose.ParseEncrypted(string(compact)) - if err != nil { - return nil, fmt.Errorf("psso inbound jwe: parse: %w", err) - } - plaintext, err := obj.Decrypt(recipientPriv) - if err != nil { - return nil, fmt.Errorf("psso inbound jwe: decrypt: %w", err) - } - return plaintext, nil -} - -// parseEmbeddedAssertionPassword pulls the password out of a decrypted embedded -// login assertion. Apple's typ ends in "+jwt", so the plaintext is a JWT whose -// claims carry the password; a bare JSON claims object is also accepted. The -// username is taken from the signed outer JWT, not here. The assertion is -// encrypted-only — its integrity is covered by the outer signed JWT and the JWE -// GCM tag, so no inner signature is verified here. -func parseEmbeddedAssertionPassword(plaintext []byte) (string, error) { - s := strings.TrimSpace(string(plaintext)) - claimsJSON := []byte(s) - if len(s) > 0 && s[0] != '{' { - // Compact JWT: header.payload[.signature]; the claims are the payload. - parts := strings.Split(s, ".") - if len(parts) < 2 { - return "", errors.New("psso embedded assertion: not JSON or a compact JWT") - } - decoded, derr := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) - if derr != nil { - return "", fmt.Errorf("psso embedded assertion: decode claims segment: %w", derr) - } - claimsJSON = decoded - } - var claims struct { - Password string `json:"password"` - } - if err := json.Unmarshal(claimsJSON, &claims); err != nil { - return "", fmt.Errorf("psso embedded assertion: parse claims: %w", err) - } - return claims.Password, nil -} - -// decodeJOSEB64 base64url-decodes a JOSE value, tolerating optional padding. -func decodeJOSEB64(s string) ([]byte, error) { - if s == "" { - return nil, nil - } - return base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) -} - -// deriveKeyContextKey derives the AES-256 key that seals key_context blobs, -// from Fleet's PSSO signing key. This lets the provisioned private key live +// deriveKeyContextKey derives the AES-256 key that seals key_context blobs, from +// Fleet's PSSO signing key. This lets the provisioned private key live // statelessly inside the key_context the device round-trips between the key // request and key exchange — no per-device server storage. func deriveKeyContextKey(signingKey *ecdsa.PrivateKey) ([]byte, error) { @@ -528,7 +150,7 @@ func deriveKeyContextKey(signingKey *ecdsa.PrivateKey) ([]byte, error) { // pssoKeyPurposeUserUnlock is the only key purpose Fleet provisions today: the // offline FileVault/keychain unlock key. It's recorded in the sealed key_context // so key exchange can validate it and future purposes can be distinguished. -const pssoKeyPurposeUserUnlock = "user_unlock" +const pssoKeyPurposeUserUnlock = pssocrypto.KeyPurposeUserUnlock // pssoKeyContext is the plaintext sealed into the opaque key_context blob that // rides between a key request and its matching key exchange. Binding the host @@ -591,39 +213,13 @@ func openKeyContext(keyContext string, kcKey []byte) (*pssoKeyContext, *ecdsa.Pr return &kc, key, nil } -// computeECDHShared returns the raw ECDH shared secret (P-256 X coordinate, 32 -// bytes) between priv and the uncompressed peer public point — the key field -// of a key-exchange response. -func computeECDHShared(priv *ecdsa.PrivateKey, peerRaw []byte) ([]byte, error) { - ecdhPriv, err := priv.ECDH() - if err != nil { - return nil, fmt.Errorf("provisioned key to ecdh: %w", err) - } - peer, err := ecdh.P256().NewPublicKey(peerRaw) - if err != nil { - return nil, fmt.Errorf("parse other_publickey: %w", err) - } - return ecdhPriv.ECDH(peer) -} - -// decodeBase64Flexible decodes standard or url base64, with or without padding -// — the device sends other_publickey as padded standard base64. -func decodeBase64Flexible(s string) ([]byte, error) { - for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { - if b, err := enc.DecodeString(s); err == nil { - return b, nil - } - } - return nil, errors.New("psso: value is not valid base64") -} - -// pssoSessionInfo is the HKDF info string distinguishing PSSO-derived keys -// from any other derivation the same input keying material could feed. +// pssoSessionInfo is the HKDF info string distinguishing PSSO-derived keys from +// any other derivation the same input keying material could feed. var pssoSessionInfo = []byte("fleetdm-psso-session-key-v1") // deriveSessionKey returns a 32-byte AES-256 key derived from ikm via -// HKDF-SHA256. The salt parameter binds the derivation to a purpose (e.g. -// the key_context info string in deriveKeyContextKey). +// HKDF-SHA256. The salt parameter binds the derivation to a purpose (e.g. the +// key_context info string in deriveKeyContextKey). func deriveSessionKey(ikm []byte, salt []byte) ([]byte, error) { r := hkdf.New(sha256.New, ikm, salt, pssoSessionInfo) out := make([]byte, 32) @@ -633,9 +229,9 @@ func deriveSessionKey(ikm []byte, salt []byte) ([]byte, error) { return out, nil } -// buildSymmetricJWE returns an A256GCM JWE of payload, keyed by sessionKey. -// Used to seal key_context blobs so the provisioned private key can -// round-trip statelessly between key_request and key_exchange. +// buildSymmetricJWE returns an A256GCM JWE of payload, keyed by sessionKey. Used +// to seal key_context blobs so the provisioned private key can round-trip +// statelessly between key_request and key_exchange. func buildSymmetricJWE(payload []byte, sessionKey []byte) ([]byte, error) { if len(sessionKey) != 32 { return nil, fmt.Errorf("psso: session key must be 32 bytes, got %d", len(sessionKey)) @@ -653,9 +249,9 @@ func buildSymmetricJWE(payload []byte, sessionKey []byte) ([]byte, error) { return nil, fmt.Errorf("rand nonce: %w", err) } ct := gcm.Seal(nil, nonce, payload, nil) - // JOSE-compatible flat-JSON serialization keeps the result inspectable - // for the POC. A real client may require compact form; switch when - // confirmed against the extension's expectations. + // JOSE-compatible flat-JSON serialization keeps the result inspectable for + // the POC. A real client may require compact form; switch when confirmed + // against the extension's expectations. envelope := struct { Alg string `json:"alg"` Enc string `json:"enc"` @@ -670,8 +266,8 @@ func buildSymmetricJWE(payload []byte, sessionKey []byte) ([]byte, error) { return json.Marshal(envelope) } -// decryptSymmetricBlob is the inverse of buildSymmetricJWE — used to open -// the key_context blob a device echoes back in a key-exchange request. +// decryptSymmetricBlob is the inverse of buildSymmetricJWE — used to open the +// key_context blob a device echoes back in a key-exchange request. func decryptSymmetricBlob(blob []byte, sessionKey []byte) ([]byte, error) { if len(sessionKey) != 32 { return nil, fmt.Errorf("psso: session key must be 32 bytes, got %d", len(sessionKey)) @@ -699,8 +295,8 @@ func decryptSymmetricBlob(blob []byte, sessionKey []byte) ([]byte, error) { } // signServerJWT returns a signed compact JWS with the given claims, using -// Fleet's PSSO signing key. Used to wrap payloads that must be authenticated -// as coming from Fleet (e.g. claims responses). +// Fleet's PSSO signing key. Used to wrap payloads that must be authenticated as +// coming from Fleet (e.g. claims responses). func (svc *Service) signServerJWT(ctx context.Context, claims jwt.Claims) ([]byte, error) { key, kid, err := svc.getPSSOSigningKey(ctx) if err != nil { diff --git a/ee/server/service/apple_psso_crypto_test.go b/ee/server/service/apple_psso_crypto_test.go index 10718f78290..bc7ae277e67 100644 --- a/ee/server/service/apple_psso_crypto_test.go +++ b/ee/server/service/apple_psso_crypto_test.go @@ -5,27 +5,31 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" - "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/pem" "io" "log/slog" "testing" - "time" "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/pssocrypto" "github.com/fleetdm/fleet/v4/server/mock" - jose "github.com/go-jose/go-jose/v3" jwt "github.com/golang-jwt/jwt/v4" "github.com/jmoiron/sqlx" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// TestPSSO_SymmetricRoundTrip exercises the AES-256-GCM envelope used to -// seal key_context blobs. Encrypting and then decrypting under the same -// session key must yield the original plaintext. +// The symmetric wire-format crypto these flows build on (party-info JWE, kid +// canonicalization, EC parsing, the inbound claim types) is tested in +// server/mdm/apple/psso/pssocrypto. The tests here cover the server-only logic +// that wraps it: key_context sealing under Fleet's signing key, resolving a +// device key from the datastore, and the password/JWT login plumbing. + +// TestPSSO_SymmetricRoundTrip exercises the AES-256-GCM envelope used to seal +// key_context blobs. Encrypting and then decrypting under the same session key +// must yield the original plaintext. func TestPSSO_SymmetricRoundTrip(t *testing.T) { key := make([]byte, 32) _, err := rand.Read(key) @@ -58,8 +62,8 @@ func TestPSSO_SymmetricWrongKeyFails(t *testing.T) { require.Error(t, err) } -// TestPSSO_SymmetricWrongKeySize confirms we reject session keys with the -// wrong byte length, since AES-256 expects exactly 32. +// TestPSSO_SymmetricWrongKeySize confirms we reject session keys with the wrong +// byte length, since AES-256 expects exactly 32. func TestPSSO_SymmetricWrongKeySize(t *testing.T) { _, err := buildSymmetricJWE([]byte("x"), make([]byte, 16)) require.Error(t, err) @@ -68,8 +72,7 @@ func TestPSSO_SymmetricWrongKeySize(t *testing.T) { } // TestPSSO_HKDFDifferentSaltDifferentKey confirms the session-key derivation -// produces distinct outputs for distinct salts (i.e. distinct request -// nonces). +// produces distinct outputs for distinct salts (i.e. distinct request nonces). func TestPSSO_HKDFDifferentSaltDifferentKey(t *testing.T) { kek := make([]byte, 32) _, err := rand.Read(kek) @@ -89,98 +92,8 @@ func TestPSSO_HKDFDifferentSaltDifferentKey(t *testing.T) { assert.Equal(t, k1, k1again) } -// TestPSSO_AsymmetricEncryptRoundTrip confirms that a payload encrypted to -// a device's encryption pubkey via JWE ECDH-ES + A256GCM produces a valid -// compact JWE. -func TestPSSO_AsymmetricEncryptRoundTrip(t *testing.T) { - deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - payload := []byte(`{"claims":"AAECAwQF"}`) - jweCompact, err := buildAsymmetricJWE(payload, &deviceKey.PublicKey, "") - require.NoError(t, err) - require.NotEmpty(t, jweCompact) - - // JWE compact form has 5 base64url segments separated by dots; smoke - // check we got something of that shape rather than re-implementing the - // whole decrypt path here (the JOSE library is well-tested upstream). - dots := 0 - for _, b := range jweCompact { - if b == '.' { - dots++ - } - } - assert.Equal(t, 4, dots, "expected JWE compact form with 4 dots") -} - -// TestPSSO_LoginResponseJWERoundTrip confirms the hand-assembled PSSO login -// response JWE decrypts back to the original payload using the device's -// encryption private key. Decrypting via go-jose (which reads apu/apv from the -// protected header and feeds them to the same Concat KDF) proves both the -// compact wire format and the apv party-info binding are correct: a wrong apv -// would derive a different content-encryption key and fail the GCM tag. -func TestPSSO_LoginResponseJWERoundTrip(t *testing.T) { - deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - apv := testAPV(t, deviceKey) - payload := []byte(`{"id_token":"x","refresh_token":"y"}`) - - jweCompact, err := buildPSSOResponseJWE(payload, &deviceKey.PublicKey, apv, pssoTypLoginResponse) - require.NoError(t, err) - require.NotEmpty(t, jweCompact) - - parsed, err := jose.ParseEncrypted(string(jweCompact)) - require.NoError(t, err) - got, err := parsed.Decrypt(deviceKey) - require.NoError(t, err) - assert.Equal(t, payload, got) - - // Per Apple's doc, apu is "APPLE" (uppercase) || ephemeral epk, with NO - // nonce — distinct from apv's "Apple" || key || nonce framing. - hdr := parsed.Header - apuB64, ok := hdr.ExtraHeaders[jose.HeaderKey("apu")].(string) - require.True(t, ok, "apu header must be present") - apuRaw, err := base64.RawURLEncoding.DecodeString(apuB64) - require.NoError(t, err) - apuFields, err := parseApplePartyInfo(apuRaw) - require.NoError(t, err) - require.Len(t, apuFields, 2, "apu is exactly [label, epk] — no nonce") - assert.Equal(t, apuPartyLabel, string(apuFields[0])) - assert.Equal(t, byte(0x04), apuFields[1][0], "apu field 2 is the uncompressed epk") -} - -// testAPV builds an Apple-shaped apv ("Apple" || deviceKey || nonce) and -// returns it base64url-encoded, mimicking what the device sends. -func testAPV(t *testing.T, deviceKey *ecdsa.PrivateKey) (apvB64 string) { - t.Helper() - devECDH, err := deviceKey.PublicKey.ECDH() - require.NoError(t, err) - nonce := "3B94D3F7-5907-44C2-B6AF-05A0B0017669" - raw := encodeApplePartyInfo([]byte(apvPartyLabel), devECDH.Bytes(), []byte(nonce)) - return base64.RawURLEncoding.EncodeToString(raw) -} - -// TestPSSO_LoginResponseJWEWrongKeyFails confirms the JWE can't be decrypted -// with a key other than the intended device key. -func TestPSSO_LoginResponseJWEWrongKeyFails(t *testing.T) { - deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - apv := testAPV(t, deviceKey) - jweCompact, err := buildPSSOResponseJWE([]byte("secret"), &deviceKey.PublicKey, apv, pssoTypLoginResponse) - require.NoError(t, err) - - parsed, err := jose.ParseEncrypted(string(jweCompact)) - require.NoError(t, err) - _, err = parsed.Decrypt(otherKey) - require.Error(t, err) -} - -// TestPSSO_KeyContextRoundTrip confirms a provisioned private key sealed into -// a key_context (key request) is recovered intact when opened (key exchange). +// TestPSSO_KeyContextRoundTrip confirms a provisioned private key sealed into a +// key_context (key request) is recovered intact when opened (key exchange). func TestPSSO_KeyContextRoundTrip(t *testing.T) { signing, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) @@ -231,7 +144,7 @@ func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { } signed := func(method jwt.SigningMethod, key any) string { - tok := jwt.NewWithClaims(method, &pssoTokenClaims{RequestType: pssoRequestKey}) + tok := jwt.NewWithClaims(method, &pssocrypto.TokenClaims{RequestType: pssocrypto.RequestKey}) tok.Header["kid"] = kid s, err := tok.SignedString(key) require.NoError(t, err) @@ -241,7 +154,7 @@ func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { // A valid ES256 token from the registered device verifies. claims, gotKey, err := svc.parsePSSOInboundJWT(t.Context(), []byte(signed(jwt.SigningMethodES256, deviceKey))) require.NoError(t, err) - assert.Equal(t, pssoRequestKey, claims.RequestType) + assert.Equal(t, pssocrypto.RequestKey, claims.RequestType) assert.Equal(t, fleet.PSSOKeyTypeSigning, gotKey.KeyType) // An HS256 token sharing the same kid is rejected (alg confusion). @@ -249,7 +162,7 @@ func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { require.Error(t, err) // An unsigned ("none") token is rejected. - none := jwt.NewWithClaims(jwt.SigningMethodNone, &pssoTokenClaims{RequestType: pssoRequestKey}) + none := jwt.NewWithClaims(jwt.SigningMethodNone, &pssocrypto.TokenClaims{RequestType: pssocrypto.RequestKey}) none.Header["kid"] = kid noneStr, err := none.SignedString(jwt.UnsafeAllowNoneSignatureType) require.NoError(t, err) @@ -257,133 +170,26 @@ func TestPSSO_InboundJWTAlgorithmPinned(t *testing.T) { require.Error(t, err) } -// TestPSSO_KeyExchangeSharedSecretMatches confirms the unlock-key DH is -// symmetric: the server's ECDH(provisioned_priv, device_pub) equals the -// device's ECDH(device_priv, provisioned_pub). -func TestPSSO_KeyExchangeSharedSecretMatches(t *testing.T) { - provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - deviceDH, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - // Server side: what computeECDHShared does, against the device's public. - deviceECDH, err := deviceDH.PublicKey.ECDH() - require.NoError(t, err) - serverShared, err := computeECDHShared(provisioned, deviceECDH.Bytes()) - require.NoError(t, err) - require.Len(t, serverShared, 32) - - // Device side: ECDH(device_priv, provisioned_pub) — must match. - provECDH, err := provisioned.PublicKey.ECDH() - require.NoError(t, err) - devPriv, err := deviceDH.ECDH() - require.NoError(t, err) - deviceShared, err := devPriv.ECDH(provECDH) - require.NoError(t, err) - assert.Equal(t, deviceShared, serverShared) -} - -// TestPSSO_TokenClaimsLeeway confirms inbound JWT time claims tolerate small -// clock skew between the Mac and the server: an iat slightly in the future -// (Mac clock ahead) or an exp slightly in the past must not fail validation, -// while skew beyond the leeway still does. -func TestPSSO_TokenClaimsLeeway(t *testing.T) { - now := time.Now() - claimsAt := func(iat, exp time.Time) *pssoTokenClaims { - return &pssoTokenClaims{RegisteredClaims: jwt.RegisteredClaims{ - IssuedAt: jwt.NewNumericDate(iat), - ExpiresAt: jwt.NewNumericDate(exp), - }} - } - - // In sync: valid. - require.NoError(t, claimsAt(now, now.Add(5*time.Minute)).Valid()) - - // Mac clock slightly ahead: iat in the (server's) future, within leeway. - require.NoError(t, claimsAt(now.Add(30*time.Second), now.Add(5*time.Minute)).Valid()) - - // exp just passed, within leeway. - require.NoError(t, claimsAt(now.Add(-5*time.Minute), now.Add(-30*time.Second)).Valid()) - - // Beyond leeway both ways. - err := claimsAt(now.Add(pssoJWTLeeway+time.Minute), now.Add(10*time.Minute)).Valid() - require.ErrorIs(t, err, jwt.ErrTokenUsedBeforeIssued) - err = claimsAt(now.Add(-10*time.Minute), now.Add(-pssoJWTLeeway-time.Minute)).Valid() - require.ErrorIs(t, err, jwt.ErrTokenExpired) - - // Absent time claims are not required (registration-era JWTs). - require.NoError(t, (&pssoTokenClaims{}).Valid()) -} - -// TestPSSO_CanonicalizeKID confirms the padded base64 kid Apple's framework -// sends in the JWT header and the unpadded base64url kid the extension -// registers collapse to the same value, so device lookup by kid succeeds. -func TestPSSO_CanonicalizeKID(t *testing.T) { - // Real values from a live device: register sends no padding, the JWT - // header kid carries '='. - registered := "Yk8ghfYYyiUzsp0tcfVFn4TJUu0B45fzUnmonZZILZE" - jwtKID := "Yk8ghfYYyiUzsp0tcfVFn4TJUu0B45fzUnmonZZILZE=" - assert.Equal(t, canonicalizeKID(registered), canonicalizeKID(jwtKID)) - - // 32 random bytes encoded every which way must all canonicalize equal. - raw := make([]byte, 32) - _, err := rand.Read(raw) - require.NoError(t, err) - variants := []string{ - base64.RawURLEncoding.EncodeToString(raw), - base64.URLEncoding.EncodeToString(raw), - base64.RawStdEncoding.EncodeToString(raw), - base64.StdEncoding.EncodeToString(raw), - } - want := canonicalizeKID(variants[0]) - for _, v := range variants { - assert.Equal(t, want, canonicalizeKID(v), "variant %q", v) - } - - // A non-base64 value is returned unchanged rather than mangled. - assert.Equal(t, "not base64 at all!!", canonicalizeKID("not base64 at all!!")) -} - -// TestPSSO_ParseECPublicKey covers both PEM forms we accept on inbound key -// material from the extension. -func TestPSSO_ParseECPublicKey(t *testing.T) { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) - require.NoError(t, err) - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) - - got, err := parseECPublicKeyPEM(pemBytes) - require.NoError(t, err) - gotDER, err := x509.MarshalPKIXPublicKey(got) - require.NoError(t, err) - assert.Equal(t, der, gotDER) - - _, err = parseECPublicKeyPEM([]byte("not a pem block")) - require.Error(t, err) -} - // TestPSSO_ResolveEncryptionKey covers resolving the response-encryption key // from a request's apv blob: the kid is recomputed as SHA-256 of the raw key // bytes the device placed in apv (matching how the extension registers its // kids), looked up, and validated as an encryption key belonging to the -// requesting host. When the kid lookup misses, the host's registered -// encryption keys are compared point-by-point as a fallback. +// requesting host. When the kid lookup misses, the host's registered encryption +// keys are compared point-by-point as a fallback. func TestPSSO_ResolveEncryptionKey(t *testing.T) { const hostUUID = "ABCDEFGH-0000-0000-0000-111111111111" encPriv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) - encECDH, err := encPriv.PublicKey.ECDH() + rawPoint, err := pssocrypto.RawECPoint(&encPriv.PublicKey) require.NoError(t, err) - rawPoint := encECDH.Bytes() pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rawPoint}) - sum := sha256.Sum256(rawPoint) - kid := canonicalizeKID(base64.RawURLEncoding.EncodeToString(sum[:])) + kid, err := pssocrypto.KIDFromRawECPoint(&encPriv.PublicKey) + require.NoError(t, err) - apv := base64.RawURLEncoding.EncodeToString( - encodeApplePartyInfo([]byte(apvPartyLabel), rawPoint, []byte("nonce"))) + apv, err := pssocrypto.BuildAPV(&encPriv.PublicKey, []byte("nonce")) + require.NoError(t, err) newSvc := func() (*Service, *mock.DataStore) { ds := new(mock.DataStore) @@ -465,65 +271,6 @@ func TestPSSO_ResolveEncryptionKey(t *testing.T) { }) } -// TestPSSO_InboundAssertionDecryptRoundTrip confirms Fleet can decrypt the -// embedded login assertion the device encrypts to Fleet's PSSO encryption key. -// The device-side JWE (ECDH-ES + A256GCM, apu/apv) has the same wire shape as a -// Fleet login response in the opposite direction, so it's modeled with -// buildPSSOResponseJWE: the device is the ephemeral ECDH-ES sender, Fleet the -// static recipient. -func TestPSSO_InboundAssertionDecryptRoundTrip(t *testing.T) { - encKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - apv := testAPV(t, encKey) - plaintext := []byte(`{"password":"hunter2","username":"foo"}`) - - jwe, err := buildPSSOResponseJWE(plaintext, &encKey.PublicKey, apv, pssoTypEncryptedLoginAssertion) - require.NoError(t, err) - - got, err := decryptPSSOInboundJWE(jwe, encKey) - require.NoError(t, err) - assert.Equal(t, plaintext, got) - - // A different recipient key can't open it. - other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - _, err = decryptPSSOInboundJWE(jwe, other) - require.Error(t, err) - - // The typ is pinned: a JWE of another media type is rejected even with the - // right key, so a key/login response can't be replayed as a login assertion. - wrongTyp, err := buildPSSOResponseJWE(plaintext, &encKey.PublicKey, apv, pssoTypLoginResponse) - require.NoError(t, err) - _, err = decryptPSSOInboundJWE(wrongTyp, encKey) - require.ErrorContains(t, err, "unexpected typ") -} - -// TestPSSO_ParseEmbeddedAssertionPassword confirms the password is extracted -// whether the decrypted assertion is a compact JWT (Apple's +jwt typ) or a bare -// JSON claims object. The username is read from the signed outer JWT elsewhere, -// not from the assertion. -func TestPSSO_ParseEmbeddedAssertionPassword(t *testing.T) { - t.Run("compact JWT", func(t *testing.T) { - header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) - body := base64.RawURLEncoding.EncodeToString([]byte(`{"password":"pw","username":"alice"}`)) - password, err := parseEmbeddedAssertionPassword([]byte(header + "." + body + ".")) - require.NoError(t, err) - assert.Equal(t, "pw", password) - }) - - t.Run("bare JSON object", func(t *testing.T) { - password, err := parseEmbeddedAssertionPassword([]byte(`{"password":"pw2","username":"bob"}`)) - require.NoError(t, err) - assert.Equal(t, "pw2", password) - }) - - t.Run("neither JSON nor JWT is rejected", func(t *testing.T) { - _, err := parseEmbeddedAssertionPassword([]byte("not-json-not-jwt")) - require.Error(t, err) - }) -} - // TestPSSO_ResolveLoginPassword confirms the password is taken from the // plaintext claim when present, and decrypted out of the embedded assertion // (using Fleet's stored encryption key) when password encryption is enabled. @@ -549,49 +296,23 @@ func TestPSSO_ResolveLoginPassword(t *testing.T) { } t.Run("plaintext password claim", func(t *testing.T) { - pw, err := newSvc().resolvePSSOLoginPassword(t.Context(), &pssoTokenClaims{Password: "plain"}) + pw, err := newSvc().resolvePSSOLoginPassword(t.Context(), &pssocrypto.TokenClaims{Password: "plain"}) require.NoError(t, err) assert.Equal(t, "plain", pw) }) t.Run("encrypted embedded assertion", func(t *testing.T) { - apv := testAPV(t, encKey) + apv, err := pssocrypto.BuildAPV(&encKey.PublicKey, []byte("nonce")) + require.NoError(t, err) inner := []byte(`{"password":"secret","username":"carol"}`) - jwe, err := buildPSSOResponseJWE(inner, &encKey.PublicKey, apv, pssoTypEncryptedLoginAssertion) + jwe, err := pssocrypto.BuildPartyInfoJWE(inner, &encKey.PublicKey, apv, pssocrypto.TypEncryptedLoginAssertion) require.NoError(t, err) - pw, err := newSvc().resolvePSSOLoginPassword(t.Context(), &pssoTokenClaims{ - GrantType: pssoGrantTypeJWTBearer, + pw, err := newSvc().resolvePSSOLoginPassword(t.Context(), &pssocrypto.TokenClaims{ + GrantType: pssocrypto.GrantTypeJWTBearer, Assertion: string(jwe), }) require.NoError(t, err) assert.Equal(t, "secret", pw) }) } - -// TestPSSO_ParseRawECPointPEM covers the form the macOS extension actually -// sends: a raw ANSI X9.63 uncompressed point (0x04 || X || Y) PEM-wrapped -// under a "PUBLIC KEY" label rather than DER SubjectPublicKeyInfo. -func TestPSSO_ParseRawECPointPEM(t *testing.T) { - priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) - require.NoError(t, err) - - // SecKeyCopyExternalRepresentation's raw-point equivalent. - ecdhPub, err := priv.PublicKey.ECDH() - require.NoError(t, err) - rawPoint := ecdhPub.Bytes() - require.Len(t, rawPoint, 65) - require.Equal(t, byte(0x04), rawPoint[0]) - - pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rawPoint}) - got, err := parseECPublicKeyPEM(pemBytes) - require.NoError(t, err) - gotECDH, err := got.ECDH() - require.NoError(t, err) - assert.Equal(t, rawPoint, gotECDH.Bytes()) - - // Garbage inside a valid PEM block is neither SPKI nor a valid point. - bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("nope")}) - _, err = parseECPublicKeyPEM(bad) - require.Error(t, err) -} diff --git a/pkg/mdm/mdmtest/psso.go b/pkg/mdm/mdmtest/psso.go new file mode 100644 index 00000000000..473ba9d5a28 --- /dev/null +++ b/pkg/mdm/mdmtest/psso.go @@ -0,0 +1,641 @@ +package mdmtest + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/fleetdm/fleet/v4/pkg/fleethttp" + "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/pssocrypto" + "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" + jose "github.com/go-jose/go-jose/v3" + jwt "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + micromdm "github.com/micromdm/micromdm/mdm/mdm" + "github.com/micromdm/plist" + "github.com/smallstep/pkcs7" +) + +// HTTP paths for the Apple Platform SSO (PSSO) endpoints. These mirror the +// constants in server/service/apple_psso.go; the integration test and any +// load test exercise them against the real server, so drift fails fast. +const ( + pssoNoncePath = "/api/mdm/apple/psso/nonce" + pssoRegistrationPath = "/api/mdm/apple/psso/registration" + pssoTokenPath = "/api/mdm/apple/psso/token" //nolint:gosec // G101 false positive, this is a URL path + pssoJWKSPath = "/api/mdm/apple/psso/jwks" + pssoAASAPath = "/.well-known/apple-app-site-association" +) + +// TestApplePSSODevice simulates the macOS side of Apple Platform SSO against a +// Fleet server: device registration, password login (against the proxied IdP), +// the offline-unlock key request and key exchange, plus validating Fleet's +// minted id_token against the published JWKS. +// +// It is the device half of the PSSO exchange and shares all wire-format crypto +// with the server via server/mdm/apple/psso/pssocrypto, so the two halves can't +// drift. It speaks real HTTP, so the same client drives both the in-process +// integration tests (httptest server) and load testing in osquery-perf (a +// remote server). A device is composed onto a TestAppleMDMClient: it reuses that +// client's device UUID (which the registration token is bound to) and reads the +// PSSO profile out of the InstallProfile command the MDM server delivers. +type TestApplePSSODevice struct { + // mdm is the enrolled MDM client this PSSO device rides on. Its UUID is the + // host the registration token is bound to. + mdm *TestAppleMDMClient + + // serverURL is the Fleet base URL the PSSO endpoints hang off of. + serverURL string + httpClient *http.Client + + // clientID is the IdP/extension client ID, sent as the assertion issuer and + // echoed by Fleet as the id_token audience. + clientID string + + // The device's Secure Enclave-equivalent keypairs and their kids (base64url + // SHA-256 of the raw public point, matching how the real extension registers). + signingKey *ecdsa.PrivateKey + encryptionKey *ecdsa.PrivateKey + signingKID string + encryptionKID string + + // registrationToken is the Fleet-signed JWT delivered in the PSSO profile; + // set via RegistrationTokenFromCommand or SetRegistrationToken. + registrationToken string + + // keyContext and provisionedPub are captured from a key request so the + // following key exchange can echo the context and independently verify the + // returned shared secret. + keyContext string + provisionedPub *ecdsa.PublicKey + + // username and refreshToken remember the most recent login identity so later + // requests (key request/exchange) carry the same sub/username/refresh_token a + // real extension would, making the traffic a closer facsimile. + username string + refreshToken string +} + +// PSSOLoginOptions tunes a Login call. The zero value is a plaintext-password +// login with the device's registered signing key and a freshly fetched nonce. +type PSSOLoginOptions struct { + // EncryptOnWire models the extension's loginRequestEncryptionPublicKey + // behavior: the password is sealed in an embedded ECDH-ES JWE encrypted to + // Fleet's published encryption key instead of riding as a plaintext claim. + EncryptOnWire bool + + // SigningKeyOverride signs the outer assertion with this key instead of the + // device's registered signing key (the registered kid is kept). Used to + // exercise the "device authenticating with the wrong key" rejection. + SigningKeyOverride *ecdsa.PrivateKey + + // RequestNonceOverride uses this request_nonce verbatim instead of fetching a + // fresh one. Used to exercise nonce replay (single-use) rejection. + RequestNonceOverride string +} + +// PSSOLoginResult is the decrypted login response plus the material a test needs +// to validate it. +type PSSOLoginResult struct { + IDToken string + RefreshToken string + TokenType string + ExpiresIn int + // SessionNonce is the Apple session nonce the device sent; Fleet echoes it as + // the id_token `nonce` claim. + SessionNonce string + // RawAssertion is the signed outer JWS the device sent, so a caller can + // confirm e.g. that no plaintext password appears on the wire. + RawAssertion string + // RawResponse is the decrypted JWE plaintext (the OAuth token-response JSON). + RawResponse []byte +} + +// NewApplePSSODevice builds a PSSO device on top of an enrolled MDM client. It +// generates the device's signing and encryption keypairs. fleetServerURL is the +// Fleet base URL; clientID is the IdP/extension client ID used as the assertion +// issuer. +func NewApplePSSODevice(mdmClient *TestAppleMDMClient, fleetServerURL, clientID string) (*TestApplePSSODevice, error) { + signingKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate psso signing key: %w", err) + } + encryptionKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate psso encryption key: %w", err) + } + signingKID, err := pssocrypto.KIDFromRawECPoint(&signingKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("compute psso signing kid: %w", err) + } + encryptionKID, err := pssocrypto.KIDFromRawECPoint(&encryptionKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("compute psso encryption kid: %w", err) + } + return &TestApplePSSODevice{ + mdm: mdmClient, + serverURL: strings.TrimRight(fleetServerURL, "/"), + httpClient: fleethttp.NewClient(), + clientID: clientID, + signingKey: signingKey, + encryptionKey: encryptionKey, + signingKID: signingKID, + encryptionKID: encryptionKID, + }, nil +} + +// UUID is the device UUID (the enrolled MDM client's), which the registration +// token is bound to. +func (c *TestApplePSSODevice) UUID() string { return c.mdm.UUID } + +// SigningKID and EncryptionKID expose the registered key IDs for assertions. +func (c *TestApplePSSODevice) SigningKID() string { return c.signingKID } +func (c *TestApplePSSODevice) EncryptionKID() string { return c.encryptionKID } + +// SetRegistrationToken sets the registration token used by Register, e.g. to a +// deliberately invalid value for a negative test. +func (c *TestApplePSSODevice) SetRegistrationToken(token string) { c.registrationToken = token } + +// RegistrationTokenFromCommand extracts the substituted RegistrationToken from a +// delivered InstallProfile command (the PSSO extension payload) and stores it +// for the next Register call. This is the real path: the token reaches the +// device only inside the MDM-delivered profile. +func (c *TestApplePSSODevice) RegistrationTokenFromCommand(cmd *mdm.Command) (string, error) { + if cmd == nil || cmd.Command.RequestType != "InstallProfile" { + return "", fmt.Errorf("psso: expected an InstallProfile command, got %v", cmd) + } + var full micromdm.CommandPayload + if err := plist.Unmarshal(cmd.Raw, &full); err != nil { + return "", fmt.Errorf("psso: unmarshal install profile command: %w", err) + } + if full.Command.InstallProfile == nil { + return "", errors.New("psso: command has no InstallProfile payload") + } + raw := full.Command.InstallProfile.Payload + // The mobileconfig may be PKCS7-signed; unwrap to the raw XML plist. + if !bytes.HasPrefix(raw, []byte(" len(raw) { + return nil, errors.New("pssocrypto: truncated party-info length prefix") + } + n := int(binary.BigEndian.Uint32(raw[i:])) + i += 4 + if i+n > len(raw) { + return nil, errors.New("pssocrypto: party-info field overruns buffer") + } + fields = append(fields, raw[i:i+n]) + i += n + } + return fields, nil +} + +// BuildAPV returns the base64url-encoded apv (PartyVInfo) party-info blob the +// device sends in its jwe_crypto recipe: "Apple" || rawEncKeyPoint || nonce. +func BuildAPV(encPub *ecdsa.PublicKey, nonce []byte) (string, error) { + raw, err := RawECPoint(encPub) + if err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString( + EncodeApplePartyInfo([]byte(APVPartyLabel), raw, nonce)), nil +} + +// BuildPartyInfoJWE encrypts payload to the recipient's encryption public key as +// a compact JWE using ECDH-ES key agreement + A256GCM. typ is the JWE header +// media type — TypLoginResponse for login, TypKeyResponse for key/key-exchange +// responses, TypEncryptedLoginAssertion for the device's embedded password +// assertion. +// +// Apple's framework requires both apu and apv in the protected header and +// validates apu by recomputing it from the epk it sees. apv (PartyVInfo) is +// echoed verbatim from the request. apu (PartyUInfo) is built as +// "APPLE" || ephemeralPubKey — uppercase label, no nonce — per Apple's JWE +// login-response doc. +// +// The compact JWE is assembled by hand rather than via jose.NewEncrypter because +// go-jose's ECDH-ES key generator hardcodes empty apu/apv (see +// ecKeyGenerator.genKey) and exposes no way to set them. The Concat KDF itself +// is reused from go-jose's exported cipher package — no PSSO SDK is involved. +func BuildPartyInfoJWE(payload []byte, recipientPub *ecdsa.PublicKey, apvB64, typ string) ([]byte, error) { + apvRaw, err := DecodeJOSEB64(apvB64) + if err != nil { + return nil, fmt.Errorf("decode apv: %w", err) + } + + ephemeral, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate ephemeral key: %w", err) + } + epkECDH, err := ephemeral.PublicKey.ECDH() + if err != nil { + return nil, fmt.Errorf("pssocrypto: ephemeral key to ecdh: %w", err) + } + apuRaw := EncodeApplePartyInfo([]byte(APUPartyLabel), epkECDH.Bytes()) + + // ECDH-ES direct: the agreed key is the A256GCM content-encryption key, so + // the Concat KDF algorithm ID is the content-encryption alg ("A256GCM"). + cek := josecipher.DeriveECDHES(ContentEncryptionAlg, apuRaw, apvRaw, ephemeral, recipientPub, 32) + + epkJSON, err := json.Marshal(&jose.JSONWebKey{Key: &ephemeral.PublicKey}) + if err != nil { + return nil, fmt.Errorf("marshal epk: %w", err) + } + + // No cty: the decrypted payload is a JSON object (OAuth token response or + // key response), not a nested JWT. + header := map[string]any{ + "alg": EncryptionAlg, + "enc": ContentEncryptionAlg, + "epk": json.RawMessage(epkJSON), + "typ": typ, + "apu": base64.RawURLEncoding.EncodeToString(apuRaw), + "apv": strings.TrimRight(apvB64, "="), + } + protected, err := json.Marshal(header) + if err != nil { + return nil, fmt.Errorf("marshal protected header: %w", err) + } + protectedB64 := base64.RawURLEncoding.EncodeToString(protected) + + block, err := aes.NewCipher(cek) + if err != nil { + return nil, fmt.Errorf("aes new cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("aes-gcm: %w", err) + } + iv := make([]byte, gcm.NonceSize()) + if _, err := rand.Read(iv); err != nil { + return nil, fmt.Errorf("rand iv: %w", err) + } + // JWE AAD for compact serialization is the ASCII base64url protected header. + sealed := gcm.Seal(nil, iv, payload, []byte(protectedB64)) + ct := sealed[:len(sealed)-gcm.Overhead()] + tag := sealed[len(sealed)-gcm.Overhead():] + + enc := base64.RawURLEncoding.EncodeToString + // Compact JWE: protected.encrypted_key.iv.ciphertext.tag — encrypted_key is + // empty for ECDH-ES direct key agreement. + compact := protectedB64 + "." + "" + "." + enc(iv) + "." + enc(ct) + "." + enc(tag) + return []byte(compact), nil +} + +// DecryptPartyInfoJWE decrypts a compact ECDH-ES + A256GCM JWE built by +// BuildPartyInfoJWE, where recipientPriv is the static ECDH-ES recipient and the +// sender supplied the ephemeral epk in the header. go-jose reads epk/apu/apv from +// the protected header and runs the same Concat KDF to recover the +// content-encryption key. alg/enc are pinned; expectedTyp pins the JWE media +// type the caller requires (e.g. TypLoginResponse on the device, or +// TypEncryptedLoginAssertion on the server decrypting the embedded password). +func DecryptPartyInfoJWE(compact []byte, recipientPriv *ecdsa.PrivateKey, expectedTyp string) ([]byte, error) { + protectedB64, _, ok := strings.Cut(string(compact), ".") + if !ok { + return nil, errors.New("pssocrypto: not a compact JWE") + } + protected, err := DecodeJOSEB64(protectedB64) + if err != nil { + return nil, fmt.Errorf("pssocrypto: decode protected header: %w", err) + } + var hdr struct { + Alg string `json:"alg"` + Enc string `json:"enc"` + Typ string `json:"typ"` + } + if err := json.Unmarshal(protected, &hdr); err != nil { + return nil, fmt.Errorf("pssocrypto: parse protected header: %w", err) + } + if hdr.Alg != EncryptionAlg || hdr.Enc != ContentEncryptionAlg { + return nil, fmt.Errorf("pssocrypto: unsupported alg/enc %q/%q", hdr.Alg, hdr.Enc) + } + if expectedTyp != "" && hdr.Typ != expectedTyp { + return nil, fmt.Errorf("pssocrypto: unexpected typ %q", hdr.Typ) + } + + obj, err := jose.ParseEncrypted(string(compact)) + if err != nil { + return nil, fmt.Errorf("pssocrypto: parse jwe: %w", err) + } + plaintext, err := obj.Decrypt(recipientPriv) + if err != nil { + return nil, fmt.Errorf("pssocrypto: decrypt jwe: %w", err) + } + return plaintext, nil +} + +// BuildEmbeddedAssertionPlaintext returns the JSON plaintext a device encrypts +// into the embedded login assertion when password encryption is enabled. The +// username is taken from the signed outer JWT, not here, so only the password is +// carried. It is the inverse of ParseEmbeddedAssertionPassword. +func BuildEmbeddedAssertionPlaintext(password string) ([]byte, error) { + return json.Marshal(map[string]string{"password": password}) +} + +// ParseEmbeddedAssertionPassword pulls the password out of a decrypted embedded +// login assertion. Apple's typ ends in "+jwt", so the plaintext is a JWT whose +// claims carry the password; a bare JSON claims object is also accepted. The +// username is taken from the signed outer JWT, not here. The assertion is +// encrypted-only — its integrity is covered by the outer signed JWT and the JWE +// GCM tag, so no inner signature is verified here. +func ParseEmbeddedAssertionPassword(plaintext []byte) (string, error) { + s := strings.TrimSpace(string(plaintext)) + claimsJSON := []byte(s) + if len(s) > 0 && s[0] != '{' { + // Compact JWT: header.payload[.signature]; the claims are the payload. + parts := strings.Split(s, ".") + if len(parts) < 2 { + return "", errors.New("pssocrypto: embedded assertion is not JSON or a compact JWT") + } + decoded, derr := base64.RawURLEncoding.DecodeString(strings.TrimRight(parts[1], "=")) + if derr != nil { + return "", fmt.Errorf("pssocrypto: decode embedded assertion claims segment: %w", derr) + } + claimsJSON = decoded + } + var claims struct { + Password string `json:"password"` + } + if err := json.Unmarshal(claimsJSON, &claims); err != nil { + return "", fmt.Errorf("pssocrypto: parse embedded assertion claims: %w", err) + } + return claims.Password, nil +} + +// DecodeJOSEB64 base64url-decodes a JOSE value, tolerating optional padding. +func DecodeJOSEB64(s string) ([]byte, error) { + if s == "" { + return nil, nil + } + return base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")) +} + +// DecodeBase64Flexible decodes standard or url base64, with or without padding — +// the device sends other_publickey as padded standard base64. +func DecodeBase64Flexible(s string) ([]byte, error) { + for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { + if b, err := enc.DecodeString(s); err == nil { + return b, nil + } + } + return nil, errors.New("pssocrypto: value is not valid base64") +} + +// ComputeECDHShared returns the raw ECDH shared secret (P-256 X coordinate, 32 +// bytes) between priv and the uncompressed peer public point — the key field of +// a key-exchange response. +func ComputeECDHShared(priv *ecdsa.PrivateKey, peerRaw []byte) ([]byte, error) { + ecdhPriv, err := priv.ECDH() + if err != nil { + return nil, fmt.Errorf("pssocrypto: private key to ecdh: %w", err) + } + peer, err := ecdh.P256().NewPublicKey(peerRaw) + if err != nil { + return nil, fmt.Errorf("pssocrypto: parse peer public key: %w", err) + } + return ecdhPriv.ECDH(peer) +} diff --git a/server/mdm/apple/psso/pssocrypto/pssocrypto_test.go b/server/mdm/apple/psso/pssocrypto/pssocrypto_test.go new file mode 100644 index 00000000000..a80b7945244 --- /dev/null +++ b/server/mdm/apple/psso/pssocrypto/pssocrypto_test.go @@ -0,0 +1,297 @@ +package pssocrypto + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "encoding/base64" + "encoding/pem" + "testing" + "time" + + jose "github.com/go-jose/go-jose/v3" + jwt "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// buildAPV is a test helper mirroring what a PSSO client sends: an Apple-shaped +// apv ("Apple" || encKey || nonce), base64url-encoded. +func buildAPV(t *testing.T, key *ecdsa.PrivateKey) string { + t.Helper() + apv, err := BuildAPV(&key.PublicKey, []byte("3B94D3F7-5907-44C2-B6AF-05A0B0017669")) + require.NoError(t, err) + return apv +} + +// TestAsymmetricEncryptRoundTrip confirms that a payload encrypted to a device's +// encryption pubkey via JWE ECDH-ES + A256GCM produces a valid compact JWE. +func TestAsymmetricEncryptRoundTrip(t *testing.T) { + deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + payload := []byte(`{"claims":"AAECAwQF"}`) + jweCompact, err := BuildAsymmetricJWE(payload, &deviceKey.PublicKey, "") + require.NoError(t, err) + require.NotEmpty(t, jweCompact) + + // JWE compact form has 5 base64url segments separated by dots; smoke check we + // got something of that shape rather than re-implementing the whole decrypt + // path here (the JOSE library is well-tested upstream). + dots := 0 + for _, b := range jweCompact { + if b == '.' { + dots++ + } + } + assert.Equal(t, 4, dots, "expected JWE compact form with 4 dots") +} + +// TestLoginResponseJWERoundTrip confirms the hand-assembled PSSO login response +// JWE decrypts back to the original payload using the device's encryption +// private key. Decrypting via go-jose (which reads apu/apv from the protected +// header and feeds them to the same Concat KDF) proves both the compact wire +// format and the apv party-info binding are correct: a wrong apv would derive a +// different content-encryption key and fail the GCM tag. +func TestLoginResponseJWERoundTrip(t *testing.T) { + deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + apv := buildAPV(t, deviceKey) + payload := []byte(`{"id_token":"x","refresh_token":"y"}`) + + jweCompact, err := BuildPartyInfoJWE(payload, &deviceKey.PublicKey, apv, TypLoginResponse) + require.NoError(t, err) + require.NotEmpty(t, jweCompact) + + parsed, err := jose.ParseEncrypted(string(jweCompact)) + require.NoError(t, err) + got, err := parsed.Decrypt(deviceKey) + require.NoError(t, err) + assert.Equal(t, payload, got) + + // Per Apple's doc, apu is "APPLE" (uppercase) || ephemeral epk, with NO nonce + // — distinct from apv's "Apple" || key || nonce framing. + hdr := parsed.Header + apuB64, ok := hdr.ExtraHeaders[jose.HeaderKey("apu")].(string) + require.True(t, ok, "apu header must be present") + apuRaw, err := base64.RawURLEncoding.DecodeString(apuB64) + require.NoError(t, err) + apuFields, err := ParseApplePartyInfo(apuRaw) + require.NoError(t, err) + require.Len(t, apuFields, 2, "apu is exactly [label, epk] — no nonce") + assert.Equal(t, APUPartyLabel, string(apuFields[0])) + assert.Equal(t, byte(0x04), apuFields[1][0], "apu field 2 is the uncompressed epk") +} + +// TestLoginResponseJWEWrongKeyFails confirms the JWE can't be decrypted with a +// key other than the intended device key. +func TestLoginResponseJWEWrongKeyFails(t *testing.T) { + deviceKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + otherKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + apv := buildAPV(t, deviceKey) + jweCompact, err := BuildPartyInfoJWE([]byte("secret"), &deviceKey.PublicKey, apv, TypLoginResponse) + require.NoError(t, err) + + parsed, err := jose.ParseEncrypted(string(jweCompact)) + require.NoError(t, err) + _, err = parsed.Decrypt(otherKey) + require.Error(t, err) +} + +// TestKeyExchangeSharedSecretMatches confirms the unlock-key DH is symmetric: +// the server's ECDH(provisioned_priv, device_pub) equals the device's +// ECDH(device_priv, provisioned_pub). +func TestKeyExchangeSharedSecretMatches(t *testing.T) { + provisioned, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + deviceDH, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // Server side: what ComputeECDHShared does, against the device's public. + deviceECDH, err := deviceDH.PublicKey.ECDH() + require.NoError(t, err) + serverShared, err := ComputeECDHShared(provisioned, deviceECDH.Bytes()) + require.NoError(t, err) + require.Len(t, serverShared, 32) + + // Device side: ECDH(device_priv, provisioned_pub) — must match. + provECDH, err := provisioned.PublicKey.ECDH() + require.NoError(t, err) + devPriv, err := deviceDH.ECDH() + require.NoError(t, err) + deviceShared, err := devPriv.ECDH(provECDH) + require.NoError(t, err) + assert.Equal(t, deviceShared, serverShared) +} + +// TestTokenClaimsLeeway confirms inbound JWT time claims tolerate small clock +// skew between the Mac and the server: an iat slightly in the future (Mac clock +// ahead) or an exp slightly in the past must not fail validation, while skew +// beyond the leeway still does. +func TestTokenClaimsLeeway(t *testing.T) { + now := time.Now() + claimsAt := func(iat, exp time.Time) *TokenClaims { + return &TokenClaims{RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(iat), + ExpiresAt: jwt.NewNumericDate(exp), + }} + } + + // In sync: valid. + require.NoError(t, claimsAt(now, now.Add(5*time.Minute)).Valid()) + + // Mac clock slightly ahead: iat in the (server's) future, within leeway. + require.NoError(t, claimsAt(now.Add(30*time.Second), now.Add(5*time.Minute)).Valid()) + + // exp just passed, within leeway. + require.NoError(t, claimsAt(now.Add(-5*time.Minute), now.Add(-30*time.Second)).Valid()) + + // Beyond leeway both ways. + err := claimsAt(now.Add(JWTLeeway+time.Minute), now.Add(10*time.Minute)).Valid() + require.ErrorIs(t, err, jwt.ErrTokenUsedBeforeIssued) + err = claimsAt(now.Add(-10*time.Minute), now.Add(-JWTLeeway-time.Minute)).Valid() + require.ErrorIs(t, err, jwt.ErrTokenExpired) + + // Absent time claims are not required (registration-era JWTs). + require.NoError(t, (&TokenClaims{}).Valid()) +} + +// TestCanonicalizeKID confirms the padded base64 kid Apple's framework sends in +// the JWT header and the unpadded base64url kid the extension registers collapse +// to the same value, so device lookup by kid succeeds. +func TestCanonicalizeKID(t *testing.T) { + // Real values from a live device: register sends no padding, the JWT header + // kid carries '='. + registered := "Yk8ghfYYyiUzsp0tcfVFn4TJUu0B45fzUnmonZZILZE" + jwtKID := "Yk8ghfYYyiUzsp0tcfVFn4TJUu0B45fzUnmonZZILZE=" + assert.Equal(t, CanonicalizeKID(registered), CanonicalizeKID(jwtKID)) + + // 32 random bytes encoded every which way must all canonicalize equal. + raw := make([]byte, 32) + _, err := rand.Read(raw) + require.NoError(t, err) + variants := []string{ + base64.RawURLEncoding.EncodeToString(raw), + base64.URLEncoding.EncodeToString(raw), + base64.RawStdEncoding.EncodeToString(raw), + base64.StdEncoding.EncodeToString(raw), + } + want := CanonicalizeKID(variants[0]) + for _, v := range variants { + assert.Equal(t, want, CanonicalizeKID(v), "variant %q", v) + } + + // A non-base64 value is returned unchanged rather than mangled. + assert.Equal(t, "not base64 at all!!", CanonicalizeKID("not base64 at all!!")) +} + +// TestParseECPublicKey covers both PEM forms we accept on inbound key material +// from the extension. +func TestParseECPublicKey(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + der, err := x509.MarshalPKIXPublicKey(&priv.PublicKey) + require.NoError(t, err) + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + + got, err := ParseECPublicKeyPEM(pemBytes) + require.NoError(t, err) + gotDER, err := x509.MarshalPKIXPublicKey(got) + require.NoError(t, err) + assert.Equal(t, der, gotDER) + + _, err = ParseECPublicKeyPEM([]byte("not a pem block")) + require.Error(t, err) +} + +// TestParseRawECPointPEM covers the form the macOS extension actually sends: a +// raw ANSI X9.63 uncompressed point (0x04 || X || Y) PEM-wrapped under a "PUBLIC +// KEY" label rather than DER SubjectPublicKeyInfo. +func TestParseRawECPointPEM(t *testing.T) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + // SecKeyCopyExternalRepresentation's raw-point equivalent. + rawPoint, err := RawECPoint(&priv.PublicKey) + require.NoError(t, err) + require.Len(t, rawPoint, 65) + require.Equal(t, byte(0x04), rawPoint[0]) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: rawPoint}) + got, err := ParseECPublicKeyPEM(pemBytes) + require.NoError(t, err) + gotRaw, err := RawECPoint(got) + require.NoError(t, err) + assert.Equal(t, rawPoint, gotRaw) + + // Garbage inside a valid PEM block is neither SPKI nor a valid point. + bad := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: []byte("nope")}) + _, err = ParseECPublicKeyPEM(bad) + require.Error(t, err) +} + +// TestInboundAssertionDecryptRoundTrip confirms a party-info JWE built by one +// side decrypts on the other, and that the typ header is pinned so a response of +// one media type can't be replayed as another. +func TestInboundAssertionDecryptRoundTrip(t *testing.T) { + encKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + apv := buildAPV(t, encKey) + plaintext := []byte(`{"password":"hunter2","username":"foo"}`) + + jwe, err := BuildPartyInfoJWE(plaintext, &encKey.PublicKey, apv, TypEncryptedLoginAssertion) + require.NoError(t, err) + + got, err := DecryptPartyInfoJWE(jwe, encKey, TypEncryptedLoginAssertion) + require.NoError(t, err) + assert.Equal(t, plaintext, got) + + // A different recipient key can't open it. + other, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + _, err = DecryptPartyInfoJWE(jwe, other, TypEncryptedLoginAssertion) + require.Error(t, err) + + // The typ is pinned: a JWE of another media type is rejected even with the + // right key, so a key/login response can't be replayed as a login assertion. + wrongTyp, err := BuildPartyInfoJWE(plaintext, &encKey.PublicKey, apv, TypLoginResponse) + require.NoError(t, err) + _, err = DecryptPartyInfoJWE(wrongTyp, encKey, TypEncryptedLoginAssertion) + require.ErrorContains(t, err, "unexpected typ") +} + +// TestEmbeddedAssertionPasswordRoundTrip confirms the password survives a build +// → parse round trip, and that the parser also accepts a compact JWT and rejects +// non-JSON/JWT input. The username is read from the signed outer JWT elsewhere. +func TestEmbeddedAssertionPasswordRoundTrip(t *testing.T) { + plaintext, err := BuildEmbeddedAssertionPlaintext("hunter2") + require.NoError(t, err) + pw, err := ParseEmbeddedAssertionPassword(plaintext) + require.NoError(t, err) + assert.Equal(t, "hunter2", pw) + + t.Run("compact JWT", func(t *testing.T) { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + body := base64.RawURLEncoding.EncodeToString([]byte(`{"password":"pw","username":"alice"}`)) + password, err := ParseEmbeddedAssertionPassword([]byte(header + "." + body + ".")) + require.NoError(t, err) + assert.Equal(t, "pw", password) + }) + + t.Run("bare JSON object", func(t *testing.T) { + password, err := ParseEmbeddedAssertionPassword([]byte(`{"password":"pw2","username":"bob"}`)) + require.NoError(t, err) + assert.Equal(t, "pw2", password) + }) + + t.Run("neither JSON nor JWT is rejected", func(t *testing.T) { + _, err := ParseEmbeddedAssertionPassword([]byte("not-json-not-jwt")) + require.Error(t, err) + }) +} diff --git a/server/service/integration_mdm_apple_psso_test.go b/server/service/integration_mdm_apple_psso_test.go new file mode 100644 index 00000000000..e35c8f96d78 --- /dev/null +++ b/server/service/integration_mdm_apple_psso_test.go @@ -0,0 +1,407 @@ +package service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/fleetdm/fleet/v4/pkg/mdm/mdmtest" + "github.com/fleetdm/fleet/v4/pkg/optjson" + "github.com/fleetdm/fleet/v4/server/fleet" + "github.com/fleetdm/fleet/v4/server/mdm/apple/psso/regtoken" + jwt "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/require" +) + +// pssoMockIdP is a stand-in OAuth2 ROPG (Resource Owner Password Grant) token +// endpoint. Fleet's PSSO login flow POSTs grant_type=password here and reads the +// user's claims out of the returned id_token, which it does not signature-verify +// (it trusts the direct TLS channel), so the token can be signed with any key. +type pssoMockIdP struct { + mu sync.Mutex + lastForm url.Values + + validUser, validPass string + // idToken returns the id_token JSON value for a successful login. + idToken func() string +} + +func (m *pssoMockIdP) handler() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + m.mu.Lock() + m.lastForm = r.PostForm + m.mu.Unlock() + + if r.FormValue("grant_type") != "password" { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "unsupported_grant_type"}) + return + } + if r.FormValue("username") != m.validUser || r.FormValue("password") != m.validPass { + w.WriteHeader(http.StatusUnauthorized) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant", "error_description": "bad credentials"}) + return + } + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ //nolint:gosec // G101: opaque test fixture refresh_token, not a real credential + "id_token": m.idToken(), + "refresh_token": "idp-refresh-token", + "token_type": "Bearer", + "expires_in": 3600, + } + _ = json.NewEncoder(w).Encode(resp) + } +} + +func (m *pssoMockIdP) lastGrantType() string { + m.mu.Lock() + defer m.mu.Unlock() + return m.lastForm.Get("grant_type") +} + +// TestApplePlatformSSO drives the full Apple Platform SSO server flow end to end +// with the reusable mdmtest device simulator: profile upload, MDM delivery of +// the (substituted) registration token, device registration, password login +// against a mocked IdP (plaintext and encrypted-on-the-wire), TokenToUserMapping +// claim forwarding, the offline-unlock key request/exchange, and the required +// error cases. The minted id_token is validated against Fleet's published JWKS. +func (s *integrationMDMTestSuite) TestApplePlatformSSO() { + t := s.T() + ctx := context.Background() + + const ( + clientID = "test-psso-client-id" + validUser = "fleetie@example.com" + validPass = "correct-horse-battery-staple" + idpSub = "00ufleetiesubject" + shortName = "fleetie" + fullName = "Fleetie Example" + ) + + // Mocked OAuth ROPG IdP. The id_token carries an "accountName" custom claim + // (the account* prefix is what Fleet forwards into the minted id_token for the + // profile's TokenToUserMapping to map to the macOS short name). + idp := &pssoMockIdP{ + validUser: validUser, + validPass: validPass, + idToken: func() string { + return mockOIDCIDToken(t, jwt.MapClaims{ + "sub": idpSub, + "email": validUser, + "name": fullName, + "preferred_username": shortName, + "accountName": shortName, + }) + }, + } + idpSrv := httptest.NewServer(idp.handler()) + t.Cleanup(idpSrv.Close) + + serverHost, err := url.Parse(s.server.URL) + require.NoError(t, err) + + // Enroll a Mac in MDM and build a PSSO device on top of it. Enrollment + // enqueues the post-enroll worker job, which must run before profiles flow. + host, mdmDevice := createHostThenEnrollMDM(s.ds, s.server.URL, t) + s.awaitRunAppleMDMWorkerSchedule() + dev, err := mdmtest.NewApplePSSODevice(mdmDevice, s.server.URL, clientID) + require.NoError(t, err) + + // Before the feature is configured, the public PSSO endpoints are 404 so they + // are indistinguishable from absent. + status, _, err := dev.JWKSResponse() + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, status) + status, _, err = dev.AASA() + require.NoError(t, err) + require.Equal(t, http.StatusNotFound, status) + + s.enableApplePSSO(t, idpSrv.URL+"/token", clientID, "test-client-secret") + + // Configured: JWKS publishes a signing and an encryption key; AASA lists the + // extension. + sigPub, encPub, err := dev.JWKS() + require.NoError(t, err) + require.NotNil(t, sigPub) + require.NotNil(t, encPub) + status, aasaBody, err := dev.AASA() + require.NoError(t, err) + require.Equal(t, http.StatusOK, status) + require.Contains(t, string(aasaBody), "com.fleetdm.fleet-desktop.pssoextension") + + // Registration must present a valid Fleet-signed token bound to this host. + t.Run("registration requires a valid token", func(t *testing.T) { + dev.SetRegistrationToken("") + require.Error(t, dev.Register(), "empty token must be rejected") + + dev.SetRegistrationToken("not-a-jwt") + require.Error(t, dev.Register(), "garbage token must be rejected") + + wrongKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + forged, err := regtoken.Mint(wrongKey, host.UUID, time.Now()) + require.NoError(t, err) + dev.SetRegistrationToken(forged) + require.Error(t, dev.Register(), "token signed by a non-Fleet key must be rejected") + + gotDev, err := s.ds.GetPSSODevice(ctx, host.UUID) + require.True(t, err != nil || gotDev == nil, "no device should be registered after failures") + }) + + // Upload the PSSO profile, reconcile, and have the device pull the substituted + // registration token out of the delivered InstallProfile command. + s.uploadApplePSSOProfile(serverHost.Host) + // The per-host profile-processing key debounces reconciliation; clear it so + // the next schedule run delivers the newly uploaded profile. + require.NoError(t, s.keyValueStore.Delete(ctx, fleet.MDMProfileProcessingKeyPrefix+":"+mdmDevice.UUID)) + s.awaitTriggerProfileSchedule(t) + + regToken := s.deliverApplePSSORegToken(t, mdmDevice, dev) + require.NotEmpty(t, regToken) + + require.NoError(t, dev.Register()) + pssoDevice, err := s.ds.GetPSSODevice(ctx, host.UUID) + require.NoError(t, err) + require.NotNil(t, pssoDevice) + keys, err := s.ds.ListPSSOKeys(ctx, host.UUID) + require.NoError(t, err) + require.Len(t, keys, 2, "a signing and an encryption key are registered") + + t.Run("password login and id_token validated against jwks", func(t *testing.T) { + res, err := dev.Login(validUser, validPass, mdmtest.PSSOLoginOptions{}) + require.NoError(t, err) + require.NotEmpty(t, res.IDToken) + require.Equal(t, "password", idp.lastGrantType()) + + // The plaintext password rode in the (signed) assertion claims. + assertion := decodeJWSClaims(t, res.RawAssertion) + require.Equal(t, validPass, assertion["password"]) + require.Equal(t, "password", assertion["grant_type"]) + + // The device validates the response id_token against Fleet's published JWKS. + claims, err := dev.ValidateIDToken(res.IDToken) + require.NoError(t, err) + require.Equal(t, clientID, claims["aud"]) + require.Equal(t, res.SessionNonce, claims["nonce"]) + require.Equal(t, serverHost.Hostname(), claims["iss"]) + require.Equal(t, idpSub, claims["sub"]) + require.Equal(t, validUser, claims["email"]) + require.Equal(t, fullName, claims["name"]) + require.Equal(t, shortName, claims["preferred_username"]) + // TokenToUserMapping: the account-prefixed claim is forwarded so the + // profile can map the macOS short name to it. + require.Equal(t, shortName, claims["accountName"]) + }) + + t.Run("password encrypted on the wire", func(t *testing.T) { + res, err := dev.Login(validUser, validPass, mdmtest.PSSOLoginOptions{EncryptOnWire: true}) + require.NoError(t, err) + + // No plaintext password on the wire — it rides inside an encrypted assertion. + assertion := decodeJWSClaims(t, res.RawAssertion) + require.NotContains(t, assertion, "password") + require.NotEmpty(t, assertion["assertion"]) + require.Equal(t, "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion["grant_type"]) + + claims, err := dev.ValidateIDToken(res.IDToken) + require.NoError(t, err) + require.Equal(t, idpSub, claims["sub"]) + }) + + t.Run("key request and key exchange", func(t *testing.T) { + certDER, err := dev.KeyRequest() + require.NoError(t, err) + require.NotEmpty(t, certDER) + + // KeyExchange independently recomputes the ECDH against the provisioned + // certificate's key and fails if it doesn't match the server's secret. + shared, err := dev.KeyExchange() + require.NoError(t, err) + require.Len(t, shared, 32) + }) + + t.Run("invalid IdP credentials are rejected", func(t *testing.T) { + _, err := dev.Login(validUser, "wrong-password", mdmtest.PSSOLoginOptions{}) + require.Error(t, err) + }) + + t.Run("assertion signed with the wrong key is rejected", func(t *testing.T) { + wrongKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + _, err = dev.Login(validUser, validPass, mdmtest.PSSOLoginOptions{SigningKeyOverride: wrongKey}) + require.Error(t, err) + }) + + t.Run("request_nonce is single-use", func(t *testing.T) { + nonce, err := dev.Nonce() + require.NoError(t, err) + _, err = dev.Login(validUser, validPass, mdmtest.PSSOLoginOptions{RequestNonceOverride: nonce}) + require.NoError(t, err) + _, err = dev.Login(validUser, validPass, mdmtest.PSSOLoginOptions{RequestNonceOverride: nonce}) + require.Error(t, err, "replaying a consumed nonce must be rejected") + }) +} + +// enableApplePSSO configures the macOS account-provisioning (Platform SSO) +// feature: it points the IdP at the mock token URL, stores the client secret, +// and bootstraps Fleet's PSSO signing/CA/encryption assets. The config is set +// directly (not via the API) so the mock IdP can use a plain-http URL — the +// API's https validation is covered by the appconfig tests. State is restored on +// cleanup so the shared suite isn't left with the feature enabled. +func (s *integrationMDMTestSuite) enableApplePSSO(t *testing.T, tokenURL, clientID, secret string) { + ctx := context.Background() + appCfg, err := s.ds.AppConfig(ctx) + require.NoError(t, err) + orig := appCfg.MDM.AppleAccountProvisioning + + appCfg.MDM.AppleAccountProvisioning = fleet.AppleAccountProvisioning{ + OAuthIdPTokenURL: optjson.SetString(tokenURL), + OAuthIdPClientID: optjson.SetString(clientID), + } + require.NoError(t, s.ds.SaveAppConfig(ctx, appCfg)) + require.NoError(t, s.ds.InsertMDMConfigAssets(ctx, []fleet.MDMConfigAsset{ + {Name: fleet.MDMAssetAppleAccountProvisioningIdPClientSecret, Value: []byte(secret)}, + }, nil)) + require.NoError(t, bootstrapPSSOAssets(ctx, s.ds)) + + t.Cleanup(func() { + appCfg, err := s.ds.AppConfig(context.Background()) + if err != nil { + return + } + appCfg.MDM.AppleAccountProvisioning = orig + _ = s.ds.SaveAppConfig(context.Background(), appCfg) + }) +} + +// uploadApplePSSOProfile uploads the Fleet Platform SSO configuration profile +// (carrying the $FLEET_VAR_PSSO_DEVICE_REGISTRATION_TOKEN variable) as a no-team +// profile so it reconciles onto the enrolled host. +func (s *integrationMDMTestSuite) uploadApplePSSOProfile(serverHost string) { + profile := strings.ReplaceAll(applePSSOProfileTemplate, "fleet.example.com", serverHost) + s.Do("POST", "/api/latest/fleet/mdm/profiles/batch", batchSetMDMProfilesRequest{ + Profiles: []fleet.MDMProfileBatchPayload{ + {Name: "Fleet Platform SSO", Contents: []byte(profile)}, + }, + }, http.StatusNoContent) +} + +// deliverApplePSSORegToken drains the host's pending MDM commands and extracts +// the substituted registration token from the delivered PSSO InstallProfile. +func (s *integrationMDMTestSuite) deliverApplePSSORegToken(t *testing.T, mdmDevice *mdmtest.TestAppleMDMClient, dev *mdmtest.TestApplePSSODevice) string { + var token string + cmd, err := mdmDevice.Idle() + require.NoError(t, err) + for cmd != nil { + if cmd.Command.RequestType == "InstallProfile" { + if tok, terr := dev.RegistrationTokenFromCommand(cmd); terr == nil && tok != "" { + token = tok + } + } + cmd, err = mdmDevice.Acknowledge(cmd.CommandUUID) + require.NoError(t, err) + } + require.NotEmpty(t, token, "PSSO registration token was not delivered in an InstallProfile") + return token +} + +// mockOIDCIDToken builds an id_token JWT the mock IdP returns. Fleet reads the +// claims without verifying the signature, so any signing key works. +func mockOIDCIDToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + claims["iat"] = time.Now().Unix() + claims["exp"] = time.Now().Add(time.Hour).Unix() + signed, err := jwt.NewWithClaims(jwt.SigningMethodES256, claims).SignedString(key) + require.NoError(t, err) + return signed +} + +// decodeJWSClaims decodes the claims segment of a compact JWS without verifying +// it, for asserting on what the device put on the wire. +func decodeJWSClaims(t *testing.T, compact string) map[string]any { + t.Helper() + parts := strings.Split(compact, ".") + require.Len(t, parts, 3) + raw, err := base64.RawURLEncoding.DecodeString(parts[1]) + require.NoError(t, err) + var claims map[string]any + require.NoError(t, json.Unmarshal(raw, &claims)) + return claims +} + +// applePSSOProfileTemplate is a Fleet Platform SSO v2 (com.apple.extensiblesso, +// UseSharedDeviceKeys) configuration profile whose RegistrationToken is the +// Fleet variable. "fleet.example.com" is replaced with the test server host. +const applePSSOProfileTemplate = ` + + + + PayloadContent + + + ExtensionData + + BaseURL + https://fleet.example.com + + ExtensionIdentifier + com.fleetdm.fleet-desktop.pssoextension + PayloadDisplayName + Fleet Extensible Single Sign-On + PayloadIdentifier + com.apple.extensiblesso.AF68D4CF-1250-4FF4-AFFB-1176DB539C49 + PayloadType + com.apple.extensiblesso + PayloadUUID + AF68D4CF-1250-4FF4-AFFB-1176DB539C49 + PayloadVersion + 1 + PlatformSSO + + AuthenticationMethod + Password + UseSharedDeviceKeys + + EnableRegistrationDuringSetup + + + RegistrationToken + $FLEET_VAR_PSSO_DEVICE_REGISTRATION_TOKEN + ScreenLockedBehavior + DoNotHandle + TeamIdentifier + 8VBZ3948LU + Type + Redirect + URLs + + https://fleet.example.com + + + + PayloadDisplayName + Fleet Platform SSO + PayloadIdentifier + com.fleetdm.platformsso.fleet.A72B07D0-2E08-45CE-9423-1FCAFFAEC390 + PayloadType + Configuration + PayloadUUID + A72B07D0-2E08-45CE-9423-1FCAFFAEC390 + PayloadVersion + 1 + + +` diff --git a/server/service/testing_utils_test.go b/server/service/testing_utils_test.go index 4beb5758eed..7213f32b5e3 100644 --- a/server/service/testing_utils_test.go +++ b/server/service/testing_utils_test.go @@ -60,6 +60,7 @@ import ( "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/mdm" "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" nanomdm_push "github.com/fleetdm/fleet/v4/server/mdm/nanomdm/push" + "github.com/fleetdm/fleet/v4/server/mdm/psso" "github.com/fleetdm/fleet/v4/server/mdm/scep/depot" fleet_mock "github.com/fleetdm/fleet/v4/server/mock" nanodep_mock "github.com/fleetdm/fleet/v4/server/mock/nanodep" @@ -128,6 +129,10 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf keyValueStore = opts[0].KeyValueStore } + // pssoNonceStore backs the PSSO single-use nonces; wired from the test Redis + // pool when one is provided (integration tests), nil otherwise. + var pssoNonceStore fleet.PSSONonceStore + task := async.NewTask(ds, nil, c, nil) if len(opts) > 0 { if opts[0].Task != nil { @@ -149,6 +154,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf profMatcher = apple_mdm.NewProfileMatcher(opts[0].Pool) distributedLock = redis_lock.NewLock(opts[0].Pool) keyValueStore = redis_key_value.New(opts[0].Pool) + pssoNonceStore = psso.NewRedisNonceStore(opts[0].Pool) } if opts[0].ProfileMatcher != nil { profMatcher = opts[0].ProfileMatcher @@ -295,7 +301,7 @@ func newTestServiceWithConfig(t *testing.T, ds fleet.Datastore, fleetConfig conf digiCertService, androidModule, estCAService, - nil, // PSSO nonce store; integration tests don't exercise PSSO + pssoNonceStore, ) if err != nil { panic(err) From 1a0975a73a240d6da986aa7796fd23b3e90d0c02 Mon Sep 17 00:00:00 2001 From: Jordan Montgomery Date: Wed, 1 Jul 2026 15:59:45 -0400 Subject: [PATCH 3/3] Fix lint --- server/service/integration_mdm_apple_psso_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/service/integration_mdm_apple_psso_test.go b/server/service/integration_mdm_apple_psso_test.go index e35c8f96d78..c5dc33db5dc 100644 --- a/server/service/integration_mdm_apple_psso_test.go +++ b/server/service/integration_mdm_apple_psso_test.go @@ -38,17 +38,17 @@ type pssoMockIdP struct { func (m *pssoMockIdP) handler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - _ = r.ParseForm() + _ = r.ParseForm() // nolint:gosec // dismiss G120 since this is just test code m.mu.Lock() m.lastForm = r.PostForm m.mu.Unlock() - if r.FormValue("grant_type") != "password" { + if r.FormValue("grant_type") != "password" { // nolint:gosec // dismiss G120 since this is just test code w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "unsupported_grant_type"}) return } - if r.FormValue("username") != m.validUser || r.FormValue("password") != m.validPass { + if r.FormValue("username") != m.validUser || r.FormValue("password") != m.validPass { // nolint:gosec // dismiss G120 since this is just test code w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]string{"error": "invalid_grant", "error_description": "bad credentials"}) return