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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion api/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1372,10 +1372,15 @@ components:
type: object
description: >
Platform attestation configuration used to verify the binary identity of a mobile client
when it initiates a flow directly over HTTP.
when it initiates a flow directly over HTTP. Configure exactly one platform.
oneOf:
- required: [android]
- required: [apple]
properties:
android:
$ref: '#/components/schemas/AndroidAttestation'
apple:
$ref: '#/components/schemas/AppleAttestation'
Comment thread
coderabbitai[bot] marked this conversation as resolved.

AndroidAttestation:
type: object
Expand All @@ -1400,6 +1405,22 @@ components:
Google Cloud service account credentials (JSON) used to call the Play Integrity API.
Write-only: never returned in responses. Omit on update to preserve the stored value.

AppleAttestation:
type: object
description: Apple App Attest attestation configuration for iOS clients.
required: [teamId, bundleId]
properties:
teamId:
type: string
minLength: 1
description: Apple Developer Team ID.
example: "ABCDE12345"
bundleId:
type: string
minLength: 1
description: iOS application bundle identifier that must match the attested app.
example: "com.example.myapp"

InboundAuthConfig:
type: object
properties:
Expand Down
13 changes: 12 additions & 1 deletion backend/cmd/server/servicemanager.go
Original file line number Diff line number Diff line change
Expand Up @@ -471,7 +471,7 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
serverConfigService,
)

attestationProvider := attestation.Initialize(runtimeCryptoSvc)
attestationProvider := initAttestationProvider(ctx, logger, runtimeCryptoSvc)
flowExecService, err := flowexec.Initialize(mux, flowMgtService, actorProvider,
execRegistry, interceptorRegistry, observabilitySvc, runtimeCryptoSvc, attestationProvider,
graphBuilder, runtimeStoreProvider, transactioner, flowConfig)
Expand Down Expand Up @@ -501,6 +501,17 @@ func registerServices(mux *http.ServeMux, cacheManager cache.CacheManagerInterfa
return jwtService, runtimeCryptoSvc, importService
}

// initAttestationProvider initializes the platform attestation provider, terminating server startup
// on failure rather than running with a non-functional verifier.
func initAttestationProvider(ctx context.Context, logger *log.Logger,
cryptoSvc kmprovider.RuntimeCryptoProvider) providers.AttestationProvider {
attestationProvider, err := attestation.Initialize(cryptoSvc)
if err != nil {
logger.Fatal(ctx, "Failed to initialize attestation provider", log.Error(err))
}
return attestationProvider
}

// dependencyConsumers groups the services that check the dependency registry before deleting their
// own resources.
type dependencyConsumers struct {
Expand Down
2 changes: 1 addition & 1 deletion backend/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ require (
cloud.google.com/go/auth v0.22.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/cloudflare/circl v1.6.4
github.com/fxamacker/cbor/v2 v2.9.2
github.com/go-webauthn/webauthn v0.17.4
github.com/google/jsonschema-go v0.4.3
github.com/lib/pq v1.10.9
Expand Down Expand Up @@ -35,7 +36,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
Expand Down
15 changes: 15 additions & 0 deletions backend/internal/application/error_constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -488,4 +488,19 @@ var (
"browser-based single-page applications.",
},
}
// ErrorAmbiguousAttestationConfig is returned when an application's attestation configuration
// sets more than one platform, which the flow-initiation verifier cannot unambiguously dispatch.
ErrorAmbiguousAttestationConfig = tidcommon.ServiceError{
Type: tidcommon.ClientErrorType,
Code: "APP-1038",
Error: tidcommon.I18nMessage{
Key: "error.applicationservice.ambiguous_attestation_config",
DefaultValue: "Attestation configuration must set exactly one platform",
},
ErrorDescription: tidcommon.I18nMessage{
Key: "error.applicationservice.ambiguous_attestation_config_description",
DefaultValue: "An application's attestation configuration may configure only one platform " +
"(android or apple) at a time",
},
}
)
9 changes: 8 additions & 1 deletion backend/internal/application/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1123,6 +1123,13 @@ func (as *applicationService) validateApplicationFields(
isOAuthConfig = true
}
as.validateConsentConfig(app)

// An attestation config identifies exactly one platform build of the app; the verifier dispatch
// cannot pick between two simultaneously.
if attestation := app.Attestation; attestation != nil &&
attestation.Android != nil && attestation.Apple != nil {
return &ErrorAmbiguousAttestationConfig
}
return nil
}

Expand Down Expand Up @@ -1692,7 +1699,7 @@ func (as *applicationService) resolveAttestationCredentialsForPersist(
}
}

inboundClient.Attestation = &providers.AttestationConfig{Android: &android}
inboundClient.Attestation = &providers.AttestationConfig{Android: &android, Apple: inboundClient.Attestation.Apple}
return nil
}

Expand Down
29 changes: 29 additions & 0 deletions backend/internal/application/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1365,6 +1365,35 @@ func (suite *ServiceTestSuite) TestValidateApplication_InvalidURL() {
assert.Equal(suite.T(), &ErrorInvalidApplicationURL, svcErr)
}

func (suite *ServiceTestSuite) TestValidateApplication_AmbiguousAttestationConfig() {
testConfig := &config.Config{}
config.ResetServerRuntime()
err := config.InitializeServerRuntime("/tmp/test", testConfig)
require.NoError(suite.T(), err)
defer config.ResetServerRuntime()

service, _ := suite.setupTestService()

app := &model.ApplicationDTO{
Name: "Test App",
OUID: testOUID,
InboundAuthProfile: providers.InboundAuthProfile{
AuthFlowID: "edc013d0-e893-4dc0-990c-3e1d203e005b",
Attestation: &providers.AttestationConfig{
Android: &providers.AndroidAttestationConfig{PackageName: "com.example.app"},
Apple: &providers.AppleAttestationConfig{TeamID: "TEAM123", BundleID: "com.example.app"},
},
},
}

result, inboundAuth, svcErr := service.ValidateApplication(context.Background(), app)

assert.Nil(suite.T(), result)
assert.Nil(suite.T(), inboundAuth)
assert.NotNil(suite.T(), svcErr)
assert.Equal(suite.T(), &ErrorAmbiguousAttestationConfig, svcErr)
}

//nolint:dupl // Testing different URL validation scenarios
func (suite *ServiceTestSuite) TestValidateApplication_InvalidLogoURL() {
testConfig := &config.Config{}
Expand Down
231 changes: 231 additions & 0 deletions backend/internal/attestation/app_attest.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
Comment thread
ThaminduDilshan marked this conversation as resolved.
* Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com).
*
* WSO2 LLC. licenses this file to you under the Apache License,
* Version 2.0 (the "License"); you may not use this file except
* in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

package attestation

import (
"context"
"crypto/ecdsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/binary"
"fmt"

"github.com/fxamacker/cbor/v2"

"github.com/thunder-id/thunderid/internal/system/log"
tidcommon "github.com/thunder-id/thunderid/pkg/thunderidengine/common"
"github.com/thunder-id/thunderid/pkg/thunderidengine/providers"
)

// appAttestVerifier verifies Apple App Attest attestation objects for iOS clients, entirely
// offline: it validates the attestation certificate chain against Apple's App Attest root and
// matches the attested app identifier and key.
//
// It does not yet bind verification to a server-issued challenge — the same scope limitation as the
// Play Integrity verifier's lack of request-freshness checking. Closing that gap is tracked as a
// follow-up.
//
// Because verification never leaves the process, any problem with the token is a definitive
// rejection; only a missing or incomplete configuration is an operational error.
type appAttestVerifier struct {
rootPool *x509.CertPool
logger *log.Logger
}

// newAppAttestVerifier creates a verifier trusted against Apple's public App Attestation Root CA. It
// errors if the embedded root certificate fails to parse, so the caller can fail server startup
// instead of running with a non-functional verifier.
func newAppAttestVerifier() (providers.AttestationProvider, error) {
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM([]byte(appleAppAttestRootPEM)) {
return nil, fmt.Errorf("attestation: failed to parse embedded Apple App Attestation Root CA")
}
return &appAttestVerifier{
rootPool: pool,
logger: log.GetLogger().With(log.String(log.LoggerKeyComponentName, "AppAttestVerifier")),
}, nil
}

// Verify decodes the attestation object and checks the attested app identifier and key against the
// registered Team ID and Bundle ID. A definitive rejection (malformed object, bad chain, identity
// mismatch) is (false, nil); a missing or incomplete configuration is (false, ServiceError).
func (v *appAttestVerifier) Verify(ctx context.Context, cfg *providers.AttestationConfig, token string) (
bool, *tidcommon.ServiceError) {
if cfg == nil || cfg.Apple == nil {
v.logger.Error(ctx, "Attestation requested without an Apple attestation configuration")
return false, &tidcommon.InternalServerError
}

// Both Team ID and Bundle ID are required to verify identity; reject an incomplete config up front.
apple := cfg.Apple
if apple.TeamID == "" || apple.BundleID == "" {
v.logger.Error(ctx, "Apple attestation configuration is incomplete")
return false, &tidcommon.InternalServerError
}

if err := v.verifyAttestation(apple, token); err != nil {
v.logger.Debug(ctx, "Attestation token rejected", log.Error(err))
return false, nil
}
return true, nil
}

// verifyAttestation decodes and validates the attestation object against the registered Apple config,
// returning a non-nil error describing the first check that fails.
func (v *appAttestVerifier) verifyAttestation(apple *providers.AppleAttestationConfig, token string) error {
raw, err := base64.StdEncoding.DecodeString(token)
if err != nil {
// Attestation objects may also be presented URL-safe; try that before failing.
raw, err = base64.URLEncoding.DecodeString(token)
if err != nil {
return fmt.Errorf("%w: %w", errInvalidPayload, err)
}
}

var obj attestationObject
if err := cbor.Unmarshal(raw, &obj); err != nil {
return fmt.Errorf("%w: %w", errInvalidPayload, err)
}
if obj.Fmt != appAttestFormat || len(obj.AttStmt.X5C) == 0 {
return errInvalidPayload
}

leaf, err := v.verifyCertificateChain(obj.AttStmt.X5C)
if err != nil {
return err
}

authData, err := v.parseAuthData(obj.AuthData)
if err != nil {
return err
}

if err := v.verifyAppIdentifier(authData.rpIDHash, apple); err != nil {
return err
}
if !v.isRecognizedAAGUID(authData.aaguid) {
return errEnvironmentUnrecognized
}
if authData.signCount != 0 {
return errSignCountNonZero
}
return v.verifyKeyIdentifier(leaf, authData.credentialID)
}

// verifyCertificateChain verifies the credCert (x5c[0]) chains to the trusted root, through any
// intermediates presented in x5c[1:]. It returns the parsed leaf certificate on success.
func (v *appAttestVerifier) verifyCertificateChain(x5c [][]byte) (*x509.Certificate, error) {
leaf, err := x509.ParseCertificate(x5c[0])
if err != nil {
return nil, fmt.Errorf("%w: %w", errInvalidPayload, err)
}

intermediates := x509.NewCertPool()
for _, der := range x5c[1:] {
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("%w: %w", errInvalidPayload, err)
}
intermediates.AddCert(cert)
}

// App Attest certs aren't TLS server certs, so relax the default ExtKeyUsageServerAuth requirement.
opts := x509.VerifyOptions{
Roots: v.rootPool,
Intermediates: intermediates,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
}
if _, err := leaf.Verify(opts); err != nil {
return nil, fmt.Errorf("%w: %w", errCertificateChainInvalid, err)
}
return leaf, nil
}

// parseAuthData parses the fixed-layout authenticator data fields, with explicit bounds checks so
// a short or malformed value returns an error instead of panicking.
func (v *appAttestVerifier) parseAuthData(data []byte) (*parsedAuthData, error) {
if len(data) < authDataMinLen {
return nil, errInvalidPayload
}

rpIDHash := data[0:authDataRPIDHashLen]
flags := data[authDataRPIDHashLen]
if flags&authDataFlagAttestedCD == 0 {
return nil, errInvalidPayload
}

signCountOffset := authDataRPIDHashLen + authDataFlagsLen
signCount := binary.BigEndian.Uint32(data[signCountOffset : signCountOffset+authDataSignCountLen])

aaguidOffset := signCountOffset + authDataSignCountLen
var aaguid [16]byte
copy(aaguid[:], data[aaguidOffset:aaguidOffset+authDataAAGUIDLen])

credIDLenOffset := aaguidOffset + authDataAAGUIDLen
credIDLen := int(binary.BigEndian.Uint16(data[credIDLenOffset : credIDLenOffset+authDataCredIDLenLen]))

credIDOffset := credIDLenOffset + authDataCredIDLenLen
if len(data) < credIDOffset+credIDLen {
return nil, errInvalidPayload
}
credentialID := data[credIDOffset : credIDOffset+credIDLen]

return &parsedAuthData{
rpIDHash: rpIDHash,
signCount: signCount,
aaguid: aaguid,
credentialID: credentialID,
}, nil
}

// verifyAppIdentifier checks that the authenticator data's RP ID hash matches the SHA-256 hash of
// the registered Team ID and Bundle ID, as Apple's App Attest spec defines the App ID.
func (v *appAttestVerifier) verifyAppIdentifier(rpIDHash []byte, apple *providers.AppleAttestationConfig) error {
expected := sha256.Sum256([]byte(apple.TeamID + "." + apple.BundleID))
if string(rpIDHash) != string(expected[:]) {
return errAppIdentifierMismatch
}
return nil
}

// isRecognizedAAGUID reports whether aaguid identifies a genuine App Attest key, in either the
// production or development environment.
func (v *appAttestVerifier) isRecognizedAAGUID(aaguid [16]byte) bool {
return aaguid == aaguidProduction || aaguid == aaguidDevelopment
}

// verifyKeyIdentifier checks that the authenticator data's credential ID equals the SHA-256 hash of
// the credCert's public key, encoded as an ANSI X9.63 uncompressed point (0x04 || X || Y), not the
// DER SubjectPublicKeyInfo.
func (v *appAttestVerifier) verifyKeyIdentifier(leaf *x509.Certificate, credentialID []byte) error {
pub, ok := leaf.PublicKey.(*ecdsa.PublicKey)
if !ok {
return errInvalidPayload
}
ecdhPub, err := pub.ECDH()
if err != nil {
return fmt.Errorf("%w: %w", errInvalidPayload, err)
}
expected := sha256.Sum256(ecdhPub.Bytes())
if string(credentialID) != string(expected[:]) {
return errKeyIdentifierMismatch
}
return nil
}
Loading
Loading