diff --git a/acme/api/order.go b/acme/api/order.go index e0104e14d..bb7786b05 100644 --- a/acme/api/order.go +++ b/acme/api/order.go @@ -54,7 +54,7 @@ func (n *NewOrderRequest) Validate() error { if err != nil { return acme.WrapError(acme.ErrorMalformedType, err, "failed parsing Wire ID") } - if _, err = wire.ParseClientID(wireID.ClientID); err != nil { + if _, err := wire.ParseClientID(wireID.ClientID); err != nil { return acme.WrapError(acme.ErrorMalformedType, err, "invalid Wire client ID %q", wireID.ClientID) } default: @@ -282,18 +282,21 @@ func newAuthorization(ctx context.Context, az *acme.Authorization) error { if err != nil { return acme.WrapError(acme.ErrorMalformedType, err, "failed parsing ClientID") } - - var targetProvider interface{ GetTarget(string) (string, error) } + wireOptions, err := prov.GetOptions().GetWireOptions() + if err != nil { + return acme.WrapErrorISE(err, "failed getting Wire options") + } + var targetProvider interface{ EvaluateTarget(string) (string, error) } switch typ { case acme.WIREOIDC01: - targetProvider = prov.GetOptions().GetOIDCOptions() + targetProvider = wireOptions.GetOIDCOptions() case acme.WIREDPOP01: - targetProvider = prov.GetOptions().GetDPOPOptions() + targetProvider = wireOptions.GetDPOPOptions() default: return acme.NewError(acme.ErrorMalformedType, "unsupported type %q", typ) } - target, err = targetProvider.GetTarget(clientID.DeviceID) + target, err = targetProvider.EvaluateTarget(clientID.DeviceID) if err != nil { return acme.WrapError(acme.ErrorMalformedType, err, "invalid Go template registered for 'target'") } diff --git a/acme/api/order_test.go b/acme/api/order_test.go index 8ebf9c609..4948ea947 100644 --- a/acme/api/order_test.go +++ b/acme/api/order_test.go @@ -24,6 +24,7 @@ import ( "github.com/smallstep/certificates/acme" "github.com/smallstep/certificates/authority/policy" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/wire" ) func TestNewOrderRequest_Validate(t *testing.T) { @@ -884,6 +885,10 @@ func TestHandler_NewOrder(t *testing.T) { u := fmt.Sprintf("%s/acme/%s/order/ordID", baseURL.String(), escProvName) + fakeWireSigningKey := `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` + type test struct { ca acme.CertificateAuthority db acme.DB @@ -1716,27 +1721,29 @@ func TestHandler_NewOrder(t *testing.T) { }, "ok/default-naf-nbf-wireapp": func(t *testing.T) test { acmeWireProv := newWireProvisionerWithOptions(t, &provisioner.Options{ - OIDC: &provisioner.OIDCOptions{ - Provider: provisioner.ProviderJSON{ - IssuerURL: "", - AuthURL: "", - TokenURL: "", - JWKSURL: "", - UserInfoURL: "", - Algorithms: []string{}, - }, - Config: provisioner.ConfigJSON{ - ClientID: "integration test", - SupportedSigningAlgs: []string{}, - SkipClientIDCheck: true, - SkipExpiryCheck: true, - SkipIssuerCheck: true, - InsecureSkipSignatureCheck: true, - Now: time.Now, - }, - }, - DPOP: &provisioner.DPOPOptions{ - ValidationExecPath: "true", // true will always exit with code 0 + Wire: &wire.Options{ + OIDC: &wire.OIDCOptions{ + Provider: &wire.Provider{ + IssuerURL: "https://issuer.example.com", + AuthURL: "", + TokenURL: "", + JWKSURL: "", + UserInfoURL: "", + Algorithms: []string{"ES256"}, + }, + Config: &wire.Config{ + ClientID: "integration test", + SignatureAlgorithms: []string{"ES256"}, + SkipClientIDCheck: true, + SkipExpiryCheck: true, + SkipIssuerCheck: true, + InsecureSkipSignatureCheck: true, + Now: time.Now, + }, + }, + DPOP: &wire.DPOPOptions{ + SigningKey: []byte(fakeWireSigningKey), + }, }, }) acc := &acme.Account{ID: "accID"} diff --git a/acme/api/wire_integration_test.go b/acme/api/wire_integration_test.go index 45f28d2d4..3bb62a9ae 100644 --- a/acme/api/wire_integration_test.go +++ b/acme/api/wire_integration_test.go @@ -10,9 +10,9 @@ import ( "encoding/asn1" "encoding/base64" "encoding/json" + "encoding/pem" "errors" "io" - "math/big" "net/http" "net/http/httptest" "net/url" @@ -26,9 +26,14 @@ import ( "github.com/smallstep/certificates/acme/db/nosql" "github.com/smallstep/certificates/authority" "github.com/smallstep/certificates/authority/provisioner" + "github.com/smallstep/certificates/authority/provisioner/wire" nosqlDB "github.com/smallstep/nosql" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.step.sm/crypto/jose" + "go.step.sm/crypto/minica" + "go.step.sm/crypto/pemutil" + "go.step.sm/crypto/x509util" ) const ( @@ -49,30 +54,67 @@ func newWireProvisionerWithOptions(t *testing.T, options *provisioner.Options) * return a } +// TODO(hs): replace with test CA server + acmez based test client for +// more realistic integration test? func TestWireIntegration(t *testing.T) { + accessTokenSignerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + + accessTokenSignerPEMBlock, err := pemutil.Serialize(accessTokenSignerJWK.Public().Key) + require.NoError(t, err) + accessTokenSignerPEMBytes := pem.EncodeToMemory(accessTokenSignerPEMBlock) + + accessTokenSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(accessTokenSignerJWK.Algorithm), + Key: accessTokenSignerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + + oidcTokenSignerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + oidcTokenSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(oidcTokenSignerJWK.Algorithm), + Key: oidcTokenSignerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + prov := newWireProvisionerWithOptions(t, &provisioner.Options{ - OIDC: &provisioner.OIDCOptions{ - Provider: provisioner.ProviderJSON{ - IssuerURL: "", - AuthURL: "", - TokenURL: "", - JWKSURL: "", - UserInfoURL: "", - Algorithms: []string{}, + X509: &provisioner.X509Options{ + Template: `{ + "subject": { + "organization": "WireTest", + "commonName": {{ toJson .Oidc.name }} + }, + "uris": [{{ toJson .Oidc.preferred_username }}, {{ toJson .Dpop.sub }}], + "keyUsage": ["digitalSignature"], + "extKeyUsage": ["clientAuth"] + }`, + }, + Wire: &wire.Options{ + OIDC: &wire.OIDCOptions{ + Provider: &wire.Provider{ + IssuerURL: "https://issuer.example.com", + AuthURL: "", + TokenURL: "", + JWKSURL: "", + UserInfoURL: "", + Algorithms: []string{"ES256"}, + }, + Config: &wire.Config{ + ClientID: "integration test", + SignatureAlgorithms: []string{"ES256"}, + SkipClientIDCheck: true, + SkipExpiryCheck: true, + SkipIssuerCheck: true, + InsecureSkipSignatureCheck: true, // NOTE: this skips actual token verification + Now: time.Now, + }, + TransformTemplate: "", }, - Config: provisioner.ConfigJSON{ - ClientID: "integration test", - SupportedSigningAlgs: []string{}, - SkipClientIDCheck: true, - SkipExpiryCheck: true, - SkipIssuerCheck: true, - InsecureSkipSignatureCheck: true, - Now: time.Now, + DPOP: &wire.DPOPOptions{ + SigningKey: accessTokenSignerPEMBytes, }, }, - DPOP: &provisioner.DPOPOptions{ - ValidationExecPath: "true", // true will always exit with code 0 - }, }) // mock provisioner and linker @@ -107,6 +149,12 @@ func TestWireIntegration(t *testing.T) { ed25519PrivKey, ok := jwk.Key.(ed25519.PrivateKey) require.True(t, ok) + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + ed25519PubKey, ok := ed25519PrivKey.Public().(ed25519.PublicKey) require.True(t, ok) @@ -250,7 +298,106 @@ func TestWireIntegration(t *testing.T) { chiCtx := chi.NewRouteContext() chiCtx.URLParams.Add("chID", challenge.ID) ctx = context.WithValue(ctx, chi.RouteCtxKey, chiCtx) - ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: nil}) + + var payload []byte + switch challenge.Type { + case acme.WIREDPOP01: + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://lJGYPz0ZRq2kvc_XpdaDlA!ed416ce8ecdd9fad@example.com", + }, + Challenge: "token", + Handle: "wireapp://%40alice.smith.qa@example.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://lJGYPz0ZRq2kvc_XpdaDlA!ed416ce8ecdd9fad@example.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + + require.NoError(t, err) + signed, err := accessTokenSigner.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + + p, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + payload = p + case acme.WIREOIDC01: + keyAuth, err := acme.KeyAuthorization("token", jwk) + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + }{ + Claims: jose.Claims{ + Issuer: "https://issuer.example.com", + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + }) + require.NoError(t, err) + signed, err := oidcTokenSigner.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + p, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + payload = p + default: + require.Fail(t, "unexpected challenge payload type") + } + + ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: payload}) req := httptest.NewRequest(http.MethodGet, "https://random.local/", http.NoBody).WithContext(ctx) w := httptest.NewRecorder() @@ -291,6 +438,16 @@ func TestWireIntegration(t *testing.T) { updatedAz := updateAz(ctx, az) for _, challenge := range updatedAz.Challenges { t.Log("updated challenge:", challenge.ID, challenge.Status) + switch challenge.Type { + case acme.WIREOIDC01: + err = db.CreateOidcToken(ctx, order.ID, map[string]any{"name": "Smith, Alice M (QA)", "preferred_username": "wireapp://%40alice.smith.qa@example.com"}) + require.NoError(t, err) + case acme.WIREDPOP01: + err = db.CreateDpopToken(ctx, order.ID, map[string]any{"sub": "wireapp://lJGYPz0ZRq2kvc_XpdaDlA!ed416ce8ecdd9fad@example.com"}) + require.NoError(t, err) + default: + require.Fail(t, "unexpected challenge type") + } } } @@ -322,11 +479,36 @@ func TestWireIntegration(t *testing.T) { // finalize order finalizedOrder := func(ctx context.Context) (finalizedOrder *acme.Order) { + ca, err := minica.New(minica.WithName("WireTestCA")) + require.NoError(t, err) mockMustAuthority(t, &mockCASigner{ - signer: func(*x509.CertificateRequest, provisioner.SignOptions, ...provisioner.SignOption) ([]*x509.Certificate, error) { - return []*x509.Certificate{ - {SerialNumber: big.NewInt(2)}, - }, nil + signer: func(csr *x509.CertificateRequest, signOpts provisioner.SignOptions, extraOpts ...provisioner.SignOption) ([]*x509.Certificate, error) { + var ( + certOptions []x509util.Option + ) + for _, op := range extraOpts { + if k, ok := op.(provisioner.CertificateOptions); ok { + certOptions = append(certOptions, k.Options(signOpts)...) + } + } + + x509utilTemplate, err := x509util.NewCertificate(csr, certOptions...) + require.NoError(t, err) + + template := x509utilTemplate.GetCertificate() + require.NotNil(t, template) + + cert, err := ca.Sign(template) + require.NoError(t, err) + + u1, err := url.Parse("wireapp://%40alice.smith.qa@example.com") + require.NoError(t, err) + u2, err := url.Parse("wireapp://lJGYPz0ZRq2kvc_XpdaDlA%21ed416ce8ecdd9fad@example.com") + require.NoError(t, err) + assert.Equal(t, []*url.URL{u1, u2}, cert.URIs) + assert.Equal(t, "Smith, Alice M (QA)", cert.Subject.CommonName) + + return []*x509.Certificate{cert, ca.Intermediate}, nil }, }) @@ -363,12 +545,6 @@ func TestWireIntegration(t *testing.T) { frRaw, err := json.Marshal(fr) require.NoError(t, err) - // TODO(hs): move these to a more appropriate place and/or provide more realistic value - err = db.CreateDpopToken(ctx, order.ID, map[string]any{"fake-dpop": "dpop-value"}) - require.NoError(t, err) - err = db.CreateOidcToken(ctx, order.ID, map[string]any{"fake-oidc": "oidc-value"}) - require.NoError(t, err) - ctx = context.WithValue(ctx, payloadContextKey, &payloadInfo{value: frRaw}) chiCtx := chi.NewRouteContext() diff --git a/acme/challenge.go b/acme/challenge.go index d9bc6bab6..3a53ed3af 100644 --- a/acme/challenge.go +++ b/acme/challenge.go @@ -1,7 +1,6 @@ package acme import ( - "bytes" "context" "crypto" "crypto/ecdsa" @@ -21,13 +20,12 @@ import ( "io" "net" "net/url" - "os" - "os/exec" "reflect" "strconv" "strings" "time" + "github.com/coreos/go-oidc/v3/oidc" "github.com/fxamacker/cbor/v2" "github.com/google/go-tpm/legacy/tpm2" "github.com/smallstep/go-attestation/attest" @@ -39,6 +37,7 @@ import ( "github.com/smallstep/certificates/acme/wire" "github.com/smallstep/certificates/authority/provisioner" + wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" ) type ChallengeType string @@ -116,7 +115,7 @@ func (ch *Challenge) Validate(ctx context.Context, db DB, jwk *jose.JSONWebKey, case WIREDPOP01: return wireDPOP01Validate(ctx, ch, db, jwk, payload) default: - return NewErrorISE("unexpected challenge type '%s'", ch.Type) + return NewErrorISE("unexpected challenge type %q", ch.Type) } } @@ -353,63 +352,78 @@ func dns01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSONWebK return nil } -type WireChallengePayload struct { - // IDToken - IDToken string `json:"id_token,omitempty"` - // KeyAuth ({challenge-token}.{jwk-thumbprint}) - KeyAuth string `json:"keyauth,omitempty"` - // AccessToken is the token generated by wire-server - AccessToken string `json:"access_token,omitempty"` +type wireOidcPayload struct { + // IDToken contains the OIDC identity token + IDToken string `json:"id_token"` } func wireOIDC01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSONWebKey, payload []byte) error { prov, ok := ProvisionerFromContext(ctx) if !ok { - return NewErrorISE("no provisioner provided") + return NewErrorISE("missing provisioner") + } + linker, ok := LinkerFromContext(ctx) + if !ok { + return NewErrorISE("missing linker") } - var wireChallengePayload WireChallengePayload - err := json.Unmarshal(payload, &wireChallengePayload) + var oidcPayload wireOidcPayload + err := json.Unmarshal(payload, &oidcPayload) if err != nil { - return storeError(ctx, db, ch, false, WrapError(ErrorRejectedIdentifierType, err, - "error unmarshalling Wire challenge payload")) + return WrapError(ErrorMalformedType, err, "error unmarshalling Wire OIDC challenge payload") } - oidcOptions := prov.GetOptions().GetOIDCOptions() - idToken, err := oidcOptions.GetProvider(ctx).Verifier(oidcOptions.GetConfig()).Verify(ctx, wireChallengePayload.IDToken) + wireID, err := wire.ParseID([]byte(ch.Value)) if err != nil { - return storeError(ctx, db, ch, false, WrapError(ErrorRejectedIdentifierType, err, - "error verifying ID token signature")) + return WrapErrorISE(err, "error unmarshalling challenge data") } - var claims struct { - Name string `json:"preferred_username,omitempty"` - Handle string `json:"name"` - Issuer string `json:"iss,omitempty"` - GivenName string `json:"given_name,omitempty"` - KeyAuth string `json:"keyauth"` // TODO(hs): use this property instead of the one in the payload after https://github.com/wireapp/rusty-jwt-tools/tree/fix/keyauth is done - } - if err = idToken.Claims(&claims); err != nil { - return storeError(ctx, db, ch, false, WrapError(ErrorRejectedIdentifierType, err, - "error retrieving claims from ID token")) + wireOptions, err := prov.GetOptions().GetWireOptions() + if err != nil { + return WrapErrorISE(err, "failed getting Wire options") } - challengeValues, err := wire.ParseID([]byte(ch.Value)) + oidcOptions := wireOptions.GetOIDCOptions() + verifier := oidcOptions.GetProvider(ctx).Verifier(oidcOptions.GetConfig()) + idToken, err := verifier.Verify(ctx, oidcPayload.IDToken) if err != nil { - return WrapErrorISE(err, "error unmarshalling challenge data") + return storeError(ctx, db, ch, true, WrapError(ErrorRejectedIdentifierType, err, + "error verifying ID token signature")) + } + + var claims struct { + Name string `json:"preferred_username,omitempty"` + Handle string `json:"name"` + Issuer string `json:"iss,omitempty"` + GivenName string `json:"given_name,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud,omitempty"` + } + if err := idToken.Claims(&claims); err != nil { + return storeError(ctx, db, ch, true, WrapError(ErrorRejectedIdentifierType, err, + "error retrieving claims from ID token")) } + // TODO(hs): move this into validation below? expectedKeyAuth, err := KeyAuthorization(ch.Token, jwk) if err != nil { - return err + return WrapErrorISE(err, "error determining key authorization") } - if expectedKeyAuth != wireChallengePayload.KeyAuth { + if expectedKeyAuth != claims.KeyAuth { return storeError(ctx, db, ch, true, NewError(ErrorRejectedIdentifierType, - "keyAuthorization does not match; expected %s, but got %s", expectedKeyAuth, wireChallengePayload.KeyAuth)) + "keyAuthorization does not match; expected %q, but got %q", expectedKeyAuth, claims.KeyAuth)) } - if challengeValues.Name != claims.Name || challengeValues.Handle != claims.Handle { - return storeError(ctx, db, ch, false, NewError(ErrorRejectedIdentifierType, "OIDC claims don't match")) + // audience is the full URL to the challenge + acmeAudience := linker.GetLink(ctx, ChallengeLinkType, ch.AuthorizationID, ch.ID) + if claims.ACMEAudience != acmeAudience { + return storeError(ctx, db, ch, true, NewError(ErrorRejectedIdentifierType, + "invalid 'acme_aud' %q", claims.ACMEAudience)) + } + + transformedIDToken, err := validateWireOIDCClaims(oidcOptions, idToken, wireID) + if err != nil { + return storeError(ctx, db, ch, true, WrapError(ErrorRejectedIdentifierType, err, "claims in OIDC ID token don't match")) } // Update and store the challenge. @@ -421,133 +435,110 @@ func wireOIDC01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSO return WrapErrorISE(err, "error updating challenge") } - parsedIDToken, err := jose.ParseSigned(wireChallengePayload.IDToken) - if err != nil { - return WrapErrorISE(err, "invalid OIDC ID token") - } - oidcToken := make(map[string]interface{}) - if err := parsedIDToken.UnsafeClaimsWithoutVerification(&oidcToken); err != nil { - return WrapErrorISE(err, "failed parsing OIDC id token") - } - orders, err := db.GetAllOrdersByAccountID(ctx, ch.AccountID) if err != nil { - return WrapErrorISE(err, "could not find current order by account id") + return WrapErrorISE(err, "could not retrieve current order by account id") } if len(orders) == 0 { return NewErrorISE("there are not enough orders for this account for this custom OIDC challenge") } order := orders[len(orders)-1] - if err := db.CreateOidcToken(ctx, order, oidcToken); err != nil { + if err := db.CreateOidcToken(ctx, order, transformedIDToken); err != nil { return WrapErrorISE(err, "failed storing OIDC id token") } return nil } -func wireDPOP01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSONWebKey, payload []byte) error { - prov, ok := ProvisionerFromContext(ctx) - if !ok { - return NewErrorISE("missing provisioner") +func validateWireOIDCClaims(o *wireprovisioner.OIDCOptions, token *oidc.IDToken, wireID wire.ID) (map[string]any, error) { + var m map[string]any + if err := token.Claims(&m); err != nil { + return nil, fmt.Errorf("failed extracting OIDC ID token claims: %w", err) } - - rawKid, err := jwk.Thumbprint(crypto.SHA256) + transformed, err := o.Transform(m) if err != nil { - return storeError(ctx, db, ch, false, WrapError(ErrorServerInternalType, err, "failed to compute JWK thumbprint")) + return nil, fmt.Errorf("failed transforming OIDC ID token: %w", err) } - kid := base64.RawURLEncoding.EncodeToString(rawKid) - dpopOptions := prov.GetOptions().GetDPOPOptions() - key := dpopOptions.GetSigningKey() - - var wireChallengePayload WireChallengePayload - if err := json.Unmarshal(payload, &wireChallengePayload); err != nil { - return storeError(ctx, db, ch, false, WrapError(ErrorRejectedIdentifierType, err, - "error unmarshalling Wire challenge payload")) + name, ok := transformed["name"] + if !ok { + return nil, fmt.Errorf("transformed OIDC ID token does not contain 'name'") + } + if wireID.Name != name { + return nil, fmt.Errorf("invalid 'name' %q after transformation", name) } - file, err := os.CreateTemp(os.TempDir(), "acme-validate-challenge-pubkey-") - if err != nil { - return WrapErrorISE(err, "temporary file could not be created") + preferredUsername, ok := transformed["preferred_username"] + if !ok { + return nil, fmt.Errorf("transformed OIDC ID token does not contain 'preferred_username'") + } + if wireID.Handle != preferredUsername { + return nil, fmt.Errorf("invalid 'preferred_username' %q after transformation", preferredUsername) } - defer file.Close() - defer os.Remove(file.Name()) - buf := bytes.NewBuffer(nil) - buf.WriteString(key) + return transformed, nil +} - n, err := file.Write(buf.Bytes()) - if err != nil { - return WrapErrorISE(err, "failed writing signature key to temp file") +type wireDpopPayload struct { + // AccessToken is the token generated by wire-server + AccessToken string `json:"access_token"` +} + +func wireDPOP01Validate(ctx context.Context, ch *Challenge, db DB, accountJWK *jose.JSONWebKey, payload []byte) error { + prov, ok := ProvisionerFromContext(ctx) + if !ok { + return NewErrorISE("missing provisioner") } - if n != buf.Len() { - return WrapErrorISE(err, "expected to write %d characters to the key file, got %d", buf.Len(), n) + linker, ok := LinkerFromContext(ctx) + if !ok { + return NewErrorISE("missing linker") } - challengeValues, err := wire.ParseID([]byte(ch.Value)) - if err != nil { - return WrapErrorISE(err, "error unmarshalling challenge data") + var dpopPayload wireDpopPayload + if err := json.Unmarshal(payload, &dpopPayload); err != nil { + return WrapError(ErrorMalformedType, err, "error unmarshalling Wire DPoP challenge payload") } - clientID, err := wire.ParseClientID(challengeValues.ClientID) + wireID, err := wire.ParseID([]byte(ch.Value)) if err != nil { - return WrapErrorISE(err, "error parsing device id") + return WrapErrorISE(err, "error unmarshalling challenge data") } - issuer, err := dpopOptions.GetTarget(clientID.DeviceID) + clientID, err := wire.ParseClientID(wireID.ClientID) if err != nil { - return WrapErrorISE(err, "invalid Go template registered for 'target'") + return WrapErrorISE(err, "error parsing device id") } - expiry := strconv.FormatInt(time.Now().Add(time.Hour*24*365).Unix(), 10) - cmd := exec.CommandContext( //nolint:gosec // TODO(hs): replace this with Go implementation - ctx, - dpopOptions.GetValidationExecPath(), - "verify-access", - "--client-id", - challengeValues.ClientID, - "--handle", - challengeValues.Handle, - "--challenge", - ch.Token, - "--leeway", - "360", - "--max-expiry", - expiry, - "--issuer", - issuer, - "--hash-algorithm", - `SHA-256`, - "--kid", - kid, - "--key", - file.Name(), - ) - - stdin, err := cmd.StdinPipe() + wireOptions, err := prov.GetOptions().GetWireOptions() if err != nil { - return WrapErrorISE(err, "error getting process stdin") + return WrapErrorISE(err, "failed getting Wire options") } - err = cmd.Start() + dpopOptions := wireOptions.GetDPOPOptions() + issuer, err := dpopOptions.EvaluateTarget(clientID.DeviceID) if err != nil { - return WrapErrorISE(err, "error starting validation process") + return WrapErrorISE(err, "invalid Go template registered for 'target'") } - _, err = stdin.Write([]byte(wireChallengePayload.AccessToken)) - if err != nil { - return WrapErrorISE(err, "error writing to stdin") - } + // audience is the full URL to the challenge + audience := linker.GetLink(ctx, ChallengeLinkType, ch.AuthorizationID, ch.ID) - err = stdin.Close() - if err != nil { - return WrapErrorISE(err, "error closing stdin") + params := wireVerifyParams{ + token: dpopPayload.AccessToken, + tokenKey: dpopOptions.GetSigningKey(), + dpopKey: accountJWK.Public(), + dpopKeyID: accountJWK.KeyID, + issuer: issuer, + audience: audience, + wireID: wireID, + chToken: ch.Token, + t: clock.Now().UTC(), } - - err = cmd.Wait() + _, dpop, err := parseAndVerifyWireAccessToken(params) if err != nil { - return storeError(ctx, db, ch, true, NewError(ErrorRejectedIdentifierType, "error finishing validation: %s", err)) + return storeError(ctx, db, ch, true, WrapError(ErrorRejectedIdentifierType, err, + "failed validating Wire access token")) } // Update and store the challenge. @@ -559,45 +550,178 @@ func wireDPOP01Validate(ctx context.Context, ch *Challenge, db DB, jwk *jose.JSO return WrapErrorISE(err, "error updating challenge") } - parsedAccessToken, err := jose.ParseSigned(wireChallengePayload.AccessToken) + orders, err := db.GetAllOrdersByAccountID(ctx, ch.AccountID) if err != nil { - return WrapErrorISE(err, "invalid access token") + return WrapErrorISE(err, "could not find current order by account id") } - access := make(map[string]interface{}) - if err := parsedAccessToken.UnsafeClaimsWithoutVerification(&access); err != nil { - return WrapErrorISE(err, "failed parsing access token") + if len(orders) == 0 { + return NewErrorISE("there are not enough orders for this account for this custom OIDC challenge") } - rawDpop, ok := access["proof"].(string) - if !ok { - return WrapErrorISE(err, "invalid dpop proof format in access token") + order := orders[len(orders)-1] + if err := db.CreateDpopToken(ctx, order, map[string]any(*dpop)); err != nil { + return WrapErrorISE(err, "failed storing DPoP token") } - parsedDpopToken, err := jose.ParseSigned(rawDpop) + return nil +} + +type wireCnf struct { + Kid string `json:"kid"` +} + +type wireAccessToken struct { + jose.Claims + Challenge string `json:"chal"` + Nonce string `json:"nonce"` + Cnf wireCnf `json:"cnf"` + Proof string `json:"proof"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` +} + +type wireDpopJwt struct { + jose.Claims + ClientID string `json:"client_id"` + Challenge string `json:"chal"` + Nonce string `json:"nonce"` + HTU string `json:"htu"` +} + +type wireDpopToken map[string]any + +type wireVerifyParams struct { + token string + tokenKey crypto.PublicKey + dpopKey crypto.PublicKey + dpopKeyID string + issuer string + audience string + wireID wire.ID + chToken string + t time.Time +} + +func parseAndVerifyWireAccessToken(v wireVerifyParams) (*wireAccessToken, *wireDpopToken, error) { + jwt, err := jose.ParseSigned(v.token) if err != nil { - return WrapErrorISE(err, "invalid DPoP token") + return nil, nil, fmt.Errorf("failed parsing token: %w", err) + } + + if len(jwt.Headers) != 1 { + return nil, nil, fmt.Errorf("token has wrong number of headers %d", len(jwt.Headers)) + } + keyID, err := KeyToID(&jose.JSONWebKey{Key: v.tokenKey}) + if err != nil { + return nil, nil, fmt.Errorf("failed calculating token key ID: %w", err) + } + jwtKeyID := jwt.Headers[0].KeyID + if jwtKeyID == "" { + if jwtKeyID, err = KeyToID(jwt.Headers[0].JSONWebKey); err != nil { + return nil, nil, fmt.Errorf("failed extracting token key ID: %w", err) + } } - dpop := make(map[string]interface{}) - if err := parsedDpopToken.UnsafeClaimsWithoutVerification(&dpop); err != nil { - return WrapErrorISE(err, "failed parsing dpop token") + if jwtKeyID != keyID { + return nil, nil, fmt.Errorf("invalid token key ID %q", jwtKeyID) } - orders, err := db.GetAllOrdersByAccountID(ctx, ch.AccountID) + var accessToken wireAccessToken + if err = jwt.Claims(v.tokenKey, &accessToken); err != nil { + return nil, nil, fmt.Errorf("failed validating Wire DPoP token claims: %w", err) + } + + if err := accessToken.ValidateWithLeeway(jose.Expected{ + Time: v.t, + Issuer: v.issuer, + Audience: jose.Audience{v.audience}, + }, 1*time.Minute); err != nil { + return nil, nil, fmt.Errorf("failed validation: %w", err) + } + + if accessToken.Challenge == "" { + return nil, nil, errors.New("access token challenge must not be empty") + } + if accessToken.Cnf.Kid == "" || accessToken.Cnf.Kid != v.dpopKeyID { + return nil, nil, fmt.Errorf("expected kid %q; got %q", v.dpopKeyID, accessToken.Cnf.Kid) + } + if accessToken.ClientID != v.wireID.ClientID { + return nil, nil, fmt.Errorf("invalid Wire client ID %q", accessToken.ClientID) + } + if accessToken.Expiry.Time().After(v.t.Add(time.Hour)) { + return nil, nil, fmt.Errorf("'exp' %s is too far into the future", accessToken.Expiry.Time().String()) + } + if accessToken.Scope != "wire_client_id" { + return nil, nil, fmt.Errorf("invalid Wire scope %q", accessToken.Scope) + } + + dpopJWT, err := jose.ParseSigned(accessToken.Proof) if err != nil { - return WrapErrorISE(err, "could not find current order by account id") + return nil, nil, fmt.Errorf("invalid Wire DPoP token: %w", err) + } + if len(dpopJWT.Headers) != 1 { + return nil, nil, fmt.Errorf("DPoP token has wrong number of headers %d", len(jwt.Headers)) + } + dpopJwtKeyID := dpopJWT.Headers[0].KeyID + if dpopJwtKeyID == "" { + if dpopJwtKeyID, err = KeyToID(dpopJWT.Headers[0].JSONWebKey); err != nil { + return nil, nil, fmt.Errorf("failed extracting DPoP token key ID: %w", err) + } + } + if dpopJwtKeyID != v.dpopKeyID { + return nil, nil, fmt.Errorf("invalid DPoP token key ID %q", dpopJWT.Headers[0].KeyID) } - if len(orders) == 0 { - return WrapErrorISE(err, "there are not enough orders for this account for this custom OIDC challenge") + var wireDpop wireDpopJwt + if err := dpopJWT.Claims(v.dpopKey, &wireDpop); err != nil { + return nil, nil, fmt.Errorf("failed validating Wire DPoP token claims: %w", err) } - order := orders[len(orders)-1] + if err := wireDpop.ValidateWithLeeway(jose.Expected{ + Time: v.t, + Audience: jose.Audience{v.audience}, + }, 1*time.Minute); err != nil { + return nil, nil, fmt.Errorf("failed DPoP validation: %w", err) + } + if wireDpop.HTU == "" || wireDpop.HTU != v.issuer { // DPoP doesn't contains "iss" claim, but has it in the "htu" claim + return nil, nil, fmt.Errorf("DPoP contains invalid issuer (htu) %q", wireDpop.HTU) + } + if wireDpop.Expiry.Time().After(v.t.Add(time.Hour)) { + return nil, nil, fmt.Errorf("'exp' %s is too far into the future", wireDpop.Expiry.Time().String()) + } + if wireDpop.Subject != v.wireID.ClientID { + return nil, nil, fmt.Errorf("DPoP contains invalid Wire client ID %q", wireDpop.ClientID) + } + if wireDpop.Nonce == "" || wireDpop.Nonce != accessToken.Nonce { + return nil, nil, fmt.Errorf("DPoP contains invalid nonce %q", wireDpop.Nonce) + } + if wireDpop.Challenge == "" || wireDpop.Challenge != accessToken.Challenge { + return nil, nil, fmt.Errorf("DPoP contains invalid challenge %q", wireDpop.Challenge) + } - if err := db.CreateDpopToken(ctx, order, dpop); err != nil { - return WrapErrorISE(err, "failed storing DPoP token") + // TODO(hs): can we use the wireDpopJwt and map that instead of doing Claims() twice? + var dpopToken wireDpopToken + if err := dpopJWT.Claims(v.dpopKey, &dpopToken); err != nil { + return nil, nil, fmt.Errorf("failed validating Wire DPoP token claims: %w", err) } - return nil + challenge, ok := dpopToken["chal"].(string) + if !ok { + return nil, nil, fmt.Errorf("invalid challenge in Wire DPoP token") + } + if challenge == "" || challenge != v.chToken { + return nil, nil, fmt.Errorf("invalid Wire DPoP challenge %q", challenge) + } + + handle, ok := dpopToken["handle"].(string) + if !ok { + return nil, nil, fmt.Errorf("invalid handle in Wire DPoP token") + } + if handle == "" || handle != v.wireID.Handle { + return nil, nil, fmt.Errorf("invalid Wire client handle %q", handle) + } + + return &accessToken, &dpopToken, nil } type payloadType struct { diff --git a/acme/challenge_test.go b/acme/challenge_test.go index 5cede1c5c..35d943765 100644 --- a/acme/challenge_test.go +++ b/acme/challenge_test.go @@ -33,11 +33,13 @@ import ( "github.com/fxamacker/cbor/v2" "github.com/smallstep/certificates/authority/config" "github.com/smallstep/certificates/authority/provisioner" + wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.step.sm/crypto/jose" "go.step.sm/crypto/keyutil" "go.step.sm/crypto/minica" + "go.step.sm/crypto/pemutil" "go.step.sm/crypto/x509util" ) @@ -196,6 +198,25 @@ func mustAttestYubikey(t *testing.T, _, keyAuthorization string, serial int) ([] return payload, leaf, ca.Root } +func newWireProvisionerWithOptions(t *testing.T, options *provisioner.Options) *provisioner.ACME { + t.Helper() + prov := &provisioner.ACME{ + Type: "ACME", + Name: "wire", + Options: options, + Challenges: []provisioner.ACMEChallenge{ + provisioner.WIREOIDC_01, + provisioner.WIREDPOP_01, + }, + } + if err := prov.Init(provisioner.Config{ + Claims: config.GlobalProvisionerClaims, + }); err != nil { + t.Fatal(err) + } + return prov +} + func Test_storeError(t *testing.T) { type test struct { ch *Challenge @@ -396,6 +417,9 @@ func TestKeyAuthorization(t *testing.T) { } func TestChallenge_Validate(t *testing.T) { + fakeKey := `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` type test struct { ch *Challenge vc Client @@ -430,7 +454,7 @@ func TestChallenge_Validate(t *testing.T) { } return test{ ch: ch, - err: NewErrorISE("unexpected challenge type 'foo'"), + err: NewErrorISE(`unexpected challenge type "foo"`), } }, "fail/http-01": func(t *testing.T) test { @@ -853,6 +877,261 @@ func TestChallenge_Validate(t *testing.T) { }, } }, + "ok/wire-oidc-01": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateOidcToken: func(ctx context.Context, orderID string, idToken map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "Alice Smith", idToken["name"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", idToken["preferred_username"].(string)) + return nil + }, + }, + } + }, + "ok/wire-dpop-01": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuerexample.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateDpopToken: func(ctx context.Context, orderID string, dpop map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "token", dpop["chal"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", dpop["handle"].(string)) + assert.Equal(t, "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", dpop["sub"].(string)) + return nil + }, + }, + } + }, } for name, run := range tests { t.Run(name, func(t *testing.T) { @@ -867,25 +1146,63 @@ func TestChallenge_Validate(t *testing.T) { ctx = context.Background() } ctx = NewClientContext(ctx, tc.vc) - if err := tc.ch.Validate(ctx, tc.db, tc.jwk, tc.payload); err != nil { - if assert.Error(t, tc.err) { - var k *Error - if errors.As(err, &k) { - assert.Equal(t, tc.err.Type, k.Type) - assert.Equal(t, tc.err.Detail, k.Detail) - assert.Equal(t, tc.err.Status, k.Status) - assert.Equal(t, tc.err.Err.Error(), k.Err.Error()) - } else { - assert.Fail(t, "unexpected error type") - } + err := tc.ch.Validate(ctx, tc.db, tc.jwk, tc.payload) + if tc.err != nil { + var k *Error + if errors.As(err, &k) { + assert.Equal(t, tc.err.Type, k.Type) + assert.Equal(t, tc.err.Detail, k.Detail) + assert.Equal(t, tc.err.Status, k.Status) + assert.Equal(t, tc.err.Err.Error(), k.Err.Error()) + } else { + assert.Fail(t, "unexpected error type") } - } else { - assert.Nil(t, tc.err) + return } + + assert.NoError(t, err) }) } } +func mustJWKServer(t *testing.T, pub jose.JSONWebKey) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + server := httptest.NewServer(mux) + b, err := json.Marshal(struct { + Keys []jose.JSONWebKey `json:"keys,omitempty"` + }{ + Keys: []jose.JSONWebKey{pub}, + }) + require.NoError(t, err) + jwks := string(b) + + wellKnown := fmt.Sprintf(`{ + "issuer": "%[1]s", + "authorization_endpoint": "%[1]s/auth", + "token_endpoint": "%[1]s/token", + "jwks_uri": "%[1]s/keys", + "userinfo_endpoint": "%[1]s/userinfo", + "id_token_signing_alg_values_supported": ["ES256"] + }`, server.URL) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, req *http.Request) { + _, err := io.WriteString(w, wellKnown) + if err != nil { + w.WriteHeader(500) + } + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, req *http.Request) { + _, err := io.WriteString(w, jwks) + if err != nil { + w.WriteHeader(500) + } + }) + + t.Cleanup(server.Close) + return server +} + type errReader int func (errReader) Read([]byte) (int, error) { diff --git a/acme/challenge_wire_test.go b/acme/challenge_wire_test.go new file mode 100644 index 000000000..5a471a0fc --- /dev/null +++ b/acme/challenge_wire_test.go @@ -0,0 +1,2215 @@ +package acme + +import ( + "context" + "crypto" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/smallstep/certificates/acme/wire" + "github.com/smallstep/certificates/authority/provisioner" + wireprovisioner "github.com/smallstep/certificates/authority/provisioner/wire" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.step.sm/crypto/jose" + "go.step.sm/crypto/pemutil" +) + +func Test_wireDPOP01Validate(t *testing.T) { + fakeKey := `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` + type test struct { + ch *Challenge + jwk *jose.JSONWebKey + db DB + payload []byte + ctx context.Context + expectedErr *Error + } + tests := map[string]func(t *testing.T) test{ + "fail/no-provisioner": func(t *testing.T) test { + return test{ + ctx: context.Background(), + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New("missing provisioner"), + }, + } + }, + "fail/no-linker": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + return test{ + ctx: ctx, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New("missing linker"), + }, + } + }, + "fail/unmarshal": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ctx: ctx, + payload: []byte("?!"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: "1234", + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:malformed", + Detail: "The request message was malformed", + Status: 400, + Err: errors.New(`error unmarshalling Wire DPoP challenge payload: invalid character '?' looking for beginning of value`), + }, + } + }, + "fail/wire-parse-id": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ctx: ctx, + payload: []byte("{}"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: "1234", + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`error unmarshalling challenge data: json: cannot unmarshal number into Go value of type wire.ID`), + }, + } + }, + "fail/wire-parse-client-id": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + return test{ + ctx: ctx, + payload: []byte("{}"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`error parsing device id: invalid Wire client ID username "594930e9d50bb175"`), + }, + } + }, + "fail/no-wire-options": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + return test{ + ctx: ctx, + payload: []byte("{}"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`failed getting Wire options: no Wire options available`), + }, + } + }, + "fail/parse-and-verify": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "{{ .DeviceID }}", + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + jwk, _ := mustAccountAndKeyAuthorization(t, "token") + return test{ + ctx: ctx, + payload: []byte("{}"), + jwk: jwk, + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, ch *Challenge) error { + assert.Equal(t, "chID", ch.ID) + assert.Equal(t, "azID", ch.AuthorizationID) + assert.Equal(t, "accID", ch.AccountID) + assert.Equal(t, "token", ch.Token) + assert.Equal(t, ChallengeType("wire-dpop-01"), ch.Type) + assert.Equal(t, StatusInvalid, ch.Status) + assert.Equal(t, string(valueBytes), ch.Value) + if assert.NotNil(t, ch.Error) { + var k *Error // NOTE: the error is not returned up, but stored with the challenge instead + if errors.As(ch.Error, &k) { + assert.Equal(t, "urn:ietf:params:acme:error:rejectedIdentifier", k.Type) + assert.Equal(t, "The server will not issue certificates for the identifier", k.Detail) + assert.Equal(t, 400, k.Status) + assert.Equal(t, `failed validating Wire access token: failed parsing token: go-jose/go-jose: compact JWS format must have three parts`, k.Err.Error()) + } + } + return nil + }, + }, + } + }, + "fail/db.UpdateChallenge": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`error updating challenge: fail`), + }, + } + }, + "fail/db.GetAllOrdersByAccountID": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return nil, errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`could not find current order by account id: fail`), + }, + } + }, + "fail/db.GetAllOrdersByAccountID-zero": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{}, nil + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`there are not enough orders for this account for this custom OIDC challenge`), + }, + } + }, + "fail/db.CreateDpopToken": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateDpopToken: func(ctx context.Context, orderID string, dpop map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "token", dpop["chal"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", dpop["handle"].(string)) + assert.Equal(t, "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", dpop["sub"].(string)) + return errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`failed storing DPoP token: fail`), + }, + } + }, + "ok": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + _ = keyAuth // TODO(hs): keyAuth (not) required for DPoP? Or needs to be added to validation? + dpopSigner, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(jwk.Algorithm), + Key: jwk, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + signerPEMBlock, err := pemutil.Serialize(signerJWK.Public().Key) + require.NoError(t, err) + signerPEMBytes := pem.EncodeToMemory(signerPEMBlock) + dpopBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Handle string `json:"handle,omitempty"` + Nonce string `json:"nonce,omitempty"` + HTU string `json:"htu,omitempty"` + }{ + Claims: jose.Claims{ + Subject: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + }, + Challenge: "token", + Handle: "wireapp://%40alice_wire@wire.com", + Nonce: "nonce", + HTU: "http://issuer.example.com", + }) + require.NoError(t, err) + dpop, err := dpopSigner.Sign(dpopBytes) + require.NoError(t, err) + proof, err := dpop.CompactSerialize() + require.NoError(t, err) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Challenge string `json:"chal,omitempty"` + Nonce string `json:"nonce,omitempty"` + Cnf struct { + Kid string `json:"kid,omitempty"` + } `json:"cnf"` + Proof string `json:"proof,omitempty"` + ClientID string `json:"client_id"` + APIVersion int `json:"api_version"` + Scope string `json:"scope"` + }{ + Claims: jose.Claims{ + Issuer: "http://issuer.example.com", + Audience: jose.Audience{"https://ca.example.com/acme/wire/challenge/azID/chID"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Challenge: "token", + Nonce: "nonce", + Cnf: struct { + Kid string `json:"kid,omitempty"` + }{ + Kid: jwk.KeyID, + }, + Proof: proof, + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + APIVersion: 5, + Scope: "wire_client_id", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + accessToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + AccessToken string `json:"access_token"` + }{ + AccessToken: accessToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + Target: "http://issuer.example.com", + SigningKey: signerPEMBytes, + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-dpop-01", + Status: StatusPending, + Value: string(valueBytes), + }, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-dpop-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateDpopToken: func(ctx context.Context, orderID string, dpop map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "token", dpop["chal"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", dpop["handle"].(string)) + assert.Equal(t, "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", dpop["sub"].(string)) + return nil + }, + }, + } + }, + } + for name, run := range tests { + t.Run(name, func(t *testing.T) { + tc := run(t) + err := wireDPOP01Validate(tc.ctx, tc.ch, tc.db, tc.jwk, tc.payload) + if tc.expectedErr != nil { + var k *Error + if errors.As(err, &k) { + assert.Equal(t, tc.expectedErr.Type, k.Type) + assert.Equal(t, tc.expectedErr.Detail, k.Detail) + assert.Equal(t, tc.expectedErr.Status, k.Status) + assert.Equal(t, tc.expectedErr.Err.Error(), k.Err.Error()) + } else { + assert.Fail(t, "unexpected error type") + } + return + } + + assert.NoError(t, err) + }) + } +} + +func Test_wireOIDC01Validate(t *testing.T) { + fakeKey := `-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` + type test struct { + ch *Challenge + jwk *jose.JSONWebKey + db DB + payload []byte + srv *httptest.Server + ctx context.Context + expectedErr *Error + } + tests := map[string]func(t *testing.T) test{ + "fail/no-provisioner": func(t *testing.T) test { + return test{ + ctx: context.Background(), + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New("missing provisioner"), + }, + } + }, + "fail/no-linker": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + return test{ + ctx: ctx, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New("missing linker"), + }, + } + }, + "fail/unmarshal": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ctx: ctx, + payload: []byte("?!"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: "1234", + }, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, ch *Challenge) error { + assert.Equal(t, "chID", ch.ID) + return nil + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:malformed", + Detail: "The request message was malformed", + Status: 400, + Err: errors.New(`error unmarshalling Wire OIDC challenge payload: invalid character '?' looking for beginning of value`), + }, + } + }, + "fail/wire-parse-id": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ctx: ctx, + payload: []byte("{}"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: "1234", + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`error unmarshalling challenge data: json: cannot unmarshal number into Go value of type wire.ID`), + }, + } + }, + "fail/no-wire-options": func(t *testing.T) test { + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{})) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + return test{ + ctx: ctx, + payload: []byte("{}"), + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`failed getting Wire options: no Wire options available`), + }, + } + }, + "fail/verify": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + anotherSignerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + srv := mustJWKServer(t, anotherSignerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + if assert.NotNil(t, updch.Error) { + var k *Error // NOTE: the error is not returned up, but stored with the challenge instead + if errors.As(updch.Error, &k) { + assert.Equal(t, "urn:ietf:params:acme:error:rejectedIdentifier", k.Type) + assert.Equal(t, "The server will not issue certificates for the identifier", k.Detail) + assert.Equal(t, 400, k.Status) + assert.Equal(t, `error verifying ID token signature: failed to verify signature: failed to verify id token signature`, k.Err.Error()) + } + } + return nil + }, + }, + } + }, + "fail/keyauth-mismatch": func(t *testing.T) test { + jwk, _ := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: "wrong-keyauth", + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + if assert.NotNil(t, updch.Error) { + var k *Error // NOTE: the error is not returned up, but stored with the challenge instead + if errors.As(updch.Error, &k) { + assert.Equal(t, "urn:ietf:params:acme:error:rejectedIdentifier", k.Type) + assert.Equal(t, "The server will not issue certificates for the identifier", k.Detail) + assert.Equal(t, 400, k.Status) + assert.Contains(t, k.Err.Error(), "keyAuthorization does not match") + } + } + return nil + }, + }, + } + }, + "fail/validateWireOIDCClaims": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40bob@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusInvalid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + if assert.NotNil(t, updch.Error) { + var k *Error // NOTE: the error is not returned up, but stored with the challenge instead + if errors.As(updch.Error, &k) { + assert.Equal(t, "urn:ietf:params:acme:error:rejectedIdentifier", k.Type) + assert.Equal(t, "The server will not issue certificates for the identifier", k.Detail) + assert.Equal(t, 400, k.Status) + assert.Equal(t, `claims in OIDC ID token don't match: invalid 'preferred_username' "wireapp://%40bob@wire.com" after transformation`, k.Err.Error()) + } + } + return nil + }, + }, + } + }, + "fail/db.UpdateChallenge": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`error updating challenge: fail`), + }, + } + }, + "fail/db.GetAllOrdersByAccountID": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return nil, errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`could not retrieve current order by account id: fail`), + }, + } + }, + "fail/db.GetAllOrdersByAccountID-zero": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{}, nil + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`there are not enough orders for this account for this custom OIDC challenge`), + }, + } + }, + "fail/db.CreateOidcToken": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateOidcToken: func(ctx context.Context, orderID string, idToken map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "Alice Smith", idToken["name"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", idToken["preferred_username"].(string)) + return errors.New("fail") + }, + }, + expectedErr: &Error{ + Type: "urn:ietf:params:acme:error:serverInternal", + Detail: "The server experienced an internal error", + Status: 500, + Err: errors.New(`failed storing OIDC id token: fail`), + }, + } + }, + "ok/wire-oidc-01": func(t *testing.T) test { + jwk, keyAuth := mustAccountAndKeyAuthorization(t, "token") + signerJWK, err := jose.GenerateJWK("EC", "P-256", "ES256", "sig", "", 0) + require.NoError(t, err) + signer, err := jose.NewSigner(jose.SigningKey{ + Algorithm: jose.SignatureAlgorithm(signerJWK.Algorithm), + Key: signerJWK, + }, new(jose.SignerOptions)) + require.NoError(t, err) + srv := mustJWKServer(t, signerJWK.Public()) + tokenBytes, err := json.Marshal(struct { + jose.Claims + Name string `json:"name,omitempty"` + PreferredUsername string `json:"preferred_username,omitempty"` + KeyAuth string `json:"keyauth"` + ACMEAudience string `json:"acme_aud"` + }{ + Claims: jose.Claims{ + Issuer: srv.URL, + Audience: []string{"test"}, + Expiry: jose.NewNumericDate(time.Now().Add(1 * time.Minute)), + }, + Name: "Alice Smith", + PreferredUsername: "wireapp://%40alice_wire@wire.com", + KeyAuth: keyAuth, + ACMEAudience: "https://ca.example.com/acme/wire/challenge/azID/chID", + }) + require.NoError(t, err) + signed, err := signer.Sign(tokenBytes) + require.NoError(t, err) + idToken, err := signed.CompactSerialize() + require.NoError(t, err) + payload, err := json.Marshal(struct { + IDToken string `json:"id_token"` + }{ + IDToken: idToken, + }) + require.NoError(t, err) + valueBytes, err := json.Marshal(struct { + Name string `json:"name,omitempty"` + Domain string `json:"domain,omitempty"` + ClientID string `json:"client-id,omitempty"` + Handle string `json:"handle,omitempty"` + }{ + Name: "Alice Smith", + Domain: "wire.com", + ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + }) + require.NoError(t, err) + ctx := NewProvisionerContext(context.Background(), newWireProvisionerWithOptions(t, &provisioner.Options{ + Wire: &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: srv.URL, + JWKSURL: srv.URL + "/keys", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: "", + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + }, + })) + ctx = NewLinkerContext(ctx, NewLinker("ca.example.com", "acme")) + return test{ + ch: &Challenge{ + ID: "chID", + AuthorizationID: "azID", + AccountID: "accID", + Token: "token", + Type: "wire-oidc-01", + Status: StatusPending, + Value: string(valueBytes), + }, + srv: srv, + payload: payload, + ctx: ctx, + jwk: jwk, + db: &MockDB{ + MockUpdateChallenge: func(ctx context.Context, updch *Challenge) error { + assert.Equal(t, "chID", updch.ID) + assert.Equal(t, "token", updch.Token) + assert.Equal(t, StatusValid, updch.Status) + assert.Equal(t, ChallengeType("wire-oidc-01"), updch.Type) + assert.Equal(t, string(valueBytes), updch.Value) + return nil + }, + MockGetAllOrdersByAccountID: func(ctx context.Context, accountID string) ([]string, error) { + assert.Equal(t, "accID", accountID) + return []string{"orderID"}, nil + }, + MockCreateOidcToken: func(ctx context.Context, orderID string, idToken map[string]interface{}) error { + assert.Equal(t, "orderID", orderID) + assert.Equal(t, "Alice Smith", idToken["name"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", idToken["preferred_username"].(string)) + return nil + }, + }, + } + }, + } + for name, run := range tests { + t.Run(name, func(t *testing.T) { + tc := run(t) + if tc.srv != nil { + defer tc.srv.Close() + } + err := wireOIDC01Validate(tc.ctx, tc.ch, tc.db, tc.jwk, tc.payload) + if tc.expectedErr != nil { + var k *Error + if errors.As(err, &k) { + assert.Equal(t, tc.expectedErr.Type, k.Type) + assert.Equal(t, tc.expectedErr.Detail, k.Detail) + assert.Equal(t, tc.expectedErr.Status, k.Status) + assert.Equal(t, tc.expectedErr.Err.Error(), k.Err.Error()) + } else { + assert.Fail(t, "unexpected error type") + } + return + } + + assert.NoError(t, err) + }) + } +} + +func Test_parseAndVerifyWireAccessToken(t *testing.T) { + t.Skip("skip until we can retrieve public key from e2e test, so that we can actually verify the token") + key := ` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAB2IYqBWXAouDt3WcCZgCM3t9gumMEKMlgMsGenSu+fA= +-----END PUBLIC KEY-----` + publicKey, err := pemutil.Parse([]byte(key)) + require.NoError(t, err) + pk, ok := publicKey.(ed25519.PublicKey) + require.True(t, ok) + + issuer := "http://wire.com:19983/clients/7a41cf5b79683410/access-token" + wireID := wire.ID{ + ClientID: "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", + Handle: "wireapp://%40alice_wire@wire.com", + } + + token := `eyJhbGciOiJFZERTQSIsInR5cCI6ImF0K2p3dCIsImp3ayI6eyJrdHkiOiJPS1AiLCJjcnYiOiJFZDI1NTE5IiwieCI6Im8zcWZhQ045a2FzSnZJRlhPdFNMTGhlYW0wTE5jcVF5MHdBMk9PeFRRNW8ifX0.eyJpYXQiOjE3MDU0OTc3MzksImV4cCI6MTcwNTUwMTY5OSwibmJmIjoxNzA1NDk3NzM5LCJpc3MiOiJodHRwOi8vd2lyZS5jb206MTY4MjQvY2xpZW50cy8zN2ZlOThiZDQwZDBkZmUvYWNjZXNzLXRva2VuIiwic3ViIjoid2lyZWFwcDovLzE4NXdIUmtRVHdTOTVGODhaZTQ1SlEhMzdmZTk4YmQ0MGQwZGZlQHdpcmUuY29tIiwiYXVkIjoiaHR0cHM6Ly9zdGVwY2E6NTUwMjMvYWNtZS93aXJlL2NoYWxsZW5nZS9SeEdSWGVoRGxCcHcxNTJQTVUzem0xY2M0cEtGcHVWRi9RWnRFazdQNUVFRXhadHBSYngydjVoYlc3QXB1S2NOSSIsImp0aSI6ImU1MzllODYzLTRkNTgtNGMwMS1iYjk3LTYwODdiNTEzOWIyMCIsIm5vbmNlIjoiUzJKYWVWcExkV28wUkZKaFFrWndXR0ZKY0VoVlFrNUxXVGd4WkhkRFVqQSIsImNoYWwiOiIyaDFPdUdxbTBKUXd6bHVsWGtLSTJEMGZiRDgzRUIxdyIsImNuZiI6eyJraWQiOiJhSEY3MVhYeG0tTWE5Q05zSjNaU1RKTjlYS0ZxOFFmOGh2UTJLN3NLQmQ4In0sInByb29mIjoiZXlKaGJHY2lPaUpGWkVSVFFTSXNJblI1Y0NJNkltUndiM0FyYW5kMElpd2lhbmRySWpwN0ltdDBlU0k2SWs5TFVDSXNJbU55ZGlJNklrVmtNalUxTVRraUxDSjRJam9pWVVsaVMwcFBha0poWXpZeVF6TnRhVmhHVjAxb09ITTJkRXQzUkROaGNHRnVSMHBQZURaVVFYVklRU0o5ZlEuZXlKcFlYUWlPakUzTURVME9UYzNNemtzSW1WNGNDSTZNVGN3TlRVd05Ea3pPU3dpYm1KbUlqb3hOekExTkRrM056TTVMQ0p6ZFdJaU9pSjNhWEpsWVhCd09pOHZNVGcxZDBoU2ExRlVkMU01TlVZNE9GcGxORFZLVVNFek4yWmxPVGhpWkRRd1pEQmtabVZBZDJseVpTNWpiMjBpTENKaGRXUWlPaUpvZEhSd2N6b3ZMM04wWlhCallUbzFOVEF5TXk5aFkyMWxMM2RwY21VdlkyaGhiR3hsYm1kbEwxSjRSMUpZWldoRWJFSndkekUxTWxCTlZUTjZiVEZqWXpSd1MwWndkVlpHTDFGYWRFVnJOMUExUlVWRmVGcDBjRkppZURKMk5XaGlWemRCY0hWTFkwNUpJaXdpYW5ScElqb2lNV1kxTUdRM1lUQXRaamt6WmkwME5XWXdMV0V3TWpBdE1ETm1NREJpTlRreVlUUmtJaXdpYm05dVkyVWlPaUpUTWtwaFpWWndUR1JYYnpCU1JrcG9VV3RhZDFkSFJrcGpSV2hXVVdzMVRGZFVaM2hhU0dSRVZXcEJJaXdpYUhSdElqb2lVRTlUVkNJc0ltaDBkU0k2SW1oMGRIQTZMeTkzYVhKbExtTnZiVG94TmpneU5DOWpiR2xsYm5Sekx6TTNabVU1T0dKa05EQmtNR1JtWlM5aFkyTmxjM010ZEc5clpXNGlMQ0pqYUdGc0lqb2lNbWd4VDNWSGNXMHdTbEYzZW14MWJGaHJTMGt5UkRCbVlrUTRNMFZDTVhjaUxDSm9ZVzVrYkdVaU9pSjNhWEpsWVhCd09pOHZKVFF3WVd4cFkyVmZkMmx5WlVCM2FYSmxMbU52YlNJc0luUmxZVzBpT2lKM2FYSmxJbjAuZlNmQnFuWWlfMTRhZEc5MDAyZ0RJdEgybXNyYW55eXVnR0g5bHpFcmprdmRGbkRPOFRVWWRYUXJKUzdlX3BlU0lzcGxlRUVkaGhzc0gwM3FBWHY2QXciLCJjbGllbnRfaWQiOiJ3aXJlYXBwOi8vMTg1d0hSa1FUd1M5NUY4OFplNDVKUSEzN2ZlOThiZDQwZDBkZmVAd2lyZS5jb20iLCJhcGlfdmVyc2lvbiI6NSwic2NvcGUiOiJ3aXJlX2NsaWVudF9pZCJ9.GKK7ZsJ8EWJjeaHqf8P48H9mluJhxyXUmI0FO3xstda3XDJIK7Z5Ur4hi1OIJB0ZsS5BqRVT2q5whL4KP9hZCA` + ch := &Challenge{ + Token: "bXUGNpUfcRx3EhB34xP3y62aQZoGZS6j", + } + + issuedAtUnix, err := strconv.ParseInt("1704985205", 10, 64) + require.NoError(t, err) + issuedAt := time.Unix(issuedAtUnix, 0) + + jwkBytes := []byte(`{"crv": "Ed25519", "kty": "OKP", "x": "1L1eH2a6AgVvzTp5ZalKRfq6pVPOtEjI7h8TPzBYFgM"}`) + var accountJWK jose.JSONWebKey + json.Unmarshal(jwkBytes, &accountJWK) + + rawKid, err := accountJWK.Thumbprint(crypto.SHA256) + require.NoError(t, err) + accountJWK.KeyID = base64.RawURLEncoding.EncodeToString(rawKid) + + at, dpop, err := parseAndVerifyWireAccessToken(wireVerifyParams{ + token: token, + tokenKey: pk, + dpopKey: accountJWK.Public(), + dpopKeyID: accountJWK.KeyID, + issuer: issuer, + wireID: wireID, + chToken: ch.Token, + t: issuedAt.Add(1 * time.Minute), // set validation time to be one minute after issuance + }) + + if assert.NoError(t, err) { + // token assertions + assert.Equal(t, "42c46d4c-e510-4175-9fb5-d055e125a49d", at.ID) + assert.Equal(t, "http://wire.com:19983/clients/7a41cf5b79683410/access-token", at.Issuer) + assert.Equal(t, "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", at.Subject) + assert.Contains(t, at.Audience, "http://wire.com:19983/clients/7a41cf5b79683410/access-token") + assert.Equal(t, "bXUGNpUfcRx3EhB34xP3y62aQZoGZS6j", at.Challenge) + assert.Equal(t, "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", at.ClientID) + assert.Equal(t, 5, at.APIVersion) + assert.Equal(t, "wire_client_id", at.Scope) + if assert.NotNil(t, at.Cnf) { + assert.Equal(t, "oMWfNDJQsI5cPlXN5UoBNncKtc4f2dq2vwCjjXsqw7Q", at.Cnf.Kid) + } + + // dpop proof assertions + dt := *dpop + assert.Equal(t, "bXUGNpUfcRx3EhB34xP3y62aQZoGZS6j", dt["chal"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", dt["handle"].(string)) + assert.Equal(t, "POST", dt["htm"].(string)) + assert.Equal(t, "http://wire.com:19983/clients/7a41cf5b79683410/access-token", dt["htu"].(string)) + assert.Equal(t, "5e6684cb-6b48-468d-b091-ff04bed6ec2e", dt["jti"].(string)) + assert.Equal(t, "UEJyR2dqOEhzZFJEYWJBaTkyODNEYTE2aEs0dHIxcEc", dt["nonce"].(string)) + assert.Equal(t, "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", dt["sub"].(string)) + assert.Equal(t, "wire", dt["team"].(string)) + } +} + +func Test_validateWireOIDCClaims(t *testing.T) { + fakeKey := ` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` + opts := &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "http://dex:15818/dex", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "wireapp", + SignatureAlgorithms: []string{"ES256"}, + Now: func() time.Time { + return time.Date(2024, 1, 12, 18, 32, 41, 0, time.UTC) // (Token Expiry: 2024-01-12 21:32:42 +0100 CET) + }, + InsecureSkipSignatureCheck: true, // skipping signature check for this specific test + }, + TransformTemplate: `{"name": "{{ .preferred_username }}", "preferred_username": "{{ .name }}"}`, + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + } + + err := opts.Validate() + require.NoError(t, err) + + idTokenString := `eyJhbGciOiJSUzI1NiIsImtpZCI6IjZhNDZlYzQ3YTQzYWI1ZTc4NzU3MzM5NWY1MGY4ZGQ5MWI2OTM5MzcifQ.eyJpc3MiOiJodHRwOi8vZGV4OjE1ODE4L2RleCIsInN1YiI6IkNqcDNhWEpsWVhCd09pOHZTMmh0VjBOTFpFTlRXakoyT1dWTWFHRk9XVlp6WnlFeU5UZzFNVEpoT0RRek5qTXhaV1V6UUhkcGNtVXVZMjl0RWdSc1pHRnciLCJhdWQiOiJ3aXJlYXBwIiwiZXhwIjoxNzA1MDkxNTYyLCJpYXQiOjE3MDUwMDUxNjIsIm5vbmNlIjoib0VjUzBRQUNXLVIyZWkxS09wUmZ2QSIsImF0X2hhc2giOiJoYzk0NmFwS25FeEV5TDVlSzJZMzdRIiwiY19oYXNoIjoidmRubFp2V1d1bVd1Z2NYR1JpOU5FUSIsIm5hbWUiOiJ3aXJlYXBwOi8vJTQwYWxpY2Vfd2lyZUB3aXJlLmNvbSIsInByZWZlcnJlZF91c2VybmFtZSI6IkFsaWNlIFNtaXRoIn0.aEBhWJugBJ9J_0L_4odUCg8SR8HMXVjd__X8uZRo42BSJQQO7-wdpy0jU3S4FOX9fQKr68wD61gS_QsnhfiT7w9U36mLpxaYlNVDCYfpa-gklVFit_0mjUOukXajTLK6H527TGiSss8z22utc40ckS1SbZa2BzKu3yOcqnFHUQwQc5sLYfpRABTB6WBoYFtnWDzdpyWJDaOzz7lfKYv2JBnf9vV8u8SYm-6gNKgtiQ3UUnjhIVUjdfHet2BMvmV2ooZ8V441RULCzKKG_sWZba-D_k_TOnSholGobtUOcKHlmVlmfUe8v7kuyBdhbPcembfgViaNldLQGKZjZfgvLg` + ctx := context.Background() + o := opts.GetOIDCOptions() + c := o.GetConfig() + verifier := o.GetProvider(ctx).Verifier(c) + idToken, err := verifier.Verify(ctx, idTokenString) + require.NoError(t, err) + + wireID := wire.ID{ + Name: "Alice Smith", + Handle: "wireapp://%40alice_wire@wire.com", + } + + got, err := validateWireOIDCClaims(o, idToken, wireID) + assert.NoError(t, err) + + assert.Equal(t, "wireapp://%40alice_wire@wire.com", got["preferred_username"].(string)) + assert.Equal(t, "Alice Smith", got["name"].(string)) + assert.Equal(t, "http://dex:15818/dex", got["iss"].(string)) +} + +func createWireOptions(t *testing.T, transformTemplate string) *wireprovisioner.Options { + t.Helper() + fakeKey := ` +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----` + opts := &wireprovisioner.Options{ + OIDC: &wireprovisioner.OIDCOptions{ + Provider: &wireprovisioner.Provider{ + IssuerURL: "https://issuer.example.com", + Algorithms: []string{"ES256"}, + }, + Config: &wireprovisioner.Config{ + ClientID: "unit test", + SignatureAlgorithms: []string{"ES256"}, + Now: time.Now, + }, + TransformTemplate: transformTemplate, + }, + DPOP: &wireprovisioner.DPOPOptions{ + SigningKey: []byte(fakeKey), + }, + } + + err := opts.Validate() + require.NoError(t, err) + + return opts +} + +func Test_idTokenTransformation(t *testing.T) { + // {"name": "wireapp://%40alice_wire@wire.com", "preferred_username": "Alice Smith", "iss": "http://dex:15818/dex", ...} + idTokenString := `eyJhbGciOiJSUzI1NiIsImtpZCI6IjZhNDZlYzQ3YTQzYWI1ZTc4NzU3MzM5NWY1MGY4ZGQ5MWI2OTM5MzcifQ.eyJpc3MiOiJodHRwOi8vZGV4OjE1ODE4L2RleCIsInN1YiI6IkNqcDNhWEpsWVhCd09pOHZTMmh0VjBOTFpFTlRXakoyT1dWTWFHRk9XVlp6WnlFeU5UZzFNVEpoT0RRek5qTXhaV1V6UUhkcGNtVXVZMjl0RWdSc1pHRnciLCJhdWQiOiJ3aXJlYXBwIiwiZXhwIjoxNzA1MDkxNTYyLCJpYXQiOjE3MDUwMDUxNjIsIm5vbmNlIjoib0VjUzBRQUNXLVIyZWkxS09wUmZ2QSIsImF0X2hhc2giOiJoYzk0NmFwS25FeEV5TDVlSzJZMzdRIiwiY19oYXNoIjoidmRubFp2V1d1bVd1Z2NYR1JpOU5FUSIsIm5hbWUiOiJ3aXJlYXBwOi8vJTQwYWxpY2Vfd2lyZUB3aXJlLmNvbSIsInByZWZlcnJlZF91c2VybmFtZSI6IkFsaWNlIFNtaXRoIn0.aEBhWJugBJ9J_0L_4odUCg8SR8HMXVjd__X8uZRo42BSJQQO7-wdpy0jU3S4FOX9fQKr68wD61gS_QsnhfiT7w9U36mLpxaYlNVDCYfpa-gklVFit_0mjUOukXajTLK6H527TGiSss8z22utc40ckS1SbZa2BzKu3yOcqnFHUQwQc5sLYfpRABTB6WBoYFtnWDzdpyWJDaOzz7lfKYv2JBnf9vV8u8SYm-6gNKgtiQ3UUnjhIVUjdfHet2BMvmV2ooZ8V441RULCzKKG_sWZba-D_k_TOnSholGobtUOcKHlmVlmfUe8v7kuyBdhbPcembfgViaNldLQGKZjZfgvLg` + var claims struct { + Name string `json:"name,omitempty"` + Handle string `json:"preferred_username,omitempty"` + Issuer string `json:"iss,omitempty"` + } + + idToken, err := jose.ParseSigned(idTokenString) + require.NoError(t, err) + err = idToken.UnsafeClaimsWithoutVerification(&claims) + require.NoError(t, err) + + // original token contains "Alice Smith" as handle, and name as "wireapp://%40alice_wire@wire.com" + assert.Equal(t, "Alice Smith", claims.Handle) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", claims.Name) + assert.Equal(t, "http://dex:15818/dex", claims.Issuer) + + var m map[string]any + err = idToken.UnsafeClaimsWithoutVerification(&m) + require.NoError(t, err) + + opts := createWireOptions(t, "") // uses default transformation template + result, err := opts.GetOIDCOptions().Transform(m) + require.NoError(t, err) + + // default transformation sets preferred username to handle; name as name + assert.Equal(t, "Alice Smith", result["preferred_username"].(string)) + assert.Equal(t, "wireapp://%40alice_wire@wire.com", result["name"].(string)) + assert.Equal(t, "http://dex:15818/dex", result["iss"].(string)) + + // swap the preferred_name and the name + swap := `{"name": "{{ .preferred_username }}", "preferred_username": "{{ .name }}"}` + opts = createWireOptions(t, swap) + result, err = opts.GetOIDCOptions().Transform(m) + require.NoError(t, err) + + // with the transformation, handle now contains wireapp://%40alice_wire@wire.com, name contains Alice Smith + assert.Equal(t, "wireapp://%40alice_wire@wire.com", result["preferred_username"].(string)) + assert.Equal(t, "Alice Smith", result["name"].(string)) + assert.Equal(t, "http://dex:15818/dex", result["iss"].(string)) +} diff --git a/acme/db/nosql/wire.go b/acme/db/nosql/wire.go index 9ceeb52d1..03b935059 100644 --- a/acme/db/nosql/wire.go +++ b/acme/db/nosql/wire.go @@ -6,7 +6,6 @@ import ( "fmt" "time" - "github.com/pkg/errors" "github.com/smallstep/certificates/acme" "github.com/smallstep/nosql" ) @@ -20,15 +19,16 @@ type dbDpopToken struct { // getDBDpopToken retrieves and unmarshals an DPoP type from the database. func (db *DB) getDBDpopToken(_ context.Context, orderID string) (*dbDpopToken, error) { b, err := db.db.Get(wireDpopTokenTable, []byte(orderID)) - if nosql.IsErrNotFound(err) { - return nil, acme.NewError(acme.ErrorMalformedType, "dpop %s not found", orderID) - } else if err != nil { - return nil, errors.Wrapf(err, "error loading dpop %s", orderID) + if err != nil { + if nosql.IsErrNotFound(err) { + return nil, acme.NewError(acme.ErrorMalformedType, "dpop token %q not found", orderID) + } + return nil, fmt.Errorf("failed loading dpop token %q: %w", orderID, err) } d := new(dbDpopToken) if err := json.Unmarshal(b, d); err != nil { - return nil, errors.Wrapf(err, "error unmarshaling dpop %s into dbDpopToken", orderID) + return nil, fmt.Errorf("failed unmarshaling dpop token %q into dbDpopToken: %w", orderID, err) } return d, nil } @@ -50,7 +50,7 @@ func (db *DB) GetDpopToken(ctx context.Context, orderID string) (map[string]any, func (db *DB) CreateDpopToken(ctx context.Context, orderID string, dpop map[string]any) error { content, err := json.Marshal(dpop) if err != nil { - return err + return fmt.Errorf("failed marshaling dpop token: %w", err) } now := clock.Now() @@ -74,14 +74,16 @@ type dbOidcToken struct { // getDBOidcToken retrieves and unmarshals an OIDC id token type from the database. func (db *DB) getDBOidcToken(_ context.Context, orderID string) (*dbOidcToken, error) { b, err := db.db.Get(wireOidcTokenTable, []byte(orderID)) - if nosql.IsErrNotFound(err) { - return nil, acme.NewError(acme.ErrorMalformedType, "oidc token %s not found", orderID) - } else if err != nil { - return nil, errors.Wrapf(err, "error loading oidc token %s", orderID) + if err != nil { + if nosql.IsErrNotFound(err) { + return nil, acme.NewError(acme.ErrorMalformedType, "oidc token %q not found", orderID) + } + return nil, fmt.Errorf("failed loading oidc token %q: %w", orderID, err) } + o := new(dbOidcToken) if err := json.Unmarshal(b, o); err != nil { - return nil, errors.Wrapf(err, "error unmarshaling oidc token %s into dbOidcToken", orderID) + return nil, fmt.Errorf("failed unmarshaling oidc token %q into dbOidcToken: %w", orderID, err) } return o, nil } @@ -103,7 +105,7 @@ func (db *DB) GetOidcToken(ctx context.Context, orderID string) (map[string]any, func (db *DB) CreateOidcToken(ctx context.Context, orderID string, idToken map[string]any) error { content, err := json.Marshal(idToken) if err != nil { - return err + return fmt.Errorf("failed marshaling oidc token: %w", err) } now := clock.Now() diff --git a/acme/db/nosql/wire_test.go b/acme/db/nosql/wire_test.go new file mode 100644 index 000000000..6759f4208 --- /dev/null +++ b/acme/db/nosql/wire_test.go @@ -0,0 +1,394 @@ +package nosql + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/smallstep/certificates/acme" + certificatesdb "github.com/smallstep/certificates/db" + "github.com/smallstep/nosql" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDB_GetDpopToken(t *testing.T) { + type test struct { + db *DB + orderID string + expected map[string]any + expectedErr error + } + var tests = map[string]func(t *testing.T) test{ + "fail/acme-not-found": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: &acme.Error{ + Type: "urn:ietf:params:acme:error:malformed", + Status: 400, + Detail: "The request message was malformed", + Err: errors.New(`dpop token "orderID" not found`), + }, + } + }, + "fail/unmarshal-error": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + token := dbDpopToken{ + ID: "orderID", + Content: []byte("{}"), + CreatedAt: time.Now(), + } + b, err := json.Marshal(token) + require.NoError(t, err) + err = db.Set(wireDpopTokenTable, []byte("orderID"), b[1:]) // start at index 1; corrupt JSON data + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: errors.New(`failed unmarshaling dpop token "orderID" into dbDpopToken: invalid character ':' after top-level value`), + } + }, + "fail/db.Get": func(t *testing.T) test { + db := &certificatesdb.MockNoSQLDB{ + MGet: func(bucket, key []byte) ([]byte, error) { + assert.Equal(t, wireDpopTokenTable, bucket) + assert.Equal(t, []byte("orderID"), key) + return nil, errors.New("fail") + }, + } + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: errors.New(`failed loading dpop token "orderID": fail`), + } + }, + "ok": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + token := dbDpopToken{ + ID: "orderID", + Content: []byte(`{"sub": "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com"}`), + CreatedAt: time.Now(), + } + b, err := json.Marshal(token) + require.NoError(t, err) + err = db.Set(wireDpopTokenTable, []byte("orderID"), b) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expected: map[string]any{ + "sub": "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", + }, + } + }, + } + for name, run := range tests { + tc := run(t) + t.Run(name, func(t *testing.T) { + got, err := tc.db.GetDpopToken(context.Background(), tc.orderID) + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + ae := &acme.Error{} + if errors.As(err, &ae) { + ee := &acme.Error{} + require.True(t, errors.As(tc.expectedErr, &ee)) + assert.Equal(t, ee.Detail, ae.Detail) + assert.Equal(t, ee.Type, ae.Type) + assert.Equal(t, ee.Status, ae.Status) + } + assert.Nil(t, got) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestDB_CreateDpopToken(t *testing.T) { + type test struct { + db *DB + orderID string + dpop map[string]any + expectedErr error + } + var tests = map[string]func(t *testing.T) test{ + "fail/db.Save": func(t *testing.T) test { + db := &certificatesdb.MockNoSQLDB{ + MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { + assert.Equal(t, wireDpopTokenTable, bucket) + assert.Equal(t, []byte("orderID"), key) + return nil, false, errors.New("fail") + }, + } + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + dpop: map[string]any{ + "sub": "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", + }, + expectedErr: errors.New("failed saving dpop token: error saving acme dpop: fail"), + } + }, + "ok": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + dpop: map[string]any{ + "sub": "wireapp://guVX5xeFS3eTatmXBIyA4A!7a41cf5b79683410@wire.com", + }, + } + }, + "ok/nil": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + dpop: nil, + } + }, + } + for name, run := range tests { + tc := run(t) + t.Run(name, func(t *testing.T) { + err := tc.db.CreateDpopToken(context.Background(), tc.orderID, tc.dpop) + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + + dpop, err := tc.db.getDBDpopToken(context.Background(), tc.orderID) + require.NoError(t, err) + + assert.Equal(t, tc.orderID, dpop.ID) + var m map[string]any + err = json.Unmarshal(dpop.Content, &m) + require.NoError(t, err) + + assert.Equal(t, tc.dpop, m) + }) + } +} + +func TestDB_GetOidcToken(t *testing.T) { + type test struct { + db *DB + orderID string + expected map[string]any + expectedErr error + } + var tests = map[string]func(t *testing.T) test{ + "fail/acme-not-found": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: &acme.Error{ + Type: "urn:ietf:params:acme:error:malformed", + Status: 400, + Detail: "The request message was malformed", + Err: errors.New(`oidc token "orderID" not found`), + }, + } + }, + "fail/unmarshal-error": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + token := dbOidcToken{ + ID: "orderID", + Content: []byte("{}"), + CreatedAt: time.Now(), + } + b, err := json.Marshal(token) + require.NoError(t, err) + err = db.Set(wireOidcTokenTable, []byte("orderID"), b[1:]) // start at index 1; corrupt JSON data + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: errors.New(`failed unmarshaling oidc token "orderID" into dbOidcToken: invalid character ':' after top-level value`), + } + }, + "fail/db.Get": func(t *testing.T) test { + db := &certificatesdb.MockNoSQLDB{ + MGet: func(bucket, key []byte) ([]byte, error) { + assert.Equal(t, wireOidcTokenTable, bucket) + assert.Equal(t, []byte("orderID"), key) + return nil, errors.New("fail") + }, + } + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expectedErr: errors.New(`failed loading oidc token "orderID": fail`), + } + }, + "ok": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + token := dbOidcToken{ + ID: "orderID", + Content: []byte(`{"name": "Alice Smith", "preferred_username": "@alice.smith"}`), + CreatedAt: time.Now(), + } + b, err := json.Marshal(token) + require.NoError(t, err) + err = db.Set(wireOidcTokenTable, []byte("orderID"), b) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + expected: map[string]any{ + "name": "Alice Smith", + "preferred_username": "@alice.smith", + }, + } + }, + } + for name, run := range tests { + tc := run(t) + t.Run(name, func(t *testing.T) { + got, err := tc.db.GetOidcToken(context.Background(), tc.orderID) + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + ae := &acme.Error{} + if errors.As(err, &ae) { + ee := &acme.Error{} + require.True(t, errors.As(tc.expectedErr, &ee)) + assert.Equal(t, ee.Detail, ae.Detail) + assert.Equal(t, ee.Type, ae.Type) + assert.Equal(t, ee.Status, ae.Status) + } + assert.Nil(t, got) + return + } + + assert.NoError(t, err) + assert.Equal(t, tc.expected, got) + }) + } +} + +func TestDB_CreateOidcToken(t *testing.T) { + type test struct { + db *DB + orderID string + oidc map[string]any + expectedErr error + } + var tests = map[string]func(t *testing.T) test{ + "fail/db.Save": func(t *testing.T) test { + db := &certificatesdb.MockNoSQLDB{ + MCmpAndSwap: func(bucket, key, old, newval []byte) ([]byte, bool, error) { + assert.Equal(t, wireOidcTokenTable, bucket) + assert.Equal(t, []byte("orderID"), key) + return nil, false, errors.New("fail") + }, + } + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + oidc: map[string]any{ + "name": "Alice Smith", + "preferred_username": "@alice.smith", + }, + expectedErr: errors.New("failed saving oidc token: error saving acme oidc: fail"), + } + }, + "ok": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + oidc: map[string]any{ + "name": "Alice Smith", + "preferred_username": "@alice.smith", + }, + } + }, + "ok/nil": func(t *testing.T) test { + dir := t.TempDir() + db, err := nosql.New("badgerv2", dir) + require.NoError(t, err) + return test{ + db: &DB{ + db: db, + }, + orderID: "orderID", + oidc: nil, + } + }, + } + for name, run := range tests { + tc := run(t) + t.Run(name, func(t *testing.T) { + err := tc.db.CreateOidcToken(context.Background(), tc.orderID, tc.oidc) + if tc.expectedErr != nil { + assert.EqualError(t, err, tc.expectedErr.Error()) + return + } + + assert.NoError(t, err) + + oidc, err := tc.db.getDBOidcToken(context.Background(), tc.orderID) + require.NoError(t, err) + + assert.Equal(t, tc.orderID, oidc.ID) + var m map[string]any + err = json.Unmarshal(oidc.Content, &m) + require.NoError(t, err) + + assert.Equal(t, tc.oidc, m) + }) + } +} diff --git a/acme/wire/id_test.go b/acme/wire/id_test.go new file mode 100644 index 000000000..36c81d23d --- /dev/null +++ b/acme/wire/id_test.go @@ -0,0 +1,58 @@ +package wire + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseID(t *testing.T) { + ok := `{"name": "Alice Smith", "domain": "wire.com", "client-id": "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", "handle": "wireapp://%40alice_wire@wire.com"}` + tests := []struct { + name string + data []byte + wantWireID ID + expectedErr error + }{ + {name: "ok", data: []byte(ok), wantWireID: ID{Name: "Alice Smith", Domain: "wire.com", ClientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", Handle: "wireapp://%40alice_wire@wire.com"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotWireID, err := ParseID(tt.data) + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.wantWireID, gotWireID) + }) + } +} + +func TestParseClientID(t *testing.T) { + tests := []struct { + name string + clientID string + want ClientID + expectedErr error + }{ + {name: "ok", clientID: "wireapp://CzbfFjDOQrenCbDxVmgnFw!594930e9d50bb175@wire.com", want: ClientID{Scheme: "wireapp", Username: "CzbfFjDOQrenCbDxVmgnFw", DeviceID: "594930e9d50bb175", Domain: "wire.com"}}, + {name: "fail/uri", clientID: "bla", expectedErr: errors.New(`invalid Wire client ID URI "bla": error parsing bla: scheme is missing`)}, + {name: "fail/scheme", clientID: "not-wireapp://bla.com", expectedErr: errors.New(`invalid Wire client ID scheme "not-wireapp"; expected "wireapp"`)}, + {name: "fail/username", clientID: "wireapp://user@wire.com", expectedErr: errors.New(`invalid Wire client ID username "user"`)}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseClientID(tt.clientID) + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/authority/provisioner/dpop_options.go b/authority/provisioner/dpop_options.go deleted file mode 100644 index f656fb96c..000000000 --- a/authority/provisioner/dpop_options.go +++ /dev/null @@ -1,55 +0,0 @@ -package provisioner - -import ( - "bytes" - "errors" - "fmt" - "text/template" -) - -type DPOPOptions struct { - // ValidationExecPath is the name of the executable to call for DPOP - // validation. - ValidationExecPath string `json:"validation-exec-path,omitempty"` - // Backend signing key for DPoP access token - SigningKey string `json:"key"` - // URI template acme client must call to fetch the DPoP challenge proof (an access token from wire-server) - DpopTarget string `json:"dpop-target"` -} - -func (o *DPOPOptions) GetValidationExecPath() string { - if o == nil { - return "rusty-jwt-cli" - } - return o.ValidationExecPath -} - -func (o *DPOPOptions) GetSigningKey() string { - if o == nil { - return "" - } - return o.SigningKey -} - -func (o *DPOPOptions) GetDPOPTarget() string { - if o == nil { - return "" - } - return o.DpopTarget -} - -func (o *DPOPOptions) GetTarget(deviceID string) (string, error) { - if o == nil { - return "", errors.New("misconfigured target template configuration") - } - targetTemplate := o.GetDPOPTarget() - tmpl, err := template.New("DeviceId").Parse(targetTemplate) - if err != nil { - return "", fmt.Errorf("failed parsing dpop template: %w", err) - } - buf := new(bytes.Buffer) - if err = tmpl.Execute(buf, struct{ DeviceId string }{DeviceId: deviceID}); err != nil { //nolint:revive,stylecheck // TODO(hs): this requires changes in configuration - return "", fmt.Errorf("failed executing dpop template: %w", err) - } - return buf.String(), nil -} diff --git a/authority/provisioner/oidc_options.go b/authority/provisioner/oidc_options.go deleted file mode 100644 index a2c2f1735..000000000 --- a/authority/provisioner/oidc_options.go +++ /dev/null @@ -1,90 +0,0 @@ -package provisioner - -import ( - "bytes" - "context" - "errors" - "fmt" - "net/url" - "text/template" - "time" - - "github.com/coreos/go-oidc/v3/oidc" -) - -type ProviderJSON struct { - IssuerURL string `json:"issuer,omitempty"` - AuthURL string `json:"authorization_endpoint,omitempty"` - TokenURL string `json:"token_endpoint,omitempty"` - JWKSURL string `json:"jwks_uri,omitempty"` - UserInfoURL string `json:"userinfo_endpoint,omitempty"` - Algorithms []string `json:"id_token_signing_alg_values_supported,omitempty"` -} - -type ConfigJSON struct { - ClientID string `json:"client-id,omitempty"` - SupportedSigningAlgs []string `json:"support-signing-algs,omitempty"` - SkipClientIDCheck bool `json:"-"` - SkipExpiryCheck bool `json:"-"` - SkipIssuerCheck bool `json:"-"` - Now func() time.Time `json:"-"` - InsecureSkipSignatureCheck bool `json:"-"` -} - -type OIDCOptions struct { - Provider ProviderJSON `json:"provider,omitempty"` - Config ConfigJSON `json:"config,omitempty"` -} - -func (o *OIDCOptions) GetProvider(ctx context.Context) *oidc.Provider { - if o == nil { - return nil - } - return toProviderConfig(o.Provider).NewProvider(ctx) -} - -func (o *OIDCOptions) GetConfig() *oidc.Config { - if o == nil { - return &oidc.Config{} - } - config := oidc.Config(o.Config) - return &config -} - -func (o *OIDCOptions) GetTarget(deviceID string) (string, error) { - if o == nil { - return "", errors.New("misconfigured target template configuration") - } - targetTemplate := o.Provider.IssuerURL - tmpl, err := template.New("DeviceId").Parse(targetTemplate) - if err != nil { - return "", fmt.Errorf("failed parsing oidc template: %w", err) - } - buf := new(bytes.Buffer) - if err = tmpl.Execute(buf, struct{ DeviceId string }{DeviceId: deviceID}); err != nil { //nolint:revive,stylecheck // TODO(hs): this requires changes in configuration - return "", fmt.Errorf("failed executing oidc template: %w", err) - } - return buf.String(), nil -} - -func toProviderConfig(in ProviderJSON) *oidc.ProviderConfig { - issuerURL, err := url.Parse(in.IssuerURL) - if err != nil { - panic(err) // config error, it's ok to panic here - } - // Removes query params from the URL because we use it as a way to notify client about the actual OAuth ClientId - // for this provisioner. - // This URL is going to look like: "https://idp:5556/dex?clientid=foo" - // If we don't trim the query params here i.e. 'clientid' then the idToken verification is going to fail because - // the 'iss' claim of the idToken will be "https://idp:5556/dex" - issuerURL.RawQuery = "" - issuerURL.Fragment = "" - return &oidc.ProviderConfig{ - IssuerURL: issuerURL.String(), - AuthURL: in.AuthURL, - TokenURL: in.TokenURL, - UserInfoURL: in.UserInfoURL, - JWKSURL: in.JWKSURL, - Algorithms: in.Algorithms, - } -} diff --git a/authority/provisioner/options.go b/authority/provisioner/options.go index abe8722bf..135327349 100644 --- a/authority/provisioner/options.go +++ b/authority/provisioner/options.go @@ -2,6 +2,7 @@ package provisioner import ( "encoding/json" + "fmt" "strings" "github.com/pkg/errors" @@ -11,6 +12,7 @@ import ( "go.step.sm/crypto/x509util" "github.com/smallstep/certificates/authority/policy" + "github.com/smallstep/certificates/authority/provisioner/wire" ) // CertificateOptions is an interface that returns a list of options passed when @@ -30,11 +32,10 @@ func (fn certificateOptionsFunc) Options(so SignOptions) []x509util.Option { type Options struct { X509 *X509Options `json:"x509,omitempty"` SSH *SSHOptions `json:"ssh,omitempty"` - OIDC *OIDCOptions `json:"oidc,omitempty"` - DPOP *DPOPOptions `json:"dpop,omitempty"` - // Webhooks is a list of webhooks that can augment template data Webhooks []*Webhook `json:"webhooks,omitempty"` + // Wire holds the options used for the ACME Wire integration + Wire *wire.Options `json:"wire,omitempty"` } // GetX509Options returns the X.509 options. @@ -53,20 +54,18 @@ func (o *Options) GetSSHOptions() *SSHOptions { return o.SSH } -// GetOIDCOptions returns the OIDC options. -func (o *Options) GetOIDCOptions() *OIDCOptions { +// GetWireOptions returns the SSH options. +func (o *Options) GetWireOptions() (*wire.Options, error) { if o == nil { - return nil + return nil, errors.New("no options available") } - return o.OIDC -} - -// GetDPOPOptions returns the OIDC options. -func (o *Options) GetDPOPOptions() *DPOPOptions { - if o == nil { - return nil + if o.Wire == nil { + return nil, errors.New("no Wire options available") + } + if err := o.Wire.Validate(); err != nil { + return nil, fmt.Errorf("failed validating Wire options: %w", err) } - return o.DPOP + return o.Wire, nil } // GetWebhooks returns the webhooks options. diff --git a/authority/provisioner/wire/dpop_options.go b/authority/provisioner/wire/dpop_options.go new file mode 100644 index 000000000..721eab014 --- /dev/null +++ b/authority/provisioner/wire/dpop_options.go @@ -0,0 +1,45 @@ +package wire + +import ( + "bytes" + "crypto" + "fmt" + "text/template" + + "go.step.sm/crypto/pemutil" +) + +type DPOPOptions struct { + // Public part of the signing key for DPoP access token in PEM format + SigningKey []byte `json:"key"` + // URI template for the URI the ACME client must call to fetch the DPoP challenge proof (an access token from wire-server) + Target string `json:"target"` + + signingKey crypto.PublicKey + target *template.Template +} + +func (o *DPOPOptions) GetSigningKey() crypto.PublicKey { + return o.signingKey +} + +func (o *DPOPOptions) EvaluateTarget(deviceID string) (string, error) { + buf := new(bytes.Buffer) + if err := o.target.Execute(buf, struct{ DeviceID string }{DeviceID: deviceID}); err != nil { + return "", fmt.Errorf("failed executing dpop template: %w", err) + } + return buf.String(), nil +} + +func (o *DPOPOptions) validateAndInitialize() (err error) { + o.signingKey, err = pemutil.Parse(o.SigningKey) + if err != nil { + return fmt.Errorf("failed parsing key: %w", err) + } + o.target, err = template.New("DeviceID").Parse(o.Target) + if err != nil { + return fmt.Errorf("failed parsing DPoP template: %w", err) + } + + return nil +} diff --git a/authority/provisioner/wire/oidc_options.go b/authority/provisioner/wire/oidc_options.go new file mode 100644 index 000000000..5bbcbc7a0 --- /dev/null +++ b/authority/provisioner/wire/oidc_options.go @@ -0,0 +1,157 @@ +package wire + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "text/template" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "go.step.sm/crypto/x509util" +) + +type Provider struct { + IssuerURL string `json:"issuer,omitempty"` + AuthURL string `json:"authorization_endpoint,omitempty"` + TokenURL string `json:"token_endpoint,omitempty"` + JWKSURL string `json:"jwks_uri,omitempty"` + UserInfoURL string `json:"userinfo_endpoint,omitempty"` + Algorithms []string `json:"id_token_signing_alg_values_supported,omitempty"` +} + +type Config struct { + ClientID string `json:"clientId,omitempty"` + SignatureAlgorithms []string `json:"signatureAlgorithms,omitempty"` + + // the properties below are only used for testing + SkipClientIDCheck bool `json:"-"` + SkipExpiryCheck bool `json:"-"` + SkipIssuerCheck bool `json:"-"` + InsecureSkipSignatureCheck bool `json:"-"` + Now func() time.Time `json:"-"` +} + +type OIDCOptions struct { + Provider *Provider `json:"provider,omitempty"` + Config *Config `json:"config,omitempty"` + TransformTemplate string `json:"transform,omitempty"` + + oidcProviderConfig *oidc.ProviderConfig + target *template.Template + transform *template.Template +} + +func (o *OIDCOptions) GetProvider(ctx context.Context) *oidc.Provider { + if o == nil || o.Provider == nil || o.oidcProviderConfig == nil { + return nil + } + return o.oidcProviderConfig.NewProvider(ctx) +} + +func (o *OIDCOptions) GetConfig() *oidc.Config { + if o == nil || o.Config == nil { + return &oidc.Config{} + } + + return &oidc.Config{ + ClientID: o.Config.ClientID, + SupportedSigningAlgs: o.Config.SignatureAlgorithms, + SkipClientIDCheck: o.Config.SkipClientIDCheck, + SkipExpiryCheck: o.Config.SkipExpiryCheck, + SkipIssuerCheck: o.Config.SkipIssuerCheck, + Now: o.Config.Now, + InsecureSkipSignatureCheck: o.Config.InsecureSkipSignatureCheck, + } +} + +const defaultTemplate = `{"name": "{{ .name }}", "preferred_username": "{{ .preferred_username }}"}` + +func (o *OIDCOptions) validateAndInitialize() (err error) { + if o.Provider == nil { + return errors.New("provider not set") + } + if o.Provider.IssuerURL == "" { + return errors.New("issuer URL must not be empty") + } + + o.oidcProviderConfig, err = toOIDCProviderConfig(o.Provider) + if err != nil { + return fmt.Errorf("failed creationg OIDC provider config: %w", err) + } + + o.target, err = template.New("DeviceID").Parse(o.Provider.IssuerURL) + if err != nil { + return fmt.Errorf("failed parsing OIDC template: %w", err) + } + + o.transform, err = parseTransform(o.TransformTemplate) + if err != nil { + return fmt.Errorf("failed parsing OIDC transformation template: %w", err) + } + + return nil +} + +func parseTransform(transformTemplate string) (*template.Template, error) { + if transformTemplate == "" { + transformTemplate = defaultTemplate + } + + return template.New("transform").Funcs(x509util.GetFuncMap()).Parse(transformTemplate) +} + +func (o *OIDCOptions) EvaluateTarget(deviceID string) (string, error) { + buf := new(bytes.Buffer) + if err := o.target.Execute(buf, struct{ DeviceID string }{DeviceID: deviceID}); err != nil { + return "", fmt.Errorf("failed executing OIDC template: %w", err) + } + return buf.String(), nil +} + +func (o *OIDCOptions) Transform(v map[string]any) (map[string]any, error) { + if o.transform == nil || v == nil { + return v, nil + } + // TODO(hs): add support for extracting error message from template "fail" function? + buf := new(bytes.Buffer) + if err := o.transform.Execute(buf, v); err != nil { + return nil, fmt.Errorf("failed executing OIDC transformation: %w", err) + } + var r map[string]any + if err := json.Unmarshal(buf.Bytes(), &r); err != nil { + return nil, fmt.Errorf("failed unmarshaling transformed OIDC token: %w", err) + } + // add original claims if not yet in the transformed result + for key, value := range v { + if _, ok := r[key]; !ok { + r[key] = value + } + } + return r, nil +} + +func toOIDCProviderConfig(in *Provider) (*oidc.ProviderConfig, error) { + issuerURL, err := url.Parse(in.IssuerURL) + if err != nil { + return nil, fmt.Errorf("failed parsing issuer URL: %w", err) + } + // Removes query params from the URL because we use it as a way to notify client about the actual OAuth ClientId + // for this provisioner. + // This URL is going to look like: "https://idp:5556/dex?clientid=foo" + // If we don't trim the query params here i.e. 'clientid' then the idToken verification is going to fail because + // the 'iss' claim of the idToken will be "https://idp:5556/dex" + issuerURL.RawQuery = "" + issuerURL.Fragment = "" + return &oidc.ProviderConfig{ + IssuerURL: issuerURL.String(), + AuthURL: in.AuthURL, + TokenURL: in.TokenURL, + UserInfoURL: in.UserInfoURL, + JWKSURL: in.JWKSURL, + Algorithms: in.Algorithms, + }, nil +} diff --git a/authority/provisioner/wire/oidc_options_test.go b/authority/provisioner/wire/oidc_options_test.go new file mode 100644 index 000000000..8b3eaa75e --- /dev/null +++ b/authority/provisioner/wire/oidc_options_test.go @@ -0,0 +1,121 @@ +package wire + +import ( + "testing" + "text/template" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestOIDCOptions_Transform(t *testing.T) { + defaultTransform, err := parseTransform(``) + require.NoError(t, err) + swapTransform, err := parseTransform(`{"name": "{{ .preferred_username }}", "preferred_username": "{{ .name }}"}`) + require.NoError(t, err) + funcTransform, err := parseTransform(`{"name": "{{ .name }}", "preferred_username": "{{ first .usernames }}"}`) + require.NoError(t, err) + type fields struct { + transform *template.Template + } + type args struct { + v map[string]any + } + tests := []struct { + name string + fields fields + args args + want map[string]any + expectedErr error + }{ + { + name: "ok/no-transform", + fields: fields{ + transform: nil, + }, + args: args{ + v: map[string]any{ + "name": "Example", + "preferred_username": "Preferred", + }, + }, + want: map[string]any{ + "name": "Example", + "preferred_username": "Preferred", + }, + }, + { + name: "ok/empty-data", + fields: fields{ + transform: nil, + }, + args: args{ + v: map[string]any{}, + }, + want: map[string]any{}, + }, + { + name: "ok/default-transform", + fields: fields{ + transform: defaultTransform, + }, + args: args{ + v: map[string]any{ + "name": "Example", + "preferred_username": "Preferred", + }, + }, + want: map[string]any{ + "name": "Example", + "preferred_username": "Preferred", + }, + }, + { + name: "ok/swap-transform", + fields: fields{ + transform: swapTransform, + }, + args: args{ + v: map[string]any{ + "name": "Example", + "preferred_username": "Preferred", + }, + }, + want: map[string]any{ + "name": "Preferred", + "preferred_username": "Example", + }, + }, + { + name: "ok/transform-with-functions", + fields: fields{ + transform: funcTransform, + }, + args: args{ + v: map[string]any{ + "name": "Example", + "usernames": []string{"name-1", "name-2", "name-3"}, + }, + }, + want: map[string]any{ + "name": "Example", + "preferred_username": "name-1", + "usernames": []string{"name-1", "name-2", "name-3"}, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &OIDCOptions{ + transform: tt.fields.transform, + } + got, err := o.Transform(tt.args.v) + if tt.expectedErr != nil { + assert.Error(t, err) + return + } + + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/authority/provisioner/wire/wire_options.go b/authority/provisioner/wire/wire_options.go new file mode 100644 index 000000000..2ae5543f6 --- /dev/null +++ b/authority/provisioner/wire/wire_options.go @@ -0,0 +1,51 @@ +package wire + +import ( + "errors" + "fmt" +) + +// Options holds the Wire ACME extension options +type Options struct { + OIDC *OIDCOptions `json:"oidc,omitempty"` + DPOP *DPOPOptions `json:"dpop,omitempty"` +} + +// GetOIDCOptions returns the OIDC options. +func (o *Options) GetOIDCOptions() *OIDCOptions { + if o == nil { + return nil + } + return o.OIDC +} + +// GetDPOPOptions returns the DPoP options. +func (o *Options) GetDPOPOptions() *DPOPOptions { + if o == nil { + return nil + } + return o.DPOP +} + +// Validate validates and initializes the Wire OIDC and DPoP options. +// +// TODO(hs): find a good way to perform this only once. +func (o *Options) Validate() error { + if oidc := o.GetOIDCOptions(); oidc != nil { + if err := oidc.validateAndInitialize(); err != nil { + return fmt.Errorf("failed initializing OIDC options: %w", err) + } + } else { + return errors.New("no OIDC options available") + } + + if dpop := o.GetDPOPOptions(); dpop != nil { + if err := dpop.validateAndInitialize(); err != nil { + return fmt.Errorf("failed initializing DPoP options: %w", err) + } + } else { + return errors.New("no DPoP options available") + } + + return nil +} diff --git a/authority/provisioner/wire/wire_options_test.go b/authority/provisioner/wire/wire_options_test.go new file mode 100644 index 000000000..fd0acf020 --- /dev/null +++ b/authority/provisioner/wire/wire_options_test.go @@ -0,0 +1,163 @@ +package wire + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOptions_Validate(t *testing.T) { + key := []byte(`-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEA5c+4NKZSNQcR1T8qN6SjwgdPZQ0Ge12Ylx/YeGAJ35k= +-----END PUBLIC KEY-----`) + + type fields struct { + OIDC *OIDCOptions + DPOP *DPOPOptions + } + tests := []struct { + name string + fields fields + expectedErr error + }{ + { + name: "ok", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://example.com", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{ + SigningKey: key, + }, + }, + expectedErr: nil, + }, + { + name: "fail/no-oidc-options", + fields: fields{ + OIDC: nil, + DPOP: &DPOPOptions{}, + }, + expectedErr: errors.New("no OIDC options available"), + }, + { + name: "fail/empty-issuer-url", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{}, + }, + expectedErr: errors.New("failed initializing OIDC options: issuer URL must not be empty"), + }, + { + name: "fail/invalid-issuer-url", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "\x00", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{}, + }, + expectedErr: errors.New(`failed initializing OIDC options: failed creationg OIDC provider config: failed parsing issuer URL: parse "\x00": net/url: invalid control character in URL`), + }, + { + name: "fail/issuer-url-template", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://issuer.example.com/{{}", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{}, + }, + expectedErr: errors.New(`failed initializing OIDC options: failed parsing OIDC template: template: DeviceID:1: unexpected "}" in command`), + }, + { + name: "fail/invalid-transform-template", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://example.com", + }, + Config: &Config{}, + TransformTemplate: "{{}", + }, + DPOP: &DPOPOptions{ + SigningKey: key, + }, + }, + expectedErr: errors.New(`failed initializing OIDC options: failed parsing OIDC transformation template: template: transform:1: unexpected "}" in command`), + }, + { + name: "fail/no-dpop-options", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://example.com", + }, + Config: &Config{}, + }, + DPOP: nil, + }, + expectedErr: errors.New("no DPoP options available"), + }, + { + name: "fail/invalid-key", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://example.com", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{ + SigningKey: []byte{0x00}, + Target: "", + }, + }, + expectedErr: errors.New(`failed initializing DPoP options: failed parsing key: error decoding PEM: not a valid PEM encoded block`), + }, + { + name: "fail/target-template", + fields: fields{ + OIDC: &OIDCOptions{ + Provider: &Provider{ + IssuerURL: "https://example.com", + }, + Config: &Config{}, + }, + DPOP: &DPOPOptions{ + SigningKey: key, + Target: "{{}", + }, + }, + expectedErr: errors.New(`failed initializing DPoP options: failed parsing DPoP template: template: DeviceID:1: unexpected "}" in command`), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + o := &Options{ + OIDC: tt.fields.OIDC, + DPOP: tt.fields.DPOP, + } + err := o.Validate() + if tt.expectedErr != nil { + assert.EqualError(t, err, tt.expectedErr.Error()) + return + } + + assert.NoError(t, err) + }) + } +}