diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a2005c..900742e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ # Changelog +## v0.1.0-preview.21 — 2026-09-04 + +- Derive the registered credential algorithm from the verified COSE public key + embedded in authenticator data instead of the optional browser + `publicKeyAlgorithm` convenience member. +- Preserve the ES256-only policy while accepting standards-compliant response + serializers that omit redundant response conveniences, including the + Bitwarden/Vaultwarden passkey flow exercised through Gamertan. +- Add regression coverage for an ES256 credential whose convenience algorithm + is absent, plus malformed and non-ES256 credential rejection. + ## v0.1.0-preview.20 — 2026-09-04 - Extend the direct-owner transaction boundary to invitations. Creating or diff --git a/README.md b/README.md index f8f22c2..a820dfb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.20`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.21`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.20 +go get gamertan.com/web@v0.1.0-preview.21 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.20 +go get gamertan.com/web/requestmeta@v0.1.0-preview.21 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authwebauthn/algorithm_test.go b/authwebauthn/algorithm_test.go new file mode 100644 index 0000000..b939701 --- /dev/null +++ b/authwebauthn/algorithm_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authwebauthn + +import ( + "crypto/ecdh" + "crypto/rand" + "errors" + "testing" + + "gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor" + "gamertan.com/web/internal/webauthnvendored/protocol/webauthncose" + wa "gamertan.com/web/internal/webauthnvendored/webauthn" +) + +func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) { + privateKey, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + publicKey := privateKey.PublicKey().Bytes() + encoded, err := webauthncbor.Marshal(map[int64]any{ + 1: int64(webauthncose.EllipticKey), + 3: int64(webauthncose.AlgES256), + -1: int64(webauthncose.P256), + -2: publicKey[1:33], + -3: publicKey[33:65], + }) + if err != nil { + t.Fatal(err) + } + + credential := &wa.Credential{ + PublicKey: encoded, + // This value is absent when a standards-compliant client serializes the + // mandatory attestation object without optional response conveniences. + Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0}, + } + if err = enforceCredentialAlgorithm(credential); err != nil { + t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err) + } +} + +func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) { + rsaKey, err := webauthncbor.Marshal(map[int64]any{ + 1: int64(webauthncose.RSAKey), + 3: int64(webauthncose.AlgRS256), + -1: []byte{0xff}, + -2: []byte{0x01, 0x00, 0x01}, + }) + if err != nil { + t.Fatal(err) + } + for name, credential := range map[string]*wa.Credential{ + "nil": nil, + "malformed": {PublicKey: []byte("not-cose")}, + "rsa": {PublicKey: rsaKey}, + } { + t.Run(name, func(t *testing.T) { + if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) { + t.Fatalf("error=%v", err) + } + }) + } +} diff --git a/authwebauthn/service.go b/authwebauthn/service.go index 39a7205..2657c5d 100644 --- a/authwebauthn/service.go +++ b/authwebauthn/service.go @@ -375,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony if err != nil { return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err) } - if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) { + if err = enforceCredentialAlgorithm(verified); err != nil { return Credential{}, ErrUnsupportedCredential } encoded, err := json.Marshal(verified) @@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony return record, nil } +// enforceCredentialAlgorithm derives the algorithm from the verified COSE key +// carried inside authenticator data. AuthenticatorAttestationResponse's +// publicKeyAlgorithm member is an optional browser convenience value: clients +// that serialize the mandatory attestation object directly may omit it, and it +// is not the cryptographically authoritative representation. +func enforceCredentialAlgorithm(credential *wa.Credential) error { + if credential == nil { + return ErrUnsupportedCredential + } + parsed, err := webauthncose.ParsePublicKey(credential.PublicKey) + if err != nil { + return ErrUnsupportedCredential + } + key, ok := parsed.(webauthncose.EC2PublicKeyData) + if !ok || key.Algorithm != int64(webauthncose.AlgES256) { + return ErrUnsupportedCredential + } + return nil +} + func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) { challenge, err := service.randomBytes(32) if err != nil { diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index ddd7fa7..bb29f57 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -85,3 +85,11 @@ application concern belongs in the shared module. role into invitation mutations and rechecks a current active direct Owner after acquiring the SQLite write lock. The application still owns fresh authentication, recipient delivery, and the one-time secret presentation. +- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn + verification but was rejected by a redundant algorithm check because the + application's direct response serializer omitted the optional browser + `publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy + enforcement but derives it from the verified COSE key embedded in + authenticator data. This makes the server independent of serializer-specific + convenience fields without weakening origin, challenge, user-verification, + or algorithm validation. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 1c9acc3..f78680a 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.20 +go get gamertan.com/web/requestmeta@v0.1.0-preview.21 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index ed507fe..09e8a60 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.20 +go get gamertan.com/web/requestmeta@v0.1.0-preview.21 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/PASSKEYS.md b/docs/PASSKEYS.md index 96f2a6c..126e4b4 100644 --- a/docs/PASSKEYS.md +++ b/docs/PASSKEYS.md @@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command. - Request no attestation conveyance. - Permit ES256 only until another algorithm has explicit interoperability and security evidence. +- Enforce that policy from the verified COSE public key embedded in + authenticator data. Do not rely on the optional browser + `publicKeyAlgorithm` convenience member: direct standards-compliant response + serializers may omit it even when the attested credential is ES256. - Store random challenges and verifier session data only behind opaque, single-use ceremony tokens. - Treat clone warnings as audit signals rather than automatic lockout for