Author SHA1 Message Date
gamertan bfe6cfd29e auth: publish passkey foundations preview
verify / verify (push) Successful in 3m40s
2026-08-21 17:33:00 -04:00
gamertan fb6bbd0dad auth: publish audited password recovery
verify / verify (push) Successful in 3m7s
Publish the reviewed Gamertan Web Foundations v0.1.0-preview.4 snapshot with local-only administrative reset, atomic Argon2id credential replacement, mandatory rotation, all-session revocation, secret-free audit evidence, rollback coverage, and exact application-boundary guidance.

Exported from reviewed private source 403e5f6ef4d0cac683aaa76ed922236571d259a9 after trusted CI run 317 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 09:31:08 -04:00
gamertan 5905fe6fb2 auth: publish one-time bootstrap rotation
verify / verify (push) Successful in 3m14s
Publish the reviewed Web Foundations v0.1.0-preview.3 snapshot with cryptographic temporary credentials, explicit forced-rotation state, atomic password replacement and session revocation, additive SQLite migration, tests, and application-boundary documentation.

Exported from reviewed private source b8fb4ff3cd012859f2d307dfb2a1cc783a38f6db after trusted CI run 257 and exact Go 1.26.6 verification.

Material implementation assistance provided by OpenAI Codex; reviewed and verified through the maintainer workflow.

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-18 00:08:01 -04:00
gamertan 920e68f57f release: publish Web Foundations Preview 2 snapshot
verify / verify (push) Successful in 3m2s
Sanitized allowlisted snapshot of private source 0acd276fb3423405daf7fff26dedc92b8281e2bd. Adds organization, team, invitation, resource hierarchy, scoped access, and audited break-glass foundations while preserving Preview 1.

AI-Assistance: OpenAI Codex assisted implementation, testing, security review, and release preparation.
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-17 00:26:22 -04:00
gamertan 206d09e4cd docs: publish the Web Foundations application onramp
verify / verify (push) Successful in 2m58s
Sanitized snapshot of private source 144ca0a9544042b0477ae732c1356cf0b9d62b3f. Add package selection, adoption workflow, and optional Sandwich Hime integration guidance.

AI-Assistance: OpenAI Codex assisted documentation, verification, and publication.
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
2026-08-16 21:10:18 -04:00
244 changed files with 46701 additions and 44 deletions
+1
View File
@@ -4,3 +4,4 @@
*.go text eol=lf *.go text eol=lf
*.md text eol=lf *.md text eol=lf
*.sql text eol=lf *.sql text eol=lf
third_party/go-webauthn/** -text linguist-vendored
+1
View File
@@ -33,6 +33,7 @@ jobs:
run: | run: |
go test ./requestmeta -run '^$' -fuzz '^FuzzForwardedChain$' -fuzztime 30s go test ./requestmeta -run '^$' -fuzz '^FuzzForwardedChain$' -fuzztime 30s
go test ./analytics -run '^$' -fuzz '^FuzzJSONL$' -fuzztime 30s go test ./analytics -run '^$' -fuzz '^FuzzJSONL$' -fuzztime 30s
go test ./authwebauthn -run '^$' -fuzz '^FuzzPasskeyResponseParsers$' -fuzztime 30s
- name: Verify reproducible starter build - name: Verify reproducible starter build
run: | run: |
mkdir -p "$RUNNER_TEMP/build-a" "$RUNNER_TEMP/build-b" mkdir -p "$RUNNER_TEMP/build-a" "$RUNNER_TEMP/build-b"
+49
View File
@@ -2,6 +2,55 @@
# Changelog # Changelog
## Unreleased
- Add storage-neutral passkey registration, discoverable login, and
operation-bound fresh assertions without adding self-registration, password
fallback, TOTP, email recovery, or application-owned routes.
- Require exact HTTPS relying-party origins, user verification, discoverable
credentials, no attestation conveyance, and an initial ES256-only algorithm
policy.
- Add transactional SQLite credential, ceremony, enrollment, recovery, and
last-credential protections with atomic single-use consumption.
- Add a neutral session-issuance boundary for independently verified
credentials while retaining existing password behavior.
- Pin WebAuthn protocol verification to `github.com/go-webauthn/webauthn`
`v0.17.1` and record its source identity, module checksums, licence, and
transitive security boundary.
## v0.1.0-preview.4 — 2026-08-18
- Add an explicit local-administrator password recovery operation without
adding a public recovery endpoint or network protocol.
- Atomically install a one-time Argon2id credential, restore mandatory password
rotation, revoke every session, and append a secret-free audit event.
- Prove transaction rollback when the audit event cannot commit and document
private mode-`0600` delivery as application-owned policy.
- Keep Previews 13 immutable; applications select Preview 4 explicitly when
adopting administrative recovery.
## v0.1.0-preview.3 — 2026-08-18
- Add cryptographically generated temporary credentials and an explicit
password-change-required account state.
- Replace credentials, clear the requirement, and revoke all existing sessions
in one repository transaction after verifying the current password.
- Migrate existing SQLite users with the new requirement disabled; applications
continue to own first-login routing, private credential delivery, and audit
policy.
- Keep Preview 1 and Preview 2 immutable; applications select Preview 3
explicitly when adopting forced bootstrap rotation.
## v0.1.0-preview.2 — 2026-08-17
- Add storage-neutral organizations, teams, projects, environments, services,
single-use invitations, and independently scoped access roles.
- Separate platform-level authentication roles from organization data access.
- Add expiring break-glass grants with transactional organization-visible audit
events and a no-CGO SQLite implementation.
- Keep `v0.1.0-preview.1` immutable; applications adopt these additive packages
by explicitly selecting Preview 2.
## v0.1.0-preview.1 — 2026-08-16 ## v0.1.0-preview.1 — 2026-08-16
- Establish independent request metadata, logging, browser security, abuse, - Establish independent request metadata, logging, browser security, abuse,
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2025 github.com/go-webauthn/webauthn authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+45 -10
View File
@@ -2,12 +2,13 @@
# Gamertan Web Foundations # Gamertan Web Foundations
> Status: `v0.1.0-preview.1` public preview. APIs may change before a stable > Status: `v0.1.0-preview.4` public preview. APIs may change before a stable
> release; Linux is the maintained release platform. > release; Linux is the maintained release platform.
Small, composable Go packages for the unglamorous boundaries of a careful web Small, composable Go packages for the unglamorous boundaries of a careful web
application: request identity, structured request logs, browser security, application: request identity, structured request logs, browser security,
passwords and sessions, permissions, SQLite persistence, and private analytics. passwords, passkeys, sessions, permissions, SQLite persistence, and private
analytics.
This is a toolkit, not an application framework. Your application keeps its This is a toolkit, not an application framework. Your application keeps its
router, HTTP policy, HTML, authorization decisions, cache behavior, and router, HTTP policy, HTML, authorization decisions, cache behavior, and
@@ -23,10 +24,20 @@ Pin the preview in an application module, then import only the packages that
application needs: application needs:
```bash ```bash
go get gamertan.com/web@v0.1.0-preview.1 go get gamertan.com/web@v0.1.0-preview.4
go mod verify go mod verify
``` ```
An application may also name the first package it intends to adopt:
```bash
go get gamertan.com/web/requestmeta@v0.1.0-preview.4
```
The version belongs to the `gamertan.com/web` module. Go compiles and links
only the packages the application imports. See the [getting-started guide](docs/GETTING_STARTED.md)
and [module-boundary policy](docs/MODULES.md) before choosing a first slice.
Canonical source, issues, security policy, and release notes live on Canonical source, issues, security policy, and release notes live on
[Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only [Gamertan Gitea](https://gitea.speelman.ca/gamertan/web). GitHub is a read-only
discovery snapshot rather than a second release origin. discovery snapshot rather than a second release origin.
@@ -38,17 +49,41 @@ without turning that portability into a maintained compatibility claim.
## Packages ## Packages
- `requestmeta`: trusted-proxy resolution, HTTPS/origin metadata, and request IDs. - [`requestmeta`](requestmeta): trusted-proxy resolution, HTTPS/origin metadata,
- `requestlog`: bounded versioned records, middleware, sinks, and private JSONL. and request IDs.
- `websec`: headers, origin checks, CSRF, redirects, body limits, and rate limits. - [`requestlog`](requestlog): bounded versioned records, middleware, sinks, and
- `abuse`: application-classified request abuse with pluggable persistence. private JSONL.
- `auth`, `authhttp`, `authsqlite`: passwords, sessions, permissions, cookies, - [`websec`](websec): headers, origin checks, CSRF, redirects, body limits, and
and a no-CGO SQLite adapter. rate limits.
- `analytics`: safe and sensitive aggregate projections over request records. - [`abuse`](abuse): application-classified request abuse with pluggable persistence.
- [`auth`](auth), [`authhttp`](authhttp), and [`authsqlite`](authsqlite):
passwords, forced first-login rotation, local administrative recovery,
session revocation, platform-level permissions, cookies, and a no-CGO SQLite
adapter.
- [`authwebauthn`](authwebauthn): passkey-only registration, discoverable
login, fresh-operation approval, local recovery tokens, and an ES256-first
WebAuthn policy. See the [passkey integration guide](docs/PASSKEYS.md).
- [`organizations`](organizations) and [`access`](access): organizations,
teams, invitations, resource hierarchy, scoped roles, and audited temporary
access without turning platform operation into tenant-data access.
- [`analytics`](analytics): safe and sensitive aggregate projections over request
records.
The copyable starter under `starters/basic` demonstrates the packages without The copyable starter under `starters/basic` demonstrates the packages without
turning them into a router or template system. turning them into a router or template system.
## HTML and templates
Web Foundations deliberately does not provide a template language. Sandwich
Hime is the preferred companion for Gamertan applications that want HTML-first,
typed, ahead-of-time Go templates. The two projects remain independently
usable: this module does not import the `sando` runtime, and Sandwich Hime does
not own middleware, authentication, logging, routing, or deployment.
See [HTML with Sandwich Hime](docs/SANDWICH_HIME.md), then follow the official
[first site tutorial](https://sandwichhime.com/docs/tutorial/) and
[application integration tutorial](https://sandwichhime.com/docs/tutorial/application/).
## Security boundary ## Security boundary
Client addresses are accepted from forwarding headers only when the immediate Client addresses are accepted from forwarding headers only when the immediate
+13
View File
@@ -14,3 +14,16 @@ service-level agreement. There is no bug bounty.
The preview supports only versions explicitly listed in release notes. Security The preview supports only versions explicitly listed in release notes. Security
claims stop at the documented trust boundaries and executable tests. claims stop at the documented trust boundaries and executable tests.
Password recovery is an explicitly local administrative capability. It must
not be wired directly to a public route. Applications using it are responsible
for local operator authorization and exclusive mode-`0600` credential delivery;
the library transaction requires a new password change, revokes all sessions,
and records a secret-free audit event.
Passkey recovery is also local-only. Applications must not expose bootstrap or
recovery issuance as a public route. Enrollment tokens are single-use,
short-lived, digest-backed values and their plaintext belongs only in an
exclusive mode-`0600` delivery file. A passkey assertion proves control of a
credential; applications must still bind sensitive actions to exact server-side
state and authorize the resulting principal.
+38
View File
@@ -0,0 +1,38 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Third-party notices
## go-webauthn
- Module: `github.com/go-webauthn/webauthn`
- Version: `v0.17.1`
- Source commit: `de0a809e3027957ca15b72b252540317f9ba581b`
- Module sum: `h1:N8/ycHNeibifKhG+0ZFuQZsDvYiNRE5UpukUc8hb+k4=`
- Go module sum: `h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=`
- Downloaded module ZIP SHA-256:
`6f1e06307fdc998087675db3a6cb5f133fdf7b91ac0a4ced689c336a5f28a91e`
- Licence: BSD-3-Clause; the upstream licence text is preserved in
`LICENSES/BSD-3-Clause-go-webauthn.txt`, the unchanged audit source, and the
compiled internal derivative.
The complete upstream module is retained unchanged at
`third_party/go-webauthn`. `third_party/go-webauthn.SHA256SUMS` records every
source file. The required non-test packages are compiled from
`internal/webauthnvendored`; a deterministic gate derives that tree from the
audited source, rewrites only the self-import prefix, and requires an exact
match. The public module contains no local replacement because downstream Go
modules do not honor dependency replacement directives.
The module performs WebAuthn protocol parsing, CBOR/COSE handling, attestation
and assertion verification, and signature-counter updates. Web Foundations
retains relying-party policy, storage, sessions, recovery, operation binding,
and application authorization.
Transitive modules and their exact checksums are recorded in `go.mod` and
`go.sum`. Release assurance runs `go mod verify`, licence-boundary checks,
`govulncheck`, race tests, and bounded malformed-response fuzzing.
The 2026-08-19 Go 1.26.6 `govulncheck` review found no reachable
vulnerabilities. It reported `GO-2026-5932` against the unmaintained
`golang.org/x/crypto/openpgp` package at the module level; Web Foundations uses
`argon2` and does not import or call `openpgp`.
+270
View File
@@ -0,0 +1,270 @@
// SPDX-License-Identifier: MPL-2.0
// Package access defines organization-scoped role bindings and audited,
// short-lived break-glass authorization.
package access
import (
"context"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"regexp"
"sort"
"strings"
"time"
)
var (
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
namePattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{1,127}$`)
)
type SubjectKind string
const (
User SubjectKind = "user"
Team SubjectKind = "team"
)
type Scope struct {
OrganizationID string
ProjectID string
EnvironmentID string
ServiceID string
}
func (scope Scope) Validate() error {
if !idPattern.MatchString(scope.OrganizationID) || scope.ProjectID != "" && !idPattern.MatchString(scope.ProjectID) || scope.EnvironmentID != "" && !idPattern.MatchString(scope.EnvironmentID) || scope.ServiceID != "" && !idPattern.MatchString(scope.ServiceID) {
return errors.New("access: invalid scope")
}
if scope.EnvironmentID != "" && scope.ProjectID == "" || scope.ServiceID != "" && scope.EnvironmentID == "" {
return errors.New("access: incomplete scope hierarchy")
}
return nil
}
func (scope Scope) contains(requested Scope) bool {
if scope.OrganizationID != requested.OrganizationID {
return false
}
for _, pair := range [][2]string{{scope.ProjectID, requested.ProjectID}, {scope.EnvironmentID, requested.EnvironmentID}, {scope.ServiceID, requested.ServiceID}} {
if pair[0] != "" && pair[0] != pair[1] {
return false
}
}
return true
}
type Binding struct {
ID string
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
GrantedAt time.Time
}
type Policy struct {
Roles map[string]string
Permissions map[string]string
Grants map[string][]string
}
func (policy Policy) Validate() error {
if len(policy.Roles) == 0 || len(policy.Roles) > 1000 || len(policy.Permissions) == 0 || len(policy.Permissions) > 10000 || len(policy.Grants) > 1000 {
return errors.New("access: invalid policy size")
}
for name, description := range policy.Roles {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid role")
}
}
for name, description := range policy.Permissions {
if !namePattern.MatchString(name) || !text(description, 512, true) {
return errors.New("access: invalid permission")
}
}
for role, permissions := range policy.Grants {
if _, ok := policy.Roles[role]; !ok || len(permissions) > 10000 {
return errors.New("access: invalid role grant")
}
for _, permission := range permissions {
if _, ok := policy.Permissions[permission]; !ok {
return errors.New("access: role references unknown permission")
}
}
}
return nil
}
type BreakGlass struct {
ID, OrganizationID, UserID, Permission, Reason string
CreatedAt, ExpiresAt time.Time
}
type AuditEvent struct {
ID, OrganizationID, ActorUserID, Action, ResourceType, ResourceID, RequestID, Summary string
CreatedAt time.Time
}
type Repository interface {
SeedAccessPolicy(context.Context, Policy) error
Grant(context.Context, Binding) error
Revoke(context.Context, string, string, time.Time) error
EffectiveBindings(context.Context, string, string) ([]Binding, error)
CreateBreakGlass(context.Context, BreakGlass, AuditEvent) error
ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error)
AppendAccessAudit(context.Context, AuditEvent) error
AccessAudit(context.Context, string, int) ([]AuditEvent, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
policy Policy
random io.Reader
now func() time.Time
}
func New(repository Repository, policy Policy, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("access: repository is required")
}
if err := policy.Validate(); err != nil {
return nil, err
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, policy: policy, random: options.Random, now: options.Now}, nil
}
func (service *Service) Seed(ctx context.Context) error {
return service.repository.SeedAccessPolicy(ctx, service.policy)
}
type Grant struct {
SubjectKind SubjectKind
SubjectID string
Role string
Scope Scope
GrantedBy string
}
func (service *Service) Grant(ctx context.Context, input Grant) (Binding, error) {
if (input.SubjectKind != User && input.SubjectKind != Team) || !idPattern.MatchString(input.SubjectID) || !idPattern.MatchString(input.GrantedBy) {
return Binding{}, errors.New("access: invalid binding subject")
}
if _, ok := service.policy.Roles[input.Role]; !ok {
return Binding{}, errors.New("access: unknown role")
}
if err := input.Scope.Validate(); err != nil {
return Binding{}, err
}
id, err := randomID(service.random)
if err != nil {
return Binding{}, err
}
binding := Binding{ID: id, SubjectKind: input.SubjectKind, SubjectID: input.SubjectID, Role: input.Role, Scope: input.Scope, GrantedBy: input.GrantedBy, GrantedAt: service.now().UTC()}
if err = service.repository.Grant(ctx, binding); err != nil {
return Binding{}, err
}
return binding, nil
}
type Decision struct {
Allowed bool
Source string
Role string
}
func (service *Service) Authorize(ctx context.Context, userID string, scope Scope, permission string) (Decision, error) {
if !idPattern.MatchString(userID) || !namePattern.MatchString(permission) {
return Decision{}, errors.New("access: invalid authorization request")
}
if err := scope.Validate(); err != nil {
return Decision{}, err
}
if _, ok := service.policy.Permissions[permission]; !ok {
return Decision{}, errors.New("access: unknown permission")
}
bindings, err := service.repository.EffectiveBindings(ctx, scope.OrganizationID, userID)
if err != nil {
return Decision{}, err
}
sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID })
for _, binding := range bindings {
if !binding.Scope.contains(scope) {
continue
}
for _, granted := range service.policy.Grants[binding.Role] {
if granted == permission {
return Decision{Allowed: true, Source: "role", Role: binding.Role}, nil
}
}
}
breakGlass, err := service.repository.ActiveBreakGlass(ctx, scope.OrganizationID, userID, service.now().UTC())
if err != nil {
return Decision{}, err
}
for _, grant := range breakGlass {
if grant.Permission == permission {
return Decision{Allowed: true, Source: "break_glass"}, nil
}
}
return Decision{}, nil
}
func (service *Service) ActivateBreakGlass(ctx context.Context, organizationID, userID, permission, reason, requestID string, lifetime time.Duration) (BreakGlass, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !namePattern.MatchString(permission) || !text(strings.TrimSpace(reason), 1024, false) || !text(requestID, 128, true) || lifetime < time.Minute || lifetime > time.Hour {
return BreakGlass{}, errors.New("access: invalid break-glass request")
}
if _, ok := service.policy.Permissions[permission]; !ok {
return BreakGlass{}, errors.New("access: unknown permission")
}
id, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
auditID, err := randomID(service.random)
if err != nil {
return BreakGlass{}, err
}
now := service.now().UTC()
grant := BreakGlass{ID: id, OrganizationID: organizationID, UserID: userID, Permission: permission, Reason: strings.TrimSpace(reason), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
audit := AuditEvent{ID: auditID, OrganizationID: organizationID, ActorUserID: userID, Action: "break_glass.activate", ResourceType: "organization", ResourceID: organizationID, RequestID: requestID, Summary: "Temporary emergency access activated", CreatedAt: now}
if err = service.repository.CreateBreakGlass(ctx, grant, audit); err != nil {
return BreakGlass{}, err
}
return grant, nil
}
func (service *Service) Audit(ctx context.Context, organizationID string, limit int) ([]AuditEvent, error) {
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("access: invalid audit query")
}
return service.repository.AccessAudit(ctx, organizationID, limit)
}
func randomID(random io.Reader) (string, error) {
value := make([]byte, 18)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("access: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func text(value string, limit int, emptyOK bool) bool {
return (emptyOK || value != "") && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
+80
View File
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: MPL-2.0
package access
import (
"context"
"strings"
"testing"
"time"
)
func TestScopedRoleAndBreakGlass(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &repositoryStub{}
policy := Policy{Roles: map[string]string{"viewer": "Read safe telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
service, err := New(repository, policy, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
scope := Scope{OrganizationID: "org-12345678", ProjectID: "project-12345678"}
binding, err := service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: scope, GrantedBy: "user-87654321"})
if err != nil {
t.Fatal(err)
}
repository.bindings = []Binding{binding}
decision, err := service.Authorize(t.Context(), "user-12345678", Scope{OrganizationID: scope.OrganizationID, ProjectID: scope.ProjectID, EnvironmentID: "env-12345678"}, "telemetry.read")
if err != nil || !decision.Allowed || decision.Source != "role" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
if err != nil || decision.Allowed {
t.Fatalf("unexpected sensitive decision=%+v err=%v", decision, err)
}
grant, err := service.ActivateBreakGlass(t.Context(), scope.OrganizationID, "user-12345678", "telemetry.sensitive.read", "Investigate active incident", "request-12345678", 15*time.Minute)
if err != nil {
t.Fatal(err)
}
repository.breakGlass = []BreakGlass{grant}
decision, err = service.Authorize(t.Context(), "user-12345678", scope, "telemetry.sensitive.read")
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
}
}
func TestScopeHierarchyAndLifetimeFailClosed(t *testing.T) {
policy := Policy{Roles: map[string]string{"viewer": ""}, Permissions: map[string]string{"telemetry.read": ""}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
service, err := New(&repositoryStub{}, policy, Options{Random: strings.NewReader(strings.Repeat("x", 256))})
if err != nil {
t.Fatal(err)
}
if _, err = service.Grant(t.Context(), Grant{SubjectKind: User, SubjectID: "user-12345678", Role: "viewer", Scope: Scope{OrganizationID: "org-12345678", EnvironmentID: "env-12345678"}, GrantedBy: "user-87654321"}); err == nil {
t.Fatal("incomplete hierarchy accepted")
}
if _, err = service.ActivateBreakGlass(t.Context(), "org-12345678", "user-12345678", "telemetry.read", "reason", "", 2*time.Hour); err == nil {
t.Fatal("unbounded break-glass lifetime accepted")
}
}
type repositoryStub struct {
bindings []Binding
breakGlass []BreakGlass
}
func (*repositoryStub) SeedAccessPolicy(context.Context, Policy) error { return nil }
func (*repositoryStub) Grant(context.Context, Binding) error { return nil }
func (*repositoryStub) Revoke(context.Context, string, string, time.Time) error { return nil }
func (repository *repositoryStub) EffectiveBindings(context.Context, string, string) ([]Binding, error) {
return repository.bindings, nil
}
func (repository *repositoryStub) CreateBreakGlass(_ context.Context, grant BreakGlass, _ AuditEvent) error {
repository.breakGlass = []BreakGlass{grant}
return nil
}
func (repository *repositoryStub) ActiveBreakGlass(context.Context, string, string, time.Time) ([]BreakGlass, error) {
return repository.breakGlass, nil
}
func (*repositoryStub) AppendAccessAudit(context.Context, AuditEvent) error { return nil }
func (*repositoryStub) AccessAudit(context.Context, string, int) ([]AuditEvent, error) {
return nil, nil
}
+141 -4
View File
@@ -21,6 +21,7 @@ import (
var ( var (
ErrInvalidCredentials = errors.New("auth: invalid credentials") ErrInvalidCredentials = errors.New("auth: invalid credentials")
ErrInactiveUser = errors.New("auth: account is not active") ErrInactiveUser = errors.New("auth: account is not active")
ErrPasswordUnchanged = errors.New("auth: new password must differ from the current password")
ErrSessionNotFound = errors.New("auth: session not found") ErrSessionNotFound = errors.New("auth: session not found")
ErrUserNotFound = errors.New("auth: user not found") ErrUserNotFound = errors.New("auth: user not found")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`) identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
@@ -29,6 +30,7 @@ var (
type User struct { type User struct {
ID, Username, Email, DisplayName, Status string ID, Username, Email, DisplayName, Status string
CreatedAt, UpdatedAt time.Time CreatedAt, UpdatedAt time.Time
PasswordChangeRequired bool
} }
type Principal struct { type Principal struct {
@@ -59,6 +61,9 @@ type PolicySeed struct {
type Repository interface { type Repository interface {
CreateUser(context.Context, User, string) error CreateUser(context.Context, User, string) error
CredentialByIdentifier(context.Context, string) (User, string, error) CredentialByIdentifier(context.Context, string) (User, string, error)
CredentialByUserID(context.Context, string) (User, string, error)
ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error
ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error
UpdateLastLogin(context.Context, string, time.Time) error UpdateLastLogin(context.Context, string, time.Time) error
CreateSession(context.Context, Session) error CreateSession(context.Context, Session) error
PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error)
@@ -102,7 +107,17 @@ func New(repository Repository, options Options) (*Service, error) {
return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil return &Service{repository: repository, random: options.Random, now: options.Now, touchInterval: options.TouchInterval}, nil
} }
type CreateUser struct{ Username, Email, DisplayName, Password string } type CreateUser struct {
Username, Email, DisplayName, Password string
RequirePasswordChange bool
}
// AdministrativePasswordReset describes a locally authorized recovery. The
// application is responsible for delivering TemporaryPassword through a
// private, one-time channel; the value must never be logged or audited.
type AdministrativePasswordReset struct {
Identifier, TemporaryPassword string
}
func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) { func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User, error) {
username := strings.TrimSpace(input.Username) username := strings.TrimSpace(input.Username)
@@ -120,13 +135,104 @@ func (service *Service) CreateUser(ctx context.Context, input CreateUser) (User,
return User{}, err return User{}, err
} }
now := service.now().UTC() now := service.now().UTC()
user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now} user := User{ID: id, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now, PasswordChangeRequired: input.RequirePasswordChange}
if err = service.repository.CreateUser(ctx, user, hash); err != nil { if err = service.repository.CreateUser(ctx, user, hash); err != nil {
return User{}, err return User{}, err
} }
return user, nil return user, nil
} }
// GenerateTemporaryPassword returns 256 bits of URL-safe cryptographic
// entropy suitable for an application-managed one-time bootstrap credential.
func GenerateTemporaryPassword(random io.Reader) (string, error) {
if random == nil {
random = rand.Reader
}
return randomToken(random, 32)
}
// ChangePassword verifies the current credential, rejects reuse, replaces the
// Argon2id hash, clears the password-change requirement, and revokes every
// existing session through one repository operation.
func (service *Service) ChangePassword(ctx context.Context, userID, currentPassword, newPassword string) error {
user, currentHash, err := service.repository.CredentialByUserID(ctx, strings.TrimSpace(userID))
if errors.Is(err, ErrUserNotFound) {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return ErrInvalidCredentials
}
if err != nil {
_ = VerifyPassword(dummyPasswordHash, currentPassword)
return fmt.Errorf("auth: load credentials: %w", err)
}
if !VerifyPassword(currentHash, currentPassword) {
return ErrInvalidCredentials
}
if user.Status != "active" {
return ErrInactiveUser
}
if currentPassword == newPassword {
return ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(newPassword, service.random)
if err != nil {
return err
}
if err = service.repository.ReplacePasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, service.now().UTC()); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return ErrInvalidCredentials
}
return fmt.Errorf("auth: replace password: %w", err)
}
return nil
}
// ResetPassword replaces an active user's credential without requiring the
// current password. It is intended only for a locally authorized
// administrative recovery command. The repository atomically requires another
// password change, revokes all sessions, and appends a secret-free audit event.
func (service *Service) ResetPassword(ctx context.Context, input AdministrativePasswordReset) (User, error) {
identifier := strings.TrimSpace(input.Identifier)
user, currentHash, err := service.repository.CredentialByIdentifier(ctx, identifier)
if errors.Is(err, ErrUserNotFound) {
return User{}, ErrUserNotFound
}
if err != nil {
return User{}, fmt.Errorf("auth: load credentials for administrative reset: %w", err)
}
if user.Status != "active" {
return User{}, ErrInactiveUser
}
if VerifyPassword(currentHash, input.TemporaryPassword) {
return User{}, ErrPasswordUnchanged
}
newHash, err := HashPasswordWithRandom(input.TemporaryPassword, service.random)
if err != nil {
return User{}, err
}
auditID, err := randomToken(service.random, 18)
if err != nil {
return User{}, err
}
now := service.now().UTC()
audit := AuditEvent{
ID: auditID,
Action: "auth.password.reset",
ResourceType: "user",
ResourceID: user.ID,
Summary: "A local administrator issued a one-time credential and revoked all sessions.",
CreatedAt: now,
}
if err = service.repository.ResetPasswordAndRevokeSessions(ctx, user.ID, currentHash, newHash, now, audit); err != nil {
if errors.Is(err, ErrInvalidCredentials) {
return User{}, ErrInvalidCredentials
}
return User{}, fmt.Errorf("auth: reset password: %w", err)
}
user.PasswordChangeRequired = true
user.UpdatedAt = now
return user, nil
}
func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) { func (service *Service) Authenticate(ctx context.Context, identifier, password string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour { if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime") return "", Principal{}, errors.New("auth: invalid session lifetime")
@@ -146,21 +252,40 @@ func (service *Service) Authenticate(ctx context.Context, identifier, password s
if user.Status != "active" { if user.Status != "active" {
return "", Principal{}, ErrInactiveUser return "", Principal{}, ErrInactiveUser
} }
return service.IssueSession(ctx, user.ID, lifetime)
}
// IssueSession creates an opaque session for an already authenticated user.
// Authentication mechanisms such as passkeys call this only after completing
// their credential verification. The repository remains authoritative for the
// account's current status and permissions.
func (service *Service) IssueSession(ctx context.Context, userID string, lifetime time.Duration) (string, Principal, error) {
if lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Principal{}, errors.New("auth: invalid session lifetime")
}
userID = strings.TrimSpace(userID)
if !opaqueID(userID) {
return "", Principal{}, errors.New("auth: invalid user id")
}
token, err := randomToken(service.random, 32) token, err := randomToken(service.random, 32)
if err != nil { if err != nil {
return "", Principal{}, err return "", Principal{}, err
} }
now := service.now().UTC() now := service.now().UTC()
digest := sha256.Sum256([]byte(token)) digest := sha256.Sum256([]byte(token))
if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil { if err = service.repository.CreateSession(ctx, Session{Digest: digest, UserID: userID, CreatedAt: now, ExpiresAt: now.Add(lifetime), LastSeenAt: now}); err != nil {
return "", Principal{}, err return "", Principal{}, err
} }
_ = service.repository.UpdateLastLogin(ctx, user.ID, now) _ = service.repository.UpdateLastLogin(ctx, userID, now)
principal, _, err := service.repository.PrincipalBySession(ctx, digest, now) principal, _, err := service.repository.PrincipalBySession(ctx, digest, now)
if err != nil { if err != nil {
_ = service.repository.DeleteSession(ctx, digest) _ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, err return "", Principal{}, err
} }
if principal.User.Status != "active" {
_ = service.repository.DeleteSession(ctx, digest)
return "", Principal{}, ErrInactiveUser
}
return token, principal, nil return token, principal, nil
} }
@@ -211,6 +336,18 @@ func randomToken(random io.Reader, bytes int) (string, error) {
return base64.RawURLEncoding.EncodeToString(value), nil return base64.RawURLEncoding.EncodeToString(value), nil
} }
func opaqueID(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, character := range value {
if !(character == '-' || character == '_' || character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || character >= '0' && character <= '9') {
return false
}
}
return true
}
func sortedUnique(values []string) []string { func sortedUnique(values []string) []string {
set := make(map[string]struct{}, len(values)) set := make(map[string]struct{}, len(values))
for _, value := range values { for _, value := range values {
+13
View File
@@ -28,6 +28,19 @@ func TestPasswordEntropyFailsClosed(t *testing.T) {
} }
} }
func TestTemporaryPasswordUsesBoundedCryptographicEntropy(t *testing.T) {
password, err := GenerateTemporaryPassword(strings.NewReader(strings.Repeat("t", 32)))
if err != nil {
t.Fatal(err)
}
if len(password) != 43 || ValidatePassword(password) != nil || strings.ContainsAny(password, " \t\r\n") {
t.Fatalf("temporary password length=%d", len(password))
}
if _, err = GenerateTemporaryPassword(errorReader{}); err == nil {
t.Fatal("temporary password accepted entropy failure")
}
}
type errorReader struct{} type errorReader struct{}
func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") } func (errorReader) Read([]byte) (int, error) { return 0, errors.New("no entropy") }
+37
View File
@@ -43,11 +43,39 @@ func TestRevokeSessionRejectsInvalidTokenBeforeStorage(t *testing.T) {
} }
} }
func TestIssueSessionRejectsInactiveRepositoryPrincipal(t *testing.T) {
repository := &activeSessionRepository{principal: Principal{User: User{ID: "valid-user-id", Status: "disabled"}}}
service, err := New(repository, Options{})
if err != nil {
t.Fatal(err)
}
if _, _, err = service.IssueSession(t.Context(), "valid-user-id", time.Hour); !errors.Is(err, ErrInactiveUser) {
t.Fatalf("err=%v", err)
}
if !repository.deleted {
t.Fatal("inactive session was not deleted")
}
}
type recordingRepository struct { type recordingRepository struct {
repositoryStub repositoryStub
deleted bool deleted bool
} }
type activeSessionRepository struct {
repositoryStub
principal Principal
deleted bool
}
func (repository *activeSessionRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
return repository.principal, Session{}, nil
}
func (repository *activeSessionRepository) DeleteSession(context.Context, [32]byte) error {
repository.deleted = true
return nil
}
func (repository *recordingRepository) DeleteSession(context.Context, [32]byte) error { func (repository *recordingRepository) DeleteSession(context.Context, [32]byte) error {
repository.deleted = true repository.deleted = true
return nil return nil
@@ -59,6 +87,15 @@ func (repositoryStub) CreateUser(context.Context, User, string) error { return n
func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) { func (repositoryStub) CredentialByIdentifier(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound return User{}, "", ErrUserNotFound
} }
func (repositoryStub) CredentialByUserID(context.Context, string) (User, string, error) {
return User{}, "", ErrUserNotFound
}
func (repositoryStub) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (repositoryStub) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (repositoryStub) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (repositoryStub) CreateSession(context.Context, Session) error { return nil } func (repositoryStub) CreateSession(context.Context, Session) error { return nil }
func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) { func (repository repositoryStub) PrincipalBySession(context.Context, [32]byte, time.Time) (Principal, Session, error) {
+9
View File
@@ -109,6 +109,15 @@ func (authHTTPRepository) CreateUser(context.Context, auth.User, string) error {
func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) { func (authHTTPRepository) CredentialByIdentifier(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
func (authHTTPRepository) CredentialByUserID(context.Context, string) (auth.User, string, error) {
return auth.User{}, "", auth.ErrUserNotFound
}
func (authHTTPRepository) ReplacePasswordAndRevokeSessions(context.Context, string, string, string, time.Time) error {
return nil
}
func (authHTTPRepository) ResetPasswordAndRevokeSessions(context.Context, string, string, string, time.Time, auth.AuditEvent) error {
return nil
}
func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil } func (authHTTPRepository) UpdateLastLogin(context.Context, string, time.Time) error { return nil }
func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil } func (authHTTPRepository) CreateSession(context.Context, auth.Session) error { return nil }
func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) { func (repository authHTTPRepository) PrincipalBySession(context.Context, [32]byte, time.Time) (auth.Principal, auth.Session, error) {
+64
View File
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"bytes"
"encoding/json"
"errors"
"io"
"mime"
"net/http"
"strings"
"gamertan.com/web/authwebauthn"
)
const maxPasskeyBodyBytes = 160 << 10
type PasskeyFinish struct {
CeremonyToken string `json:"ceremony_token"`
Credential json.RawMessage `json:"credential"`
}
func WritePasskeyBegin(response http.ResponseWriter, result authwebauthn.BeginResult) error {
if len(result.CeremonyToken) < 32 || len(result.PublicKey) == 0 || !json.Valid(result.PublicKey) || result.ExpiresAt.IsZero() {
return errors.New("authhttp: invalid passkey ceremony")
}
response.Header().Set("Cache-Control", "no-store")
response.Header().Set("Content-Type", "application/json; charset=utf-8")
response.Header().Set("X-Content-Type-Options", "nosniff")
encoder := json.NewEncoder(response)
encoder.SetEscapeHTML(true)
return encoder.Encode(result)
}
func ReadPasskeyFinish(request *http.Request) (PasskeyFinish, error) {
if request == nil || request.Method != http.MethodPost {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires POST")
}
mediaType, _, err := mime.ParseMediaType(request.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return PasskeyFinish{}, errors.New("authhttp: passkey response requires application/json")
}
body, err := io.ReadAll(io.LimitReader(request.Body, maxPasskeyBodyBytes+1))
if err != nil || len(body) > maxPasskeyBodyBytes {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var input PasskeyFinish
if err = decoder.Decode(&input); err != nil {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
var trailing any
if err = decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return PasskeyFinish{}, errors.New("authhttp: passkey response contains trailing data")
}
input.CeremonyToken = strings.TrimSpace(input.CeremonyToken)
input.Credential = bytes.TrimSpace(input.Credential)
if len(input.CeremonyToken) < 32 || len(input.CeremonyToken) > 128 || len(input.Credential) == 0 || len(input.Credential) > maxPasskeyBodyBytes || !json.Valid(input.Credential) {
return PasskeyFinish{}, errors.New("authhttp: invalid passkey response")
}
return input, nil
}
+49
View File
@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MPL-2.0
package authhttp
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyJSONBoundary(t *testing.T) {
result := authwebauthn.BeginResult{CeremonyToken: strings.Repeat("a", 43), PublicKey: []byte(`{"challenge":"example"}`), ExpiresAt: time.Now().UTC().Add(time.Minute)}
recorder := httptest.NewRecorder()
if err := WritePasskeyBegin(recorder, result); err != nil {
t.Fatal(err)
}
if recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("Content-Type") != "application/json; charset=utf-8" {
t.Fatalf("headers=%v", recorder.Header())
}
request := httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/passkey/finish", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{"id":"x"}}`))
request.Header.Set("Content-Type", "application/json")
finish, err := ReadPasskeyFinish(request)
if err != nil {
t.Fatal(err)
}
if finish.CeremonyToken == "" || string(finish.Credential) != `{"id":"x"}` {
t.Fatalf("finish=%+v", finish)
}
}
func TestPasskeyJSONRejectsWrongMethodUnknownFieldsAndOversize(t *testing.T) {
for name, request := range map[string]*http.Request{
"method": httptest.NewRequest(http.MethodGet, "https://tend.gamertan.com/", nil),
"unknown": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(`{"ceremony_token":"`+strings.Repeat("b", 43)+`","credential":{},"extra":true}`)),
"large": httptest.NewRequest(http.MethodPost, "https://tend.gamertan.com/", strings.NewReader(strings.Repeat("x", maxPasskeyBodyBytes+1))),
} {
t.Run(name, func(t *testing.T) {
request.Header.Set("Content-Type", "application/json")
if _, err := ReadPasskeyFinish(request); err == nil {
t.Fatal("accepted invalid request")
}
})
}
}
+221
View File
@@ -0,0 +1,221 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/access"
)
func (store *Store) SeedAccessPolicy(ctx context.Context, policy access.Policy) error {
if err := policy.Validate(); err != nil {
return err
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for name, description := range policy.Roles {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_roles(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
return err
}
}
for name, description := range policy.Permissions {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_permissions(name,description) VALUES(?,?) ON CONFLICT(name) DO UPDATE SET description=excluded.description`, name, description); err != nil {
return err
}
}
for role, permissions := range policy.Grants {
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_access_role_permissions WHERE role_name=?`, role); err != nil {
return err
}
for _, permission := range permissions {
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_role_permissions(role_name,permission_name) VALUES(?,?)`, role, permission); err != nil {
return err
}
}
}
return tx.Commit()
}
func (store *Store) Grant(ctx context.Context, binding access.Binding) error {
if !opaqueID(binding.ID) || (binding.SubjectKind != access.User && binding.SubjectKind != access.Team) || !opaqueID(binding.SubjectID) || !safeName(binding.Role) || binding.Scope.Validate() != nil || !opaqueID(binding.GrantedBy) || binding.GrantedAt.IsZero() {
return errors.New("authsqlite: invalid access binding")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var exists int
query := `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`
if binding.SubjectKind == access.Team {
query = `SELECT COUNT(*) FROM gwf_teams WHERE organization_id=? AND id=?`
}
if err = tx.QueryRowContext(ctx, query, binding.Scope.OrganizationID, binding.SubjectID).Scan(&exists); err != nil {
return err
}
if exists != 1 {
return errors.New("authsqlite: access subject is not active in organization")
}
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE organization_id=? AND user_id=? AND status='active'`, binding.Scope.OrganizationID, binding.GrantedBy).Scan(&exists); err != nil || exists != 1 {
if err != nil {
return err
}
return errors.New("authsqlite: grantor is not active in organization")
}
scopeQuery, arguments := `SELECT 1`, []any{}
switch {
case binding.Scope.ServiceID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_application_services WHERE id=? AND environment_id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.ServiceID, binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
case binding.Scope.EnvironmentID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, []any{binding.Scope.EnvironmentID, binding.Scope.ProjectID, binding.Scope.OrganizationID}
case binding.Scope.ProjectID != "":
scopeQuery, arguments = `SELECT COUNT(*) FROM gwf_projects WHERE id=? AND organization_id=?`, []any{binding.Scope.ProjectID, binding.Scope.OrganizationID}
}
if err = tx.QueryRowContext(ctx, scopeQuery, arguments...).Scan(&exists); err != nil {
return err
}
if exists != 1 {
return errors.New("authsqlite: access scope does not exist")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) VALUES(?,?,?,?,?,NULLIF(?,''),NULLIF(?,''),NULLIF(?,''),?,?)`, binding.ID, binding.Scope.OrganizationID, binding.SubjectKind, binding.SubjectID, binding.Role, binding.Scope.ProjectID, binding.Scope.EnvironmentID, binding.Scope.ServiceID, binding.GrantedBy, binding.GrantedAt.Unix()); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) Revoke(ctx context.Context, bindingID, actorUserID string, when time.Time) error {
if !opaqueID(bindingID) || !opaqueID(actorUserID) || when.IsZero() {
return errors.New("authsqlite: invalid access revocation")
}
result, err := store.db.ExecContext(ctx, `UPDATE gwf_access_bindings SET revoked_by_user_id=?,revoked_at=? WHERE id=? AND revoked_at IS NULL`, actorUserID, when.Unix(), bindingID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: access binding not found")
}
return nil
}
func (store *Store) EffectiveBindings(ctx context.Context, organizationID, userID string) ([]access.Binding, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid access query")
}
rows, err := store.db.QueryContext(ctx, `SELECT b.id,b.subject_kind,b.subject_id,b.role_name,b.project_id,b.environment_id,b.service_id,b.granted_by_user_id,b.granted_at
FROM gwf_access_bindings b
WHERE b.organization_id=? AND b.revoked_at IS NULL
AND EXISTS (SELECT 1 FROM gwf_organization_memberships m WHERE m.organization_id=b.organization_id AND m.user_id=? AND m.status='active')
AND ((b.subject_kind='user' AND b.subject_id=?) OR (b.subject_kind='team' AND EXISTS (SELECT 1 FROM gwf_team_members tm JOIN gwf_teams t ON t.id=tm.team_id WHERE tm.team_id=b.subject_id AND tm.user_id=? AND t.organization_id=b.organization_id)))
ORDER BY b.id`, organizationID, userID, userID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.Binding
for rows.Next() {
var binding access.Binding
var project, environment, service sql.NullString
var granted int64
if err = rows.Scan(&binding.ID, &binding.SubjectKind, &binding.SubjectID, &binding.Role, &project, &environment, &service, &binding.GrantedBy, &granted); err != nil {
return nil, err
}
binding.Scope = access.Scope{OrganizationID: organizationID, ProjectID: project.String, EnvironmentID: environment.String, ServiceID: service.String}
binding.GrantedAt = time.Unix(granted, 0).UTC()
result = append(result, binding)
}
return result, rows.Err()
}
func (store *Store) CreateBreakGlass(ctx context.Context, grant access.BreakGlass, audit access.AuditEvent) error {
if !validBreakGlass(grant) || !validAccessAudit(audit) || audit.OrganizationID != grant.OrganizationID || audit.ActorUserID != grant.UserID {
return errors.New("authsqlite: invalid break-glass event")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_break_glass(id,organization_id,user_id,permission_name,reason,created_at,expires_at) VALUES(?,?,?,?,?,?,?)`, grant.ID, grant.OrganizationID, grant.UserID, grant.Permission, grant.Reason, grant.CreatedAt.Unix(), grant.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAccessAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ActiveBreakGlass(ctx context.Context, organizationID, userID string, now time.Time) ([]access.BreakGlass, error) {
if !opaqueID(organizationID) || !opaqueID(userID) || now.IsZero() {
return nil, errors.New("authsqlite: invalid break-glass query")
}
rows, err := store.db.QueryContext(ctx, `SELECT id,permission_name,reason,created_at,expires_at FROM gwf_break_glass WHERE organization_id=? AND user_id=? AND expires_at>? ORDER BY expires_at`, organizationID, userID, now.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.BreakGlass
for rows.Next() {
var grant access.BreakGlass
var created, expires int64
if err = rows.Scan(&grant.ID, &grant.Permission, &grant.Reason, &created, &expires); err != nil {
return nil, err
}
grant.OrganizationID, grant.UserID = organizationID, userID
grant.CreatedAt, grant.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
result = append(result, grant)
}
return result, rows.Err()
}
func (store *Store) AppendAccessAudit(ctx context.Context, audit access.AuditEvent) error {
if !validAccessAudit(audit) {
return errors.New("authsqlite: invalid access audit")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
return err
}
func (store *Store) AccessAudit(ctx context.Context, organizationID string, limit int) ([]access.AuditEvent, error) {
if !opaqueID(organizationID) || limit < 1 || limit > 1000 {
return nil, errors.New("authsqlite: invalid access audit query")
}
rows, err := store.db.QueryContext(ctx, `SELECT id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at FROM gwf_access_audit_events WHERE organization_id=? ORDER BY created_at DESC,id DESC LIMIT ?`, organizationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var result []access.AuditEvent
for rows.Next() {
var event access.AuditEvent
var requestID sql.NullString
var created int64
if err = rows.Scan(&event.ID, &event.ActorUserID, &event.Action, &event.ResourceType, &event.ResourceID, &requestID, &event.Summary, &created); err != nil {
return nil, err
}
event.OrganizationID = organizationID
event.RequestID = requestID.String
event.CreatedAt = time.Unix(created, 0).UTC()
result = append(result, event)
}
return result, rows.Err()
}
func appendAccessAudit(ctx context.Context, tx *sql.Tx, audit access.AuditEvent) error {
_, err := tx.ExecContext(ctx, `INSERT INTO gwf_access_audit_events(id,organization_id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,?,?,?,?,?,NULLIF(?,''),?,?)`, audit.ID, audit.OrganizationID, audit.ActorUserID, audit.Action, audit.ResourceType, audit.ResourceID, audit.RequestID, audit.Summary, audit.CreatedAt.Unix())
return err
}
func validBreakGlass(grant access.BreakGlass) bool {
return opaqueID(grant.ID) && opaqueID(grant.OrganizationID) && opaqueID(grant.UserID) && safeName(grant.Permission) && text(grant.Reason, 1024, false) && !grant.CreatedAt.IsZero() && grant.ExpiresAt.After(grant.CreatedAt) && grant.ExpiresAt.Sub(grant.CreatedAt) <= time.Hour
}
func validAccessAudit(audit access.AuditEvent) bool {
return opaqueID(audit.ID) && opaqueID(audit.OrganizationID) && opaqueID(audit.ActorUserID) && safeName(audit.Action) && safeName(audit.ResourceType) && text(audit.ResourceID, 256, false) && text(audit.RequestID, 128, true) && text(audit.Summary, 1024, true) && !audit.CreatedAt.IsZero()
}
+220
View File
@@ -0,0 +1,220 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"errors"
"time"
"gamertan.com/web/organizations"
)
func (store *Store) CreateOrganization(ctx context.Context, organization organizations.Organization, owner organizations.Membership) error {
if !opaqueID(organization.ID) || !slugValue(organization.Slug) || !text(organization.Name, 128, false) || organization.CreatedAt.IsZero() || owner.OrganizationID != organization.ID || !opaqueID(owner.UserID) || owner.Status != "active" || owner.JoinedAt.IsZero() {
return errors.New("authsqlite: invalid organization")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var personalOwner any
if organization.Personal {
personalOwner = owner.UserID
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at) VALUES(?,?,?,?,?,?)`, organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, organization.CreatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, owner.OrganizationID, owner.UserID, owner.Status, owner.JoinedAt.Unix()); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CreateTeam(ctx context.Context, team organizations.Team) error {
if !opaqueID(team.ID) || !opaqueID(team.OrganizationID) || !slugValue(team.Slug) || !text(team.Name, 128, false) || team.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid team")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_teams(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, team.ID, team.OrganizationID, team.Slug, team.Name, team.CreatedAt.Unix())
return err
}
func (store *Store) AddTeamMember(ctx context.Context, membership organizations.TeamMembership) error {
if !opaqueID(membership.TeamID) || !opaqueID(membership.UserID) || membership.JoinedAt.IsZero() {
return errors.New("authsqlite: invalid team membership")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_team_members(team_id,user_id,joined_at)
SELECT t.id,?,? FROM gwf_teams t
JOIN gwf_organization_memberships m ON m.organization_id=t.organization_id AND m.user_id=? AND m.status='active'
WHERE t.id=? ON CONFLICT(team_id,user_id) DO UPDATE SET joined_at=gwf_team_members.joined_at`, membership.UserID, membership.JoinedAt.Unix(), membership.UserID, membership.TeamID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
return nil
}
func (store *Store) CreateProject(ctx context.Context, project organizations.Project) error {
if !opaqueID(project.ID) || !opaqueID(project.OrganizationID) || !slugValue(project.Slug) || !text(project.Name, 128, false) || project.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid project")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_projects(id,organization_id,slug,name,created_at) VALUES(?,?,?,?,?)`, project.ID, project.OrganizationID, project.Slug, project.Name, project.CreatedAt.Unix())
return err
}
func (store *Store) CreateEnvironment(ctx context.Context, environment organizations.Environment) error {
if !opaqueID(environment.ID) || !opaqueID(environment.OrganizationID) || !opaqueID(environment.ProjectID) || !slugValue(environment.Slug) || !text(environment.Name, 128, false) || environment.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid environment")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_environments(id,organization_id,project_id,slug,name,created_at)
SELECT ?,?,?,?,?,? FROM gwf_projects WHERE id=? AND organization_id=?`, environment.ID, environment.OrganizationID, environment.ProjectID, environment.Slug, environment.Name, environment.CreatedAt.Unix(), environment.ProjectID, environment.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: project is outside organization")
}
return nil
}
func (store *Store) CreateApplicationService(ctx context.Context, application organizations.ApplicationService) error {
if !opaqueID(application.ID) || !opaqueID(application.OrganizationID) || !opaqueID(application.ProjectID) || !opaqueID(application.EnvironmentID) || !slugValue(application.Slug) || !text(application.Name, 128, false) || application.CreatedAt.IsZero() {
return errors.New("authsqlite: invalid application service")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_application_services(id,organization_id,project_id,environment_id,slug,name,created_at)
SELECT ?,?,?,?,?,?,? FROM gwf_environments WHERE id=? AND project_id=? AND organization_id=?`, application.ID, application.OrganizationID, application.ProjectID, application.EnvironmentID, application.Slug, application.Name, application.CreatedAt.Unix(), application.EnvironmentID, application.ProjectID, application.OrganizationID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return errors.New("authsqlite: environment is outside project")
}
return nil
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation) error {
if zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() {
return errors.New("authsqlite: invalid invitation")
}
result, err := store.db.ExecContext(ctx, `INSERT INTO gwf_organization_invitations(token_hash,organization_id,email_normalized,invited_by_user_id,created_at,expires_at)
SELECT ?,?,?,?,?,? FROM gwf_organization_memberships
WHERE organization_id=? AND user_id=? AND status='active'`, invitation.Digest[:], invitation.OrganizationID, normalize(invitation.Email), invitation.InvitedByUserID, invitation.CreatedAt.Unix(), invitation.ExpiresAt.Unix(), invitation.OrganizationID, invitation.InvitedByUserID)
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrMembershipNotFound
}
return nil
}
func (store *Store) InvitationByDigest(ctx context.Context, digest [32]byte, now time.Time) (organizations.Invitation, error) {
if zeroDigest(digest) || now.IsZero() {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
var invitation organizations.Invitation
var created, expires int64
err := store.db.QueryRowContext(ctx, `SELECT organization_id,email_normalized,invited_by_user_id,created_at,expires_at FROM gwf_organization_invitations WHERE token_hash=? AND used_at IS NULL AND expires_at>?`, digest[:], now.Unix()).Scan(&invitation.OrganizationID, &invitation.Email, &invitation.InvitedByUserID, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return organizations.Invitation{}, organizations.ErrInvitationNotFound
}
if err != nil {
return organizations.Invitation{}, err
}
invitation.Digest = digest
invitation.CreatedAt = time.Unix(created, 0).UTC()
invitation.ExpiresAt = time.Unix(expires, 0).UTC()
return invitation, nil
}
func (store *Store) AcceptInvitation(ctx context.Context, digest [32]byte, userID string, acceptedAt time.Time) error {
if zeroDigest(digest) || !opaqueID(userID) || acceptedAt.IsZero() {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var organizationID string
err = tx.QueryRowContext(ctx, `SELECT i.organization_id FROM gwf_organization_invitations i JOIN gwf_users u ON u.id=? AND u.email_normalized=i.email_normalized WHERE i.token_hash=? AND i.used_at IS NULL AND i.expires_at>?`, userID, digest[:], acceptedAt.Unix()).Scan(&organizationID)
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
if err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,'active',?) ON CONFLICT(organization_id,user_id) DO UPDATE SET status='active'`, organizationID, userID, acceptedAt.Unix()); err != nil {
return err
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET used_at=? WHERE token_hash=? AND used_at IS NULL`, acceptedAt.Unix(), digest[:])
if err != nil {
return err
}
if changed, _ := result.RowsAffected(); changed != 1 {
return organizations.ErrInvitationNotFound
}
return tx.Commit()
}
func (store *Store) MembershipsForUser(ctx context.Context, userID string) ([]organizations.Membership, error) {
if !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid user")
}
rows, err := store.db.QueryContext(ctx, `SELECT organization_id,status,joined_at FROM gwf_organization_memberships WHERE user_id=? ORDER BY organization_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []organizations.Membership
for rows.Next() {
var membership organizations.Membership
var joined int64
if err = rows.Scan(&membership.OrganizationID, &membership.Status, &joined); err != nil {
return nil, err
}
membership.UserID = userID
membership.JoinedAt = time.Unix(joined, 0).UTC()
result = append(result, membership)
}
return result, rows.Err()
}
func (store *Store) TeamsForUser(ctx context.Context, organizationID, userID string) ([]organizations.Team, error) {
if !opaqueID(organizationID) || !opaqueID(userID) {
return nil, errors.New("authsqlite: invalid team query")
}
rows, err := store.db.QueryContext(ctx, `SELECT t.id,t.slug,t.name,t.created_at FROM gwf_teams t JOIN gwf_team_members tm ON tm.team_id=t.id WHERE t.organization_id=? AND tm.user_id=? ORDER BY t.slug`, organizationID, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var result []organizations.Team
for rows.Next() {
var team organizations.Team
var created int64
if err = rows.Scan(&team.ID, &team.Slug, &team.Name, &created); err != nil {
return nil, err
}
team.OrganizationID = organizationID
team.CreatedAt = time.Unix(created, 0).UTC()
result = append(result, team)
}
return result, rows.Err()
}
func slugValue(value string) bool {
if len(value) < 2 || len(value) > 63 || (value[0] < 'a' || value[0] > 'z') && (value[0] < '0' || value[0] > '9') {
return false
}
for _, character := range value {
if character != '-' && (character < 'a' || character > 'z') && (character < '0' || character > '9') {
return false
}
}
return true
}
+320
View File
@@ -0,0 +1,320 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"time"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
const (
maxPasskeysPerUser = 16
maxCredentialBytes = 64 << 10
maxCeremonySessionBytes = 64 << 10
)
func (store *Store) CreatePasskeyUser(ctx context.Context, user auth.User, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) error {
if !validPasskeyUser(user) || !validEnrollment(enrollment) || enrollment.UserID != user.ID || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey bootstrap")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, 0, user.CreatedAt.Unix(), user.UpdatedAt.Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], enrollment.UserID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UserByID(ctx context.Context, userID string) (auth.User, error) {
if !opaqueID(userID) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
}
func (store *Store) UserByIdentifier(ctx context.Context, identifier string) (auth.User, error) {
identifier = strings.TrimSpace(identifier)
if !text(identifier, 320, false) {
return auth.User{}, auth.ErrUserNotFound
}
return scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
}
func (store *Store) UserByCredentialID(ctx context.Context, credentialID []byte) (auth.User, error) {
if !boundedCredentialID(credentialID) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
user, err := scanPasskeyUser(store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at FROM gwf_users u JOIN gwf_passkey_credentials c ON c.user_id=u.id WHERE c.credential_id=?`, credentialID))
if errors.Is(err, auth.ErrUserNotFound) {
return auth.User{}, authwebauthn.ErrCredentialNotFound
}
return user, err
}
func (store *Store) CredentialsByUserID(ctx context.Context, userID string) ([]authwebauthn.Credential, error) {
if !opaqueID(userID) {
return nil, auth.ErrUserNotFound
}
rows, err := store.db.QueryContext(ctx, `SELECT credential_id,label,credential_json,created_at,COALESCE(last_used_at,0) FROM gwf_passkey_credentials WHERE user_id=? ORDER BY created_at,credential_id`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
credentials := make([]authwebauthn.Credential, 0)
for rows.Next() {
var credential authwebauthn.Credential
var created, used int64
if err = rows.Scan(&credential.ID, &credential.Label, &credential.Data, &created, &used); err != nil {
return nil, err
}
credential.UserID = userID
credential.CreatedAt = time.Unix(created, 0).UTC()
if used != 0 {
credential.LastUsedAt = time.Unix(used, 0).UTC()
}
credentials = append(credentials, credential)
}
return credentials, rows.Err()
}
func (store *Store) SaveCredential(ctx context.Context, credential authwebauthn.Credential, audit auth.AuditEvent) error {
if !validCredential(credential, true) || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, credential.UserID).Scan(&count); err != nil {
return err
}
if count >= maxPasskeysPerUser {
return errors.New("authsqlite: passkey credential limit reached")
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_credentials(credential_id,user_id,label,credential_json,created_at,last_used_at) VALUES(?,?,?,?,?,NULL)`, credential.ID, credential.UserID, credential.Label, []byte(credential.Data), credential.CreatedAt.Unix()); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateCredential(ctx context.Context, credential authwebauthn.Credential) error {
if !validCredential(credential, false) || credential.LastUsedAt.IsZero() {
return errors.New("authsqlite: invalid passkey credential update")
}
result, err := store.db.ExecContext(ctx, `UPDATE gwf_passkey_credentials SET credential_json=?,last_used_at=? WHERE credential_id=? AND user_id=?`, []byte(credential.Data), credential.LastUsedAt.Unix(), credential.ID, credential.UserID)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return authwebauthn.ErrCredentialNotFound
}
return nil
}
func (store *Store) DeleteCredential(ctx context.Context, userID string, credentialID []byte, minimumRemaining int, audit auth.AuditEvent) error {
if !opaqueID(userID) || !boundedCredentialID(credentialID) || minimumRemaining < 1 || minimumRemaining > maxPasskeysPerUser || !validAuditEvent(audit) {
return errors.New("authsqlite: invalid passkey credential deletion")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var count int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count); err != nil {
return err
}
if count <= minimumRemaining {
if minimumRemaining == 1 {
return authwebauthn.ErrLastCredential
}
return authwebauthn.ErrCredentialFloor
}
result, err := tx.ExecContext(ctx, `DELETE FROM gwf_passkey_credentials WHERE user_id=? AND credential_id=?`, userID, credentialID)
if err != nil {
return err
}
if changed, rowsErr := result.RowsAffected(); rowsErr != nil || changed != 1 {
if rowsErr != nil {
return rowsErr
}
return authwebauthn.ErrCredentialNotFound
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) CredentialCount(ctx context.Context, userID string) (int, error) {
if !opaqueID(userID) {
return 0, auth.ErrUserNotFound
}
var count int
err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_passkey_credentials WHERE user_id=?`, userID).Scan(&count)
return count, err
}
func (store *Store) CreateCeremony(ctx context.Context, ceremony authwebauthn.Ceremony) error {
if !validCeremony(ceremony) {
return errors.New("authsqlite: invalid passkey ceremony")
}
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_passkey_ceremonies(token_hash,kind,user_id,label,session_json,binding_hash,created_at,expires_at) VALUES(?,?,NULLIF(?,''),?,?,?,?,?)`, ceremony.Digest[:], ceremony.Kind, ceremony.UserID, ceremony.Label, []byte(ceremony.SessionData), ceremony.BindingDigest[:], ceremony.CreatedAt.Unix(), ceremony.ExpiresAt.Unix())
return err
}
func (store *Store) TakeCeremony(ctx context.Context, digest [32]byte, now time.Time) (authwebauthn.Ceremony, error) {
if zeroDigest(digest) || now.IsZero() {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
var ceremony authwebauthn.Ceremony
var userID sql.NullString
var binding []byte
var created, expires int64
err := store.db.QueryRowContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE token_hash=? RETURNING kind,user_id,label,session_json,binding_hash,created_at,expires_at`, digest[:]).Scan(&ceremony.Kind, &userID, &ceremony.Label, &ceremony.SessionData, &binding, &created, &expires)
if errors.Is(err, sql.ErrNoRows) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
if err != nil {
return authwebauthn.Ceremony{}, err
}
ceremony.Digest = digest
ceremony.UserID = userID.String
copy(ceremony.BindingDigest[:], binding)
ceremony.CreatedAt, ceremony.ExpiresAt = time.Unix(created, 0).UTC(), time.Unix(expires, 0).UTC()
if len(binding) != sha256Size || !now.Before(ceremony.ExpiresAt) {
return authwebauthn.Ceremony{}, authwebauthn.ErrCeremonyNotFound
}
return ceremony, nil
}
func (store *Store) ConsumeEnrollmentToken(ctx context.Context, digest [32]byte, now time.Time) (auth.User, error) {
if zeroDigest(digest) || now.IsZero() {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
var userID string
err = tx.QueryRowContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE token_hash=? AND expires_at>? RETURNING user_id`, digest[:], now.Unix()).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, authwebauthn.ErrEnrollmentNotFound
}
if err != nil {
return auth.User{}, err
}
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE id=?`, userID))
if err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
func (store *Store) RecoverUser(ctx context.Context, identifier string, enrollment authwebauthn.EnrollmentToken, audit auth.AuditEvent) (auth.User, error) {
if !text(strings.TrimSpace(identifier), 320, false) || !validEnrollment(enrollment) || !validAuditEvent(audit) {
return auth.User{}, errors.New("authsqlite: invalid passkey recovery")
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return auth.User{}, err
}
defer tx.Rollback()
user, err := scanPasskeyUser(tx.QueryRowContext(ctx, `SELECT id,username,email,display_name,status,password_change_required,created_at,updated_at FROM gwf_users WHERE username_normalized=? OR email_normalized=?`, normalize(identifier), normalize(identifier)))
if err != nil {
return auth.User{}, err
}
if enrollment.UserID != user.ID || audit.ResourceID != user.ID {
return auth.User{}, errors.New("authsqlite: passkey recovery identity mismatch")
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_ceremonies WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_passkey_enrollment_tokens WHERE user_id=?`, user.ID); err != nil {
return auth.User{}, err
}
if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_passkey_enrollment_tokens(token_hash,user_id,created_at,expires_at) VALUES(?,?,?,?)`, enrollment.Digest[:], user.ID, enrollment.CreatedAt.Unix(), enrollment.ExpiresAt.Unix()); err != nil {
return auth.User{}, err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return auth.User{}, err
}
if err = tx.Commit(); err != nil {
return auth.User{}, err
}
return user, nil
}
type rowScanner interface{ Scan(...any) error }
func scanPasskeyUser(row rowScanner) (auth.User, error) {
var user auth.User
var passwordChangeRequired int
var created, updated int64
if err := row.Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, auth.ErrUserNotFound
}
return auth.User{}, err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, nil
}
func validPasskeyUser(user auth.User) bool {
return opaqueID(user.ID) && text(user.Username, 64, false) && text(user.Email, 320, false) && text(user.DisplayName, 128, false) && user.Status == "active" && !user.CreatedAt.IsZero() && !user.UpdatedAt.IsZero()
}
func validEnrollment(token authwebauthn.EnrollmentToken) bool {
return !zeroDigest(token.Digest) && opaqueID(token.UserID) && !token.CreatedAt.IsZero() && token.ExpiresAt.After(token.CreatedAt)
}
func boundedCredentialID(value []byte) bool { return len(value) >= 16 && len(value) <= 1024 }
func validCredential(credential authwebauthn.Credential, requireLabel bool) bool {
return boundedCredentialID(credential.ID) && opaqueID(credential.UserID) && (!requireLabel || text(credential.Label, 80, false)) && len(credential.Data) > 0 && len(credential.Data) <= maxCredentialBytes && json.Valid(credential.Data) && (!requireLabel || !credential.CreatedAt.IsZero())
}
func validCeremony(ceremony authwebauthn.Ceremony) bool {
validKind := ceremony.Kind == authwebauthn.CeremonyRegistration || ceremony.Kind == authwebauthn.CeremonyLogin || ceremony.Kind == authwebauthn.CeremonyApproval
validUser := ceremony.Kind == authwebauthn.CeremonyLogin && ceremony.UserID == "" || opaqueID(ceremony.UserID)
validLabel := ceremony.Kind == authwebauthn.CeremonyRegistration && text(ceremony.Label, 80, false) || ceremony.Kind != authwebauthn.CeremonyRegistration && ceremony.Label == ""
zeroBinding := zeroDigest(ceremony.BindingDigest)
validBinding := ceremony.Kind == authwebauthn.CeremonyApproval && !zeroBinding || ceremony.Kind != authwebauthn.CeremonyApproval && zeroBinding
return !zeroDigest(ceremony.Digest) && validKind && validUser && validLabel && validBinding && len(ceremony.SessionData) > 0 && len(ceremony.SessionData) <= maxCeremonySessionBytes && json.Valid(ceremony.SessionData) && !ceremony.CreatedAt.IsZero() && ceremony.ExpiresAt.After(ceremony.CreatedAt)
}
const sha256Size = 32
+97
View File
@@ -0,0 +1,97 @@
// SPDX-License-Identifier: MPL-2.0
package authsqlite
import (
"bytes"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
"gamertan.com/web/authwebauthn"
)
func TestPasskeyCredentialDeletionPreservesConfiguredFloor(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
user := auth.User{ID: "passkey-user-id", Username: "passkey.user", Email: "passkey@example.test", DisplayName: "Passkey User", Status: "active", CreatedAt: now, UpdatedAt: now}
enrollment := authwebauthn.EnrollmentToken{Digest: [32]byte{1}, UserID: user.ID, CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
if err = store.CreatePasskeyUser(t.Context(), user, enrollment, testAudit("bootstrap-audit", "auth.passkey.bootstrap", user.ID, now)); err != nil {
t.Fatal(err)
}
ids := [][]byte{bytes.Repeat([]byte{1}, 32), bytes.Repeat([]byte{2}, 32), bytes.Repeat([]byte{3}, 32)}
for index, id := range ids {
encoded, marshalErr := json.Marshal(wa.Credential{ID: id, PublicKey: []byte{1, 2, 3}})
if marshalErr != nil {
t.Fatal(marshalErr)
}
credential := authwebauthn.Credential{ID: id, UserID: user.ID, Label: "Credential", Data: encoded, CreatedAt: now}
if err = store.SaveCredential(t.Context(), credential, testAudit("add-audit-"+string(rune('a'+index)), "auth.passkey.add", user.ID, now)); err != nil {
t.Fatal(err)
}
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[0], 2, testAudit("delete-audit", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 2, testAudit("delete-floor", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential floor err=%v", err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[1], 1, testAudit("delete-second", "auth.passkey.remove", user.ID, now)); err != nil {
t.Fatal(err)
}
if err = store.DeleteCredential(t.Context(), user.ID, ids[2], 1, testAudit("delete-last", "auth.passkey.remove", user.ID, now)); !errors.Is(err, authwebauthn.ErrLastCredential) {
t.Fatalf("last credential err=%v", err)
}
}
func TestPasskeyCeremonyIsConsumedExactlyOnceConcurrently(t *testing.T) {
store, err := Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
digest := [32]byte{1, 2, 3}
if err = store.CreateCeremony(t.Context(), authwebauthn.Ceremony{Digest: digest, Kind: authwebauthn.CeremonyLogin, SessionData: []byte(`{"challenge":"example"}`), CreatedAt: now, ExpiresAt: now.Add(time.Minute)}); err != nil {
t.Fatal(err)
}
var successes atomic.Int32
unexpected := make(chan error, 16)
var group sync.WaitGroup
for range 16 {
group.Add(1)
go func() {
defer group.Done()
_, takeErr := store.TakeCeremony(t.Context(), digest, now)
if takeErr == nil {
successes.Add(1)
return
}
if !errors.Is(takeErr, authwebauthn.ErrCeremonyNotFound) {
unexpected <- takeErr
}
}()
}
group.Wait()
close(unexpected)
for value := range unexpected {
t.Fatalf("unexpected concurrent error: %v", value)
}
if successes.Load() != 1 {
t.Fatalf("successful consumes=%d", successes.Load())
}
}
func testAudit(id, action, resourceID string, now time.Time) auth.AuditEvent {
return auth.AuditEvent{ID: id, Action: action, ResourceType: "user", ResourceID: resourceID, Summary: "test", CreatedAt: now}
}
+167 -6
View File
@@ -77,7 +77,7 @@ func (store *Store) Migrate(ctx context.Context) error {
defer tx.Rollback() defer tx.Rollback()
statements := []string{ statements := []string{
`CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gamertan_web_migrations (version INTEGER PRIMARY KEY, applied_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`, `CREATE TABLE IF NOT EXISTS gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('active','suspended','disabled')), password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1)), created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`,
`CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_password_credentials (user_id TEXT PRIMARY KEY REFERENCES gwf_users(id) ON DELETE CASCADE, password_hash TEXT NOT NULL, changed_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
@@ -88,18 +88,82 @@ func (store *Store) Migrate(ctx context.Context) error {
`CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`, `CREATE INDEX IF NOT EXISTS gwf_auth_sessions_expiry ON gwf_auth_sessions(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`, `CREATE TABLE IF NOT EXISTS gwf_audit_events (id TEXT PRIMARY KEY, actor_user_id TEXT REFERENCES gwf_users(id) ON DELETE SET NULL, action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`, `CREATE INDEX IF NOT EXISTS gwf_audit_created ON gwf_audit_events(created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_credentials (credential_id BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, credential_json BLOB NOT NULL, created_at INTEGER NOT NULL, last_used_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_credentials_user ON gwf_passkey_credentials(user_id,created_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_enrollment_tokens (token_hash BLOB PRIMARY KEY, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_enrollment_expiry ON gwf_passkey_enrollment_tokens(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_passkey_ceremonies (token_hash BLOB PRIMARY KEY, kind TEXT NOT NULL CHECK(kind IN ('registration','login','approval')), user_id TEXT REFERENCES gwf_users(id) ON DELETE CASCADE, label TEXT NOT NULL, session_json BLOB NOT NULL, binding_hash BLOB NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_passkey_ceremonies_expiry ON gwf_passkey_ceremonies(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_organizations (id TEXT PRIMARY KEY, slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL, personal INTEGER NOT NULL CHECK(personal IN (0,1)), personal_owner_user_id TEXT UNIQUE REFERENCES gwf_users(id) ON DELETE CASCADE, created_at INTEGER NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_organization_memberships (organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, status TEXT NOT NULL CHECK(status IN ('active','suspended')), joined_at INTEGER NOT NULL, PRIMARY KEY(organization_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_organization_memberships_user ON gwf_organization_memberships(user_id,organization_id)`,
`CREATE TABLE IF NOT EXISTS gwf_teams (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_team_members (team_id TEXT NOT NULL REFERENCES gwf_teams(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id) ON DELETE CASCADE, joined_at INTEGER NOT NULL, PRIMARY KEY(team_id,user_id))`,
`CREATE INDEX IF NOT EXISTS gwf_team_members_user ON gwf_team_members(user_id,team_id)`,
`CREATE TABLE IF NOT EXISTS gwf_projects (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(organization_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_environments (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(project_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_application_services (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, project_id TEXT NOT NULL REFERENCES gwf_projects(id) ON DELETE CASCADE, environment_id TEXT NOT NULL REFERENCES gwf_environments(id) ON DELETE CASCADE, slug TEXT NOT NULL, name TEXT NOT NULL, created_at INTEGER NOT NULL, UNIQUE(environment_id,slug))`,
`CREATE TABLE IF NOT EXISTS gwf_organization_invitations (token_hash BLOB PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, email_normalized TEXT NOT NULL, invited_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_organization_invitations_expiry ON gwf_organization_invitations(expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_access_roles (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_access_permissions (name TEXT PRIMARY KEY, description TEXT NOT NULL)`,
`CREATE TABLE IF NOT EXISTS gwf_access_role_permissions (role_name TEXT NOT NULL REFERENCES gwf_access_roles(name) ON DELETE CASCADE, permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name) ON DELETE CASCADE, PRIMARY KEY(role_name,permission_name))`,
`CREATE TABLE IF NOT EXISTS gwf_access_bindings (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, subject_kind TEXT NOT NULL CHECK(subject_kind IN ('user','team')), subject_id TEXT NOT NULL, role_name TEXT NOT NULL REFERENCES gwf_access_roles(name), project_id TEXT, environment_id TEXT, service_id TEXT, granted_by_user_id TEXT NOT NULL REFERENCES gwf_users(id), granted_at INTEGER NOT NULL, revoked_by_user_id TEXT REFERENCES gwf_users(id), revoked_at INTEGER)`,
`CREATE INDEX IF NOT EXISTS gwf_access_bindings_scope ON gwf_access_bindings(organization_id,subject_kind,subject_id,revoked_at)`,
`CREATE TABLE IF NOT EXISTS gwf_break_glass (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, user_id TEXT NOT NULL REFERENCES gwf_users(id), permission_name TEXT NOT NULL REFERENCES gwf_access_permissions(name), reason TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_break_glass_active ON gwf_break_glass(organization_id,user_id,expires_at)`,
`CREATE TABLE IF NOT EXISTS gwf_access_audit_events (id TEXT PRIMARY KEY, organization_id TEXT NOT NULL REFERENCES gwf_organizations(id) ON DELETE CASCADE, actor_user_id TEXT NOT NULL REFERENCES gwf_users(id), action TEXT NOT NULL, resource_type TEXT NOT NULL, resource_id TEXT NOT NULL, request_id TEXT, summary TEXT NOT NULL, created_at INTEGER NOT NULL)`,
`CREATE INDEX IF NOT EXISTS gwf_access_audit_created ON gwf_access_audit_events(organization_id,created_at)`,
} }
for _, statement := range statements { for _, statement := range statements {
if _, err = tx.ExecContext(ctx, statement); err != nil { if _, err = tx.ExecContext(ctx, statement); err != nil {
return err return err
} }
} }
hasPasswordRequirement, err := sqliteColumnExists(ctx, tx, "gwf_users", "password_change_required")
if err != nil {
return err
}
if !hasPasswordRequirement {
if _, err = tx.ExecContext(ctx, `ALTER TABLE gwf_users ADD COLUMN password_change_required INTEGER NOT NULL DEFAULT 0 CHECK(password_change_required IN (0,1))`); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil { if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(1,?)`, time.Now().UTC().Unix()); err != nil {
return err return err
} }
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(2,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(3,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO gamertan_web_migrations(version,applied_at) VALUES(4,?)`, time.Now().UTC().Unix()); err != nil {
return err
}
return tx.Commit() return tx.Commit()
} }
func sqliteColumnExists(ctx context.Context, tx *sql.Tx, table, column string) (bool, error) {
rows, err := tx.QueryContext(ctx, `PRAGMA table_info(`+table+`)`)
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var position, notNull, primaryKey int
var name, kind string
var defaultValue sql.NullString
if err = rows.Scan(&position, &name, &kind, &notNull, &defaultValue, &primaryKey); err != nil {
return false, err
}
if name == column {
return true, nil
}
}
return false, rows.Err()
}
func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error { func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash string) error {
if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) { if !opaqueID(user.ID) || !text(user.Username, 64, false) || !text(user.Email, 320, false) || !text(user.DisplayName, 128, false) || (user.Status != "active" && user.Status != "suspended" && user.Status != "disabled") || user.CreatedAt.IsZero() || user.UpdatedAt.IsZero() || !text(passwordHash, 1024, false) {
return errors.New("authsqlite: invalid user") return errors.New("authsqlite: invalid user")
@@ -109,7 +173,7 @@ func (store *Store) CreateUser(ctx context.Context, user auth.User, passwordHash
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
_, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.CreatedAt.Unix(), user.UpdatedAt.Unix()) _, err = tx.ExecContext(ctx, `INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,password_change_required,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`, user.ID, user.Username, normalize(user.Username), user.Email, normalize(user.Email), user.DisplayName, user.Status, user.PasswordChangeRequired, user.CreatedAt.Unix(), user.UpdatedAt.Unix())
if err != nil { if err != nil {
return err return err
} }
@@ -125,18 +189,101 @@ func (store *Store) CredentialByIdentifier(ctx context.Context, identifier strin
} }
var user auth.User var user auth.User
var created, updated int64 var created, updated int64
var passwordChangeRequired int
var hash string var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &created, &updated, &hash) err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.username_normalized=? OR u.email_normalized=?`, normalize(identifier), normalize(identifier)).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound return auth.User{}, "", auth.ErrUserNotFound
} }
if err != nil { if err != nil {
return auth.User{}, "", err return auth.User{}, "", err
} }
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC() user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil return user, hash, nil
} }
func (store *Store) CredentialByUserID(ctx context.Context, userID string) (auth.User, string, error) {
if !opaqueID(userID) {
return auth.User{}, "", auth.ErrUserNotFound
}
var user auth.User
var created, updated int64
var passwordChangeRequired int
var hash string
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,c.password_hash FROM gwf_users u JOIN gwf_password_credentials c ON c.user_id=u.id WHERE u.id=?`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.Status, &passwordChangeRequired, &created, &updated, &hash)
if errors.Is(err, sql.ErrNoRows) {
return auth.User{}, "", auth.ErrUserNotFound
}
if err != nil {
return auth.User{}, "", err
}
user.PasswordChangeRequired = passwordChangeRequired == 1
user.CreatedAt, user.UpdatedAt = time.Unix(created, 0).UTC(), time.Unix(updated, 0).UTC()
return user, hash, nil
}
func (store *Store) ReplacePasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=0,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) ResetPasswordAndRevokeSessions(ctx context.Context, userID, expectedHash, newHash string, changedAt time.Time, audit auth.AuditEvent) error {
if !opaqueID(userID) || !text(expectedHash, 1024, false) || !text(newHash, 1024, false) || changedAt.IsZero() || !validAuditEvent(audit) || audit.ActorUserID != "" || audit.ResourceType != "user" || audit.ResourceID != userID || !audit.CreatedAt.Equal(changedAt) {
return auth.ErrInvalidCredentials
}
tx, err := store.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
result, err := tx.ExecContext(ctx, `UPDATE gwf_password_credentials SET password_hash=?,changed_at=? WHERE user_id=? AND password_hash=?`, newHash, changedAt.Unix(), userID, expectedHash)
if err != nil {
return err
}
changed, err := result.RowsAffected()
if err != nil {
return err
}
if changed != 1 {
return auth.ErrInvalidCredentials
}
if _, err = tx.ExecContext(ctx, `UPDATE gwf_users SET password_change_required=1,updated_at=? WHERE id=?`, changedAt.Unix(), userID); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, `DELETE FROM gwf_auth_sessions WHERE user_id=?`, userID); err != nil {
return err
}
if err = appendAudit(ctx, tx, audit); err != nil {
return err
}
return tx.Commit()
}
func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error { func (store *Store) UpdateLastLogin(ctx context.Context, userID string, when time.Time) error {
if !opaqueID(userID) || when.IsZero() { if !opaqueID(userID) || when.IsZero() {
return errors.New("authsqlite: invalid login update") return errors.New("authsqlite: invalid login update")
@@ -160,10 +307,12 @@ func (store *Store) PrincipalBySession(ctx context.Context, digest [32]byte, now
var principal auth.Principal var principal auth.Principal
var session auth.Session var session auth.Session
var created, updated, sessionCreated, expires, lastSeen int64 var created, updated, sessionCreated, expires, lastSeen int64
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen) var passwordChangeRequired int
err := store.db.QueryRowContext(ctx, `SELECT u.id,u.username,u.email,u.display_name,u.status,u.password_change_required,u.created_at,u.updated_at,s.user_id,s.created_at,s.expires_at,s.last_seen_at FROM gwf_auth_sessions s JOIN gwf_users u ON u.id=s.user_id WHERE s.token_hash=? AND s.expires_at>?`, digest[:], now.Unix()).Scan(&principal.User.ID, &principal.User.Username, &principal.User.Email, &principal.User.DisplayName, &principal.User.Status, &passwordChangeRequired, &created, &updated, &session.UserID, &sessionCreated, &expires, &lastSeen)
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound return auth.Principal{}, auth.Session{}, auth.ErrSessionNotFound
} }
principal.User.PasswordChangeRequired = passwordChangeRequired == 1
if err != nil { if err != nil {
return auth.Principal{}, auth.Session{}, err return auth.Principal{}, auth.Session{}, err
} }
@@ -271,13 +420,25 @@ func (store *Store) GrantRole(ctx context.Context, userID, role string, when tim
return err return err
} }
func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error { func (store *Store) AppendAudit(ctx context.Context, event auth.AuditEvent) error {
if !opaqueID(event.ID) || event.ActorUserID != "" && !opaqueID(event.ActorUserID) || !safeName(event.Action) || !safeName(event.ResourceType) || !text(event.ResourceID, 256, false) || !text(event.RequestID, 128, true) || !text(event.Summary, 1024, true) || event.CreatedAt.IsZero() { if !validAuditEvent(event) {
return errors.New("authsqlite: invalid audit event") return errors.New("authsqlite: invalid audit event")
} }
_, err := store.db.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix()) return appendAudit(ctx, store.db, event)
}
type auditExecer interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}
func appendAudit(ctx context.Context, execer auditExecer, event auth.AuditEvent) error {
_, err := execer.ExecContext(ctx, `INSERT INTO gwf_audit_events(id,actor_user_id,action,resource_type,resource_id,request_id,summary,created_at) VALUES(?,NULLIF(?,''),?,?,?,?,?,?)`, event.ID, event.ActorUserID, event.Action, event.ResourceType, event.ResourceID, event.RequestID, event.Summary, event.CreatedAt.Unix())
return err return err
} }
func validAuditEvent(event auth.AuditEvent) bool {
return opaqueID(event.ID) && (event.ActorUserID == "" || opaqueID(event.ActorUserID)) && safeName(event.Action) && safeName(event.ResourceType) && text(event.ResourceID, 256, false) && text(event.RequestID, 128, true) && text(event.Summary, 1024, true) && !event.CreatedAt.IsZero()
}
func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) } func normalize(value string) string { return strings.ToLower(strings.TrimSpace(value)) }
func safeName(value string) bool { func safeName(value string) bool {
if value == "" || len(value) > 128 { if value == "" || len(value) > 128 {
+259
View File
@@ -3,6 +3,8 @@
package authsqlite package authsqlite
import ( import (
"database/sql"
"errors"
"os" "os"
"path/filepath" "path/filepath"
"runtime" "runtime"
@@ -10,7 +12,9 @@ import (
"testing" "testing"
"time" "time"
"gamertan.com/web/access"
"gamertan.com/web/auth" "gamertan.com/web/auth"
"gamertan.com/web/organizations"
) )
func TestServiceRoundTripWithApplicationPolicy(t *testing.T) { func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
@@ -53,6 +57,171 @@ func TestServiceRoundTripWithApplicationPolicy(t *testing.T) {
} }
} }
func TestRequiredPasswordChangeRotatesCredentialAndRevokesSessions(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(3000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "bootstrap", Email: "bootstrap@example.test", DisplayName: "Bootstrap Operator", Password: "temporary bootstrap credential", RequirePasswordChange: true})
if err != nil || !user.PasswordChangeRequired {
t.Fatalf("user=%+v err=%v", user, err)
}
token, principal, err := service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("principal=%+v err=%v", principal, err)
}
if err = service.ChangePassword(t.Context(), user.ID, "wrong current credential", "new permanent credential"); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("wrong current credential err=%v", err)
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("failed rotation revoked session: %v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "temporary bootstrap credential"); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("reused credential err=%v", err)
}
if err = service.ChangePassword(t.Context(), user.ID, "temporary bootstrap credential", "new permanent credential"); err != nil {
t.Fatal(err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("old session survived rotation: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "temporary bootstrap credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("temporary credential survived rotation: %v", err)
}
_, principal, err = service.Authenticate(t.Context(), user.Username, "new permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("rotated principal=%+v err=%v", principal, err)
}
}
func TestAdministrativePasswordResetIsAtomicAndAudited(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(4000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recovery Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
reset, err := service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Email, TemporaryPassword: "one-time recovery credential"})
if err != nil || !reset.PasswordChangeRequired {
t.Fatalf("reset=%+v err=%v", reset, err)
}
if _, err = service.Session(t.Context(), token); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived reset: %v", err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("old credential survived reset: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour)
if err != nil || !principal.User.PasswordChangeRequired {
t.Fatalf("recovery principal=%+v err=%v", principal, err)
}
var action, summary string
var events int
if err = store.db.QueryRow(`SELECT COUNT(*),action,summary FROM gwf_audit_events WHERE resource_id=?`, user.ID).Scan(&events, &action, &summary); err != nil {
t.Fatal(err)
}
if events != 1 || action != "auth.password.reset" || strings.Contains(summary, "one-time recovery credential") || !strings.Contains(summary, "revoked all sessions") {
t.Fatalf("events=%d action=%q summary=%q", events, action, summary)
}
if _, err = service.ResetPassword(t.Context(), auth.AdministrativePasswordReset{Identifier: user.Username, TemporaryPassword: "one-time recovery credential"}); !errors.Is(err, auth.ErrPasswordUnchanged) {
t.Fatalf("same credential err=%v", err)
}
}
func TestAdministrativePasswordResetRollsBackWhenAuditCannotCommit(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(5000, 0).UTC()
service, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
user, err := service.CreateUser(t.Context(), auth.CreateUser{Username: "rollback.me", Email: "rollback@example.test", DisplayName: "Rollback Test", Password: "original permanent credential"})
if err != nil {
t.Fatal(err)
}
token, _, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil {
t.Fatal(err)
}
user, currentHash, err := store.CredentialByUserID(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
newHash, err := auth.HashPassword("one-time recovery credential")
if err != nil {
t.Fatal(err)
}
audit := auth.AuditEvent{ID: "duplicate-audit-id", Action: "auth.password.reset", ResourceType: "user", ResourceID: user.ID, Summary: "A local administrator issued a one-time credential and revoked all sessions.", CreatedAt: now}
if err = store.AppendAudit(t.Context(), audit); err != nil {
t.Fatal(err)
}
if err = store.ResetPasswordAndRevokeSessions(t.Context(), user.ID, currentHash, newHash, now, audit); err == nil {
t.Fatal("duplicate audit unexpectedly committed reset")
}
if _, err = service.Session(t.Context(), token); err != nil {
t.Fatalf("rollback revoked session: %v", err)
}
_, principal, err := service.Authenticate(t.Context(), user.Username, "original permanent credential", time.Hour)
if err != nil || principal.User.PasswordChangeRequired {
t.Fatalf("original credential not restored: principal=%+v err=%v", principal, err)
}
if _, _, err = service.Authenticate(t.Context(), user.Username, "one-time recovery credential", time.Hour); !errors.Is(err, auth.ErrInvalidCredentials) {
t.Fatalf("uncommitted recovery credential accepted: %v", err)
}
}
func TestMigrationAddsPasswordRequirementWithoutChangingExistingUsers(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
database, err := sql.Open("sqlite", path)
if err != nil {
t.Fatal(err)
}
_, err = database.Exec(`CREATE TABLE gwf_users (id TEXT PRIMARY KEY, username TEXT NOT NULL, username_normalized TEXT NOT NULL UNIQUE, email TEXT NOT NULL, email_normalized TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, last_login_at INTEGER)`)
if err == nil {
_, err = database.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,created_at,updated_at) VALUES('existing-user','existing','existing','existing@example.test','existing@example.test','Existing','active',1,1)`)
}
if closeErr := database.Close(); err == nil {
err = closeErr
}
if err != nil {
t.Fatal(err)
}
store, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer store.Close()
var required, migrations int
if err = store.db.QueryRow(`SELECT password_change_required FROM gwf_users WHERE id='existing-user'`).Scan(&required); err != nil || required != 0 {
t.Fatalf("required=%d err=%v", required, err)
}
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gamertan_web_migrations WHERE version=3`).Scan(&migrations); err != nil || migrations != 1 {
t.Fatalf("migrations=%d err=%v", migrations, err)
}
}
func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) { func TestSchemaIsNamespacedAndSeedsNothing(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db") path := filepath.Join(t.TempDir(), "accounts.db")
store, err := Open(path) store, err := Open(path)
@@ -115,3 +284,93 @@ func TestOpenRejectsSymlinkDatabase(t *testing.T) {
t.Fatal("symlink database accepted") t.Fatal("symlink database accepted")
} }
} }
func TestOrganizationTeamResourceAndScopedAccessRoundTrip(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(2000, 0).UTC()
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "owner.one", Email: "owner@example.test", DisplayName: "Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
member, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "member.one", Email: "member@example.test", DisplayName: "Member", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "observatory-test", Name: "Observatory Test", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, member.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, member.ID); err != nil {
t.Fatal(err)
}
team, err := organizationService.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: organization.ID, Slug: "operators", Name: "Operators"})
if err != nil {
t.Fatal(err)
}
if err = organizationService.AddTeamMember(t.Context(), team.ID, member.ID); err != nil {
t.Fatal(err)
}
project, err := organizationService.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: organization.ID, Slug: "eql", Name: "EQL"})
if err != nil {
t.Fatal(err)
}
environment, err := organizationService.CreateEnvironment(t.Context(), organizations.CreateEnvironment{OrganizationID: organization.ID, ProjectID: project.ID, Slug: "production", Name: "Production"})
if err != nil {
t.Fatal(err)
}
application, err := organizationService.CreateApplicationService(t.Context(), organizations.CreateApplicationService{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, Slug: "web", Name: "Web"})
if err != nil {
t.Fatal(err)
}
policy := access.Policy{Roles: map[string]string{"viewer": "Read telemetry"}, Permissions: map[string]string{"telemetry.read": "Read telemetry", "telemetry.sensitive.read": "Read sensitive telemetry"}, Grants: map[string][]string{"viewer": {"telemetry.read"}}}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
scope := access.Scope{OrganizationID: organization.ID, ProjectID: project.ID, EnvironmentID: environment.ID, ServiceID: application.ID}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.Team, SubjectID: team.ID, Role: "viewer", Scope: scope, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
decision, err := accessService.Authorize(t.Context(), member.ID, scope, "telemetry.read")
if err != nil || !decision.Allowed || decision.Source != "role" {
t.Fatalf("decision=%+v err=%v", decision, err)
}
decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read")
if err != nil || decision.Allowed {
t.Fatalf("sensitive decision=%+v err=%v", decision, err)
}
if _, err = accessService.ActivateBreakGlass(t.Context(), organization.ID, member.ID, "telemetry.sensitive.read", "Investigate the active production incident", "request-12345678", 15*time.Minute); err != nil {
t.Fatal(err)
}
decision, err = accessService.Authorize(t.Context(), member.ID, scope, "telemetry.sensitive.read")
if err != nil || !decision.Allowed || decision.Source != "break_glass" {
t.Fatalf("break-glass decision=%+v err=%v", decision, err)
}
var audits int
if err = store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_audit_events WHERE organization_id=?`, organization.ID).Scan(&audits); err != nil || audits != 1 {
t.Fatalf("audits=%d err=%v", audits, err)
}
auditEvents, err := accessService.Audit(t.Context(), organization.ID, 10)
if err != nil || len(auditEvents) != 1 || auditEvents[0].Action != "break_glass.activate" {
t.Fatalf("audit events=%+v err=%v", auditEvents, err)
}
}
+21
View File
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
func FuzzPasskeyResponseParsers(f *testing.F) {
f.Add([]byte(`{}`))
f.Add([]byte(`{"id":"credential","rawId":"Y3JlZGVudGlhbA","type":"public-key","response":{}}`))
f.Fuzz(func(t *testing.T, value []byte) {
if len(value) > maxResponseBytes {
t.Skip()
}
_, _ = protocol.ParseCredentialCreationResponseBytes(value)
_, _ = protocol.ParseCredentialRequestResponseBytes(value)
})
}
+616
View File
@@ -0,0 +1,616 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"bytes"
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"regexp"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
)
const (
defaultEnrollmentLifetime = 15 * time.Minute
defaultRegistrationTTL = 5 * time.Minute
defaultLoginTTL = 2 * time.Minute
defaultApprovalTTL = 90 * time.Second
maxCredentialLabelBytes = 80
maxResponseBytes = 128 << 10
)
var accountNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.-]{2,63}$`)
type Config struct {
RPID string
RPDisplayName string
Origin string
EnrollmentLifetime time.Duration
RegistrationTTL time.Duration
LoginTTL time.Duration
ApprovalTTL time.Duration
SessionLifetime time.Duration
RequiredCredentialCount int
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
auth *auth.Service
webAuthn *wa.WebAuthn
random io.Reader
now func() time.Time
config Config
}
type BootstrapInput struct {
Username, Email, DisplayName string
}
func New(repository Repository, authService *auth.Service, config Config) (*Service, error) {
if repository == nil || authService == nil {
return nil, errors.New("authwebauthn: repository and auth service are required")
}
if err := validateOrigin(config.RPID, config.Origin); err != nil {
return nil, err
}
if strings.TrimSpace(config.RPDisplayName) == "" || len(config.RPDisplayName) > 80 {
return nil, errors.New("authwebauthn: relying-party display name is invalid")
}
if config.Random == nil {
config.Random = rand.Reader
}
if config.Now == nil {
config.Now = time.Now
}
if config.EnrollmentLifetime == 0 {
config.EnrollmentLifetime = defaultEnrollmentLifetime
}
if config.RegistrationTTL == 0 {
config.RegistrationTTL = defaultRegistrationTTL
}
if config.LoginTTL == 0 {
config.LoginTTL = defaultLoginTTL
}
if config.ApprovalTTL == 0 {
config.ApprovalTTL = defaultApprovalTTL
}
if config.SessionLifetime == 0 {
config.SessionLifetime = 12 * time.Hour
}
if config.RequiredCredentialCount == 0 {
config.RequiredCredentialCount = 2
}
if config.EnrollmentLifetime < time.Minute || config.EnrollmentLifetime > time.Hour ||
config.RegistrationTTL < time.Minute || config.RegistrationTTL > 10*time.Minute ||
config.LoginTTL < time.Minute || config.LoginTTL > 5*time.Minute ||
config.ApprovalTTL < 30*time.Second || config.ApprovalTTL > 2*time.Minute ||
config.SessionLifetime < 5*time.Minute || config.SessionLifetime > 30*24*time.Hour ||
config.RequiredCredentialCount < 1 || config.RequiredCredentialCount > 8 {
return nil, errors.New("authwebauthn: lifetime or credential-count policy is invalid")
}
webAuthn, err := wa.New(&wa.Config{
RPID: config.RPID,
RPDisplayName: config.RPDisplayName,
RPOrigins: []string{config.Origin},
RPAllowCrossOrigin: false,
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
ResidentKey: protocol.ResidentKeyRequirementRequired,
RequireResidentKey: protocol.ResidentKeyRequired(),
UserVerification: protocol.VerificationRequired,
},
Timeouts: wa.TimeoutsConfig{
Login: wa.TimeoutConfig{Timeout: config.LoginTTL, TimeoutUVD: config.LoginTTL},
Registration: wa.TimeoutConfig{Timeout: config.RegistrationTTL, TimeoutUVD: config.RegistrationTTL},
},
})
if err != nil {
return nil, fmt.Errorf("authwebauthn: configure verifier: %w", err)
}
return &Service{repository: repository, auth: authService, webAuthn: webAuthn, random: config.Random, now: config.Now, config: config}, nil
}
func (service *Service) Bootstrap(ctx context.Context, input BootstrapInput) (auth.User, string, error) {
username := strings.TrimSpace(input.Username)
email := strings.TrimSpace(input.Email)
displayName := strings.TrimSpace(input.DisplayName)
if !accountNamePattern.MatchString(username) || email == "" || len(email) > 320 || !strings.Contains(email, "@") || displayName == "" || len(displayName) > 128 {
return auth.User{}, "", errors.New("authwebauthn: invalid user")
}
userID, err := service.randomToken(18)
if err != nil {
return auth.User{}, "", err
}
token, enrollment, err := service.newEnrollment(userID)
if err != nil {
return auth.User{}, "", err
}
now := service.now().UTC()
user := auth.User{ID: userID, Username: username, Email: email, DisplayName: displayName, Status: "active", CreatedAt: now, UpdatedAt: now}
audit, err := service.audit("", "auth.passkey.bootstrap", "user", userID, "A local administrator created a passkey-only account and one-time enrollment token.")
if err != nil {
return auth.User{}, "", err
}
if err = service.repository.CreatePasskeyUser(ctx, user, enrollment, audit); err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) Recover(ctx context.Context, identifier, reason string) (auth.User, string, error) {
identifier = strings.TrimSpace(identifier)
reason = strings.TrimSpace(reason)
if identifier == "" || len(identifier) > 320 || reason == "" || len(reason) > 240 || strings.ContainsAny(reason, "\x00\r\n") {
return auth.User{}, "", errors.New("authwebauthn: recovery identifier and bounded reason are required")
}
user, err := service.repository.UserByIdentifier(ctx, identifier)
if err != nil {
return auth.User{}, "", err
}
if user.Status != "active" {
return auth.User{}, "", auth.ErrInactiveUser
}
token, enrollment, err := service.newEnrollment(user.ID)
if err != nil {
return auth.User{}, "", err
}
audit, err := service.audit("", "auth.passkey.recovery", "user", user.ID, "A local administrator revoked sessions and issued a one-time passkey enrollment token. Reason: "+reason)
if err != nil {
return auth.User{}, "", err
}
user, err = service.repository.RecoverUser(ctx, identifier, enrollment, audit)
if err != nil {
return auth.User{}, "", err
}
return user, token, nil
}
func (service *Service) BeginEnrollment(ctx context.Context, enrollmentToken, label string) (BeginResult, error) {
digest, err := tokenDigest(enrollmentToken)
if err != nil {
return BeginResult{}, ErrEnrollmentNotFound
}
user, err := service.repository.ConsumeEnrollmentToken(ctx, digest, service.now().UTC())
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label)
}
func (service *Service) BeginRegistration(ctx context.Context, userID, label string) (BeginResult, error) {
user, err := service.repository.UserByID(ctx, strings.TrimSpace(userID))
if err != nil {
return BeginResult{}, err
}
return service.beginRegistration(ctx, user, label)
}
func (service *Service) beginRegistration(ctx context.Context, user auth.User, label string) (BeginResult, error) {
label, err := credentialLabel(label)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, user)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
creation, session, err := service.webAuthn.BeginRegistration(adapter,
func(options *protocol.PublicKeyCredentialCreationOptions) { options.Challenge = challenge },
wa.WithCredentialParameters([]protocol.CredentialParameter{{Type: protocol.PublicKeyCredentialType, Algorithm: webauthncose.AlgES256}}),
wa.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
wa.WithConveyancePreference(protocol.PreferNoAttestation),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin registration: %w", err)
}
return service.storeCeremony(ctx, CeremonyRegistration, user.ID, label, session, [32]byte{}, creation.Response, service.config.RegistrationTTL)
}
func (service *Service) FinishRegistration(ctx context.Context, ceremonyToken string, response []byte) (Credential, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyRegistration)
if err != nil {
return Credential{}, err
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
user, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Credential{}, err
}
adapter, err := service.user(ctx, user)
if err != nil {
return Credential{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Credential{}, err
}
parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
if err != nil {
return Credential{}, errors.New("authwebauthn: registration response is invalid")
}
verified, err := service.webAuthn.CreateCredential(adapter, session, parsed)
if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
}
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
return Credential{}, ErrUnsupportedCredential
}
encoded, err := json.Marshal(verified)
if err != nil {
return Credential{}, err
}
now := service.now().UTC()
record := Credential{ID: append([]byte(nil), verified.ID...), UserID: user.ID, Label: ceremony.Label, Data: encoded, CreatedAt: now}
audit, err := service.audit(user.ID, "auth.passkey.add", "passkey", base64.RawURLEncoding.EncodeToString(verified.ID), "A passkey was enrolled.")
if err != nil {
return Credential{}, err
}
if err = service.repository.SaveCredential(ctx, record, audit); err != nil {
return Credential{}, err
}
return record, nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginDiscoverableLogin(
wa.WithChallenge(challenge),
wa.WithUserVerification(protocol.VerificationRequired),
)
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin login: %w", err)
}
return service.storeCeremony(ctx, CeremonyLogin, "", "", session, [32]byte{}, assertion.Response, service.config.LoginTTL)
}
func (service *Service) FinishLogin(ctx context.Context, ceremonyToken string, response []byte) (Authentication, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyLogin)
if err != nil {
return Authentication{}, err
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Authentication{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Authentication{}, errors.New("authwebauthn: login response is invalid")
}
var loaded *passkeyUser
user, verified, err := service.webAuthn.ValidatePasskeyLogin(func(rawID, userHandle []byte) (wa.User, error) {
account, lookupErr := service.repository.UserByCredentialID(ctx, rawID)
if lookupErr != nil || account.ID != string(userHandle) {
return nil, ErrCredentialNotFound
}
loaded, lookupErr = service.user(ctx, account)
return loaded, lookupErr
}, session, parsed)
if err != nil || loaded == nil || user == nil {
return Authentication{}, errors.New("authwebauthn: authentication failed")
}
if err = service.persistUsedCredential(ctx, loaded.account.ID, verified); err != nil {
return Authentication{}, err
}
token, principal, err := service.auth.IssueSession(ctx, loaded.account.ID, service.config.SessionLifetime)
if err != nil {
return Authentication{}, err
}
return Authentication{SessionToken: token, Principal: principal, CredentialID: append([]byte(nil), verified.ID...), CloneWarning: verified.Authenticator.CloneWarning}, nil
}
func (service *Service) BeginApproval(ctx context.Context, userID string, binding []byte) (BeginResult, error) {
if len(binding) < 32 || len(binding) > 32<<10 {
return BeginResult{}, errors.New("authwebauthn: operation binding is invalid")
}
if err := service.RequireReady(ctx, userID); err != nil {
return BeginResult{}, err
}
account, err := service.repository.UserByID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
adapter, err := service.user(ctx, account)
if err != nil {
return BeginResult{}, err
}
challenge, err := service.randomBytes(32)
if err != nil {
return BeginResult{}, err
}
assertion, session, err := service.webAuthn.BeginLogin(adapter, wa.WithChallenge(challenge), wa.WithUserVerification(protocol.VerificationRequired))
if err != nil {
return BeginResult{}, fmt.Errorf("authwebauthn: begin approval: %w", err)
}
return service.storeCeremony(ctx, CeremonyApproval, account.ID, "", session, BindingDigest(binding), assertion.Response, service.config.ApprovalTTL)
}
func (service *Service) FinishApproval(ctx context.Context, ceremonyToken string, binding, response []byte) (Approval, error) {
ceremony, err := service.takeCeremony(ctx, ceremonyToken, CeremonyApproval)
if err != nil {
return Approval{}, err
}
if BindingDigest(binding) != ceremony.BindingDigest {
return Approval{}, ErrOperationBinding
}
if len(response) == 0 || len(response) > maxResponseBytes {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
account, err := service.repository.UserByID(ctx, ceremony.UserID)
if err != nil {
return Approval{}, err
}
adapter, err := service.user(ctx, account)
if err != nil {
return Approval{}, err
}
session, err := decodeSession(ceremony.SessionData)
if err != nil {
return Approval{}, err
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval response is invalid")
}
verified, err := service.webAuthn.ValidateLogin(adapter, session, parsed)
if err != nil {
return Approval{}, errors.New("authwebauthn: approval failed")
}
if err = service.persistUsedCredential(ctx, account.ID, verified); err != nil {
return Approval{}, err
}
return Approval{User: account, CredentialID: append([]byte(nil), verified.ID...), BindingDigest: ceremony.BindingDigest, CloneWarning: verified.Authenticator.CloneWarning, ApprovedAt: service.now().UTC()}, nil
}
func (service *Service) RequireReady(ctx context.Context, userID string) error {
count, err := service.repository.CredentialCount(ctx, userID)
if err != nil {
return err
}
if count < service.config.RequiredCredentialCount {
return ErrPasskeyReadiness
}
return nil
}
// RequiredCredentialCount reports the configured operational credential
// floor. Applications can use it to explain rotation policy without
// duplicating security configuration.
func (service *Service) RequiredCredentialCount() int {
return service.config.RequiredCredentialCount
}
// CredentialSummaries returns bounded account-owner metadata without exposing
// stored credential documents.
func (service *Service) CredentialSummaries(ctx context.Context, userID string) ([]CredentialSummary, error) {
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return nil, err
}
summaries := make([]CredentialSummary, 0, len(records))
for _, record := range records {
summaries = append(summaries, CredentialSummary{
ID: append([]byte(nil), record.ID...),
Label: record.Label,
CreatedAt: record.CreatedAt,
LastUsedAt: record.LastUsedAt,
})
}
return summaries, nil
}
func (service *Service) BeginCredentialRemoval(ctx context.Context, userID string, credentialID []byte) (BeginResult, error) {
if len(credentialID) < 16 || len(credentialID) > 1024 {
return BeginResult{}, ErrCredentialNotFound
}
records, err := service.repository.CredentialsByUserID(ctx, userID)
if err != nil {
return BeginResult{}, err
}
found := false
for _, record := range records {
if bytes.Equal(record.ID, credentialID) {
found = true
break
}
}
if !found {
return BeginResult{}, ErrCredentialNotFound
}
if len(records) <= service.config.RequiredCredentialCount {
return BeginResult{}, ErrCredentialFloor
}
return service.BeginApproval(ctx, userID, credentialRemovalBinding(userID, credentialID))
}
func (service *Service) FinishCredentialRemoval(ctx context.Context, ceremonyToken, userID string, credentialID, response []byte) error {
binding := credentialRemovalBinding(userID, credentialID)
approval, err := service.FinishApproval(ctx, ceremonyToken, binding, response)
if err != nil {
return err
}
if approval.User.ID != userID || approval.BindingDigest != BindingDigest(binding) {
return ErrOperationBinding
}
audit, err := service.audit(userID, "auth.passkey.remove", "passkey", base64.RawURLEncoding.EncodeToString(credentialID), "A passkey was removed after fresh authentication.")
if err != nil {
return err
}
return service.repository.DeleteCredential(ctx, userID, credentialID, service.config.RequiredCredentialCount, audit)
}
func (service *Service) storeCeremony(ctx context.Context, kind, userID, label string, session *wa.SessionData, binding [32]byte, publicKey any, ttl time.Duration) (BeginResult, error) {
token, err := service.randomToken(32)
if err != nil {
return BeginResult{}, err
}
digest := sha256.Sum256([]byte(token))
now := service.now().UTC()
session.Expires = now.Add(ttl)
sessionJSON, err := json.Marshal(session)
if err != nil {
return BeginResult{}, err
}
publicJSON, err := json.Marshal(publicKey)
if err != nil {
return BeginResult{}, err
}
ceremony := Ceremony{Digest: digest, Kind: kind, UserID: userID, Label: label, SessionData: sessionJSON, BindingDigest: binding, CreatedAt: now, ExpiresAt: now.Add(ttl)}
if err = service.repository.CreateCeremony(ctx, ceremony); err != nil {
return BeginResult{}, err
}
return BeginResult{CeremonyToken: token, PublicKey: publicJSON, ExpiresAt: ceremony.ExpiresAt}, nil
}
func (service *Service) takeCeremony(ctx context.Context, token, kind string) (Ceremony, error) {
digest, err := tokenDigest(token)
if err != nil {
return Ceremony{}, ErrCeremonyNotFound
}
ceremony, err := service.repository.TakeCeremony(ctx, digest, service.now().UTC())
if err != nil {
return Ceremony{}, err
}
if ceremony.Kind != kind {
return Ceremony{}, ErrCeremonyNotFound
}
return ceremony, nil
}
func (service *Service) user(ctx context.Context, account auth.User) (*passkeyUser, error) {
if account.Status != "active" {
return nil, auth.ErrInactiveUser
}
records, err := service.repository.CredentialsByUserID(ctx, account.ID)
if err != nil {
return nil, err
}
credentials := make([]wa.Credential, 0, len(records))
for _, record := range records {
var credential wa.Credential
if err = json.Unmarshal(record.Data, &credential); err != nil || len(credential.ID) == 0 {
return nil, errors.New("authwebauthn: stored credential is invalid")
}
credentials = append(credentials, credential)
}
return &passkeyUser{account: account, credentials: credentials}, nil
}
func (service *Service) persistUsedCredential(ctx context.Context, userID string, credential *wa.Credential) error {
encoded, err := json.Marshal(credential)
if err != nil {
return err
}
return service.repository.UpdateCredential(ctx, Credential{ID: append([]byte(nil), credential.ID...), UserID: userID, Data: encoded, LastUsedAt: service.now().UTC()})
}
func (service *Service) newEnrollment(userID string) (string, EnrollmentToken, error) {
token, err := service.randomToken(32)
if err != nil {
return "", EnrollmentToken{}, err
}
now := service.now().UTC()
return token, EnrollmentToken{Digest: sha256.Sum256([]byte(token)), UserID: userID, CreatedAt: now, ExpiresAt: now.Add(service.config.EnrollmentLifetime)}, nil
}
func (service *Service) audit(actor, action, resourceType, resourceID, summary string) (auth.AuditEvent, error) {
id, err := service.randomToken(18)
if err != nil {
return auth.AuditEvent{}, err
}
return auth.AuditEvent{ID: id, ActorUserID: actor, Action: action, ResourceType: resourceType, ResourceID: resourceID, Summary: summary, CreatedAt: service.now().UTC()}, nil
}
func (service *Service) randomToken(size int) (string, error) {
value, err := service.randomBytes(size)
if err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func (service *Service) randomBytes(size int) ([]byte, error) {
value := make([]byte, size)
if _, err := io.ReadFull(service.random, value); err != nil {
return nil, fmt.Errorf("authwebauthn: secure randomness unavailable: %w", err)
}
return value, nil
}
func tokenDigest(token string) ([32]byte, error) {
if len(token) < 32 || len(token) > 128 {
return [32]byte{}, errors.New("invalid token")
}
if _, err := base64.RawURLEncoding.DecodeString(token); err != nil {
return [32]byte{}, errors.New("invalid token")
}
return sha256.Sum256([]byte(token)), nil
}
func decodeSession(value []byte) (wa.SessionData, error) {
var session wa.SessionData
if len(value) == 0 || len(value) > 64<<10 || json.Unmarshal(value, &session) != nil {
return wa.SessionData{}, errors.New("authwebauthn: stored ceremony is invalid")
}
return session, nil
}
func credentialLabel(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" || len(value) > maxCredentialLabelBytes || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("authwebauthn: credential label is invalid")
}
return value, nil
}
func credentialRemovalBinding(userID string, credentialID []byte) []byte {
return []byte("gamertan-web/passkey-remove/v1\x00" + userID + "\x00" + base64.RawURLEncoding.EncodeToString(credentialID))
}
func validateOrigin(rpID, rawOrigin string) error {
if strings.TrimSpace(rpID) == "" || strings.TrimSpace(rawOrigin) == "" {
return errors.New("authwebauthn: relying-party ID and origin are required")
}
origin, err := url.Parse(rawOrigin)
if err != nil || origin.Scheme != "https" || origin.Hostname() != rpID || origin.Port() != "" || origin.User != nil || origin.Path != "" || origin.RawQuery != "" || origin.Fragment != "" {
return errors.New("authwebauthn: origin must be the exact HTTPS relying-party origin")
}
return nil
}
type passkeyUser struct {
account auth.User
credentials []wa.Credential
}
func (user *passkeyUser) WebAuthnID() []byte { return []byte(user.account.ID) }
func (user *passkeyUser) WebAuthnName() string { return user.account.Username }
func (user *passkeyUser) WebAuthnDisplayName() string { return user.account.DisplayName }
func (user *passkeyUser) WebAuthnCredentials() []wa.Credential { return user.credentials }
+202
View File
@@ -0,0 +1,202 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"testing"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
"gamertan.com/web/auth"
"gamertan.com/web/authsqlite"
"gamertan.com/web/authwebauthn"
)
func TestBootstrapEnrollmentAndApprovalPolicy(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, enrollmentToken, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "operator.one", Email: "operator@example.test", DisplayName: "Operator One"})
if err != nil {
t.Fatal(err)
}
if enrollmentToken == "" || user.PasswordChangeRequired {
t.Fatalf("unexpected bootstrap user=%+v token=%q", user, enrollmentToken)
}
begin, err := service.BeginEnrollment(t.Context(), enrollmentToken, "Primary passkey")
if err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), enrollmentToken, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("replayed enrollment err=%v", err)
}
var options protocol.PublicKeyCredentialCreationOptions
if err = json.Unmarshal(begin.PublicKey, &options); err != nil {
t.Fatal(err)
}
if options.RelyingParty.ID != "tend.gamertan.com" || options.AuthenticatorSelection.UserVerification != protocol.VerificationRequired || options.AuthenticatorSelection.ResidentKey != protocol.ResidentKeyRequirementRequired || options.Attestation != protocol.PreferNoAttestation {
t.Fatalf("unexpected registration policy: %+v", options)
}
if len(options.Parameters) != 1 || options.Parameters[0].Algorithm != webauthncose.AlgES256 {
t.Fatalf("unexpected algorithms: %+v", options.Parameters)
}
if !begin.ExpiresAt.Equal(now.Add(5 * time.Minute)) {
t.Fatalf("registration expiry=%v", begin.ExpiresAt)
}
if err = service.RequireReady(t.Context(), user.ID); !errors.Is(err, authwebauthn.ErrPasskeyReadiness) {
t.Fatalf("readiness without credentials err=%v", err)
}
for index := range 2 {
credential := wa.Credential{ID: bytes.Repeat([]byte{byte(index + 1)}, 32), PublicKey: []byte{1, 2, 3}}
encoded, marshalErr := json.Marshal(credential)
if marshalErr != nil {
t.Fatal(marshalErr)
}
audit := auth.AuditEvent{ID: "audit-passkey-" + string(rune('a'+index)), ActorUserID: user.ID, Action: "auth.passkey.add", ResourceType: "passkey", ResourceID: "fixture", Summary: "fixture", CreatedAt: now}
if err = store.SaveCredential(t.Context(), authwebauthn.Credential{ID: credential.ID, UserID: user.ID, Label: "Fixture", Data: encoded, CreatedAt: now}, audit); err != nil {
t.Fatal(err)
}
}
if err = service.RequireReady(t.Context(), user.ID); err != nil {
t.Fatal(err)
}
summaries, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil {
t.Fatal(err)
}
if len(summaries) != 2 || summaries[0].Label != "Fixture" || len(summaries[0].ID) != 32 {
t.Fatalf("unexpected summaries: %+v", summaries)
}
summaries[0].ID[0] = 99
refreshed, err := service.CredentialSummaries(t.Context(), user.ID)
if err != nil || refreshed[0].ID[0] == 99 {
t.Fatalf("credential summary did not defensively copy the identifier: summaries=%+v err=%v", refreshed, err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, refreshed[0].ID); !errors.Is(err, authwebauthn.ErrCredentialFloor) {
t.Fatalf("credential removal below operational floor err=%v", err)
}
if _, err = service.BeginCredentialRemoval(t.Context(), user.ID, bytes.Repeat([]byte{9}, 32)); !errors.Is(err, authwebauthn.ErrCredentialNotFound) {
t.Fatalf("unknown credential removal err=%v", err)
}
binding := bytes.Repeat([]byte("approved operation "), 3)
approvalBegin, err := service.BeginApproval(t.Context(), user.ID, binding)
if err != nil {
t.Fatal(err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, append([]byte(nil), binding[:len(binding)-1]...), []byte(`{}`)); !errors.Is(err, authwebauthn.ErrOperationBinding) {
t.Fatalf("tampered binding err=%v", err)
}
if _, err = service.FinishApproval(t.Context(), approvalBegin.CeremonyToken, binding, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("replayed approval err=%v", err)
}
login, err := service.BeginLogin(t.Context())
if err != nil {
t.Fatal(err)
}
now = now.Add(3 * time.Minute)
if _, err = service.FinishLogin(t.Context(), login.CeremonyToken, []byte(`{}`)); !errors.Is(err, authwebauthn.ErrCeremonyNotFound) {
t.Fatalf("expired login err=%v", err)
}
_ = authService
}
func TestRecoveryRevokesSessionsAndIssuesSingleUseEnrollment(t *testing.T) {
now := time.Date(2026, 8, 19, 12, 0, 0, 0, time.UTC)
store, authService, service := newService(t, &now, &counterReader{})
defer store.Close()
user, _, err := service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "recover.me", Email: "recover@example.test", DisplayName: "Recover Me"})
if err != nil {
t.Fatal(err)
}
session, _, err := authService.IssueSession(t.Context(), user.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
_, token, err := service.Recover(t.Context(), "recover.me", "all authenticators unavailable")
if err != nil {
t.Fatal(err)
}
if _, err = authService.Session(t.Context(), session); !errors.Is(err, auth.ErrSessionNotFound) {
t.Fatalf("session survived recovery: %v", err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Recovered passkey"); err != nil {
t.Fatal(err)
}
if _, err = service.BeginEnrollment(t.Context(), token, "Replay"); !errors.Is(err, authwebauthn.ErrEnrollmentNotFound) {
t.Fatalf("recovery token replay err=%v", err)
}
}
func TestConfigurationAndEntropyFailures(t *testing.T) {
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
defer store.Close()
authService, err := auth.New(store, auth.Options{})
if err != nil {
t.Fatal(err)
}
for _, config := range []authwebauthn.Config{
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "http://tend.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://other.gamertan.com"},
{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com/path"},
} {
if _, err = authwebauthn.New(store, authService, config); err == nil {
t.Fatalf("accepted config=%+v", config)
}
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: failingReader{}})
if err != nil {
t.Fatal(err)
}
if _, _, err = service.Bootstrap(t.Context(), authwebauthn.BootstrapInput{Username: "entropy.fail", Email: "entropy@example.test", DisplayName: "Entropy"}); err == nil || !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("entropy failure err=%v", err)
}
}
func newService(t *testing.T, now *time.Time, random io.Reader) (*authsqlite.Store, *auth.Service, *authwebauthn.Service) {
t.Helper()
store, err := authsqlite.Open(t.TempDir() + "/auth.db")
if err != nil {
t.Fatal(err)
}
authService, err := auth.New(store, auth.Options{Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
service, err := authwebauthn.New(store, authService, authwebauthn.Config{RPID: "tend.gamertan.com", RPDisplayName: "Tend", Origin: "https://tend.gamertan.com", Random: random, Now: func() time.Time { return *now }})
if err != nil {
store.Close()
t.Fatal(err)
}
return store, authService, service
}
type failingReader struct{}
func (failingReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF }
type counterReader struct{ next byte }
func (reader *counterReader) Read(value []byte) (int, error) {
for index := range value {
reader.next++
value[index] = reader.next
}
return len(value), nil
}
+113
View File
@@ -0,0 +1,113 @@
// SPDX-License-Identifier: MPL-2.0
// Package authwebauthn provides storage-neutral, passkey-only WebAuthn
// ceremonies. It owns relying-party policy, bounded single-use ceremony state,
// credential lifecycle, and recovery tokens while delegating protocol parsing
// and signature verification to a pinned WebAuthn implementation.
package authwebauthn
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"time"
"gamertan.com/web/auth"
)
var (
ErrCeremonyNotFound = errors.New("authwebauthn: ceremony not found")
ErrCredentialNotFound = errors.New("authwebauthn: credential not found")
ErrEnrollmentNotFound = errors.New("authwebauthn: enrollment token not found")
ErrCredentialFloor = errors.New("authwebauthn: the required credential floor cannot be crossed")
ErrLastCredential = errors.New("authwebauthn: the last credential cannot be removed remotely")
ErrOperationBinding = errors.New("authwebauthn: operation binding does not match")
ErrPasskeyReadiness = errors.New("authwebauthn: at least two passkeys are required")
ErrUnsupportedCredential = errors.New("authwebauthn: credential algorithm is unsupported")
)
const (
CeremonyRegistration = "registration"
CeremonyLogin = "login"
CeremonyApproval = "approval"
)
type Credential struct {
ID []byte
UserID string
Label string
Data json.RawMessage
CreatedAt time.Time
LastUsedAt time.Time
}
// CredentialSummary is the non-secret credential metadata applications may
// show to an authenticated account owner. It intentionally excludes the
// stored public-key document and user identifier.
type CredentialSummary struct {
ID []byte
Label string
CreatedAt time.Time
LastUsedAt time.Time
}
type EnrollmentToken struct {
Digest [32]byte
UserID string
CreatedAt time.Time
ExpiresAt time.Time
}
type Ceremony struct {
Digest [32]byte
Kind string
UserID string
Label string
SessionData json.RawMessage
BindingDigest [32]byte
CreatedAt time.Time
ExpiresAt time.Time
}
type BeginResult struct {
CeremonyToken string `json:"ceremony_token"`
PublicKey json.RawMessage `json:"public_key"`
ExpiresAt time.Time `json:"expires_at"`
}
type Authentication struct {
SessionToken string
Principal auth.Principal
CredentialID []byte
CloneWarning bool
}
type Approval struct {
User auth.User
CredentialID []byte
BindingDigest [32]byte
CloneWarning bool
ApprovedAt time.Time
}
// Repository persists passkey-specific state. Implementations must consume
// enrollment tokens and ceremonies atomically and must perform recovery and
// credential removal invariants in transactions.
type Repository interface {
CreatePasskeyUser(context.Context, auth.User, EnrollmentToken, auth.AuditEvent) error
UserByID(context.Context, string) (auth.User, error)
UserByIdentifier(context.Context, string) (auth.User, error)
UserByCredentialID(context.Context, []byte) (auth.User, error)
CredentialsByUserID(context.Context, string) ([]Credential, error)
SaveCredential(context.Context, Credential, auth.AuditEvent) error
UpdateCredential(context.Context, Credential) error
DeleteCredential(context.Context, string, []byte, int, auth.AuditEvent) error
CredentialCount(context.Context, string) (int, error)
CreateCeremony(context.Context, Ceremony) error
TakeCeremony(context.Context, [32]byte, time.Time) (Ceremony, error)
ConsumeEnrollmentToken(context.Context, [32]byte, time.Time) (auth.User, error)
RecoverUser(context.Context, string, EnrollmentToken, auth.AuditEvent) (auth.User, error)
}
func BindingDigest(value []byte) [32]byte { return sha256.Sum256(value) }
+25 -7
View File
@@ -2,22 +2,40 @@
# Architecture # Architecture
The dependency direction is intentionally one-way: The package dependency direction is intentionally one-way:
```text ```text
net/http application analytics ──> requestlog ──> requestmeta
-> requestmeta abuse ─────────────────────> requestmeta
-> requestlog / websec / abuse / authhttp authhttp ──> websec ───────> requestmeta
-> auth and analytics interfaces authhttp ──> auth <───────── authsqlite
-> optional authsqlite and JSONL adapters │ ▲ ▲
└──> authwebauthn ───────────┘
organizations <───────────── authsqlite
access <──────────────────── authsqlite
``` ```
An ordinary `net/http` application composes whichever branches it needs.
Packages never own application routes, templates, authorization policy, cache Packages never own application routes, templates, authorization policy, cache
policy, or deployment. Middleware communicates through typed request context. policy, or deployment. Middleware communicates through typed request context.
Storage and reporting surfaces are interfaces so an application can retain its Storage and reporting surfaces are interfaces so an application can retain its
existing database and user interface while replacing one implementation at a existing database and user interface while replacing one implementation at a
time. time.
Authentication establishes one user identity and session. Organizations own
projects, environments, and services; teams group organization members; scoped
access resolves roles against that hierarchy. Existing `auth` roles remain a
platform-level compatibility surface and do not implicitly grant access to an
organization's data. Emergency access is a separate, expiring, audited grant.
`authwebauthn` owns relying-party policy and ceremony orchestration but not
application routes. It stores only opaque token digests, bounded verifier
session state, credential public records, and audit metadata through an
interface implemented by `authsqlite`. The existing `auth` service issues the
ordinary opaque session only after the passkey verifier succeeds.
The package model is developed from explicit threat and data contracts, not by The package model is developed from explicit threat and data contracts, not by
moving an existing application's internals into a shared directory. See moving an existing application's internals into a shared directory. See
[ADOPTION.md](ADOPTION.md). [ADOPTION.md](ADOPTION.md), [GETTING_STARTED.md](GETTING_STARTED.md), and the
[module-boundary policy](MODULES.md).
+40 -1
View File
@@ -2,17 +2,56 @@
# Dependency boundary # Dependency boundary
Most packages use only the Go standard library. Two direct modules are pinned: Most packages use only the Go standard library. The principal implementation
dependencies are pinned:
- `golang.org/x/crypto` supplies the reviewed Argon2id implementation used by - `golang.org/x/crypto` supplies the reviewed Argon2id implementation used by
`auth` (BSD-3-Clause upstream licence). `auth` (BSD-3-Clause upstream licence).
- `modernc.org/sqlite` supplies the no-CGO SQLite adapter in `authsqlite` - `modernc.org/sqlite` supplies the no-CGO SQLite adapter in `authsqlite`
(BSD-3-Clause upstream licence). (BSD-3-Clause upstream licence).
- `github.com/go-webauthn/webauthn` `v0.17.1` supplies the audited source for
WebAuthn Level 3 parsing and cryptographic verification in `authwebauthn`
(BSD-3-Clause upstream licence; source commit
`de0a809e3027957ca15b72b252540317f9ba581b`). Its imported transitive modules
are pinned directly in `go.mod` because the verifier is compiled internally.
The exact, unchanged `go-webauthn` module source is retained at
`third_party/go-webauthn`. The non-test Go files from the packages used by
`authwebauthn` are copied into `internal/webauthnvendored`; only their
self-import prefix is mechanically rewritten. A derivation gate recreates that
internal tree from the audited source and requires a byte-for-byte match before
tests or builds. The complete upstream file manifest, upstream module checksum,
source commit, licence, and downloaded module ZIP SHA-256 are checked in.
The derivative is exercised by ordinary and race-enabled tests, but is excluded
from repository formatting so that gate cannot rewrite the audited upstream
source. The repository still runs `go vet` over the complete graph and permits
only the exact upstream warning for its unexported COSE structure sentinel;
every other vet diagnostic fails verification.
This arrangement is deliberate. A `replace` directive in a library module is
ignored by downstream consumers, so it cannot guarantee which verifier source
an application compiles. The public module has no local replacement and no
direct `github.com/go-webauthn/webauthn` module requirement; applications
compile the checked internal derivative instead. Its transitive modules remain
pinned by `go.mod`, `go.sum`, and SumDB. Release builders populate an isolated
verified module cache before offline compilation.
A conventional repository-wide `go mod vendor` would also copy the SQLite and
full transitive graph, currently roughly 143 MiB and more than 2,300 files.
That unrelated expansion is deliberately avoided: only the security-sensitive
WebAuthn verifier named by the policy is source-vendored here.
Applications that do not import `auth` or `authsqlite` do not link those Applications that do not import `auth` or `authsqlite` do not link those
implementations into their binaries. Optional GeoIP enrichment is an interface implementations into their binaries. Optional GeoIP enrichment is an interface
only; the base toolkit performs no lookup and adds no GeoIP dependency. only; the base toolkit performs no lookup and adds no GeoIP dependency.
All packages currently share one Go module, so these requirements remain
visible in the module graph even when an application imports only
`requestmeta`. Go still avoids compiling or linking unused packages. A future
nested module may isolate a heavyweight adapter such as `authsqlite` when its
independent dependency and release lifecycle justify the additional tags,
vanity metadata, and CI. See [MODULES.md](MODULES.md).
`go.sum`, `go mod verify`, checksum-database verification, vulnerability `go.sum`, `go mod verify`, checksum-database verification, vulnerability
scanning, and the public snapshot allowlist are release gates. Binary scanning, and the public snapshot allowlist are release gates. Binary
distributors remain responsible for preserving all applicable upstream notices. distributors remain responsible for preserving all applicable upstream notices.
+110
View File
@@ -0,0 +1,110 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Getting started
Gamertan Web Foundations is adopted one boundary at a time. Start with the
smallest package that solves a problem the application actually has; do not
install an imagined framework lifecycle around it.
## Choose a first slice
| Application need | Begin with | What remains application-owned |
| --- | --- | --- |
| Request IDs and trustworthy client addresses | `requestmeta` | Proxy configuration and operational logs |
| Bounded structured request evidence | `requestmeta`, `requestlog` | Route names, sensitive-field policy, rotation, retention, and access |
| Browser and HTTP safety primitives | `requestmeta`, `websec` | Exact CSP, route authorization, and response policy |
| Persistent request-abuse decisions | `requestmeta`, `abuse` | Route classification, storage, appeals, and operator policy |
| Users, credentials, permissions, and sessions | `auth` | Roles, permissions, login UX, and account policy |
| Secure browser cookies around `auth` | `authhttp` | Login routes, redirects, pages, and authorization decisions |
| SQLite persistence for `auth` | `authsqlite` | Database placement, backup, migration approval, and recovery |
| One account across organizations and teams | `organizations`, `authsqlite` | Invitation UX, organization naming, and lifecycle policy |
| Organization-scoped authorization | `access`, `authsqlite` | Role definitions, resource ownership, and route enforcement |
| Aggregate projections over request records | `analytics` | Collection policy, access control, report UI, and retention |
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.4
go mod verify
```
## Preserve middleware order
Packages that consume request metadata must run inside the resolver. Build the
handler from the application outward; the final resolver assignment becomes
the first middleware to receive a request:
```go
var handler http.Handler = router
handler = requestlog.Middleware(sink, logPolicy)(handler)
handler = websec.Headers(headerPolicy)(handler)
handler = resolver.Middleware(handler)
```
The complete, copyable composition is in [`starters/basic`](../starters/basic).
It binds to loopback, shuts down gracefully, and keeps request logging optional.
Configure trusted proxy networks narrowly. A forwarding header is not evidence
by itself; it becomes usable only when the immediate peer and skipped proxy
hops satisfy the resolver's trust policy. Metadata, authentication, or storage
failures that affect security decisions should stop the request rather than
quietly changing identity or policy.
## Bootstrap an account without inventing a permanent password
`auth.GenerateTemporaryPassword` returns 256 bits of URL-safe cryptographic
entropy. An application can store that value in a newly created private file
and provision an account with `RequirePasswordChange: true`. The library does
not write or print the credential because file ownership, operator identity,
and delivery are application policy.
After authentication, inspect `principal.User.PasswordChangeRequired`. Until it
is false, permit only password change and logout. `auth.ChangePassword` verifies
the current credential, rejects reuse, writes the new Argon2id hash, clears the
requirement, and revokes every existing session atomically through the storage
adapter. Clear the browser cookie and require a fresh login after success. Do
not treat a redirect alone as enforcement; apply the restriction before every
protected handler.
For operator-led recovery, expose `auth.ResetPassword` only through a local
administrative command—not a public HTTP endpoint. The operation installs an
application-generated one-time credential, sets `PasswordChangeRequired`,
revokes every existing session, and appends a secret-free audit event in the
same repository transaction. Deliver that credential through an exclusive
root-owned mode-`0600` file, delete it after successful rotation, and never put
it in command arguments, stdout, logs, manifests, or deployment state.
## Add HTML without merging responsibilities
Handlers should convert request and service state into typed display data.
They may then render those values with any HTML system. Gamertan's preferred
companion is [Sandwich Hime](SANDWICH_HIME.md), whose generated components keep
templates typed while leaving this middleware stack and the `net/http`
application in control.
## Verify the application boundary
After adopting a package:
```bash
go mod verify
go test ./...
go test -race ./...
go vet ./...
go build ./...
```
Test the composed handler with `httptest`, not only the package in isolation.
Include a normal request, malformed or spoofed metadata, a downstream failure,
and the application's intended response headers. Existing applications should
follow the differential and rollback sequence in [ADOPTION.md](ADOPTION.md).
Deeper tutorials for accounts, analytics, and persistent abuse policy will be
written after multiple application migrations have validated those seams. The
preview documentation describes demonstrated contracts rather than prescribing
an unfinished application framework.
See [Organizations and scoped access](ORGANIZATIONS.md) before storing tenant
data. In particular, do not interpret a platform role as permission to inspect
an organization's records.
+72
View File
@@ -0,0 +1,72 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Packages, modules, and repositories
These boundaries solve different problems:
- a **package** owns one Go responsibility and import path;
- a **module** owns dependency selection and semantic versions; and
- a **repository** owns contribution, security, and release operations.
The first preview uses one repository and one module, `gamertan.com/web`, with
several independently importable packages. An application may write:
```go
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.4
```
Only imported packages are compiled and linked. The packages nevertheless
share the module's version and dependency graph.
## Why not one repository per package?
Separate repositories would multiply release credentials, security updates,
vanity-import records, tags, CI, issue tracking, and coordinated API changes.
A focused pull request can already change and test one package directory. A
repository boundary is reserved for software with an independently operated
lifecycle, such as a future standalone `authd` service.
## When a nested module is justified
A package may become a nested module inside this repository when all of these
are true:
1. it introduces materially heavier or different dependencies;
2. consumers can usefully version it independently;
3. its API boundary has survived real application adoption; and
4. separate tags, release ordering, vanity metadata, and CI are less costly
than keeping it in the root module.
`authsqlite` is the clearest current candidate because it carries the optional
SQLite implementation and its transitive module graph. A future split could
retain the import path `gamertan.com/web/authsqlite` while giving that directory
its own `go.mod` and tags such as `authsqlite/v0.1.0-preview.1`.
Do not split merely to make an architecture diagram look modular. Package
interfaces provide source-level modularity today; modules are introduced only
for an independent dependency and release lifecycle.
## Session boundaries
Authenticated sessions currently belong to three deliberate packages:
- `auth` owns opaque token creation, digest-backed session lookup, revocation,
and the storage interface;
- `authhttp` binds those sessions to secure browser cookies and request
context; and
- `authsqlite` persists the storage contract.
A separate `session` package would be appropriate only for a genuinely
identity-neutral need, such as anonymous application sessions with no user,
role, or credential semantics. It should not duplicate `auth` under a more
general name.
This policy may evolve before a stable release. Any split must include a
migration guide and preserve already published versions at their original
module coordinates.
+41
View File
@@ -0,0 +1,41 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Organizations and scoped access
One `auth.User` may belong to many organizations without creating another
credential or browser session. Organizations own projects; projects own
environments; environments own application services. Teams are optional groups
of active organization members.
`organizations.Service` creates those resources and issues digest-backed,
expiring, single-use invitations. Acceptance verifies that the authenticated
user's normalized email matches the invitation before activating membership.
Applications own invitation pages, email or out-of-band delivery, organization
deletion policy, and account recovery.
`access.Service` evaluates a permission against a complete resource scope:
```go
decision, err := accessService.Authorize(ctx, principal.User.ID, access.Scope{
OrganizationID: organizationID,
ProjectID: projectID,
EnvironmentID: environmentID,
ServiceID: serviceID,
}, "telemetry.read")
```
A binding at organization scope covers its descendants. A narrower binding
covers only its matching branch. The repository resolves team membership; a
handler must never accept caller-supplied team identifiers as authority.
Platform roles in `auth.Principal` remain useful for installation health,
account administration, and other explicitly global operations. They do not
grant organization-data access. If an operator must inspect tenant data during
an incident, use a reasoned break-glass grant. It expires within one hour and
creates an append-only audit event in the same transaction.
The SQLite adapter namespaces all tables, enforces organization membership and
resource ancestry before accepting a binding, and keeps invitations and
sessions as digests. Applications remain responsible for database backup,
filesystem ownership, retention, and presenting audit history to organization
owners.
+55
View File
@@ -0,0 +1,55 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# Passkey integration
`authwebauthn` is a passkey ceremony service, not a login page or account
policy. An application supplies its exact relying-party identity, routes,
authorization decisions, session cookie, HTML, and local recovery command.
## Fixed security policy
- Use an exact HTTPS origin whose hostname equals the relying-party ID.
- Reject cross-origin ceremonies.
- Require discoverable credentials and user verification.
- Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and
security evidence.
- 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
synchronized passkeys.
The service uses a random WebAuthn challenge for every ceremony. A sensitive
application operation is bound separately by storing the SHA-256 digest of its
canonical payload with that ceremony. Never substitute an operation hash,
timestamp, UUID, or counter for the random challenge.
## Application flow
1. A local command calls `Bootstrap` or `Recover` and writes the returned
enrollment token once to a newly created mode-`0600` file.
2. A server-rendered enrollment page calls `BeginEnrollment`; the browser uses
`navigator.credentials.create` with the returned `public_key` value.
3. The browser posts the credential and opaque ceremony token to a bounded JSON
endpoint; `FinishRegistration` verifies and stores the public credential.
4. Login uses `BeginLogin`, `navigator.credentials.get`, and `FinishLogin`.
The successful result contains an ordinary opaque `auth` session token.
5. Sensitive operations call `BeginApproval` with a canonical application
payload and `FinishApproval` with those exact same bytes. Any drift fails.
`authhttp.WritePasskeyBegin` and `authhttp.ReadPasskeyFinish` provide bounded
JSON framing only. They do not register routes, authorize requests, serve
JavaScript, or set sessions automatically.
## Recovery and credential lifecycle
Recovery is deliberately host-local and should never be reachable through an
HTTP handler. It revokes all user sessions and pending ceremonies, replaces
prior enrollment tokens, appends a secret-free audit event, and returns one
15-minute token. It does not delete existing passkeys. After enrolling a
replacement, the operator reviews credential labels and removes lost keys with
a fresh passkey-bound removal ceremony. The final passkey cannot be removed
remotely.
Before enabling production mutations, applications should require at least two
independent passkeys and complete a local recovery drill.
+71
View File
@@ -0,0 +1,71 @@
<!-- SPDX-License-Identifier: MPL-2.0 -->
# HTML with Sandwich Hime
Gamertan Web Foundations owns reusable web-application boundaries; it does not
own HTML or a template language. [Sandwich Hime](https://sandwichhime.com/) is
the preferred companion for Gamertan applications that want HTML-first,
ahead-of-time templates with typed Go composition.
The relationship is intentionally optional:
| Application responsibility | Owner |
| --- | --- |
| Request identity, logging, security primitives, sessions, and analytics | Web Foundations packages selected by the application |
| Routing, authorization decisions, status, headers, caching, and deployment | The application |
| Visible HTML and typed component composition | Authored `.sando` templates |
| Template parsing, contextual analysis, and Go generation | Hime-san during development or CI |
| Rendering generated components | The small `sando` runtime in production |
Web Foundations does not import Sandwich Hime. Sandwich Hime does not import
Web Foundations. An application chooses both and provides the seam between
them.
## Request flow
```text
request
-> requestmeta / selected middleware
-> application router and handler
-> typed view data
-> generated Sandwich Hime component
-> buffered sando.Render
-> application-owned HTTP response
```
Buffer the component before committing a successful response so a rendering
error can still become a clean application error:
```go
func renderHTML(response http.ResponseWriter, request *http.Request, status int, component sando.Component) {
var output bytes.Buffer
if err := sando.Render(request.Context(), &output, component); err != nil {
log.Printf("render page: %v", err)
response.Header().Set("Cache-Control", "no-store")
http.Error(response, "could not render page", http.StatusInternalServerError)
return
}
response.Header().Set("Content-Type", "text/html; charset=utf-8")
response.WriteHeader(status)
_, _ = response.Write(output.Bytes())
}
```
The handler—not the template—should interpret request metadata, principals,
permissions, analytics, or storage errors. It passes only the resulting typed
display data into the component. Templates should not acquire an implicit
request global or turn middleware context into an inheritance framework.
Handwritten `sando.Component` implementations and `Trust*` values are explicit
trusted-output capabilities. Keep them conspicuous and review them separately
from ordinary untrusted values.
## Continue with the official lessons
- [Build a component, page, and small site](https://sandwichhime.com/docs/tutorial/).
- [Follow a request through a larger Go application](https://sandwichhime.com/docs/tutorial/application/).
- [Review the Sandwich Hime security boundary](https://sandwichhime.com/docs/security/).
Those tutorials own the template syntax and compiler workflow. This repository
documents only the application seam so the two projects do not drift into a
single mandatory framework.
+44 -1
View File
@@ -10,7 +10,44 @@ selected storage adapters are trusted.
Controls include explicit proxy trust, bounded parsing, cryptographic request Controls include explicit proxy trust, bounded parsing, cryptographic request
and session identifiers, digest-only session storage, Argon2id passwords, and session identifiers, digest-only session storage, Argon2id passwords,
constant-time comparisons, same-origin and CSRF primitives, fail-closed storage constant-time comparisons, same-origin and CSRF primitives, fail-closed storage
errors, and separate safe/sensitive analytics projections. errors, separate safe/sensitive analytics projections, organization-scoped
bindings, single-use invitation digests, and short-lived audited break-glass
grants.
Passkey ceremonies require one exact HTTPS origin and relying-party ID,
discoverable credentials, user verification, and single-use random challenges.
The SQLite adapter consumes enrollment tokens and ceremonies atomically.
Operation approvals retain a separate digest of canonical application state;
the random WebAuthn challenge is never replaced by a predictable state hash.
Cross-origin ceremonies, unsupported credential algorithms, expired state, and
binding drift fail closed. Signature-counter clone warnings are surfaced for
audit but do not automatically lock out multi-device passkeys whose counters
legitimately remain zero.
An application may create an account with a cryptographically generated
temporary credential and `RequirePasswordChange`. Successful rotation compares
the current credential, replaces its Argon2id hash, clears the requirement, and
revokes every session in one repository transaction. The application must
restrict such a principal to password change and logout until rotation succeeds;
the library does not infer route policy. Temporary credentials must be written
to a private channel or mode-`0600` file and must never be printed into logs,
manifests, process arguments, or deployment state.
Administrative recovery is deliberately a separate capability. The storage
adapter atomically replaces the credential, restores the password-change
requirement, revokes all sessions, and appends a generic audit event. The core
library does not expose a recovery HTTP handler, deliver the credential, or
authorize the local operator. Applications must keep that command local,
generate the credential cryptographically, and write it only to a newly created
private file. A recovery must not reveal whether an account exists through a
public request surface.
Passkey-only recovery never creates a password or remote fallback. A local
administrator revokes sessions and active ceremonies and issues a short-lived
single-use enrollment token. Existing passkeys remain visible so the operator
can review and remove lost credentials after enrolling a replacement. The
library prevents remote removal of the final credential; applications should
require a freshly bound passkey assertion before every removal.
Unsafe methods without an exact Origin or trustworthy same-origin Fetch Unsafe methods without an exact Origin or trustworthy same-origin Fetch
Metadata fail the origin check. Authentication middleware fails closed when its Metadata fail the origin check. Authentication middleware fails closed when its
@@ -22,6 +59,12 @@ configured reverse proxy, authorize application routes automatically, encrypt a
compromised host, or decide how long an operator may lawfully retain personal compromised host, or decide how long an operator may lawfully retain personal
request evidence. request evidence.
Applications must pass the authenticated user and requested resource hierarchy
to `access.Authorize`; possessing a platform-level `auth` role does not bypass
that decision. Team membership is resolved by the repository rather than
accepted from request input. Break-glass access lasts at most one hour and is
not a substitute for ordinary role policy.
Local storage adapters assume the parent directory and host account are trusted. Local storage adapters assume the parent directory and host account are trusted.
They reject a symlink at the configured final path and apply private file modes, They reject a symlink at the configured final path and apply private file modes,
but they do not defend against a concurrent privileged actor replacing path but they do not defend against a concurrent privileged actor replacing path
+9 -1
View File
@@ -5,16 +5,24 @@ module gamertan.com/web
go 1.26 go 1.26
require ( require (
github.com/fxamacker/cbor/v2 v2.9.1
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/go-webauthn/x v0.2.3
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/go-tpm v0.9.8
github.com/google/uuid v1.6.0
github.com/tinylib/msgp v1.6.4
golang.org/x/crypto v0.54.0 golang.org/x/crypto v0.54.0
modernc.org/sqlite v1.56.0 modernc.org/sqlite v1.56.0
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.47.0 // indirect golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
+26 -6
View File
@@ -1,5 +1,19 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -10,24 +24,30 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2025 github.com/go-webauthn/webauthn authors.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,41 @@
package metadata
const (
// ProductionMDSRoot is the root certificate for the MDS.
//
// See: https://secure.globalsign.com/cacert/root-r3.crt
ProductionMDSRoot = "MIIDXzCCAkegAwIBAgILBAAAAAABIVhTCKIwDQYJKoZIhvcNAQELBQAwTDEgMB4GA1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjMxEzARBgNVBAoTCkdsb2JhbFNpZ24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDkwMzE4MTAwMDAwWhcNMjkwMzE4MTAwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMzETMBEGA1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMwldpB5BngiFvXAg7aEyiie/QV2EcWtiHL8RgJDx7KKnQRfJMsuS+FggkbhUqsMgUdwbN1k0ev1LKMPgj0MK66X17YUhhB5uzsTgHeMCOFJ0mpiLx9e+pZo34knlTifBtc+ycsmWQ1z3rDI6SYOgxXG71uL0gRgykmmKPZpO/bLyCiR5Z2KYVc3rHQU3HTgOu5yLy6c+9C7v/U9AOEGM+iCK65TpjoWc4zdQQ4gOsC0p6Hpsk+QLjJg6VfLuQSSaGjlOCZgdbKfd/+RFO+uIEn8rUAVSNECMWEZXriX7613t2Saer9fwRPvm2L7DWzgVGkWqQPabumDk3F2xmmFghcCAwEAAaNCMEAwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFI/wS3+oLkUkrk1Q+mOai97i3Ru8MA0GCSqGSIb3DQEBCwUAA4IBAQBLQNvAUKr+yAzv95ZURUm7lgAJQayzE4aGKAczymvmdLm6AC2upArT9fHxD4q/c2dKg8dEe3jgr25sbwMpjjM5RcOO5LlXbKr8EpbsU8Yt5CRsuZRj+9xTaGdWPoO4zzUhw8lo/s7awlOqzJCK6fBdRoyV3XpYKBovHd7NADdBj+1EbddTKJd+82cEHhXXipa0095MJ6RMG3NzdvQXmcIfeg7jLQitChws/zyrVQ4PkX4268NXSb7hLi18YIvDQVETI53O9zJrlAGomecsMx86OyXShkDOOyyGeMlhLxS67ttVb9+E7gUJTb0o2HLO02JQZR7rkpeDMdmztcpHWD9f"
// ProductionMDSURL is the Production MDS URL.
ProductionMDSURL = "https://mds.fidoalliance.org"
// ConformanceMDSRoot is the root certificate for the MDS Conformance Suite.
//
// See: https://mds3.fido.tools/pki/MDS3ROOT.crt
ConformanceMDSRoot = "MIICaDCCAe6gAwIBAgIPBCqih0DiJLW7+UHXx/o1MAoGCCqGSM49BAMDMGcxCzAJBgNVBAYTAlVTMRYwFAYDVQQKDA1GSURPIEFsbGlhbmNlMScwJQYDVQQLDB5GQUtFIE1ldGFkYXRhIDMgQkxPQiBST09UIEZBS0UxFzAVBgNVBAMMDkZBS0UgUm9vdCBGQUtFMB4XDTE3MDIwMTAwMDAwMFoXDTQ1MDEzMTIzNTk1OVowZzELMAkGA1UEBhMCVVMxFjAUBgNVBAoMDUZJRE8gQWxsaWFuY2UxJzAlBgNVBAsMHkZBS0UgTWV0YWRhdGEgMyBCTE9CIFJPT1QgRkFLRTEXMBUGA1UEAwwORkFLRSBSb290IEZBS0UwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAASKYiz3YltC6+lmxhPKwA1WFZlIqnX8yL5RybSLTKFAPEQeTD9O6mOz+tg8wcSdnVxHzwnXiQKJwhrav70rKc2ierQi/4QUrdsPes8TEirZOkCVJurpDFbXZOgs++pa4XmjYDBeMAsGA1UdDwQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAfBgNVHSMEGDAWgBQGcfeCs0Y8D+lh6U5B2xSrR74eHTAKBggqhkjOPQQDAwNoADBlAjEA/xFsgri0xubSa3y3v5ormpPqCwfqn9s0MLBAtzCIgxQ/zkzPKctkiwoPtDzI51KnAjAmeMygX2S5Ht8+e+EQnezLJBJXtnkRWY+Zt491wgt/AwSs5PHHMv5QgjELOuMxQBc="
// ExampleMDSRoot is the example root certificate for the MDS.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1-ps-20250521.html#sctn-examples
ExampleMDSRoot = "MIIGGTCCBAGgAwIBAgIUdT9qLX0sVMRe8l0sLmHd3mZovQ0wDQYJKoZIhvcNAQELBQAwgZsxHzAdBgNVBAMMFkVYQU1QTEUgTURTMyBURVNUIFJPT1QxIjAgBgkqhkiG9w0BCQEWE2V4YW1wbGVAZXhhbXBsZS5jb20xFDASBgNVBAoMC0V4YW1wbGUgT1JHMRAwDgYDVQQLDAdFeGFtcGxlMQswCQYDVQQGEwJVUzELMAkGA1UECAwCTVkxEjAQBgNVBAcMCVdha2VmaWVsZDAeFw0yMTA0MTkxMTM1MDdaFw00ODA5MDQxMTM1MDdaMIGbMR8wHQYDVQQDDBZFWEFNUExFIE1EUzMgVEVTVCBST09UMSIwIAYJKoZIhvcNAQkBFhNleGFtcGxlQGV4YW1wbGUuY29tMRQwEgYDVQQKDAtFeGFtcGxlIE9SRzEQMA4GA1UECwwHRXhhbXBsZTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAk1ZMRIwEAYDVQQHDAlXYWtlZmllbGQwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDDjF5wyEWuhwDHsZosGdGFTCcI677rW881vV+UfW38J+K2ioFFNeGVsxbcebK6AVOiCDPFj0974IpeD9SFOhwAHoDu/LCfXdQWp8ZgQ91ULYWoW8o7NNSp01nbN9zmaO6/xKNCa0bzjmXoGqglqnP1AtRcWYvXOSKZy1rcPeDv4Dhcpdp6W72fBw0eWIqOhsrItuY2/N8ItBPiG03EX72nACq4nZJ/nAIcUbER8STSFPPzvE97TvShsi1FD8aO6l1WkR/QkreAGjMI++GbB2Qc1nN9Y/VEDbMDhQtxXQRdpFwubTjejkN9hKOtF3B71YrwIrng3V9RoPMFdapWMzSlI+WWHog0oTj1PqwJDDg7+z1I6vSDeVWAMKr9mq1w1OGNzgBopIjd9lRWkRtt2kQSPX9XxqS4E1gDDr8MKbpM3JuubQtNCg9D7Ljvbz6vwvUrbPHH+oREvucsp0PZ5PpizloepGIcLFxDQqCulGY2n7Ahl0JOFXJqOFCaK3TWHwBvZsaY5DgBuUvdUrwtgZNg2eg2omWXEepiVFQn3Fvj43Wh2npPMgIe5P0rwncXvROxaczd4rtajKS1ucoB9b9iKqM2+M1y/FDIgVf1fWEHwK7YdzxMlgOeLdeV/kqRU5PEUlLU9a2EwdOErrPbPKZmIfbs/L4B3k4zejMDH3Y+ZwIDAQABo1MwUTAdBgNVHQ4EFgQU8sWwq1TrurK7xMTwO1dKfeJBbCMwHwYDVR0jBBgwFoAU8sWwq1TrurK7xMTwO1dKfeJBbCMwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAgEAFw6M1PiIfCPIBQ5EBUPNmRvRFuDpolOmDofnf/+mv63LqwQZAdo/W8tzZ9kOFhq24SiLw0H7fsdG/jeREXiIZMNoW/rA6Uac8sU+FYF7Q+qp6CQLlSQbDcpVMifTQjcBk2xh+aLK9SrrXBqnTAhwS+offGtAW8DpoLuH4tAcQmIjlgMlN65jnELCuqNR/wpA+zch8LZW8saQ2cwRCwdr8mAzZoLbsDSVCHxQF3/kQjPT7Nao1q2iWcY3OYcRmKrieHDP67yeLUbVmetfZis2d6ZlkqHLB4ZW1xX4otsEFkuTJA3HWDRsNyhTwx1YoCLsYut5Zp0myqPNBq28w6qGMyyoJN0Z4RzMEO3R6i/MQNfhK55/8O2HciM6xb5t/aBSuHPKlBDrFWhpRnKYkaNtlUo35qV5IbKGKau3SdZdSRciaXUd/p81YmoF01UlhhMz/Rqr1k2gyA0a9tF8+awCeanYt5izl8YO0FlrOU1SQ5UQw4szqqZqbrf4e8fRuU2TXNx4zk+ImE7WRB44f6mSD746ZCBRogZ/SA5jUBu+OPe4/sEtERWRcQD+fXgce9ZEN0+peyJIKAsl5Rm2Bmgyg5IoyWwSG5W+WekGyEokpslou2Yc6EjUj5ndZWz5EiHAiQ74hNfDoCZIxVVLU3Qbp8a0S1bmsoT2JOsspIbtZUg="
)
const (
HeaderX509URI = "x5u"
HeaderX509Certificate = "x5c"
)
var (
errIntermediateCertRevoked = &Error{
Type: "intermediate_revoked",
Details: "Intermediate certificate is on issuers revocation list",
}
errLeafCertRevoked = &Error{
Type: "leaf_revoked",
Details: "Leaf certificate is on issuers revocation list",
}
errCRLUnavailable = &Error{
Type: "crl_unavailable",
Details: "Certificate revocation list is unavailable",
}
)
@@ -0,0 +1,290 @@
package metadata
import (
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/golang-jwt/jwt/v5"
"github.com/go-webauthn/x/revoke"
)
// NewDecoder returns a new metadata decoder.
func NewDecoder(opts ...DecoderOption) (decoder *Decoder, err error) {
decoder = &Decoder{
client: &http.Client{},
parser: jwt.NewParser(),
hook: mapstructure.ComposeDecodeHookFunc(),
}
for _, opt := range opts {
if err = opt(decoder); err != nil {
return nil, fmt.Errorf("failed to apply decoder option: %w", err)
}
}
if decoder.root == "" {
decoder.root = ProductionMDSRoot
}
return decoder, nil
}
// Decoder handles decoding and specialized parsing of the metadata blob.
type Decoder struct {
client *http.Client
parser *jwt.Parser
hook mapstructure.DecodeHookFunc
root string
ignoreEntryParsingErrors bool
}
// Parse handles parsing of the raw JSON values of the metadata blob. Should be used after using [Decoder.Decode] or
// [Decoder.DecodeBytes].
func (d *Decoder) Parse(payload *PayloadJSON) (metadata *Metadata, err error) {
metadata = &Metadata{
Parsed: Parsed{
LegalHeader: payload.LegalHeader,
Number: payload.Number,
},
}
if metadata.Parsed.NextUpdate, err = time.Parse(time.DateOnly, payload.NextUpdate); err != nil {
return nil, fmt.Errorf("error occurred parsing next update value '%s': %w", payload.NextUpdate, err)
}
var parsed Entry
for _, entry := range payload.Entries {
if parsed, err = entry.Parse(); err != nil {
metadata.Unparsed = append(metadata.Unparsed, EntryError{
Error: err,
EntryJSON: entry,
})
continue
}
metadata.Parsed.Entries = append(metadata.Parsed.Entries, parsed)
}
if n := len(metadata.Unparsed); n != 0 && !d.ignoreEntryParsingErrors {
return metadata, fmt.Errorf("error occurred parsing metadata: %d entries had errors during parsing", n)
}
return metadata, nil
}
// Decode the blob from an [io.Reader]. This function will close the [io.ReadCloser] after completing.
func (d *Decoder) Decode(r io.Reader) (payload *PayloadJSON, err error) {
bytes, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return d.DecodeBytes(bytes)
}
// DecodeBytes handles decoding raw bytes. If you have a read closer it's suggested to use [Decoder.Decode].
func (d *Decoder) DecodeBytes(bytes []byte) (payload *PayloadJSON, err error) {
var token *jwt.Token
if token, err = d.parser.Parse(string(bytes), func(token *jwt.Token) (any, error) {
// 2. If the x5u attribute is present in the JWT Header.
if _, ok := token.Header[HeaderX509URI].([]any); ok {
// Never seen an x5u here, although it is in the spec.
return nil, errors.New("x5u encountered in header of metadata TOC payload")
}
// 3. If the x5u attribute is missing, the chain should be retrieved from the x5c attribute.
var (
x5c, chain []any
ok, valid bool
)
if x5c, ok = token.Header[HeaderX509Certificate].([]any); !ok {
// If that attribute is missing as well, Metadata TOC signing trust anchor is considered the TOC signing certificate chain.
chain = []any{d.root}
} else {
chain = x5c
}
// The certificate chain MUST be verified to properly chain to the metadata TOC signing trust anchor.
if valid, err = validateChain(d.root, chain); !valid || err != nil {
return nil, err
}
// Chain validated, extract the TOC signing certificate from the chain. Create a buffer large enough to hold the
// certificate bytes.
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
var (
n int
cert *x509.Certificate
)
// Decode the base64 certificate into the buffer.
if n, err = base64.StdEncoding.Decode(o, []byte(chain[0].(string))); err != nil {
return nil, err
}
// Parse the certificate from the buffer.
if cert, err = x509.ParseCertificate(o[:n]); err != nil {
return nil, err
}
// 4. Verify the signature of the Metadata TOC object using the TOC signing certificate chain
// jwt.Parse() uses the TOC signing certificate public key internally to verify the signature.
return cert.PublicKey, err
}); err != nil {
return nil, err
}
var decoder *mapstructure.Decoder
payload = &PayloadJSON{}
if decoder, err = mapstructure.NewDecoder(&mapstructure.DecoderConfig{
Metadata: nil,
Result: payload,
DecodeHook: d.hook,
TagName: "json",
}); err != nil {
return nil, err
}
if err = decoder.Decode(token.Claims); err != nil {
return payload, err
}
return payload, nil
}
// DecoderOption is a representation of a function that can set options within a decoder.
type DecoderOption func(decoder *Decoder) (err error)
// WithIgnoreEntryParsingErrors is a DecoderOption which ignores errors when parsing individual entries. The values for
// these entries will exist as an unparsed entry.
func WithIgnoreEntryParsingErrors() DecoderOption {
return func(decoder *Decoder) (err error) {
decoder.ignoreEntryParsingErrors = true
return nil
}
}
// WithRootCertificate overrides the root certificate used to validate the authenticity of the metadata payload.
func WithRootCertificate(value string) DecoderOption {
return func(decoder *Decoder) (err error) {
decoder.root = value
return nil
}
}
func validateChain(root string, chain []any) (bool, error) {
oRoot := make([]byte, base64.StdEncoding.DecodedLen(len(root)))
nRoot, err := base64.StdEncoding.Decode(oRoot, []byte(root))
if err != nil {
return false, err
}
rootcert, err := x509.ParseCertificate(oRoot[:nRoot])
if err != nil {
return false, err
}
roots := x509.NewCertPool()
roots.AddCert(rootcert)
o := make([]byte, base64.StdEncoding.DecodedLen(len(chain[1].(string))))
n, err := base64.StdEncoding.Decode(o, []byte(chain[1].(string)))
if err != nil {
return false, err
}
intcert, err := x509.ParseCertificate(o[:n])
if err != nil {
return false, err
}
if revoked, ok := revoke.VerifyCertificate(intcert); !ok {
issuer := intcert.IssuingCertificateURL
if issuer != nil {
return false, errCRLUnavailable
}
} else if revoked {
return false, errIntermediateCertRevoked
}
ints := x509.NewCertPool()
ints.AddCert(intcert)
l := make([]byte, base64.StdEncoding.DecodedLen(len(chain[0].(string))))
n, err = base64.StdEncoding.Decode(l, []byte(chain[0].(string)))
if err != nil {
return false, err
}
leafcert, err := x509.ParseCertificate(l[:n])
if err != nil {
return false, err
}
if revoked, ok := revoke.VerifyCertificate(leafcert); !ok {
return false, errCRLUnavailable
} else if revoked {
return false, errLeafCertRevoked
}
opts := x509.VerifyOptions{
Roots: roots,
Intermediates: ints,
}
_, err = leafcert.Verify(opts)
return err == nil, err
}
func mdsParseX509Certificate(value string) (certificate *x509.Certificate, err error) {
var n int
raw := make([]byte, base64.StdEncoding.DecodedLen(len(value)))
if n, err = base64.StdEncoding.Decode(raw, []byte(strings.TrimSpace(value))); err != nil {
return nil, fmt.Errorf("error occurred parsing *x509.certificate: error occurred decoding base64 data: %w", err)
}
if certificate, err = x509.ParseCertificate(raw[:n]); err != nil {
return nil, err
}
return certificate, nil
}
func mdsParseTimePointer(format, value string) (parsed *time.Time, err error) {
if value == "" {
return nil, nil
}
var p time.Time
if p, err = time.Parse(format, value); err != nil {
return nil, err
}
return &p, nil
}
@@ -0,0 +1,2 @@
// Package metadata handles metadata validation instrumentation.
package metadata
@@ -0,0 +1,1322 @@
package metadata
import (
"crypto/x509"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"github.com/google/uuid"
)
// Fetch creates a new HTTP client and gets the production metadata, decodes it, and parses it. This is an
// instrumentation simplification that makes it easier to either just grab the latest metadata or for implementers to
// see the rough process of retrieving it to implement any of their own logic.
func Fetch() (metadata *Metadata, err error) {
var (
decoder *Decoder
payload *PayloadJSON
resp *http.Response
)
client := &http.Client{}
if resp, err = client.Get(ProductionMDSURL); err != nil {
return nil, err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("error occurred fetching metadata: status code %d", resp.StatusCode)
}
if decoder, err = NewDecoder(WithIgnoreEntryParsingErrors()); err != nil {
return nil, err
}
if payload, err = decoder.Decode(resp.Body); err != nil {
return nil, err
}
return decoder.Parse(payload)
}
// Metadata represents a FIDO Metadata Service BLOB in either a fully parsed or partially parsed state.
type Metadata struct {
// Parsed contains the successfully parsed BLOB payload entries.
Parsed Parsed
// Unparsed contains entries that failed to parse, along with their errors.
Unparsed []EntryError
}
func (m *Metadata) ToMap() (metadata map[uuid.UUID]*Entry) {
metadata = make(map[uuid.UUID]*Entry)
for _, entry := range m.Parsed.Entries {
if entry.AaGUID != uuid.Nil {
metadata[entry.AaGUID] = &entry
}
}
return metadata
}
// Parsed is a structure representing the Metadata BLOB Payload dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
type Parsed struct {
// The legalHeader, which MUST be in each BLOB, is an indication of the acceptance of the relevant legal agreement
// for using the MDS.
LegalHeader string
// The serial number of this Metadata BLOB Payload. This serial number MUST be incremented whenever the contents
// of the BLOB changes. Serial numbers MUST be consecutive and strictly monotonic, i.e. the successor BLOB will
// have a no value exactly incremented by one.
Number int
// ISO-8601 formatted date when the next update will be provided at latest. The use of this field is discouraged
// and may be removed in a future version of the spec.
NextUpdate time.Time
// List of zero or more MetadataBLOBPayloadEntry objects.
Entries []Entry
}
// PayloadJSON is an intermediary JSON/JWT representation of the Metadata BLOB Payload dictionary and the JSON
// representation of the [Parsed] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-payload
type PayloadJSON struct {
// LegalHeader is an indication of the acceptance of the relevant legal agreement for using the MDS.
LegalHeader string `json:"legalHeader"`
// Number is the serial number of this Metadata BLOB Payload.
Number int `json:"no"`
// NextUpdate is an ISO-8601 formatted date when the next update will be provided at latest.
NextUpdate string `json:"nextUpdate"`
// Entries is a list of zero or more MetadataBLOBPayloadEntry objects.
Entries []EntryJSON `json:"entries"`
}
func (j PayloadJSON) Parse() (payload Parsed, err error) {
var update time.Time
if update, err = time.Parse(time.DateOnly, j.NextUpdate); err != nil {
return payload, fmt.Errorf("error occurred parsing next update value '%s': %w", j.NextUpdate, err)
}
n := len(j.Entries)
entries := make([]Entry, n)
for i := 0; i < n; i++ {
if entries[i], err = j.Entries[i].Parse(); err != nil {
return payload, fmt.Errorf("error occurred parsing entry %d: %w", i, err)
}
}
return Parsed{
LegalHeader: j.LegalHeader,
Number: j.Number,
NextUpdate: update,
Entries: entries,
}, nil
}
// Entry is a structure representing the Metadata BLOB Payload Entry dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
type Entry struct {
// Aaid is the AAID of the authenticator this metadata BLOB payload entry relates to. This field MUST be set if
// the authenticator implements FIDO UAF.
Aaid string
// AaGUID is the Authenticator Attestation GUID. This field MUST be set if the authenticator implements FIDO2.
AaGUID uuid.UUID
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
// hex string. This field MUST be set if neither aaid nor aaguid are set.
AttestationCertificateKeyIdentifiers []string
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
MetadataStatement Statement
// BiometricStatusReports is the status of the FIDO Biometric Certification of one or more biometric components of
// the Authenticator.
BiometricStatusReports []BiometricStatusReport
// StatusReports is an array of status reports applicable to this authenticator.
StatusReports []StatusReport
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set to the current
// value.
TimeOfLastStatusChange time.Time
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
RogueListURL *url.URL
// RogueListHash is the hash value computed over the Base64url encoding of the UTF-8 representation of the JSON
// encoded rogueList available at rogueListURL (with type rogueListEntry[]). This hash value MUST be present and
// non-empty whenever rogueListURL is present.
RogueListHash string
}
// EntryJSON is an intermediary JSON/JWT structure representing the Metadata BLOB Payload Entry dictionary and
// the JSON representation of the [Entry] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-mds-blob-pe
type EntryJSON struct {
// Aaid is the AAID of the authenticator. Set if the authenticator implements FIDO UAF.
Aaid string `json:"aaid"`
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
AaGUID string `json:"aaguid"`
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
// MetadataStatement is the metadataStatement JSON object as defined in FIDOMetadataStatement.
MetadataStatement StatementJSON `json:"metadataStatement"`
// BiometricStatusReports is the biometric certification status of one or more biometric components.
BiometricStatusReports []BiometricStatusReportJSON `json:"biometricStatusReports"`
// StatusReports is an array of status reports applicable to this authenticator.
StatusReports []StatusReportJSON `json:"statusReports"`
// TimeOfLastStatusChange is an ISO-8601 formatted date since when the status report array was set.
TimeOfLastStatusChange string `json:"timeOfLastStatusChange"`
// RogueListURL is a URL of a list of rogue (i.e. untrusted) individual authenticators.
RogueListURL string `json:"rogueListURL"`
// RogueListHash is the hash value computed over the Base64url encoding of the rogueList at rogueListURL.
RogueListHash string `json:"rogueListHash"`
}
func (j EntryJSON) Parse() (entry Entry, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error parsing AAGUID: %w", j.AaGUID, err)
}
}
var statement Statement
if statement, err = j.MetadataStatement.Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': %w", j.AaGUID, err)
}
var i, n int
n = len(j.BiometricStatusReports)
bsrs := make([]BiometricStatusReport, n)
for i = 0; i < n; i++ {
if bsrs[i], err = j.BiometricStatusReports[i].Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing biometric status report %d: %w", j.AaGUID, i, err)
}
}
n = len(j.StatusReports)
srs := make([]StatusReport, n)
for i = 0; i < n; i++ {
if srs[i], err = j.StatusReports[i].Parse(); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing status report %d: %w", j.AaGUID, i, err)
}
}
var change time.Time
if change, err = time.Parse(time.DateOnly, j.TimeOfLastStatusChange); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing time of last status change value: %w", j.AaGUID, err)
}
var rogues *url.URL
if len(j.RogueListURL) != 0 {
if rogues, err = url.ParseRequestURI(j.RogueListURL); err != nil {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred parsing rogue list URL value: %w", j.AaGUID, err)
}
if len(j.RogueListHash) == 0 {
return entry, fmt.Errorf("error occurred parsing metadata entry with AAGUID '%s': error occurred validating rogue list URL value: the rogue list hash was absent", j.AaGUID)
}
}
return Entry{
Aaid: j.Aaid,
AaGUID: aaguid,
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
MetadataStatement: statement,
BiometricStatusReports: bsrs,
StatusReports: srs,
TimeOfLastStatusChange: change,
RogueListURL: rogues,
RogueListHash: j.RogueListHash,
}, nil
}
// Statement is a structure representing the Metadata Statement dictionary. Authenticator metadata statements are used
// directly by the FIDO server at a relying party, but the information contained in the authoritative statement is used
// in several other places.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type Statement struct {
// The LegalHeader, if present, contains a legal guide for accessing and using metadata, which itself MAY contain
// URL(s) pointing to further information, such as a full Terms and Conditions statement.
LegalHeader string
// Aaid is the Authenticator Attestation ID.
Aaid string
// AaGUID is the Authenticator Attestation GUID.
AaGUID uuid.UUID
// AttestationCertificateKeyIdentifiers is a list of the attestation certificate public key identifiers encoded as
// hex string.
AttestationCertificateKeyIdentifiers []string
// FriendlyNames contains friendly names (i.e., public trade name) of the authenticator in multiple languages.
FriendlyNames map[string]string
// Description is a human-readable, short description of the authenticator, in English.
Description string
// AlternativeDescriptions is a list of human-readable short descriptions of the authenticator in different
// languages.
AlternativeDescriptions map[string]string
// AuthenticatorVersion is the earliest (i.e. lowest) trustworthy authenticatorVersion meeting the requirements
// specified in this metadata statement.
AuthenticatorVersion uint32
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
ProtocolFamily string
// Schema is the Metadata Schema version.
Schema uint16
// Upv is the FIDO unified protocol version(s) (related to the specific protocol family) supported by this
// authenticator.
Upv []Version
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
AuthenticationAlgorithms []AuthenticationAlgorithm
// PublicKeyAlgAndEncodings is the list of public key formats supported by the authenticator during registration
// operations.
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding
// AttestationTypes is the supported attestation type(s).
AttestationTypes AuthenticatorAttestationTypes
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
UserVerificationDetails [][]VerificationMethodDescriptor
// KeyProtection is a 16-bit number representing the bit fields defined by the KEY_PROTECTION constants in the FIDO
// Registry of Predefined Values.
KeyProtection []string
// IsKeyRestricted is set to true or it is omitted, if the Uauth private key is restricted by the authenticator to
// only sign valid FIDO signature assertions. This entry is set to false, if the authenticator doesn't restrict the
// Uauth key to only sign valid FIDO signature assertions.
IsKeyRestricted bool
// IsFreshUserVerificationRequired is set to true or it is omitted, if Uauth key usage always requires a fresh user
// verification. This entry is set to false, if the Uauth key can be used without requiring a fresh user
// verification, i.e. without any additional user interaction, if the user was verified a (potentially configurable)
// caching time ago.
IsFreshUserVerificationRequired bool
// MatcherProtection is a 16-bit number representing the bit fields defined by the MATCHER_PROTECTION constants in
// the FIDO Registry of Predefined Values.
MatcherProtection []string
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits (sometimes also called
// security strength or security level).
CryptoStrength uint16
// AttachmentHint is a 32-bit number representing the bit fields defined by the ATTACHMENT_HINT constants in the
// FIDO Registry of Predefined Values.
AttachmentHint []string
// TcDisplay is a 16-bit number representing a combination of the bit flags defined by the
// TRANSACTION_CONFIRMATION_DISPLAY constants in the FIDO Registry of Predefined Values.
TcDisplay []string
// TcDisplayContentType is the supported MIME content type [RFC2049] for the transaction confirmation display, such
// as text/plain or image/png.
TcDisplayContentType string
// TcDisplayPNGCharacteristics is a list of alternative [DisplayPNGCharacteristicsDescriptor]. Each of these entries
// is one alternative of supported image characteristics for displaying a PNG image.
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor
// AttestationRootCertificates is a list of root certificates. Each element of this array represents a PKIX
// [RFC5280] X.509 certificate that is a valid trust anchor for this authenticator model.
// Multiple certificates might be used for different batches of the same model.
// The array does not represent a certificate chain, but only the trust anchor of that chain.
// A trust anchor can be a root certificate, an intermediate CA certificate, or even the attestation certificate
// itself.
AttestationRootCertificates []*x509.Certificate
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation. This entry MUST be present if and only
// if attestationType includes ATTESTATION_ECDAA.
EcdaaTrustAnchors []EcdaaTrustAnchor
// Icon is a 'data:' url [RFC2397] encoded [PNG] or [SVG11] (light mode) icon for the Authenticator (i.e., depicting
// the security key). This icon is intended to be shown to users by RPs. Use of [SVG11] format is mandatory if any
// of the iconDark, providerLogoLight and/or providerLogoDark is used in addition to icon. Use of [SVG11] is
// recommended if only icon is used. The icon is more specific than the provider logo and should be shown if
// present.
Icon *url.URL
// IconDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the Authenticator (i.e., depicting the
// security key). This icon is intended to be shown to users by RPs. The icon is more specific than the provider
// logo and should be shown if present.
IconDark *url.URL
// ProviderLogoLight is a 'data:' url [RFC2397] encoded [SVG11] light mode icon for the provider (i.e., logomark of
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
// is intended to be shown to users by RPs.
ProviderLogoLight *url.URL
// ProviderLogoDark is a 'data:' url [RFC2397] encoded [SVG11] dark mode icon for the provider (i.e., logomark of
// the passkey provider). The SVG MUST meet all of the requirements defined in § 4.1 SVG requirements. This icon
// is intended to be shown to users by RPs.
ProviderLogoDark *url.URL
// SupportedExtensions is a list of extensions supported by the authenticator.
SupportedExtensions []ExtensionDescriptor
// KeyScope of keys generated and maintained by this authenticator model.
KeyScope KeyScope
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
MultiDeviceCredentialSupport MultiDeviceCredentialSupport
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
AuthenticatorGetInfo AuthenticatorGetInfo
// CredentialExportProtocolConfigURL specifies the URL for retrieving the configuration details for the credential
// export protocol (CXP).
CredentialExportProtocolConfigURL *url.URL
}
func (s *Statement) Verifier(x5cis []*x509.Certificate) (opts x509.VerifyOptions) {
roots := x509.NewCertPool()
for _, root := range s.AttestationRootCertificates {
roots.AddCert(root)
}
var intermediates *x509.CertPool
if len(x5cis) > 0 {
intermediates = x509.NewCertPool()
for _, x5c := range x5cis {
intermediates.AddCert(x5c)
}
}
return x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
}
}
// StatementJSON is the JSON representation of the [Statement] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type StatementJSON struct {
// LegalHeader contains a legal guide for accessing and using metadata.
LegalHeader string `json:"legalHeader"`
// Aaid is the Authenticator Attestation ID. Set if the authenticator implements FIDO UAF.
Aaid string `json:"aaid"`
// AaGUID is the Authenticator Attestation GUID. Set if the authenticator implements FIDO2.
AaGUID string `json:"aaguid"`
// AttestationCertificateKeyIdentifiers is a list of attestation certificate public key identifiers (hex).
AttestationCertificateKeyIdentifiers []string `json:"attestationCertificateKeyIdentifiers"`
// FriendlyNames contains friendly names of the authenticator in multiple languages.
FriendlyNames map[string]string `json:"friendlyNames"`
// Description is a human-readable, short description of the authenticator, in English.
Description string `json:"description"`
// AlternativeDescriptions is a list of human-readable short descriptions in different languages.
AlternativeDescriptions map[string]string `json:"alternativeDescriptions"`
// AuthenticatorVersion is the earliest trustworthy authenticatorVersion meeting the requirements in this statement.
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
// ProtocolFamily is the FIDO protocol family. The values "uaf", "u2f", and "fido2" are supported.
ProtocolFamily string `json:"protocolFamily"`
// Schema is the Metadata Schema version.
Schema uint16 `json:"schema"`
// Upv is the FIDO unified protocol version(s) supported by this authenticator.
Upv []Version `json:"upv"`
// AuthenticationAlgorithms is the list of authentication algorithms supported by the authenticator.
AuthenticationAlgorithms []AuthenticationAlgorithm `json:"authenticationAlgorithms"`
// PublicKeyAlgAndEncodings is the list of public key formats supported during registration operations.
PublicKeyAlgAndEncodings []PublicKeyAlgAndEncoding `json:"publicKeyAlgAndEncodings"`
// AttestationTypes is the supported attestation type(s).
AttestationTypes []AuthenticatorAttestationType `json:"attestationTypes"`
// UserVerificationDetails is a list of alternative VerificationMethodANDCombinations.
UserVerificationDetails [][]VerificationMethodDescriptor `json:"userVerificationDetails"`
// KeyProtection is the key protection type(s).
KeyProtection []string `json:"keyProtection"`
// IsKeyRestricted indicates if the Uauth private key is restricted to only sign valid FIDO signature assertions.
IsKeyRestricted bool `json:"isKeyRestricted"`
// IsFreshUserVerificationRequired indicates if Uauth key usage always requires a fresh user verification.
IsFreshUserVerificationRequired bool `json:"isFreshUserVerificationRequired"`
// MatcherProtection is the matcher protection type(s).
MatcherProtection []string `json:"matcherProtection"`
// CryptoStrength is the authenticator's overall claimed cryptographic strength in bits.
CryptoStrength uint16 `json:"cryptoStrength"`
// AttachmentHint is the attachment hint(s).
AttachmentHint []string `json:"attachmentHint"`
// TcDisplay is the transaction confirmation display type(s).
TcDisplay []string `json:"tcDisplay"`
// TcDisplayContentType is the supported MIME content type for the transaction confirmation display.
TcDisplayContentType string `json:"tcDisplayContentType"`
// TcDisplayPNGCharacteristics is a list of alternative DisplayPNGCharacteristicsDescriptor.
TcDisplayPNGCharacteristics []DisplayPNGCharacteristicsDescriptor `json:"tcDisplayPNGCharacteristics"`
// AttestationRootCertificates is a list of base64-encoded trust anchor certificates for this authenticator model.
AttestationRootCertificates []string `json:"attestationRootCertificates"`
// EcdaaTrustAnchors is a list of trust anchors used for ECDAA attestation.
EcdaaTrustAnchors []EcdaaTrustAnchor `json:"ecdaaTrustAnchors"`
// Icon is a data: URL encoded PNG or SVG (light mode) icon for the Authenticator.
Icon string `json:"icon"`
// IconDark is a data: URL encoded SVG dark mode icon for the Authenticator.
IconDark string `json:"iconDark"`
// ProviderLogoLight is a data: URL encoded SVG light mode icon for the provider.
ProviderLogoLight string `json:"providerLogoLight"`
// ProviderLogoDark is a data: URL encoded SVG dark mode icon for the provider.
ProviderLogoDark string `json:"providerLogoDark"`
// SupportedExtensions is a list of extensions supported by the authenticator.
SupportedExtensions []ExtensionDescriptor `json:"supportedExtensions"`
// KeyScope of keys generated and maintained by this authenticator model.
KeyScope KeyScope `json:"keyScope"`
// MultiDeviceCredentialSupport describes the support for multi-device credentials.
MultiDeviceCredentialSupport MultiDeviceCredentialSupport `json:"multiDeviceCredentialSupport"`
// AuthenticatorGetInfo describes supported versions, extensions, AAGUID of the device and its capabilities.
AuthenticatorGetInfo AuthenticatorGetInfoJSON `json:"authenticatorGetInfo"`
// CredentialExportProtocolConfigURL specifies the URL for the credential export protocol (CXP) configuration.
CredentialExportProtocolConfigURL string `json:"cxpConfigURL"`
}
// Parse converts StatementJSON into a [Statement] object, validating and parsing its fields. Returns an error on failure.
//
//nolint:gocyclo
func (j StatementJSON) Parse() (statement Statement, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing AAGUID value: %w", j.Description, err)
}
}
n := len(j.AttestationRootCertificates)
certificates := make([]*x509.Certificate, n)
for i := 0; i < n; i++ {
if certificates[i], err = mdsParseX509Certificate(j.AttestationRootCertificates[i]); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing attestation root certificate %d value: %w", j.Description, i, err)
}
}
var (
icon, iconDark *url.URL
logoLight, logoDark *url.URL
cxpConfigURL *url.URL
)
if len(j.Icon) != 0 {
if icon, err = url.ParseRequestURI(j.Icon); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon value: %w", j.Description, err)
}
}
if len(j.IconDark) != 0 {
if iconDark, err = url.ParseRequestURI(j.IconDark); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing icon dark value: %w", j.Description, err)
}
}
if len(j.ProviderLogoLight) != 0 {
if logoLight, err = url.ParseRequestURI(j.ProviderLogoLight); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo light value: %w", j.Description, err)
}
}
if len(j.ProviderLogoDark) != 0 {
if logoDark, err = url.ParseRequestURI(j.ProviderLogoDark); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing provider logo dark value: %w", j.Description, err)
}
}
if len(j.CredentialExportProtocolConfigURL) != 0 {
if cxpConfigURL, err = url.ParseRequestURI(j.CredentialExportProtocolConfigURL); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing cxp config url value: %w", j.Description, err)
}
}
var info AuthenticatorGetInfo
if info, err = j.AuthenticatorGetInfo.Parse(); err != nil {
return statement, fmt.Errorf("error occurred parsing statement with description '%s': error occurred parsing authenticator get info value: %w", j.Description, err)
}
return Statement{
LegalHeader: j.LegalHeader,
Aaid: j.Aaid,
AaGUID: aaguid,
AttestationCertificateKeyIdentifiers: j.AttestationCertificateKeyIdentifiers,
FriendlyNames: j.FriendlyNames,
Description: j.Description,
AlternativeDescriptions: j.AlternativeDescriptions,
AuthenticatorVersion: j.AuthenticatorVersion,
ProtocolFamily: j.ProtocolFamily,
Schema: j.Schema,
Upv: j.Upv,
AuthenticationAlgorithms: j.AuthenticationAlgorithms,
PublicKeyAlgAndEncodings: j.PublicKeyAlgAndEncodings,
AttestationTypes: j.AttestationTypes,
UserVerificationDetails: j.UserVerificationDetails,
KeyProtection: j.KeyProtection,
IsKeyRestricted: j.IsKeyRestricted,
IsFreshUserVerificationRequired: j.IsFreshUserVerificationRequired,
MatcherProtection: j.MatcherProtection,
CryptoStrength: j.CryptoStrength,
AttachmentHint: j.AttachmentHint,
TcDisplay: j.TcDisplay,
TcDisplayContentType: j.TcDisplayContentType,
TcDisplayPNGCharacteristics: j.TcDisplayPNGCharacteristics,
AttestationRootCertificates: certificates,
EcdaaTrustAnchors: j.EcdaaTrustAnchors,
Icon: icon,
IconDark: iconDark,
ProviderLogoLight: logoLight,
ProviderLogoDark: logoDark,
SupportedExtensions: j.SupportedExtensions,
KeyScope: j.KeyScope,
MultiDeviceCredentialSupport: j.MultiDeviceCredentialSupport,
AuthenticatorGetInfo: info,
CredentialExportProtocolConfigURL: cxpConfigURL,
}, nil
}
// BiometricStatusReport is a structure representing the BiometricStatusReport dictionary. Contains the current
// BiometricStatusReport of one of the authenticator's biometric component.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
type BiometricStatusReport struct {
// CertLevel is the achieved level of the biometric certification of this biometric component of the authenticator.
CertLevel uint16
// Modality is a single USER_VERIFY short form case-sensitive string name constant, representing biometric modality.
Modality string
// EffectiveDate is an ISO-8601 formatted date since when the certLevel achieved, if applicable. If no date is
// given, the status is assumed to be effective while present.
EffectiveDate time.Time
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
CertificationDescriptor string
// CertificateNumber is the unique identifier for the issued Biometric Certification.
CertificateNumber string
// CertificationPolicyVersion is the version of the Biometric Certification Policy the implementation is Certified
// to, i.e. "1.0.0".
CertificationPolicyVersion string
// CertificationRequirementsVersion is the version of the Biometric Requirements [FIDOBiometricsRequirements] the
// implementation is certified to, i.e. "1.0.0".
CertificationRequirementsVersion string
}
// BiometricStatusReportJSON is the JSON representation of the [BiometricStatusReport] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-bio-stat-rep
type BiometricStatusReportJSON struct {
// CertLevel is the achieved level of the biometric certification of this biometric component.
CertLevel uint16 `json:"certLevel"`
// Modality is a single USER_VERIFY short form string constant representing the biometric modality.
Modality string `json:"modality"`
// EffectiveDate is an ISO-8601 formatted date since when the certLevel was achieved.
EffectiveDate string `json:"effectiveDate"`
// CertificationDescriptor describes the externally visible aspects of the Biometric Certification evaluation.
CertificationDescriptor string `json:"certificationDescriptor"`
// CertificateNumber is the unique identifier for the issued Biometric Certification.
CertificateNumber string `json:"certificateNumber"`
// CertificationPolicyVersion is the version of the Biometric Certification Policy, i.e. "1.0.0".
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
// CertificationRequirementsVersion is the version of the Biometric Requirements, i.e. "1.0.0".
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
}
func (j BiometricStatusReportJSON) Parse() (report BiometricStatusReport, err error) {
var effective time.Time
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
}
return BiometricStatusReport{
CertLevel: j.CertLevel,
Modality: j.Modality,
EffectiveDate: effective,
CertificationDescriptor: j.CertificationDescriptor,
CertificateNumber: j.CertificateNumber,
CertificationPolicyVersion: j.CertificationPolicyVersion,
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
}, nil
}
// StatusReport is a structure representing the StatusReport dictionary. Contains an [AuthenticatorStatus] and additional
// data associated with it, if any.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
type StatusReport struct {
// Status of the authenticator. Additional fields MAY be set depending on this value.
Status AuthenticatorStatus
// EffectiveDate is an ISO-8601 formatted date since when the status code was set, if applicable. If no date is
// given, the status is assumed to be effective while present.
EffectiveDate time.Time
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to. In the
// case of FIDO_CERTIFIED* status values, the status applies to higher authenticatorVersions until there is a new
// statusReport.
AuthenticatorVersion uint32
// BatchCertificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value
// related to the current status, if applicable.
BatchCertificate *x509.Certificate
// Certificate is a Base64-encoded [RFC4648] (not base64url!) DER [ITU-X690-2008] PKIX certificate value related to
// the current status, if applicable. This field will typically not be present if field batchCertificate is present.
Certificate *x509.Certificate
// URL is a HTTPS URL where additional information may be found related to the current status, if applicable.
URL *url.URL
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
CertificationDescriptor string
// CertificateNumber is the unique identifier for the issued Certification.
CertificateNumber string
// CertificationPolicyVersion is the version of the Authenticator Certification Policy the implementation is
// Certified to, i.e. "1.0.0".
CertificationPolicyVersion string
// CertificationProfiles is a list of certification profile strings. Each entry represents a supported
// certification profile, i.e. "consumer" or "enterprise".
CertificationProfiles []string
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements (DV)
// [FIDOAuthenticatorSecurityRequirements] the implementation is certified to, i.e. "1.2.0".
CertificationRequirementsVersion string
// SunsetDate is an ISO-8601 formatted date since when the status will expire, if applicable. If no date is given,
// the status is assumed to not have a scheduled expiry.
SunsetDate *time.Time
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" in the case of FIPS 140-3. This
// entry MUST be present if and only if the status entry is one of FIPS140_CERTIFIED_L*.
FIPSRevision uint32
// FIPSPhysicalSecurityLevel is the "physical security level" of the FIPS certification. This entry MUST be present
// if and only if the status entry is one of FIPS140_CERTIFIED_L*. It MUST reflect the physical security level
// which might deviate from the overall level.
FIPSPhysicalSecurityLevel uint32
}
// StatusReportJSON is the JSON representation of the [StatusReport] struct.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-stat-rep
type StatusReportJSON struct {
// Status of the authenticator. Additional fields MAY be set depending on this value.
Status AuthenticatorStatus `json:"status"`
// EffectiveDate is an ISO-8601 formatted date since when the status code was set.
EffectiveDate string `json:"effectiveDate"`
// AuthenticatorVersion is the authenticator version (firmware version) that this status report relates to.
AuthenticatorVersion uint32 `json:"authenticatorVersion"`
// BatchCertificate is a Base64-encoded DER PKIX certificate related to the current status.
BatchCertificate string `json:"batchCertificate"`
// Certificate is a Base64-encoded DER PKIX certificate related to the current status.
Certificate string `json:"certificate"`
// URL is a HTTPS URL where additional information may be found related to the current status.
URL string `json:"url"`
// CertificationDescriptor describes the externally visible aspects of the Authenticator Certification evaluation.
CertificationDescriptor string `json:"certificationDescriptor"`
// CertificateNumber is the unique identifier for the issued Certification.
CertificateNumber string `json:"certificateNumber"`
// CertificationPolicyVersion is the version of the Authenticator Certification Policy, i.e. "1.0.0".
CertificationPolicyVersion string `json:"certificationPolicyVersion"`
// CertificationProfiles is a list of supported certification profiles, i.e. "consumer" or "enterprise".
CertificationProfiles []string `json:"certificationProfiles"`
// CertificationRequirementsVersion is the Document Version of the Authenticator Security Requirements, i.e. "1.2.0".
CertificationRequirementsVersion string `json:"certificationRequirementsVersion"`
// SunsetDate is an ISO-8601 formatted date when the status will expire.
SunsetDate string `json:"sunsetDate"`
// FIPSRevision is the revision number of the FIPS 140 specification, i.e. "3" for FIPS 140-3.
FIPSRevision uint32 `json:"fipsRevision"`
// FIPSPhysicalSecurityLevel is the physical security level of the FIPS certification.
FIPSPhysicalSecurityLevel uint32 `json:"fipsPhysicalSecurityLevel"`
}
func (j StatusReportJSON) Parse() (report StatusReport, err error) {
var (
certificate, batchCertificate *x509.Certificate
)
if len(j.Certificate) != 0 {
if certificate, err = mdsParseX509Certificate(j.Certificate); err != nil {
return report, fmt.Errorf("error occurred parsing certificate value: %w", err)
}
}
if len(j.BatchCertificate) != 0 {
if batchCertificate, err = mdsParseX509Certificate(j.BatchCertificate); err != nil {
return report, fmt.Errorf("error occurred parsing batch certificate value: %w", err)
}
}
var (
effective time.Time
sunset *time.Time
)
if effective, err = time.Parse(time.DateOnly, j.EffectiveDate); err != nil {
return report, fmt.Errorf("error occurred parsing effective date value: %w", err)
}
if sunset, err = mdsParseTimePointer(time.DateOnly, j.SunsetDate); err != nil {
return report, fmt.Errorf("error occurred parsing sunset date value: %w", err)
}
var uri *url.URL
if len(j.URL) != 0 {
if uri, err = url.ParseRequestURI(j.URL); err != nil {
if !strings.HasPrefix(j.URL, "http") {
var e error
if uri, e = url.ParseRequestURI(fmt.Sprintf("https://%s", j.URL)); e != nil {
return report, fmt.Errorf("error occurred parsing URL value: %w", err)
}
}
}
}
return StatusReport{
Status: j.Status,
EffectiveDate: effective,
AuthenticatorVersion: j.AuthenticatorVersion,
BatchCertificate: batchCertificate,
Certificate: certificate,
URL: uri,
CertificationDescriptor: j.CertificationDescriptor,
CertificateNumber: j.CertificateNumber,
CertificationPolicyVersion: j.CertificationPolicyVersion,
CertificationProfiles: j.CertificationProfiles,
CertificationRequirementsVersion: j.CertificationRequirementsVersion,
SunsetDate: sunset,
FIPSRevision: j.FIPSRevision,
FIPSPhysicalSecurityLevel: j.FIPSPhysicalSecurityLevel,
}, nil
}
// RogueListEntry is a structure representing the RogueListEntry dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-rogue-list-entry
type RogueListEntry struct {
// Sk is the base64url encoding of the rogue authenticator's secret key.
Sk string `json:"sk"`
// Data is the ISO-8601 formatted date since when this entry is effective.
Date string `json:"date"`
}
// CodeAccuracyDescriptor is a structure representing the CodeAccuracyDescriptor dictionary.
// It describes the relevant accuracy/complexity aspects of passcode user verification methods.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-cad
type CodeAccuracyDescriptor struct {
// Base is the numeric system base (radix) of the code, i.e. 10 in the case of decimal digits.
Base uint16 `json:"base"`
// MinLength is the minimum number of digits of the given base required for that code, i.e. 4 in the case of 4
// digits.
MinLength uint16 `json:"minLength"`
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
// some time). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
// similar). 0 means this user verification method will be blocked, either permanently, or until an alternative user
// verification method method succeeded. All alternative user verification methods MUST be specified appropriately
// in the Metadata in userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// BiometricAccuracyDescriptor is a structure representing the BiometricAccuracyDescriptor dictionary.
// It describes relevant accuracy/complexity aspects in the case of a biometric user verification method.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-bad
type BiometricAccuracyDescriptor struct {
// SelfAttestedFRR is the false rejection rate [ISO19795-1] for a single template, i.e. the percentage of
// verification transactions with truthful claims of identity that are incorrectly denied.
SelfAttestedFRR float64 `json:"selfAttestedFRR"`
// SelfAttestedFAR is the false acceptance rate [ISO19795-1] for a single template, i.e. the percentage of
// verification transactions with wrongful claims of identity that are incorrectly confirmed.
SelfAttestedFAR float64 `json:"selfAttestedFAR"`
// ImposterAttackPresentationAcceptRateThreshold is the threshold for Impostor Attack Presentation Accept Rate
// (IAPAR) is the proportion of impostor attack presentations using the same presentation attack instrument (PAI)
// species that result in accept [isoiec-30107-3]. For biometric certification requirements
// [FIDOBiometricsRequirements], certification can be achieved for an IAPAR threshold of less than 7% OR less than
// 15% for each of the PAI species tested.
ImposterAttackPresentationAcceptRateThreshold float64 `json:"iAPARThreshold"`
// MaxTemplates is the maximum number of alternative templates from different fingers allowed.
MaxTemplates uint16 `json:"maxTemplates"`
// MaxRetries is the maximum number of false attempts before the authenticator will block this method (at least for
// some time). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (i.e. due to forced reboot or
// similar).0 means that this user verification method will be blocked either permanently or until an alternative
// user verification method succeeded. All alternative user verification methods MUST be specified appropriately in
// the metadata in userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// PatternAccuracyDescriptor is a structure representing the PatternAccuracyDescriptor dictionary.
// It describes relevant accuracy/complexity aspects in the case that a pattern is used as the user verification method.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-pad
type PatternAccuracyDescriptor struct {
// MinComplexity is the number of possible patterns (having the minimum length) out of which exactly one would be
// the right one, i.e. 1/probability in the case of equal distribution.
MinComplexity uint32 `json:"minComplexity"`
// MaxRetries is the maximum number of false attempts before the authenticator will block authentication using this
// method (at least temporarily). 0 means it will never block.
MaxRetries uint16 `json:"maxRetries"`
// BlockSlowdown is the enforced minimum number of seconds wait time after blocking (due to forced reboot or similar
// mechanism). 0 means this user verification method will be blocked, either permanently, or until an alternative
// user verification method method succeeded. All alternative user verification methods MUST be specified
// appropriately in the metadata under userVerificationDetails.
BlockSlowdown uint16 `json:"blockSlowdown"`
}
// VerificationMethodDescriptor is a structure representing the VerificationMethodDescriptor dictionary.
// It describes a descriptor for a specific base user verification method as implemented by the authenticator.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-vmd
type VerificationMethodDescriptor struct {
// UserVerificationMethod is a single USER_VERIFY constant (see [FIDORegistry]), not a bit flag combination. This
// value MUST be non-zero.
UserVerificationMethod string `json:"userVerificationMethod"`
// CaDesc nay optionally be used in the case of method USER_VERIFY_PASSCODE.
CaDesc CodeAccuracyDescriptor `json:"caDesc"`
// BaDesc may optionally be used in the case of method USER_VERIFY_FINGERPRINT, USER_VERIFY_VOICEPRINT,
// USER_VERIFY_FACEPRINT, USER_VERIFY_EYEPRINT, or USER_VERIFY_HANDPRINT.
BaDesc BiometricAccuracyDescriptor `json:"baDesc"`
// PaDesc may optionally be used in case of method USER_VERIFY_PATTERN.
PaDesc PatternAccuracyDescriptor `json:"paDesc"`
}
// RGBPaletteEntry is a structure representing the RGBPaletteEntry dictionary.
// It describes an RGB three-sample tuple palette entry.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-rgbpe
type RGBPaletteEntry struct {
// R is the red channel sample value.
R uint16 `json:"r"`
// G is the green channel sample value.
G uint16 `json:"g"`
// B is the blue channel sample value.
B uint16 `json:"b"`
}
// DisplayPNGCharacteristicsDescriptor is a structure representing the DisplayPNGCharacteristicsDescriptor MDS3.1
// dictionary. It describes a PNG image characteristics as defined in the PNG [PNG] spec for IHDR (image header) and
// PLTE (palette table).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-dpngcd
type DisplayPNGCharacteristicsDescriptor struct {
// Width of the image.
Width uint32 `json:"width"`
// Height of the image.
Height uint32 `json:"height"`
// BitDepth is bits per sample or per palette index.
BitDepth byte `json:"bitDepth"`
// ColorType defines the PNG image type.
ColorType byte `json:"colorType"`
// Compression method used to compress the image data.
Compression byte `json:"compression"`
// Filter method is the preprocessing method applied to the image data before compression.
Filter byte `json:"filter"`
// Interlace method is the transmission order of the image data.
Interlace byte `json:"interlace"`
// Plte is a number 1 to 256 representing palette entries.
Plte []RGBPaletteEntry `json:"plte"`
}
// EcdaaTrustAnchor is a structure representing the EcdaaTrustAnchor dictionary.
// In the case of ECDAA attestation, the ECDAA-Issuer's trust anchor MUST be specified in this field.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ecdaata
type EcdaaTrustAnchor struct {
// X is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 X.
X string `json:"X"`
// Y is the base64url encoding of the result of ECPoint2ToB of the ECPoint2 Y.
Y string `json:"Y"`
// C is the base64url encoding of the result of BigNumberToB(c).
C string `json:"c"`
// SX is the base64url encoding of the result of BigNumberToB(sx).
SX string `json:"sx"`
// SY is the base64url encoding of the result of BigNumberToB(sy).
SY string `json:"sy"`
// G1Curve is the name of the Barreto-Naehrig elliptic curve for G1. "BN_P256", "BN_P638", "BN_ISOP256", and
// "BN_ISOP512" are supported.
G1Curve string `json:"G1Curve"`
}
// ExtensionDescriptor is a structure representing the ExtensionDescriptor dictionary.
// This descriptor contains an extension supported by the authenticator.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-ed
type ExtensionDescriptor struct {
// ID identifies the extension.
ID string `json:"id"`
// Tag of the extension if this was assigned. TAGs are assigned to extensions if they could appear in an assertion.
Tag uint16 `json:"tag"`
// Data contains arbitrary data further describing the extension and/or data needed to correctly process the
// extension.
Data string `json:"data"`
// FailIfUnknown indicates whether unknown extensions must be ignored (false) or must lead to an error (true) when
// the extension is to be processed by the FIDO Server, FIDO Client, ASM, or FIDO Authenticator.
FailIfUnknown bool `json:"fail_if_unknown"`
}
// Version is a structure representing the Version FIDO UAF Protocol 1.2 dictionary and represents a generic version
// with major and minor fields.
//
// See: https://fidoalliance.org/specs/fido-uaf-v1.2-ps-20201020/fido-uaf-protocol-v1.2-ps-20201020.html#version-interface
type Version struct {
// Major version.
Major uint16 `json:"major"`
// Minor version.
Minor uint16 `json:"minor"`
}
// AuthenticatorGetInfo is a structure representing the AuthenticatorGetInfo dictionary.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
type AuthenticatorGetInfo struct {
// Versions is a list of supported versions.
Versions []string
// Extensions is a list of supported extensions.
Extensions []string
// AaGUID is the claimed AAGUID.
AaGUID uuid.UUID
// Options is a list of supported options.
Options map[string]bool
// MaxMsgSize is the maximum message size supported by the authenticator.
MaxMsgSize uint
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
PivUvAuthProtocols []uint
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time by the
// authenticator.
MaxCredentialCountInList uint
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
MaxCredentialIdLength uint
// Transports is the list of supported transports.
Transports []string
// Algorithms is the list of supported algorithms for credential generation, as specified in WebAuthn.
Algorithms []PublicKeyCredentialParameters
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array that this
// authenticator can store.
MaxSerializedLargeBlobArray uint
// ForcePINChange indicates if the PIN must be changed.
ForcePINChange bool
// MinPINLength specifies the current minimum PIN length, in Unicode code points, the authenticator enforces for ClientPIN.
MinPINLength uint
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
FirmwareVersion uint
// MaxCredBlobLength indicates the maximum credential blob length in bytes supported by the authenticator.
MaxCredBlobLength uint
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that authenticator can set via setMinPINLength
// subcommand.
MaxRPIDsForSetMinPINLength uint
// PreferredPlatformUvAttempts specifies the preferred number of invocations of the
// getPinUvAuthTokenUsingUvWithPermissions subCommand the platform may attempt before falling back to the
// getPinUvAuthTokenUsingPinWithPermissions subCommand or displaying an error.
PreferredPlatformUvAttempts uint
// UvModality specifies the user verification modality supported by the authenticator via authenticatorClientPIN's
// getPinUvAuthTokenUsingUvWithPermissions subcommand.
UvModality uint
// Certifications specifies a list of authenticator certifications.
Certifications map[string]float64
// RemainingDiscoverableCredentials if present indicates the estimated number of additional discoverable credentials
// that can be stored.
RemainingDiscoverableCredentials uint
// VendorPrototypeConfigCommands if present the authenticator supports the authenticatorConfig vendorPrototype
// subcommand, and its value is a list of authenticatorConfig vendorCommandId values supported, which MAY be empty.
VendorPrototypeConfigCommands []uint
}
// AuthenticatorGetInfoJSON is the JSON representation of the [AuthenticatorGetInfo] struct. The members mirror the
// fields returned by the CTAP authenticatorGetInfo command.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-type-agid
type AuthenticatorGetInfoJSON struct {
// Versions is a list of supported CTAP versions.
Versions []string `json:"versions"`
// Extensions is a list of supported extensions.
Extensions []string `json:"extensions"`
// AaGUID is the claimed AAGUID.
AaGUID string `json:"aaguid"`
// Options is a map of supported options.
Options map[string]bool `json:"options"`
// MaxMsgSize is the maximum message size supported by the authenticator.
MaxMsgSize uint `json:"maxMsgSize"`
// PivUvAuthProtocols is a list of supported PIN/UV auth protocols in order of decreasing authenticator preference.
PivUvAuthProtocols []uint `json:"pinUvAuthProtocols"`
// MaxCredentialCountInList is the maximum number of credentials supported in credentialID list at a time.
MaxCredentialCountInList uint `json:"maxCredentialCountInList"`
// MaxCredentialIdLength is the maximum Credential ID Length supported by the authenticator.
MaxCredentialIdLength uint `json:"maxCredentialIdLength"`
// Transports is the list of supported transports.
Transports []string `json:"transports"`
// Algorithms is the list of supported algorithms for credential generation.
Algorithms []PublicKeyCredentialParameters `json:"algorithms"`
// MaxSerializedLargeBlobArray is the maximum size, in bytes, of the serialized large-blob array.
MaxSerializedLargeBlobArray uint `json:"maxSerializedLargeBlobArray"`
// ForcePINChange indicates if the PIN must be changed.
ForcePINChange bool `json:"forcePINChange"`
// MinPINLength specifies the current minimum PIN length, in Unicode code points.
MinPINLength uint `json:"minPINLength"`
// FirmwareVersion indicates the firmware version of the authenticator model identified by AAGUID.
FirmwareVersion uint `json:"firmwareVersion"`
// MaxCredBlobLength indicates the maximum credential blob length in bytes.
MaxCredBlobLength uint `json:"maxCredBlobLength"`
// MaxRPIDsForSetMinPINLength specifies the max number of RP IDs that can be set via setMinPINLength subcommand.
MaxRPIDsForSetMinPINLength uint `json:"maxRPIDsForSetMinPINLength"`
// PreferredPlatformUvAttempts specifies the preferred number of UV attempts before falling back to PIN.
PreferredPlatformUvAttempts uint `json:"preferredPlatformUvAttempts"`
// UvModality specifies the user verification modality supported by the authenticator.
UvModality uint `json:"uvModality"`
// Certifications specifies a map of authenticator certifications.
Certifications map[string]float64 `json:"certifications"`
// RemainingDiscoverableCredentials indicates the estimated number of additional discoverable credentials that
// can be stored.
RemainingDiscoverableCredentials uint `json:"remainingDiscoverableCredentials"`
// VendorPrototypeConfigCommands is a list of supported authenticatorConfig vendorCommandId values.
VendorPrototypeConfigCommands []uint `json:"vendorPrototypeConfigCommands"`
}
func (j AuthenticatorGetInfoJSON) Parse() (info AuthenticatorGetInfo, err error) {
var aaguid uuid.UUID
if len(j.AaGUID) != 0 {
if aaguid, err = uuid.Parse(j.AaGUID); err != nil {
return info, fmt.Errorf("error occurred parsing AAGUID value: %w", err)
}
}
return AuthenticatorGetInfo{
Versions: j.Versions,
Extensions: j.Extensions,
AaGUID: aaguid,
Options: j.Options,
MaxMsgSize: j.MaxMsgSize,
PivUvAuthProtocols: j.PivUvAuthProtocols,
MaxCredentialCountInList: j.MaxCredentialCountInList,
MaxCredentialIdLength: j.MaxCredentialIdLength,
Transports: j.Transports,
Algorithms: j.Algorithms,
MaxSerializedLargeBlobArray: j.MaxSerializedLargeBlobArray,
ForcePINChange: j.ForcePINChange,
MinPINLength: j.MinPINLength,
FirmwareVersion: j.FirmwareVersion,
MaxCredBlobLength: j.MaxCredBlobLength,
MaxRPIDsForSetMinPINLength: j.MaxRPIDsForSetMinPINLength,
PreferredPlatformUvAttempts: j.PreferredPlatformUvAttempts,
UvModality: j.UvModality,
Certifications: j.Certifications,
RemainingDiscoverableCredentials: j.RemainingDiscoverableCredentials,
VendorPrototypeConfigCommands: j.VendorPrototypeConfigCommands,
}, nil
}
// MDSGetEndpointsRequest is the request sent to the conformance metadata getEndpoints endpoint.
type MDSGetEndpointsRequest struct {
// Endpoint is the URL of the local server endpoint, i.e. https://webauthn.io/
Endpoint string `json:"endpoint"`
}
// MDSGetEndpointsResponse is the response received from a conformance metadata getEndpoints request.
type MDSGetEndpointsResponse struct {
// Status is the status of the response.
Status string `json:"status"`
// Result is an array of urls, each pointing to a MetadataTOCPayload.
Result []string `json:"result"`
}
// DefaultUndesiredAuthenticatorStatuses returns a copy of the defaultUndesiredAuthenticatorStatus slice.
func DefaultUndesiredAuthenticatorStatuses() []AuthenticatorStatus {
undesired := make([]AuthenticatorStatus, len(defaultUndesiredAuthenticatorStatus))
copy(undesired, defaultUndesiredAuthenticatorStatus[:])
return undesired
}
// EntryError represents an [EntryJSON] that failed to parse, along with the error that occurred.
type EntryError struct {
// Error is the parsing error that occurred.
Error error
// EntryJSON is the raw JSON entry that failed to parse.
EntryJSON
}
@@ -0,0 +1,16 @@
package metadata
// PasskeyAuthenticator is a type that represents the schema from the Passkey Developer AAGUID listing.
//
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
type PasskeyAuthenticator map[string]PassKeyAuthenticatorAAGUID
// PassKeyAuthenticatorAAGUID is a type that represents the individual schema entry from the Passkey Developer AAGUID
// listing. Used with [PasskeyAuthenticator].
//
// See: https://github.com/passkeydeveloper/passkey-authenticator-aaguids
type PassKeyAuthenticatorAAGUID struct {
Name string `json:"name"`
IconDark string `json:"icon_dark,omitempty"`
IconLight string `json:"icon_light,omitempty"`
}
@@ -0,0 +1,64 @@
package metadata
import (
"fmt"
"strings"
)
// ValidateStatusReports checks a list of [StatusReport] structs against a list of desired and undesired [AuthenticatorStatus]
// values. If the reports contain all of the desired and none of the undesired status reports then no error is returned
// otherwise an error describing the issue is returned.
//
//nolint:gocyclo
func ValidateStatusReports(reports []StatusReport, desired, undesired []AuthenticatorStatus) (err error) {
if len(desired) == 0 && (len(undesired) == 0 || len(reports) == 0) {
return nil
}
var present, absent []string
if len(undesired) != 0 {
for _, report := range reports {
for _, status := range undesired {
if report.Status == status {
present = append(present, string(status))
continue
}
}
}
}
if len(desired) != 0 {
desired:
for _, status := range desired {
for _, report := range reports {
if report.Status == status {
continue desired
}
}
absent = append(absent, string(status))
}
}
switch {
case len(present) == 0 && len(absent) == 0:
return nil
case len(present) != 0 && len(absent) == 0:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following undesired status reports were present: %s", strings.Join(present, ", ")),
}
case len(present) == 0 && len(absent) != 0:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following desired status reports were absent: %s", strings.Join(absent, ", ")),
}
default:
return &Error{
Type: "invalid_status",
Details: fmt.Sprintf("The following undesired status reports were present: %s; the following desired status reports were absent: %s", strings.Join(present, ", "), strings.Join(absent, ", ")),
}
}
}
+420
View File
@@ -0,0 +1,420 @@
package metadata
import (
"context"
"errors"
"reflect"
"time"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// The Provider is an interface which describes the elements required to satisfy validation of metadata.
type Provider interface {
// GetEntry returns a MDS3 payload entry given a AAGUID.
GetEntry(ctx context.Context, aaguid uuid.UUID) (entry *Entry, err error)
// GetValidateEntry returns true if this provider requires an entry to exist with a AAGUID matching the attestation
// statement during registration.
GetValidateEntry(ctx context.Context) (validate bool)
// GetValidateEntryPermitZeroAAGUID returns true if attestation statements with zerod AAGUID should be permitted
// when considering the result from GetValidateEntry. i.e. if the AAGUID is zeroed, and GetValidateEntry returns
// true, and this implementation returns true, the attestation statement will pass validation.
GetValidateEntryPermitZeroAAGUID(ctx context.Context) (skip bool)
// GetValidateTrustAnchor returns true if trust anchor validation of attestation statements is enforced during
// registration.
GetValidateTrustAnchor(ctx context.Context) (validate bool)
// GetValidateStatus returns true if the status reports for an authenticator should be validated against desired and
// undesired statuses.
GetValidateStatus(ctx context.Context) (validate bool)
// GetValidateAttestationTypes if true will enforce checking that the provided attestation is possible with the
// given authenticator.
GetValidateAttestationTypes(ctx context.Context) (validate bool)
// ValidateStatusReports returns nil if the provided authenticator status reports are desired.
ValidateStatusReports(ctx context.Context, reports []StatusReport) (err error)
}
var (
ErrNotInitialized = errors.New("metadata: not initialized")
)
// PublicKeyCredentialParameters describes a credential type and algorithm pair per the WebAuthn specification. It is
// used in [AuthenticatorGetInfo] to describe the algorithms supported by an authenticator.
//
// See: https://www.w3.org/TR/webauthn-3/#dictdef-publickeycredentialparameters
type PublicKeyCredentialParameters struct {
// Type is the credential type, typically "public-key".
Type string `json:"type"`
// Alg is the COSE algorithm identifier.
Alg webauthncose.COSEAlgorithmIdentifier `json:"alg"`
}
type AuthenticatorAttestationTypes []AuthenticatorAttestationType
func (t AuthenticatorAttestationTypes) HasBasicFull() bool {
for _, a := range t {
if a == BasicFull || a == AttCA {
return true
}
}
return false
}
// AuthenticatorAttestationType represents the attestation type supported by an authenticator. Each constant has a
// case-sensitive string representation used in the authoritative metadata for FIDO authenticators.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authenticator-attestation-types
type AuthenticatorAttestationType string
const (
// BasicFull - Indicates full basic attestation, based on an attestation private key shared among a class of authenticators (i.e. same model). Authenticators must provide its attestation signature during the registration process for the same reason. The attestation trust anchor is shared with FIDO Servers out of band (as part of the Metadata). This sharing process should be done according to [UAFMetadataService].
BasicFull AuthenticatorAttestationType = "basic_full"
// BasicSurrogate - Just syntactically a Basic Attestation. The attestation object self-signed, i.e. it is signed using the UAuth.priv key, i.e. the key corresponding to the UAuth.pub key included in the attestation object. As a consequence it does not provide a cryptographic proof of the security characteristics. But it is the best thing we can do if the authenticator is not able to have an attestation private key.
BasicSurrogate AuthenticatorAttestationType = "basic_surrogate"
// Ecdaa - Indicates use of elliptic curve based direct anonymous attestation as defined in [FIDOEcdaaAlgorithm]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
Ecdaa AuthenticatorAttestationType = "ecdaa"
// AttCA - Indicates PrivacyCA attestation as defined in [TCG-CMCProfile-AIKCertEnroll]. Support for this attestation type is optional at this time. It might be required by FIDO Certification.
AttCA AuthenticatorAttestationType = "attca"
// AnonCA In this case, the authenticator uses an Anonymization CA which dynamically generates per-credential attestation certificates such that the attestation statements presented to Relying Parties do not provide uniquely identifiable information, i.e., that might be used for tracking purposes. The applicable [WebAuthn] attestation formats "fmt" are Google SafetyNet Attestation "android-safetynet", Android Keystore Attestation "android-key", Apple Anonymous Attestation "apple", and Apple Application Attestation "apple-appattest".
AnonCA AuthenticatorAttestationType = "anonca"
// None - Indicates absence of attestation.
None AuthenticatorAttestationType = "none"
)
// KeyScope represents the scope of keys generated and maintained by an authenticator model.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type KeyScope string
const (
// KeyScopeNone is the zero value indicating the field is absent (defaults to PublicKeyCredentialSource).
KeyScopeNone KeyScope = ""
// PublicKeyCredentialSource indicates the authenticator only generates/maintains main FIDO credentials.
PublicKeyCredentialSource KeyScope = "public-key-credential-source" //nolint:gosec
// DeviceSupplementalPublicKeys indicates the authenticator only generates/maintains device-scoped supplemental
// public keys (SPK extension).
DeviceSupplementalPublicKeys KeyScope = "device-spk"
// ProviderSupplementalPublicKeys indicates the authenticator only generates/maintains provider-scoped supplemental
// public keys (SPK extension).
ProviderSupplementalPublicKeys KeyScope = "provider-spk"
)
// MultiDeviceCredentialSupport describes whether an authenticator supports multi-device credentials (passkeys).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-statement-v3.1-ps-20250521.html#sctn-md-keys
type MultiDeviceCredentialSupport string
const (
// MultiDeviceCredentialUnsupported indicates all private keys are designed to stay within the authenticator
// boundary. This is the implicit default when the field is absent.
MultiDeviceCredentialUnsupported MultiDeviceCredentialSupport = "unsupported"
// MultiDeviceCredentialExplicit indicates the authenticator explicitly marks keys as multi-device or single-device
// via the Backup Eligibility flag.
MultiDeviceCredentialExplicit MultiDeviceCredentialSupport = "explicit"
// MultiDeviceCredentialImplicit indicates all private keys relating to Public Key Credential Source may be backed
// up.
MultiDeviceCredentialImplicit MultiDeviceCredentialSupport = "implicit"
)
// AuthenticatorStatus describes the status of an authenticator model as identified by its AAID/AAGUID and potentially
// some additional information (such as a specific attestation key).
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#sctn-authnr-stat
type AuthenticatorStatus string
const (
// NotFidoCertified - This authenticator is not FIDO certified.
NotFidoCertified AuthenticatorStatus = "NOT_FIDO_CERTIFIED"
// FidoCertified - This authenticator has passed FIDO functional certification. This certification scheme is phased out and will be replaced by FIDO_CERTIFIED_L1.
FidoCertified AuthenticatorStatus = "FIDO_CERTIFIED"
// UserVerificationBypass - Indicates that malware is able to bypass the user verification. This means that the authenticator could be used without the user's consent and potentially even without the user's knowledge.
//nolint:gosec
UserVerificationBypass AuthenticatorStatus = "USER_VERIFICATION_BYPASS"
// AttestationKeyCompromise - Indicates that an attestation key for this authenticator is known to be compromised. Additional data should be supplied, including the key identifier and the date of compromise, if known.
AttestationKeyCompromise AuthenticatorStatus = "ATTESTATION_KEY_COMPROMISE"
// UserKeyRemoteCompromise - This authenticator has identified weaknesses that allow registered keys to be compromised and should not be trusted. This would include both, i.e. weak entropy that causes predictable keys to be generated or side channels that allow keys or signatures to be forged, guessed or extracted.
UserKeyRemoteCompromise AuthenticatorStatus = "USER_KEY_REMOTE_COMPROMISE"
// UserKeyPhysicalCompromise - This authenticator has known weaknesses in its key protection mechanism(s) that allow user keys to be extracted by an adversary in physical possession of the device.
UserKeyPhysicalCompromise AuthenticatorStatus = "USER_KEY_PHYSICAL_COMPROMISE"
// UpdateAvailable - A software or firmware update is available for the device. Additional data should be supplied including a URL where users can obtain an update and the date the update was published.
UpdateAvailable AuthenticatorStatus = "UPDATE_AVAILABLE"
// Retired - The authenticator vendor has decided to retire the product, and this authenticator should not be
// accepted any longer.
//
// See: https://fidoalliance.org/specs/mds/fido-metadata-service-v3.1.1-rd-20251016.html#dom-authenticatorstatus-retired
Retired AuthenticatorStatus = "RETIRED"
// Revoked - The FIDO Alliance has determined that this authenticator should not be trusted for any reason, for example if it is known to be a fraudulent product or contain a deliberate backdoor.
Revoked AuthenticatorStatus = "REVOKED"
// SelfAssertionSubmitted - The authenticator vendor has completed and submitted the self-certification checklist to the FIDO Alliance. If this completed checklist is publicly available, the URL will be specified in StatusReportJSON.url.
SelfAssertionSubmitted AuthenticatorStatus = "SELF_ASSERTION_SUBMITTED"
// FidoCertifiedL1 - The authenticator has passed FIDO Authenticator certification at level 1. This level is the more strict successor of FIDO_CERTIFIED.
FidoCertifiedL1 AuthenticatorStatus = "FIDO_CERTIFIED_L1"
// FidoCertifiedL1plus - The authenticator has passed FIDO Authenticator certification at level 1+. This level is the more than level 1.
FidoCertifiedL1plus AuthenticatorStatus = "FIDO_CERTIFIED_L1plus"
// FidoCertifiedL2 - The authenticator has passed FIDO Authenticator certification at level 2. This level is more strict than level 1+.
FidoCertifiedL2 AuthenticatorStatus = "FIDO_CERTIFIED_L2"
// FidoCertifiedL2plus - The authenticator has passed FIDO Authenticator certification at level 2+. This level is more strict than level 2.
FidoCertifiedL2plus AuthenticatorStatus = "FIDO_CERTIFIED_L2plus"
// FidoCertifiedL3 - The authenticator has passed FIDO Authenticator certification at level 3. This level is more strict than level 2+.
FidoCertifiedL3 AuthenticatorStatus = "FIDO_CERTIFIED_L3"
// FidoCertifiedL3plus - The authenticator has passed FIDO Authenticator certification at level 3+. This level is more strict than level 3.
FidoCertifiedL3plus AuthenticatorStatus = "FIDO_CERTIFIED_L3plus"
// FIPS140CertifiedL1 - The authenticator has passed FIPS 140 certification at overall level 1.
FIPS140CertifiedL1 AuthenticatorStatus = "FIPS140_CERTIFIED_L1"
// FIPS140CertifiedL2 - The authenticator has passed FIPS 140 certification at overall level 2.
FIPS140CertifiedL2 AuthenticatorStatus = "FIPS140_CERTIFIED_L2"
// FIPS140CertifiedL3 - The authenticator has passed FIPS 140 certification at overall level 3.
FIPS140CertifiedL3 AuthenticatorStatus = "FIPS140_CERTIFIED_L3"
// FIPS140CertifiedL4 - The authenticator has passed FIPS 140 certification at overall level 4.
FIPS140CertifiedL4 AuthenticatorStatus = "FIPS140_CERTIFIED_L4"
)
// defaultUndesiredAuthenticatorStatus is an array of undesirable authenticator statuses.
var defaultUndesiredAuthenticatorStatus = [...]AuthenticatorStatus{
AttestationKeyCompromise,
UserVerificationBypass,
UserKeyRemoteCompromise,
UserKeyPhysicalCompromise,
Retired,
Revoked,
}
// IsUndesiredAuthenticatorStatus returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatus(status AuthenticatorStatus) bool {
for _, s := range defaultUndesiredAuthenticatorStatus {
if s == status {
return true
}
}
return false
}
// IsUndesiredAuthenticatorStatusSlice returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatusSlice(status AuthenticatorStatus, values []AuthenticatorStatus) bool {
for _, s := range values {
if s == status {
return true
}
}
return false
}
// IsUndesiredAuthenticatorStatusMap returns whether the supplied authenticator status is desirable or not.
func IsUndesiredAuthenticatorStatusMap(status AuthenticatorStatus, values map[AuthenticatorStatus]bool) bool {
_, ok := values[status]
return ok
}
// AuthenticationAlgorithm represents the authentication algorithm supported by an authenticator.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#authentication-algorithms
type AuthenticationAlgorithm string
const (
// ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW is an ECDSA signature on the NIST secp256r1 curve which must have raw R and
// S buffers, encoded in big-endian order.
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_raw"
// ALG_SIGN_SECP256R1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the NIST secp256r1
// curve.
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256r1_ecdsa_sha256_der"
// ALG_SIGN_RSASSA_PSS_SHA256_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA256_RAW AuthenticationAlgorithm = "rsassa_pss_sha256_raw"
// ALG_SIGN_RSASSA_PSS_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
// RSASSA-PSS RFC3447 signature RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA256_DER AuthenticationAlgorithm = "rsassa_pss_sha256_der"
// ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW is an ECDSA signature on the secp256k1 curve which must have raw R and S
// buffers, encoded in big-endian order.
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_raw"
// ALG_SIGN_SECP256K1_ECDSA_SHA256_DER is a DER ITU-X690-2008 encoded ECDSA signature RFC5480 on the secp256k1 curve.
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER AuthenticationAlgorithm = "secp256k1_ecdsa_sha256_der"
// ALG_SIGN_SM2_SM3_RAW is a Chinese SM2 elliptic curve based signature algorithm combined with SM3 hash algorithm
// OSCCA-SM2 OSCCA-SM3.
ALG_SIGN_SM2_SM3_RAW AuthenticationAlgorithm = "sm2_sm3_raw"
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW is the EMSA-PKCS1-v1_5 signature as defined in RFC3447.
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_RAW AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_raw"
// ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER is a DER ITU-X690-2008 encoded OCTET STRING (not BIT STRING!) containing the
// EMSA-PKCS1-v1_5 signature as defined in RFC3447.
ALG_SIGN_RSA_EMSA_PKCS1_SHA256_DER AuthenticationAlgorithm = "rsa_emsa_pkcs1_sha256_der"
// ALG_SIGN_RSASSA_PSS_SHA384_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA384_RAW AuthenticationAlgorithm = "rsassa_pss_sha384_raw"
// ALG_SIGN_RSASSA_PSS_SHA512_RAW is a RSASSA-PSS RFC3447 signature must have raw S buffers, encoded in big-endian
// order RFC4055 RFC4056.
ALG_SIGN_RSASSA_PSS_SHA512_RAW AuthenticationAlgorithm = "rsassa_pss_sha512_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA256(aka RS256) signature must have raw
// S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha256_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA384(aka RS384) signature must have raw S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha384_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA512(aka RS512) signature must have raw
// S buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha512_raw"
// ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW is a RSASSA-PKCS1-v1_5 RFC3447 with SHA1(aka RS1) signature must have raw S
// buffers, encoded in big-endian order RFC8017 RFC4056.
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW AuthenticationAlgorithm = "rsassa_pkcsv15_sha1_raw"
// ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW is an ECDSA signature on the NIST secp384r1 curve with SHA384(aka: ES384)
// which must have raw R and S buffers, encoded in big-endian order.
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW AuthenticationAlgorithm = "secp384r1_ecdsa_sha384_raw"
// ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW is an ECDSA signature on the NIST secp512r1 curve with SHA512(aka: ES512)
// which must have raw R and S buffers, encoded in big-endian order.
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW AuthenticationAlgorithm = "secp521r1_ecdsa_sha512_raw"
// ALG_SIGN_ED25519_EDDSA_SHA512_RAW is an EdDSA signature on the curve 25519, which must have raw R and S buffers,
// encoded in big-endian order.
ALG_SIGN_ED25519_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed25519_eddsa_sha512_raw"
// ALG_SIGN_ED448_EDDSA_SHA512_RAW is an EdDSA signature on the curve Ed448, which must have raw R and S buffers,
// encoded in big-endian order.
ALG_SIGN_ED448_EDDSA_SHA512_RAW AuthenticationAlgorithm = "ed448_eddsa_sha512_raw"
)
// TODO: this goes away after webauthncose.CredentialPublicKey gets implemented.
type algKeyCose struct {
KeyType webauthncose.COSEKeyType
Algorithm webauthncose.COSEAlgorithmIdentifier
Curve webauthncose.COSEEllipticCurve
}
func algKeyCoseDictionary() func(AuthenticationAlgorithm) algKeyCose {
mapping := map[AuthenticationAlgorithm]algKeyCose{
ALG_SIGN_SECP256R1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
ALG_SIGN_SECP256R1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256, Curve: webauthncose.P256},
ALG_SIGN_RSASSA_PSS_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
ALG_SIGN_RSASSA_PSS_SHA256_DER: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS256},
ALG_SIGN_SECP256K1_ECDSA_SHA256_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
ALG_SIGN_SECP256K1_ECDSA_SHA256_DER: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES256K, Curve: webauthncose.Secp256k1},
ALG_SIGN_RSASSA_PSS_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS384},
ALG_SIGN_RSASSA_PSS_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgPS512},
ALG_SIGN_RSASSA_PKCSV15_SHA256_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS256},
ALG_SIGN_RSASSA_PKCSV15_SHA384_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS384},
ALG_SIGN_RSASSA_PKCSV15_SHA512_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS512},
ALG_SIGN_RSASSA_PKCSV15_SHA1_RAW: {KeyType: webauthncose.RSAKey, Algorithm: webauthncose.AlgRS1},
ALG_SIGN_SECP384R1_ECDSA_SHA384_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES384, Curve: webauthncose.P384},
ALG_SIGN_SECP521R1_ECDSA_SHA512_RAW: {KeyType: webauthncose.EllipticKey, Algorithm: webauthncose.AlgES512, Curve: webauthncose.P521},
ALG_SIGN_ED25519_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed25519},
ALG_SIGN_ED448_EDDSA_SHA512_RAW: {KeyType: webauthncose.OctetKey, Algorithm: webauthncose.AlgEdDSA, Curve: webauthncose.Ed448},
}
return func(key AuthenticationAlgorithm) algKeyCose {
return mapping[key]
}
}
func AlgKeyMatch(key algKeyCose, algs []AuthenticationAlgorithm) bool {
for _, alg := range algs {
if reflect.DeepEqual(algKeyCoseDictionary()(alg), key) {
return true
}
}
return false
}
// PublicKeyAlgAndEncoding represents the public key format supported by an authenticator during registration.
//
// See: https://fidoalliance.org/specs/common-specs/fido-registry-v2.2-ps-20220523.html#public-key-representation-formats
type PublicKeyAlgAndEncoding string
const (
// ALG_KEY_ECC_X962_RAW is a raw ANSI X9.62 formatted Elliptic Curve public key.
ALG_KEY_ECC_X962_RAW PublicKeyAlgAndEncoding = "ecc_x962_raw"
// ALG_KEY_ECC_X962_DER is a DER ITU-X690-2008 encoded ANSI X.9.62 formatted SubjectPublicKeyInfo RFC5480 specifying an elliptic curve public key.
ALG_KEY_ECC_X962_DER PublicKeyAlgAndEncoding = "ecc_x962_der"
// ALG_KEY_RSA_2048_RAW is a raw encoded 2048-bit RSA public key RFC3447.
ALG_KEY_RSA_2048_RAW PublicKeyAlgAndEncoding = "rsa_2048_raw"
// ALG_KEY_RSA_2048_DER is a ASN.1 DER [ITU-X690-2008] encoded 2048-bit RSA RFC3447 public key RFC4055.
ALG_KEY_RSA_2048_DER PublicKeyAlgAndEncoding = "rsa_2048_der"
// ALG_KEY_COSE is a COSE_Key format, as defined in Section 7 of RFC8152. This encoding includes its own field for indicating the public key algorithm.
ALG_KEY_COSE PublicKeyAlgAndEncoding = "cose"
)
type Error struct {
// Short name for the type of error that has occurred.
Type string `json:"type"`
// Additional details about the error.
Details string `json:"error"`
// Information to help debug the error.
DevInfo string `json:"debug"`
}
func (e *Error) Error() string {
return e.Details
}
// Clock is an interface used to implement clock functionality in various metadata areas.
type Clock interface {
// Now returns the current time.
Now() time.Time
}
// RealClock is just a real clock.
type RealClock struct{}
// Now returns the current time.
func (RealClock) Now() time.Time {
return time.Now()
}
@@ -0,0 +1,205 @@
package protocol
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// The CredentialAssertionResponse is the raw response returned to the Relying Party from an authenticator when we request a
// credential for login/assertion.
type CredentialAssertionResponse struct {
PublicKeyCredential
AssertionResponse AuthenticatorAssertionResponse `json:"response"`
}
// The ParsedCredentialAssertionData is the parsed [CredentialAssertionResponse] that has been marshalled into a format
// that allows us to verify the client and authenticator data inside the response.
type ParsedCredentialAssertionData struct {
ParsedPublicKeyCredential
Response ParsedAssertionResponse
Raw CredentialAssertionResponse
}
// The AuthenticatorAssertionResponse contains the raw authenticator assertion data and is parsed into
// [ParsedAssertionResponse].
type AuthenticatorAssertionResponse struct {
AuthenticatorResponse
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
Signature URLEncodedBase64 `json:"signature"`
UserHandle URLEncodedBase64 `json:"userHandle,omitempty"`
}
// ParsedAssertionResponse is the parsed form of [AuthenticatorAssertionResponse].
type ParsedAssertionResponse struct {
CollectedClientData CollectedClientData
AuthenticatorData AuthenticatorData
Signature []byte
UserHandle []byte
}
// ParseCredentialRequestResponse parses a login/assertion response from a [*http.Request]. The request body is
// automatically drained and closed after parsing.
//
// This is the standard entry point when using [net/http]. For implementations that don't use [net/http], see
// [ParseCredentialRequestResponseBody] (accepts an [io.Reader]) or [ParseCredentialRequestResponseBytes] (accepts a
// []byte).
func ParseCredentialRequestResponse(response *http.Request) (*ParsedCredentialAssertionData, error) {
if response == nil || response.Body == nil {
return nil, ErrBadRequest.WithDetails("No response given")
}
defer func(request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
_ = request.Body.Close()
}(response)
return ParseCredentialRequestResponseBody(response.Body)
}
// ParseCredentialRequestResponseBody parses a login/assertion response from an [io.Reader]. The caller is responsible
// for closing the reader if applicable.
//
// This is the framework-agnostic variant of [ParseCredentialRequestResponse]. For a [*http.Request] use
// [ParseCredentialRequestResponse] instead. For raw bytes use [ParseCredentialRequestResponseBytes].
func ParseCredentialRequestResponseBody(body io.Reader) (par *ParsedCredentialAssertionData, err error) {
var car CredentialAssertionResponse
if err = decodeBody(body, &car); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
}
return car.Parse()
}
// ParseCredentialRequestResponseBytes parses a login/assertion response from raw bytes.
//
// See also [ParseCredentialRequestResponse] (for [*http.Request]) and [ParseCredentialRequestResponseBody] (for
// [io.Reader]).
func ParseCredentialRequestResponseBytes(data []byte) (par *ParsedCredentialAssertionData, err error) {
var car CredentialAssertionResponse
if err = decodeBytes(data, &car); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Assertion").WithInfo(err.Error()).WithError(err)
}
return car.Parse()
}
// Parse validates and parses the [CredentialAssertionResponse] into a [ParsedCredentialAssertionData]. Most
// implementations should use [ParseCredentialRequestResponse], [ParseCredentialRequestResponseBody], or
// [ParseCredentialRequestResponseBytes] instead of calling this method directly.
func (car CredentialAssertionResponse) Parse() (par *ParsedCredentialAssertionData, err error) {
if car.ID == "" {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID missing")
}
if _, err = base64.RawURLEncoding.DecodeString(car.ID); err != nil {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with ID not base64url encoded").WithError(err)
}
if car.Type != string(PublicKeyCredentialType) {
return nil, ErrBadRequest.WithDetails("CredentialAssertionResponse with bad type")
}
var attachment AuthenticatorAttachment
switch att := AuthenticatorAttachment(car.AuthenticatorAttachment); att {
case Platform, CrossPlatform:
attachment = att
}
par = &ParsedCredentialAssertionData{
ParsedPublicKeyCredential{
ParsedCredential{car.ID, car.Type}, car.RawID, car.ClientExtensionResults, attachment,
},
ParsedAssertionResponse{
Signature: car.AssertionResponse.Signature,
UserHandle: car.AssertionResponse.UserHandle,
},
car,
}
// Step 5. Let JSONtext be the result of running UTF-8 decode on the value of cData.
// We don't call it cData but this is Step 5 in the spec.
if err = json.Unmarshal(car.AssertionResponse.ClientDataJSON, &par.Response.CollectedClientData); err != nil {
return nil, err
}
if err = par.Response.AuthenticatorData.Unmarshal(car.AssertionResponse.AuthenticatorData); err != nil {
return nil, ErrParsingData.WithDetails("Error unmarshalling auth data").WithError(err)
}
return par, nil
}
// Verify the remaining elements of the assertion data by following the steps outlined in the referenced specification
// documentation. It's important to note that the credentialBytes field is the CBOR representation of the credential.
//
// Specification: §7.2 Verifying an Authentication Assertion (https://www.w3.org/TR/webauthn/#sctn-verifying-assertion)
func (p *ParsedCredentialAssertionData) Verify(storedChallenge string, relyingPartyID, appID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin, verifyUser, verifyUserPresence bool, credentialBytes []byte) error {
// Steps 4 through 6 in verifying the assertion data (https://www.w3.org/TR/webauthn/#verifying-assertion) are
// "assertive" steps, i.e. "Let JSONtext be the result of running UTF-8 decode on the value of cData."
// We handle these steps in part as we verify but also beforehand
//
// Handle steps 7 through 10 of assertion by verifying stored data against the Collected Client Data
// returned by the authenticator.
validError := p.Response.CollectedClientData.Verify(storedChallenge, AssertCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify, allowCrossOrigin)
if validError != nil {
return validError
}
// Begin Step 11. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
var appIDHash [32]byte
if appID != "" {
appIDHash = sha256.Sum256([]byte(appID))
}
// Handle steps 11 through 14, verifying the authenticator data.
validError = p.Response.AuthenticatorData.Verify(rpIDHash[:], appIDHash[:], verifyUser, verifyUserPresence)
if validError != nil {
return validError
}
// Step 15. Let hash be the result of computing a hash over the cData using SHA-256.
clientDataHash := sha256.Sum256(p.Raw.AssertionResponse.ClientDataJSON)
// Step 16. Using the credential public key looked up in step 3, verify that sig is
// a valid signature over the binary concatenation of authData and hash.
sigData := append(p.Raw.AssertionResponse.AuthenticatorData, clientDataHash[:]...) //nolint:gocritic // This is intentional.
var (
key any
err error
)
// If the Session Data does not contain the appID extension or it wasn't reported as used by the Client/RP then we
// use the standard CTAP2 public key parser.
if appID == "" {
key, err = webauthncose.ParsePublicKey(credentialBytes)
} else {
key, err = webauthncose.ParseFIDOPublicKey(credentialBytes)
}
if err != nil {
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error parsing the assertion public key: %+v", err)).WithError(err)
}
valid, err := webauthncose.VerifySignature(key, sigData, p.Response.Signature)
if !valid || err != nil {
return ErrAssertionSignature.WithDetails(fmt.Sprintf("Error validating the assertion signature: %+v", err)).WithError(err)
}
return nil
}
@@ -0,0 +1,253 @@
package protocol
import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// AuthenticatorAttestationResponse is the initial unpacked 'response' object received by the relying party. This
// contains the clientDataJSON object, which will be marshalled into [CollectedClientData], and the 'attestationObject',
// which contains information about the authenticator, and the newly minted public key credential. The information in
// both objects are used to verify the authenticity of the ceremony and new credential.
//
// See: https://www.w3.org/TR/webauthn/#typedefdef-publickeycredentialjson
type AuthenticatorAttestationResponse struct {
// The byte slice of clientDataJSON, which becomes CollectedClientData.
AuthenticatorResponse
Transports []string `json:"transports,omitempty"`
AuthenticatorData URLEncodedBase64 `json:"authenticatorData"`
PublicKey URLEncodedBase64 `json:"publicKey"`
PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm"`
// AttestationObject is the byte slice version of attestationObject.
// This attribute contains an attestation object, which is opaque to, and
// cryptographically protected against tampering by, the client. The
// attestation object contains both authenticator data and an attestation
// statement. The former contains the AAGUID, a unique credential ID, and
// the credential public key. The contents of the attestation statement are
// determined by the attestation statement format used by the authenticator.
// It also contains any additional information that the Relying Party's server
// requires to validate the attestation statement, as well as to decode and
// validate the authenticator data along with the JSON-serialized client data.
AttestationObject URLEncodedBase64 `json:"attestationObject"`
}
// ParsedAttestationResponse is the parsed version of [AuthenticatorAttestationResponse].
type ParsedAttestationResponse struct {
CollectedClientData CollectedClientData
AttestationObject AttestationObject
Transports []AuthenticatorTransport
}
// AttestationObject is the raw attestationObject.
//
// Authenticators SHOULD also provide some form of attestation, if possible. If an authenticator does, the basic
// requirement is that the authenticator can produce, for each credential public key, an attestation statement
// verifiable by the WebAuthn Relying Party. Typically, this attestation statement contains a signature by an
// attestation private key over the attested credential public key and a challenge, as well as a certificate or similar
// data providing provenance information for the attestation public key, enabling the Relying Party to make a trust
// decision. However, if an attestation key pair is not available, then the authenticator MAY either perform self
// attestation of the credential public key with the corresponding credential private key, or otherwise perform no
// attestation. All this information is returned by authenticators any time a new public key credential is generated, in
// the overall form of an attestation object.
//
// Specification: §6.5. Attestation (https://www.w3.org/TR/webauthn/#sctn-attestation)
type AttestationObject struct {
// The authenticator data, including the newly created public key. See [AuthenticatorData] for more info.
AuthData AuthenticatorData
// The byteform version of the authenticator data, used in part for signature validation.
RawAuthData []byte `json:"authData"`
// The format of the Attestation data.
Format string `json:"fmt"`
// The attestation statement data sent back if attestation is requested.
AttStatement map[string]any `json:"attStmt,omitempty"`
// Type is the attestation type as conveyed by the authenticator, one of the values defined by
// [metadata.AuthenticatorAttestationType] (i.e. "basic_full", "basic_surrogate", "attca", "anonca", "none").
// It is populated as a side-effect of a successful [AttestationObject.VerifyAttestation]; before that the field
// is empty. This field is excluded from serialization because the attestation object wire format does not carry
// this value; it is derived by the format-specific verifier.
Type string `json:"-"`
}
// NonCompoundAttestationObject is a subset of [AttestationObject] used within compound attestation statements. Each
// sub-statement in a compound attestation has its own format and attestation statement but shares authenticator data
// with the parent.
//
// Specification: §8.9. Compound Attestation Statement Format (https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation)
type NonCompoundAttestationObject struct {
// The format of the Attestation data.
Format string `json:"fmt"`
// The attestation statement data sent back if attestation is requested.
AttStatement map[string]any `json:"attStmt,omitempty"`
}
type attestationFormatValidationHandler func(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error)
var attestationRegistry = make(map[AttestationFormat]attestationFormatValidationHandler)
// RegisterAttestationFormat is a method to register attestation formats with the library. Generally using one of the
// locally registered attestation formats is enough.
func RegisterAttestationFormat(format AttestationFormat, handler attestationFormatValidationHandler) {
attestationRegistry[format] = handler
}
// Parse the values returned in the authenticator response and perform attestation verification
// Step 8. This returns a fully decoded struct with the data put into a format that can be
// used to verify the user and credential that was created.
func (ccr *AuthenticatorAttestationResponse) Parse() (p *ParsedAttestationResponse, err error) {
p = &ParsedAttestationResponse{}
if err = json.Unmarshal(ccr.ClientDataJSON, &p.CollectedClientData); err != nil {
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
}
if err = webauthncbor.Unmarshal(ccr.AttestationObject, &p.AttestationObject); err != nil {
return nil, ErrParsingData.WithInfo(err.Error()).WithError(err)
}
// Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
// structure to obtain the attestation statement format fmt, the authenticator data authData, and
// the attestation statement attStmt.
if err = p.AttestationObject.AuthData.Unmarshal(p.AttestationObject.RawAuthData); err != nil {
return nil, err
}
if !p.AttestationObject.AuthData.Flags.HasAttestedCredentialData() {
return nil, ErrAttestationFormat.WithInfo("Attestation missing attested credential data flag")
}
for _, t := range ccr.Transports {
if transport, ok := internalRemappedAuthenticatorTransport[t]; ok {
p.Transports = append(p.Transports, transport)
} else {
p.Transports = append(p.Transports, AuthenticatorTransport(t))
}
}
return p, nil
}
// Verify performs Steps 13 through 19 of registration verification.
//
// Steps 13 through 15 are verified against the auth data. These steps are identical to 15 through 18 for assertion so we
// handle them with AuthData.
func (a *AttestationObject) Verify(relyingPartyID string, clientDataHash []byte, userVerificationRequired bool, userPresenceRequired bool, mds metadata.Provider, credParams []CredentialParameter) (err error) {
rpIDHash := sha256.Sum256([]byte(relyingPartyID))
// Begin Step 13 through 15. Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the RP.
if err = a.AuthData.Verify(rpIDHash[:], nil, userVerificationRequired, userPresenceRequired); err != nil {
return err
}
// Step 16. Verify that the "alg" parameter in the credential public key in
// authData matches the alg attribute of one of the items in options.pubKeyCredParams.
var pk webauthncose.PublicKeyData
if err = webauthncbor.Unmarshal(a.AuthData.AttData.CredentialPublicKey, &pk); err != nil {
return err
}
found := false
for _, credParam := range credParams {
if int(pk.Algorithm) == int(credParam.Algorithm) {
found = true
break
}
}
if !found {
return ErrAttestationFormat.WithInfo("Credential public key algorithm not supported")
}
return a.VerifyAttestation(clientDataHash, mds)
}
// VerifyAttestation only verifies the attestation object excluding the AuthData values. If you wish to also verify the
// AuthData values you should use [Verify].
func (a *AttestationObject) VerifyAttestation(clientDataHash []byte, mds metadata.Provider) (err error) {
// Step 18. Determine the attestation statement format by performing a
// USASCII case-sensitive match on fmt against the set of supported
// WebAuthn Attestation Statement Format Identifier values. The up-to-date
// list of registered WebAuthn Attestation Statement Format Identifier
// values is maintained in the IANA registry of the same name
// [WebAuthn-Registries] (https://www.w3.org/TR/webauthn/#biblio-webauthn-registries).
//
// Since there is not an active registry yet, we'll check it against our internal
// Supported types.
//
// But first let's make sure attestation is present. If it isn't, we don't need to handle
// any of the following steps.
if AttestationFormat(a.Format) == AttestationFormatNone {
if len(a.AttStatement) != 0 {
return ErrAttestationFormat.WithInfo("Attestation format none with attestation present")
}
a.Type = string(metadata.None)
return nil
}
var (
handler attestationFormatValidationHandler
valid bool
)
if handler, valid = attestationRegistry[AttestationFormat(a.Format)]; !valid {
return ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation format %s is unsupported", a.Format))
}
var (
aaguid uuid.UUID
attestationType string
x5cs []any
)
// Step 19. Verify that attStmt is a correct attestation statement, conveying a valid attestation signature, by using
// the attestation statement format fmts verification procedure given attStmt, authData and the hash of the serialized
// client data computed in step 7.
if attestationType, x5cs, err = handler(*a, clientDataHash, mds); err != nil {
var e *Error
if errors.As(err, &e) {
return e.WithInfo(attestationType)
}
return ErrInvalidAttestation.WithDetails(err.Error()).WithInfo(attestationType).WithError(err)
}
a.Type = attestationType
if len(a.AuthData.AttData.AAGUID) != 0 {
if aaguid, err = uuid.FromBytes(a.AuthData.AttData.AAGUID); err != nil {
return ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
}
}
if mds == nil {
return nil
}
if e := ValidateMetadata(context.Background(), mds, aaguid, a.Type, a.Format, x5cs); e != nil {
return ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
}
return nil
}
@@ -0,0 +1,262 @@
package protocol
import (
"bytes"
"crypto/x509"
"encoding/asn1"
"fmt"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerAndroidKey is the handler for the Android Key Attestation Statement Format.
//
// An Android key attestation statement consists simply of the Android attestation statement, which is a series of DER
// encoded X.509 certificates. See the Android developer documentation. Its syntax is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "android-key",
// attStmt: androidStmtFormat
// )
//
// androidStmtFormat = {
// alg: COSEAlgorithmIdentifier,
// sig: bytes,
// x5c: [ credCert: bytes, * (caCert: bytes) ]
// }
//
// Specification: §8.4. Android Key Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-android-key-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerAndroidKey(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
alg int64
sig []byte
ok bool
)
// Given the verification procedure inputs attStmt, authenticatorData and clientDataHash, the verification procedure is as follows:
// §8.4.1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
// the contained fields.
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
// used to generate the attestation signature.
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
// Get the sig value - A byte string containing the attestation signature.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
// §8.4.2. Verify that sig is a valid signature over the concatenation of authenticatorData and clientDataHash
// using the public key in the first certificate in x5c with the algorithm specified in alg.
var (
x5c []any
certs []*x509.Certificate
)
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
return "", nil, err
}
if len(certs) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
}
credCert := certs[0]
if _, err = attStatementCertChainVerify(certs, attAndroidKeyHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
}
signatureData := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
} else if err = credCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// Verify that the public key in the first certificate in x5c matches the credentialPublicKey in the attestedCredentialData in authenticatorData.
var attPublicKeyData webauthncose.EC2PublicKeyData
if attPublicKeyData, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
return "", nil, err
}
var valid bool
if valid, err = attPublicKeyData.Verify(signatureData, sig); err != nil || !valid {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error parsing public key: %+v", err)).WithError(err)
}
// §8.4.3. Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
// attCert.Extensions.
// As noted in §8.4.1 (https://www.w3.org/TR/webauthn/#key-attstn-cert-requirements) the Android Key Attestation
// certificate's android key attestation certificate extension data is identified by the OID
// "1.3.6.1.4.1.11129.2.1.17".
var attExtBytes []byte
for _, ext := range credCert.Extensions {
if ext.Id.Equal(oidExtensionAndroidKeystore) {
attExtBytes = ext.Value
}
}
if len(attExtBytes) == 0 {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.3.6.1.4.1.11129.2.1.17")
}
decoded := keyDescription{}
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse Android key attestation certificate extensions").WithError(err)
}
// Verify that the attestationChallenge field in the attestation certificate extension data is identical to clientDataHash.
if !bytes.Equal(decoded.AttestationChallenge, clientDataHash) {
return "", nil, ErrAttestationFormat.WithDetails("Attestation challenge not equal to clientDataHash")
}
// The AuthorizationList.allApplications field is not present on either authorization list (softwareEnforced nor teeEnforced), since PublicKeyCredential MUST be scoped to the RP ID.
if decoded.SoftwareEnforced.AllApplications != nil || decoded.TeeEnforced.AllApplications != nil {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains all applications field")
}
// For the following, use only the teeEnforced authorization list if the RP wants to accept only keys from a trusted execution environment, otherwise use the union of teeEnforced and softwareEnforced.
// The value in the AuthorizationList.origin field is equal to KM_ORIGIN_GENERATED (which == 0).
if decoded.SoftwareEnforced.Origin != KM_ORIGIN_GENERATED || decoded.TeeEnforced.Origin != KM_ORIGIN_GENERATED {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with origin not equal KM_ORIGIN_GENERATED")
}
// The value in the AuthorizationList.purpose field is equal to KM_PURPOSE_SIGN (which == 2).
if !contains(decoded.SoftwareEnforced.Purpose, KM_PURPOSE_SIGN) && !contains(decoded.TeeEnforced.Purpose, KM_PURPOSE_SIGN) {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions contains authorization list with purpose not equal KM_PURPOSE_SIGN")
}
return string(metadata.BasicFull), x5c, err
}
func contains(s []int, e int) bool {
for _, a := range s {
if a == e {
return true
}
}
return false
}
type keyDescription struct {
AttestationVersion int
AttestationSecurityLevel asn1.Enumerated
KeymasterVersion int
KeymasterSecurityLevel asn1.Enumerated
AttestationChallenge []byte
UniqueID []byte
SoftwareEnforced authorizationList
TeeEnforced authorizationList
}
type authorizationList struct {
Purpose []int `asn1:"tag:1,explicit,set,optional"`
Algorithm int `asn1:"tag:2,explicit,optional"`
KeySize int `asn1:"tag:3,explicit,optional"`
Digest []int `asn1:"tag:5,explicit,set,optional"`
Padding []int `asn1:"tag:6,explicit,set,optional"`
EcCurve int `asn1:"tag:10,explicit,optional"`
RsaPublicExponent int `asn1:"tag:200,explicit,optional"`
RollbackResistance any `asn1:"tag:303,explicit,optional"`
ActiveDateTime int `asn1:"tag:400,explicit,optional"`
OriginationExpireDateTime int `asn1:"tag:401,explicit,optional"`
UsageExpireDateTime int `asn1:"tag:402,explicit,optional"`
NoAuthRequired any `asn1:"tag:503,explicit,optional"`
UserAuthType int `asn1:"tag:504,explicit,optional"`
AuthTimeout int `asn1:"tag:505,explicit,optional"`
AllowWhileOnBody any `asn1:"tag:506,explicit,optional"`
TrustedUserPresenceRequired any `asn1:"tag:507,explicit,optional"`
TrustedConfirmationRequired any `asn1:"tag:508,explicit,optional"`
UnlockedDeviceRequired any `asn1:"tag:509,explicit,optional"`
AllApplications any `asn1:"tag:600,explicit,optional"`
ApplicationID any `asn1:"tag:601,explicit,optional"`
CreationDateTime int `asn1:"tag:701,explicit,optional"`
Origin int `asn1:"tag:702,explicit,optional"`
RootOfTrust rootOfTrust `asn1:"tag:704,explicit,optional"`
OsVersion int `asn1:"tag:705,explicit,optional"`
OsPatchLevel int `asn1:"tag:706,explicit,optional"`
AttestationApplicationID []byte `asn1:"tag:709,explicit,optional"`
AttestationIDBrand []byte `asn1:"tag:710,explicit,optional"`
AttestationIDDevice []byte `asn1:"tag:711,explicit,optional"`
AttestationIDProduct []byte `asn1:"tag:712,explicit,optional"`
AttestationIDSerial []byte `asn1:"tag:713,explicit,optional"`
AttestationIDImei []byte `asn1:"tag:714,explicit,optional"`
AttestationIDMeid []byte `asn1:"tag:715,explicit,optional"`
AttestationIDManufacturer []byte `asn1:"tag:716,explicit,optional"`
AttestationIDModel []byte `asn1:"tag:717,explicit,optional"`
VendorPatchLevel int `asn1:"tag:718,explicit,optional"`
BootPatchLevel int `asn1:"tag:719,explicit,optional"`
}
type rootOfTrust struct {
verifiedBootKey []byte //nolint:unused
deviceLocked bool //nolint:unused
verifiedBootState verifiedBootState //nolint:unused
verifiedBootHash []byte //nolint:unused
}
type verifiedBootState int
const (
Verified verifiedBootState = iota
SelfSigned
Unverified
Failed
)
const (
// KM_ORIGIN_GENERATED means generated in keymaster. Should not exist outside the TEE.
KM_ORIGIN_GENERATED = iota
// KM_ORIGIN_DERIVED means derived inside keymaster. Likely exists off-device.
KM_ORIGIN_DERIVED
// KM_ORIGIN_IMPORTED means imported into keymaster. Existed as clear text in Android.
KM_ORIGIN_IMPORTED
// KM_ORIGIN_UNKNOWN means keymaster did not record origin. This value can only be seen on keys in a keymaster0
// implementation. The keymaster0 adapter uses this value to document the fact that it is unknown whether the key
// was generated inside or imported into keymaster.
KM_ORIGIN_UNKNOWN
)
const (
// KM_PURPOSE_ENCRYPT is usable with RSA, EC and AES keys.
KM_PURPOSE_ENCRYPT = iota
// KM_PURPOSE_DECRYPT is usable with RSA, EC and AES keys.
KM_PURPOSE_DECRYPT
// KM_PURPOSE_SIGN is usable with RSA, EC and HMAC keys.
KM_PURPOSE_SIGN
// KM_PURPOSE_VERIFY is usable with RSA, EC and HMAC keys.
KM_PURPOSE_VERIFY
// KM_PURPOSE_DERIVE_KEY is usable with EC keys.
KM_PURPOSE_DERIVE_KEY
// KM_PURPOSE_WRAP is usable with wrapped keys.
KM_PURPOSE_WRAP
)
var (
attAndroidKeyHardwareRootsCertPool *x509.CertPool
)
func init() {
RegisterAttestationFormat(AttestationFormatAndroidKey, attestationFormatValidationHandlerAndroidKey)
}
@@ -0,0 +1,105 @@
package protocol
import (
"bytes"
"crypto/sha256"
"crypto/x509"
"encoding/asn1"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// attestationFormatValidationHandlerAppleAnonymous is the handler for the Apple Anonymous Attestation Statement Format.
//
// The syntax of an Apple attestation statement is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "apple",
// attStmt: appleStmtFormat
// )
//
// appleStmtFormat = {
// x5c: [ credCert: bytes, * (caCert: bytes) ]
// }
//
// Specification: §8.8. Apple Anonymous Attestation Statement Format
//
// See : https://www.w3.org/TR/webauthn/#sctn-apple-anonymous-attestation
func attestationFormatValidationHandlerAppleAnonymous(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
// to extract the contained fields.
var (
x5c []any
certs []*x509.Certificate
)
if x5c, certs, err = attStatementParseX5CS(att.AttStatement, stmtX5C); err != nil {
return "", nil, err
}
if len(certs) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("No certificates in x5c")
}
credCert := certs[0]
if _, err = attStatementCertChainVerify(certs, attAppleHardwareRootsCertPool, true, time.Now().Add(time.Hour*8760).UTC()); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Error validating x5c cert chain").WithError(err)
}
// Step 2. Concatenate authenticatorData and clientDataHash to form nonceToHash.
nonceToHash := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
// Step 3. Perform SHA-256 hash of nonceToHash to produce nonce.
nonce := sha256.Sum256(nonceToHash)
// Step 4. Verify that nonce equals the value of the extension with OID 1.2.840.113635.100.8.2 in credCert.
var attExtBytes []byte
for _, ext := range credCert.Extensions {
if ext.Id.Equal(oidExtensionAppleAnonymousAttestation) {
attExtBytes = ext.Value
break
}
}
if len(attExtBytes) == 0 {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate extensions missing 1.2.840.113635.100.8.2")
}
decoded := AppleAnonymousAttestation{}
if _, err = asn1.Unmarshal(attExtBytes, &decoded); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to parse apple attestation certificate extensions").WithError(err)
}
if !bytes.Equal(decoded.Nonce, nonce[:]) {
return "", nil, ErrInvalidAttestation.WithDetails("Attestation certificate does not contain expected nonce")
}
// Step 5. Verify that the credential public key equals the Subject Public Key of credCert.
if _, err = verifyAttestationECDSAPublicKeyMatch(att, credCert); err != nil {
return "", nil, err
}
// Step 6. If successful, return implementation-specific values representing attestation type Anonymization CA and
// attestation trust path x5c.
return string(metadata.AnonCA), x5c, nil
}
// AppleAnonymousAttestation represents the attestation format for Apple, who have not yet published a schema for the
// extension (as of JULY 2021.)
type AppleAnonymousAttestation struct {
Nonce []byte `asn1:"tag:1,explicit"`
}
var (
attAppleHardwareRootsCertPool *x509.CertPool
)
func init() {
RegisterAttestationFormat(AttestationFormatApple, attestationFormatValidationHandlerAppleAnonymous)
}
@@ -0,0 +1,117 @@
package protocol
import (
"context"
"fmt"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
func init() {
RegisterAttestationFormat(AttestationFormatCompound, attestationFormatValidationHandlerCompound)
}
// attestationFormatValidationHandlerCompound is the handler for the Compound Attestation Statement Format.
//
// The syntax of a Compound Attestation statement is defined by the following CDDL:
//
// $$attStmtType //= (
//
// fmt: "compound",
// attStmt: [2* nonCompoundAttStmt]
// )
//
// nonCompoundAttStmt = { $$attStmtType } .within { fmt: text .ne "compound", * any => any }
//
// Specification: §8.9. Compound Attestation Statement Forma
//
// See: https://www.w3.org/TR/webauthn-3/#sctn-compound-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerCompound(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
aaguid uuid.UUID
raw any
ok bool
stmts []any
subStmt map[string]any
attStmts []NonCompoundAttestationObject
)
if len(att.AuthData.AttData.AAGUID) != 0 {
if aaguid, err = uuid.FromBytes(att.AuthData.AttData.AAGUID); err != nil {
return "", nil, ErrInvalidAttestation.WithInfo("Error occurred parsing AAGUID during attestation validation").WithDetails(err.Error()).WithError(err)
}
}
if raw, ok = att.AttStatement[stmtAttStmt]; !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement missing attStmt")
}
if stmts, ok = raw.([]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array")
}
if len(stmts) < 2 {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt isn't an array with at least two other statements")
}
for _, stmt := range stmts {
if subStmt, ok = stmt.(map[string]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound statement attStmt contains one or more items that isn't an object")
}
var attStmt NonCompoundAttestationObject
if attStmt.Format, ok = subStmt[stmtFmt].(string); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have a format")
}
if attStmt.AttStatement, ok = subStmt[stmtAttStmt].(map[string]any); !ok {
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement does not have an attestation statement")
}
switch AttestationFormat(attStmt.Format) {
case AttestationFormatCompound:
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has a format of compound which is not allowed")
case "":
return "", nil, ErrInvalidAttestation.WithDetails("Compound sub-statement has an empty format which is not allowed")
default:
if _, ok = attestationRegistry[AttestationFormat(attStmt.Format)]; !ok {
return "", nil, ErrAttestationFormat.WithInfo(fmt.Sprintf("Attestation sub-statement format %s is unsupported", attStmt.Format))
}
attStmts = append(attStmts, attStmt)
}
}
for _, attStmt := range attStmts {
object := AttestationObject{
Format: attStmt.Format,
AttStatement: attStmt.AttStatement,
AuthData: att.AuthData,
RawAuthData: att.RawAuthData,
}
var (
cx5cs []any
subAttType string
)
if subAttType, cx5cs, err = attestationRegistry[AttestationFormat(object.Format)](object, clientDataHash, mds); err != nil {
return "", nil, err
}
if mds == nil {
continue
}
if e := ValidateMetadata(context.Background(), mds, aaguid, subAttType, object.Format, cx5cs); e != nil {
return "", nil, ErrInvalidAttestation.WithInfo(fmt.Sprintf("Error occurred validating metadata during attestation validation: %+v", e)).WithDetails(e.DevInfo).WithError(e)
}
}
return stmtTypNone, nil, nil
}
@@ -0,0 +1,155 @@
package protocol
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/x509"
"fmt"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerFIDOU2F is the handler for the FIDO U2F Attestation Statement Format.
//
// The syntax of a FIDO U2F attestation statement is defined as follows:
//
// $$attStmtType //= (
//
// fmt: "fido-u2f",
// attStmt: u2fStmtFormat
// )
//
// u2fStmtFormat = {
// x5c: [ attestnCert: bytes ],
// sig: bytes
// }
//
// Specification: §8.6. FIDO U2F Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation
func attestationFormatValidationHandlerFIDOU2F(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Signing procedure. Non-normative verification procedure of expected requirement.
// If the credential public key of the attested credential is not of algorithm -7 ("ES256"), stop and return an error.
var key webauthncose.EC2PublicKeyData
if err = webauthncbor.Unmarshal(att.AuthData.AttData.CredentialPublicKey, &key); err != nil {
return "", nil, ErrAttestationCertificate.WithDetails("Error parsing public key").WithError(err)
}
if webauthncose.COSEAlgorithmIdentifier(key.Algorithm) != webauthncose.AlgES256 {
return "", nil, ErrUnsupportedAlgorithm.WithDetails("Non-ES256 Public Key algorithm used")
}
var (
sig []byte
raw []byte
x5c []any
ok bool
)
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it
// to extract the contained fields.
// Check for "x5c" which is a single element array containing the attestation certificate in X.509 format.
if x5c, ok = att.AttStatement[stmtX5C].([]any); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Missing properly formatted x5c data")
}
// Note: Packed Attestation, FIDO U2F Attestation, and Assertion Signatures require ASN.1 DER sig values, but it is
// RECOMMENDED that any new attestation formats defined not use ASN.1 encodings, but instead represent signatures as
// equivalent fixed-length byte arrays without internal structure, using the same representations as used by COSE
// signatures as defined in [RFC9053](https://www.rfc-editor.org/rfc/rfc9053.html) and
// [RFC8230](https://www.rfc-editor.org/rfc/rfc8230.html).
// This is described in §6.5.5 https://www.w3.org/TR/webauthn-3/#sctn-signature-attestation-types.
// Check for "sig" which is The attestation signature. The signature was calculated over the (raw) U2F
// registration response message https://www.w3.org/TR/webauthn/#biblio-fido-u2f-message-formats]
// received by the client from the authenticator.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Missing sig data")
}
// Step 2.
// 1. Check that x5c has exactly one element and let attCert be that element.
// 2. Let certificate public key be the public key conveyed by attCert.
// 3. If certificate public key is not an Elliptic Curve (EC) public key over the P-256 curve, terminate this
// algorithm and return an appropriate error.
// Step 2.1.
if len(x5c) != 1 {
return "", nil, ErrAttestationFormat.WithDetails("x5c must contain exactly one element")
}
// Step 2.2.
if raw, ok = x5c[0].([]byte); !ok {
return "", nil, ErrAttestationFormat.WithDetails("Error decoding ASN.1 data from x5c")
}
attCert, err := x509.ParseCertificate(raw)
if err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1 data into certificate").WithError(err)
}
// Step 2.3.
if attCert.PublicKeyAlgorithm != x509.ECDSA {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate public key algorithm is not ECDSA")
}
// Step 3. Extract the claimed rpIdHash from authenticatorData, and the claimed credentialId and credentialPublicKey
// from authenticatorData.attestedCredentialData.
rpIdHash := att.AuthData.RPIDHash
credentialID := att.AuthData.AttData.CredentialID
// Step 4. Convert the COSE_KEY formatted credentialPublicKey (see Section 7 of RFC8152 [https://www.w3.org/TR/webauthn/#biblio-rfc8152])
// to Raw ANSI X9.62 public key format (see ALG_KEY_ECC_X962_RAW in Section 3.6.2 Public Key
// Representation Formats of
// [FIDO-Registry](https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-registry-v2.0-id-20180227.html#public-key-representation-formats)).
// Let x be the value corresponding to the "-2" key (representing x coordinate) in credentialPublicKey, and confirm
// its size to be of 32 bytes. If size differs or "-2" key is not found, terminate this algorithm and return an
// appropriate error.
// Let y be the value corresponding to the "-3" key (representing y coordinate) in credentialPublicKey, and confirm
// its size to be of 32 bytes. If size differs or "-3" key is not found, terminate this algorithm and return an
// appropriate error.
credentialPublicKey, ok := attCert.PublicKey.(*ecdsa.PublicKey)
if !ok || credentialPublicKey.Curve != elliptic.P256() {
return "", nil, ErrAttestationFormat.WithDetails("Attestation certificate does not contain a P-256 ECDSA public key")
}
if len(key.XCoord) != 32 || len(key.YCoord) != 32 {
return "", nil, ErrAttestation.WithDetails("X or Y Coordinate for key is invalid length")
}
// Let publicKeyU2F be the concatenation 0x04 || x || y.
publicKeyU2F := bytes.NewBuffer([]byte{0x04})
publicKeyU2F.Write(key.XCoord)
publicKeyU2F.Write(key.YCoord)
// Step 5. Let verificationData be the concatenation of (0x00 || rpIdHash || clientDataHash || credentialId || publicKeyU2F)
// (see Section 4.3 of [FIDO-U2F-Message-Formats](https://fidoalliance.org/specs/fido-u2f-v1.1-id-20160915/fido-u2f-raw-message-formats-v1.1-id-20160915.html#registration-response-message-success)).
verificationData := bytes.NewBuffer([]byte{0x00})
verificationData.Write(rpIdHash)
verificationData.Write(clientDataHash)
verificationData.Write(credentialID)
verificationData.Write(publicKeyU2F.Bytes())
// Step 6. Verify the sig using verificationData and the certificate public key per section 4.1.4 of [SEC1] with
// SHA-256 as the hash function used in step two.
if err = attCert.CheckSignature(x509.ECDSAWithSHA256, verificationData.Bytes(), sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// TODO: Step 7. Optionally, inspect x5c and consult externally provided knowledge to determine whether attStmt
// conveys a Basic or AttCA attestation.
// Step 8. If successful, return implementation-specific values representing attestation type Basic, AttCA or
// uncertainty, and attestation trust path x5c.
return string(metadata.BasicFull), x5c, nil
}
func init() {
RegisterAttestationFormat(AttestationFormatFIDOUniversalSecondFactor, attestationFormatValidationHandlerFIDOU2F)
}
@@ -0,0 +1,254 @@
package protocol
import (
"bytes"
"crypto/x509"
"encoding/asn1"
"fmt"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
func init() {
RegisterAttestationFormat(AttestationFormatPacked, attestationFormatValidationHandlerPacked)
}
// attestationFormatValidationHandlerPacked is the handler for the Packed Attestation Statement Format.
//
// The syntax of a Packed Attestation statement is defined by the following CDDL:
//
// $$attStmtType //= (
//
// fmt: "packed",
// attStmt: packedStmtFormat
// )
//
// packedStmtFormat = {
// alg: COSEAlgorithmIdentifier,
// sig: bytes,
// x5c: [ attestnCert: bytes, * (caCert: bytes) ]
// } //
// {
// alg: COSEAlgorithmIdentifier
// sig: bytes,
// }
//
// Specification: §8.2. Packed Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-packed-attestation
func attestationFormatValidationHandlerPacked(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
var (
alg int64
sig []byte
x5c []any
ok bool
)
// Step 1. Verify that attStmt is valid CBOR conforming to the syntax defined
// above and perform CBOR decoding on it to extract the contained fields.
// Get the alg value - A COSEAlgorithmIdentifier containing the identifier of the algorithm
// used to generate the attestation signature.
if alg, ok = att.AttStatement[stmtAlgorithm].(int64); !ok {
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
// Get the sig value - A byte string containing the attestation signature.
if sig, ok = att.AttStatement[stmtSignature].([]byte); !ok {
return string(AttestationFormatPacked), nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
// Step 2. If x5c is present, this indicates that the attestation type is not ECDAA.
if x5c, ok = att.AttStatement[stmtX5C].([]any); ok {
// Handle Basic Attestation steps for the x509 Certificate.
return handleBasicAttestation(sig, clientDataHash, att.RawAuthData, att.AuthData.AttData.AAGUID, alg, x5c, mds)
}
// Step 3. If ecdaaKeyId is present, then the attestation type is ECDAA.
// Also make sure the we did not have an x509.
ecdaaKeyID, ecdaaKeyPresent := att.AttStatement[stmtECDAAKID].([]byte)
if ecdaaKeyPresent {
// Handle ECDAA Attestation steps for the x509 Certificate.
return handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID, mds)
}
// Step 4. If neither x5c nor ecdaaKeyId is present, self attestation is in use.
return handleSelfAttestation(alg, att.AuthData.AttData.CredentialPublicKey, att.RawAuthData, clientDataHash, sig, mds)
}
// Handle the attestation steps laid out in the basic format.
//
//nolint:gocyclo
func handleBasicAttestation(sig, clientDataHash, authData, aaguid []byte, alg int64, x5c []any, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
// Step 2.1. Verify that sig is a valid signature over the concatenation of authenticatorData
// and clientDataHash using the attestation public key in attestnCert with the algorithm specified in alg.
var attestnCert *x509.Certificate
for i, raw := range x5c {
rawByes, ok := raw.([]byte)
if !ok {
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
cert, err := x509.ParseCertificate(rawByes)
if err != nil {
return "", x5c, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing certificate from ASN.1 data: %+v", err)).WithError(err)
}
if cert.NotBefore.After(time.Now()) || cert.NotAfter.Before(time.Now()) {
return "", x5c, ErrAttestationFormat.WithDetails("Cert in chain is either no longer valid or not yet valid")
}
if i == 0 {
attestnCert = cert
}
}
if attestnCert == nil {
return "", x5c, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
signatureData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
if sigAlg := webauthncose.SigAlgFromCOSEAlg(webauthncose.COSEAlgorithmIdentifier(alg)); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", alg))
} else if err = attestnCert.CheckSignature(sigAlg, signatureData, sig); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Signature validation error: %+v", err)).WithError(err)
}
// Step 2.2 Verify that attestnCert meets the requirements in §8.2.1 Packed attestation statement certificate requirements.
// §8.2.1 can be found here https://www.w3.org/TR/webauthn/#packed-attestation-cert-requirements
// Step 2.2.1 (from §8.2.1) Version MUST be set to 3 (which is indicated by an ASN.1 INTEGER with value 2).
if attestnCert.Version != 3 {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate is incorrect version")
}
// Step 2.2.2 (from §8.2.1) Subject field MUST be set to:
// Subject-C
// ISO 3166 code specifying the country where the Authenticator vendor is incorporated (PrintableString).
if len(attestnCert.Subject.Country) != 1 || !isISO3166Alpha2(attestnCert.Subject.Country[0]) {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Country Code is invalid")
}
// Subject-O
// Legal name of the Authenticator vendor (UTF8String).
subjectString := strings.Join(attestnCert.Subject.Organization, "")
if subjectString == "" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organization is invalid")
}
// Subject-OU
// Literal string “Authenticator Attestation” (UTF8String).
subjectString = strings.Join(attestnCert.Subject.OrganizationalUnit, " ")
if subjectString != "Authenticator Attestation" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Organizational Unit is invalid")
}
// Subject-CN
// A UTF8String of the vendors choosing.
subjectString = attestnCert.Subject.CommonName
if subjectString == "" {
return "", x5c, ErrAttestationCertificate.WithDetails("Attestation Certificate Common Name not set")
}
// Step 2.2.3 (from §8.2.1) If the related attestation root certificate is used for multiple authenticator models,
// the Extension OID 1.3.6.1.4.1.45724.1.1.4 (id-fido-gen-ce-aaguid) MUST be present, containing the
// AAGUID as a 16-byte OCTET STRING. The extension MUST NOT be marked as critical.
var foundAAGUID []byte
for _, extension := range attestnCert.Extensions {
if extension.Id.Equal(oidFIDOGenCeAAGUID) {
if extension.Critical {
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate FIDO extension marked as critical")
}
foundAAGUID = extension.Value
}
}
// We validate the AAGUID as mentioned above
// This is not well defined in§8.2.1 but mentioned in step 2.3: we validate the AAGUID if it is present within the certificate
// and make sure it matches the auth data AAGUID
// Note that an X.509 Extension encodes the DER-encoding of the value in an OCTET STRING. Thus, the
// AAGUID MUST be wrapped in two OCTET STRINGS to be valid.
if len(foundAAGUID) > 0 {
var unMarshalledAAGUID []byte
if _, err = asn1.Unmarshal(foundAAGUID, &unMarshalledAAGUID); err != nil {
return "", x5c, ErrInvalidAttestation.WithDetails("Error unmarshalling AAGUID from certificate")
}
if !bytes.Equal(aaguid, unMarshalledAAGUID) {
return "", x5c, ErrInvalidAttestation.WithDetails("Certificate AAGUID does not match Auth Data certificate")
}
}
// Step 2.2.4 The Basic Constraints extension MUST have the CA component set to false.
if attestnCert.IsCA {
return "", x5c, ErrInvalidAttestation.WithDetails("Attestation certificate's Basic Constraints marked as CA")
}
// Note for 2.2.5 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL
// Distribution Point extension [RFC5280](https://www.w3.org/TR/webauthn/#biblio-rfc5280) are
// both OPTIONAL as the status of many attestation certificates is available through authenticator
// metadata services. See, for example, the FIDO Metadata Service
// [FIDOMetadataService] (https://www.w3.org/TR/webauthn/#biblio-fidometadataservice)
// Step 2.4 If successful, return attestation type Basic and attestation trust path x5c.
// We don't handle trust paths yet but we're done.
return string(metadata.BasicFull), x5c, nil
}
func handleECDAAAttestation(sig, clientDataHash, ecdaaKeyID []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
return "Packed (ECDAA)", nil, ErrNotSpecImplemented
}
func handleSelfAttestation(alg int64, pubKey, authData, clientDataHash, sig []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
verificationData := append(authData, clientDataHash...) //nolint:gocritic // This is intentional.
var (
key any
valid bool
)
if key, err = webauthncose.ParsePublicKey(pubKey); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the public key: %+v", err))
}
// §4.1 Validate that alg matches the algorithm of the credentialPublicKey in authenticatorData.
switch k := key.(type) {
case webauthncose.OKPPublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
case webauthncose.EC2PublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
case webauthncose.RSAPublicKeyData:
err = verifyKeyAlgorithm(k.Algorithm, alg)
default:
return "", nil, ErrInvalidAttestation.WithDetails("Error verifying the public key data")
}
if err != nil {
return "", nil, err
}
// §4.2 Verify that sig is a valid signature over the concatenation of authenticatorData and
// clientDataHash using the credential public key with alg.
if valid, err = webauthncose.VerifySignature(key, verificationData, sig); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error verifying the signature: %+v", err)).WithError(err)
} else if !valid {
return "", nil, ErrInvalidAttestation.WithDetails("Unable to verify signature")
}
return string(metadata.BasicSurrogate), nil, err
}
func verifyKeyAlgorithm(keyAlgorithm, attestedAlgorithm int64) error {
if keyAlgorithm != attestedAlgorithm {
return ErrInvalidAttestation.WithDetails("Public key algorithm does not equal att statement algorithm")
}
return nil
}
@@ -0,0 +1,196 @@
package protocol
import (
"bytes"
"context"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"time"
"github.com/go-viper/mapstructure/v2"
"github.com/golang-jwt/jwt/v5"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// attestationFormatValidationHandlerAndroidSafetyNet is the handler for the Android SafetyNet Attestation Statement
// Format.
//
// When the authenticator is a platform authenticator on certain Android platforms, the attestation statement may be
// based on the SafetyNet API. In this case the authenticator data is completely controlled by the caller of the
// SafetyNet API (typically an application running on the Android platform) and the attestation statement provides some
// statements about the health of the platform and the identity of the calling application (see SafetyNet Documentation
// for more details).
//
// The syntax of an Android Attestation statement is defined as follows:
//
// $$attStmtType //= (
// fmt: "android-safetynet",
// attStmt: safetynetStmtFormat
// )
//
// safetynetStmtFormat = {
// ver: text,
// response: bytes
// }
//
// Specification: §8.5. Android SafetyNet Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-android-safetynet-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerAndroidSafetyNet(att AttestationObject, clientDataHash []byte, mds metadata.Provider) (attestationType string, x5cs []any, err error) {
// The syntax of an Android Attestation statement is defined as follows:
// $$attStmtType //= (
// fmt: "android-safetynet",
// attStmt: safetynetStmtFormat
// )
// safetynetStmtFormat = {
// ver: text,
// response: bytes
// }
// §8.5.1 Verify that attStmt is valid CBOR conforming to the syntax defined above and perform CBOR decoding on it to extract
// the contained fields.
// We have done this
// §8.5.2 Verify that response is a valid SafetyNet response of version ver.
version, present := att.AttStatement[stmtVersion].(string)
if !present {
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the version of SafetyNet")
}
if version == "" {
return "", nil, ErrAttestationFormat.WithDetails("Not a proper version for SafetyNet")
}
// TODO: provide user the ability to designate their supported versions.
response, present := att.AttStatement["response"].([]byte)
if !present {
return "", nil, ErrAttestationFormat.WithDetails("Unable to find the SafetyNet response")
}
var token *jwt.Token
if token, err = jwt.Parse(string(response), keyFuncSafetyNetJWT, jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()})); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// marshall the JWT payload into the safetynet response json.
var safetyNetResponse SafetyNetResponse
if err = mapstructure.Decode(token.Claims, &safetyNetResponse); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Error parsing the SafetyNet response: %+v", err)).WithError(err)
}
// §8.5.3 Verify that the nonce in the response is identical to the Base64 encoding of the SHA-256 hash of the concatenation
// of authenticatorData and clientDataHash.
nonceBuffer := sha256.Sum256(append(att.RawAuthData, clientDataHash...))
nonceBytes, err := base64.StdEncoding.DecodeString(safetyNetResponse.Nonce)
if !bytes.Equal(nonceBuffer[:], nonceBytes) || err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Invalid nonce for in SafetyNet response").WithError(err)
}
// §8.5.4 Let attestationCert be the attestation certificate (https://www.w3.org/TR/webauthn/#attestation-certificate)
certChain, ok := token.Header[stmtX5C].([]any)
if !ok || len(certChain) == 0 {
return "", nil, ErrInvalidAttestation.WithDetails("Error getting certificate from JWT header x5c")
}
first, ok := certChain[0].(string)
if !ok || first == "" {
return "", nil, ErrInvalidAttestation.WithDetails("Error getting first certificate from JWT header x5c")
}
l := make([]byte, base64.StdEncoding.DecodedLen(len(first)))
n, err := base64.StdEncoding.Decode(l, []byte(first))
if err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
attestationCert, err := x509.ParseCertificate(l[:n])
if err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// §8.5.5 Verify that attestationCert is issued to the hostname "attest.android.com".
if err = attestationCert.VerifyHostname(attStatementAndroidSafetyNetHostname); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error finding cert issued to correct hostname: %+v", err)).WithError(err)
}
// §8.5.6 Verify that the ctsProfileMatch attribute in the payload of response is true.
if !safetyNetResponse.CtsProfileMatch {
return "", nil, ErrInvalidAttestation.WithDetails("ctsProfileMatch attribute of the JWT payload is false")
}
if t := time.Unix(safetyNetResponse.TimestampMs/1000, 0); t.After(time.Now()) {
// Zero tolerance for post-dated timestamps.
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp after current time")
} else if t.Before(time.Now().Add(-time.Minute)) {
// Small tolerance for pre-dated timestamps.
if mds != nil && mds.GetValidateEntry(context.Background()) {
return "", nil, ErrInvalidAttestation.WithDetails("SafetyNet response with timestamp before one minute ago")
}
}
// §8.5.7 If successful, return implementation-specific values representing attestation type Basic and attestation
// trust path attestationCert.
return string(metadata.BasicFull), nil, nil
}
func keyFuncSafetyNetJWT(token *jwt.Token) (key any, err error) {
var (
ok bool
raw any
chain []any
first string
der []byte
cert *x509.Certificate
)
if raw, ok = token.Header[stmtX5C]; !ok {
return nil, fmt.Errorf("jwt header missing x5c")
}
if chain, ok = raw.([]any); !ok || len(chain) == 0 {
return nil, fmt.Errorf("jwt header x5c is not a non-empty array")
}
if first, ok = chain[0].(string); !ok || first == "" {
return nil, fmt.Errorf("jwt header x5c[0] not a base64 string")
}
if der, err = base64.StdEncoding.DecodeString(first); err != nil {
return nil, fmt.Errorf("decode x5c leaf: %w", err)
}
if cert, err = x509.ParseCertificate(der); err != nil {
if cert != nil {
return cert.PublicKey, fmt.Errorf("parse x5c leaf: %w", err)
}
return nil, fmt.Errorf("parse x5c leaf: %w", err)
}
return cert.PublicKey, nil
}
type SafetyNetResponse struct {
Nonce string `json:"nonce"`
TimestampMs int64 `json:"timestampMs"`
ApkPackageName string `json:"apkPackageName"`
ApkDigestSha256 string `json:"apkDigestSha256"`
CtsProfileMatch bool `json:"ctsProfileMatch"`
ApkCertificateDigestSha256 []any `json:"apkCertificateDigestSha256"`
BasicIntegrity bool `json:"basicIntegrity"`
}
func init() {
RegisterAttestationFormat(AttestationFormatAndroidSafetyNet, attestationFormatValidationHandlerAndroidSafetyNet)
}
@@ -0,0 +1,635 @@
package protocol
import (
"bytes"
"crypto"
"crypto/subtle"
"crypto/x509"
"crypto/x509/pkix"
"encoding/asn1"
"encoding/binary"
"errors"
"fmt"
"strings"
"github.com/google/go-tpm/tpm2"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// attestationFormatValidationHandlerTPM is the handler for the TPM Attestation Statement Format.
//
// The syntax of a TPM Attestation statement is as follows:
//
// $$attStmtType // = (
//
// fmt: "tpm",
// attStmt: tpmStmtFormat
// )
//
// tpmStmtFormat = {
// ver: "2.0",
// (
// alg: COSEAlgorithmIdentifier,
// x5c: [ aikCert: bytes, * (caCert: bytes) ]
// )
// sig: bytes,
// certInfo: bytes,
// pubArea: bytes
// }
//
// Specification: §8.3. TPM Attestation Statement Format
//
// See: https://www.w3.org/TR/webauthn/#sctn-tpm-attestation
//
//nolint:gocyclo
func attestationFormatValidationHandlerTPM(att AttestationObject, clientDataHash []byte, _ metadata.Provider) (attestationType string, x5cs []any, err error) {
var statement *tpm2AttStatement
if statement, err = newTPM2AttStatement(att.AttStatement); err != nil {
return "", nil, err
}
if statement.HasECDAAKeyID || statement.HasValidECDAAKeyID {
return "", nil, ErrNotImplemented
}
if !statement.HasX5C || !statement.HasValidX5C {
return "", nil, ErrNotImplemented
}
if statement.Version != versionTPM20 {
return "", nil, ErrAttestationFormat.WithDetails("WebAuthn only supports TPM 2.0 currently")
}
var (
pubArea *tpm2.TPMTPublic
key any
)
if pubArea, err = tpm2.Unmarshal[tpm2.TPMTPublic](statement.PubArea); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Unable to decode TPMT_PUBLIC in attestation statement").WithError(err)
}
if key, err = webauthncose.ParsePublicKey(att.AuthData.AttData.CredentialPublicKey); err != nil {
return "", nil, err
}
switch k := key.(type) {
case webauthncose.EC2PublicKeyData:
var (
params *tpm2.TPMSECCParms
point *tpm2.TPMSECCPoint
)
if params, err = pubArea.Parameters.ECCDetail(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if point, err = pubArea.Unique.ECC(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if params.CurveID != k.TPMCurveID() {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
if !bytes.Equal(point.X.Buffer, k.XCoord) || !bytes.Equal(point.Y.Buffer, k.YCoord) {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between ECCParameters in pubArea and credentialPublicKey")
}
case webauthncose.RSAPublicKeyData:
var (
params *tpm2.TPMSRSAParms
modulus *tpm2.TPM2BPublicKeyRSA
)
if params, err = pubArea.Parameters.RSADetail(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
if modulus, err = pubArea.Unique.RSA(); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
if !bytes.Equal(modulus.Buffer, k.Modulus) {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
exp := uint32(k.Exponent[0]) + uint32(k.Exponent[1])<<8 + uint32(k.Exponent[2])<<16
if tpm2Exponent(params) != exp {
return "", nil, ErrAttestationFormat.WithDetails("Mismatch between RSAParameters in pubArea and credentialPublicKey")
}
default:
return "", nil, ErrUnsupportedKey
}
// Concatenate authenticatorData and clientDataHash to form attToBeSigned.
attToBeSigned := append(att.RawAuthData, clientDataHash...) //nolint:gocritic // This is intentional.
var certInfo *tpm2.TPMSAttest
// Validate that certInfo is valid:
// 1/4 Verify that magic is set to TPM_GENERATED_VALUE, handled here.
if certInfo, err = tpm2.Unmarshal[tpm2.TPMSAttest](statement.CertInfo); err != nil {
return "", nil, err
}
if err = certInfo.Magic.Check(); err != nil {
return "", nil, ErrInvalidAttestation.WithDetails("Magic is not set to TPM_GENERATED_VALUE")
}
// 2/4 Verify that type is set to TPM_ST_ATTEST_CERTIFY.
if certInfo.Type != tpm2.TPMSTAttestCertify {
return "", nil, ErrAttestationFormat.WithDetails("Type is not set to TPM_ST_ATTEST_CERTIFY")
}
// 3/4 Verify that extraData is set to the hash of attToBeSigned using the hash algorithm employed in "alg".
coseAlg := webauthncose.COSEAlgorithmIdentifier(statement.Algorithm)
h := webauthncose.HasherFromCOSEAlg(coseAlg)
h.Write(attToBeSigned)
if !bytes.Equal(certInfo.ExtraData.Buffer, h.Sum(nil)) {
return "", nil, ErrAttestationFormat.WithDetails("ExtraData is not set to hash of attToBeSigned")
}
// Note that the remaining fields in the "Standard Attestation Structure"
// [TPMv2-Part1] section 31.2, i.e., qualifiedSigner, clockInfo and firmwareVersion
// are ignored. These fields MAY be used as an input to risk engines.
var (
aikCert *x509.Certificate
raw []byte
ok bool
)
if len(statement.X5C) == 0 {
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
// In this case:
// Verify the sig is a valid signature over certInfo using the attestation public key in aikCert with the algorithm specified in alg.
if raw, ok = statement.X5C[0].([]byte); !ok {
return "", nil, ErrAttestation.WithDetails("Error getting certificate from x5c cert chain")
}
if aikCert, err = x509.ParseCertificate(raw); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("Error parsing certificate from ASN.1")
}
if sigAlg := webauthncose.SigAlgFromCOSEAlg(coseAlg); sigAlg == x509.UnknownSignatureAlgorithm {
return "", nil, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Unsupported COSE alg: %d", statement.Algorithm))
} else if err = aikCert.CheckSignature(sigAlg, statement.CertInfo, statement.Signature); err != nil {
return "", nil, ErrAttestationFormat.WithDetails(fmt.Sprintf("Signature validation error: %+v", err))
}
// Verify that aikCert meets the requirements in §8.3.1 TPM Attestation Statement Certificate Requirements.
// 1/6 Version MUST be set to 3.
if aikCert.Version != 3 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate version must be 3")
}
// 2/6 Subject field MUST be set to empty.
if aikCert.Subject.String() != "" {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate subject must be empty")
}
var (
manufacturer, model, version string
ekuValid = false
eku []asn1.ObjectIdentifier
constraints tpmBasicConstraints
rest []byte
)
for _, ext := range aikCert.Extensions {
switch {
case ext.Id.Equal(oidExtensionSubjectAltName):
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
return "", nil, err
}
case ext.Id.Equal(oidExtensionExtendedKeyUsage):
if rest, err = asn1.Unmarshal(ext.Value, &eku); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage malformed")
} else if len(rest) != 0 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage contains extra data")
}
found := false
for _, oid := range eku {
if oid.Equal(oidTCGKpAIKCertificate) {
found = true
break
}
}
if !found {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate extended key usage missing 2.23.133.8.3")
}
ekuValid = true
case ext.Id.Equal(oidExtensionBasicConstraints):
if rest, err = asn1.Unmarshal(ext.Value, &constraints); err != nil {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints malformed")
} else if len(rest) != 0 {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints contains extra data")
}
}
}
// 3/6 The Subject Alternative Name extension MUST be set as defined in [TPMv2-EK-Profile] section 3.2.9.
if manufacturer == "" || model == "" || version == "" {
return "", nil, ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate")
}
if !isValidTPMManufacturer(manufacturer) {
return "", nil, ErrAttestationFormat.WithDetails("Invalid TPM manufacturer")
}
// 4/6 The Extended Key Usage extension MUST contain the "joint-iso-itu-t(2) internationalorganizations(23) 133 tcg-kp(8) tcg-kp-AIKCertificate(3)" OID.
if !ekuValid {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate missing EKU")
}
// 6/6 An Authority Information Access (AIA) extension with entry id-ad-ocsp and a CRL Distribution Point
// extension [RFC5280] are both OPTIONAL as the status of many attestation certificates is available
// through metadata services. See, for example, the FIDO Metadata Service.
if constraints.IsCA {
return "", nil, ErrAttestationFormat.WithDetails("AIK certificate basic constraints missing or CA is true")
}
// 4/4 Verify that attested contains a TPMS_CERTIFY_INFO structure as specified in
// [TPMv2-Part2] section 10.12.3, whose name field contains a valid Name for pubArea,
// as computed using the algorithm in the nameAlg field of pubArea
// using the procedure specified in [TPMv2-Part1] section 16.
//
// This needs to move after the x5c check as the QualifiedSigner only gets populated when it can be verified.
if ok, err = tpm2NameMatch(certInfo, pubArea); err != nil {
return "", nil, err
} else if !ok {
return "", nil, ErrAttestationFormat.WithDetails("Hash value mismatch attested and pubArea")
}
return string(metadata.AttCA), statement.X5C, err
}
func tpm2Exponent(params *tpm2.TPMSRSAParms) (exp uint32) {
if params.Exponent != 0 {
return params.Exponent
}
return 65537
}
func tpm2NameMatch(certInfo *tpm2.TPMSAttest, pubArea *tpm2.TPMTPublic) (match bool, err error) {
if certInfo == nil || pubArea == nil {
return false, nil
}
var (
certifyInfo *tpm2.TPMSCertifyInfo
name *tpm2.TPM2BName
)
if certifyInfo, err = certInfo.Attested.Certify(); err != nil {
return false, err
}
if name, err = tpm2.ObjectName(pubArea); err != nil {
return false, err
}
// Per the WebAuthn Specification §8.3 step 5:
//
// Note: The remaining fields in the "Standard Attestation Structure" [TPMv2-Part1] section 31.2, i.e.,
// qualifiedSigner, clockInfo and firmwareVersion are ignored. Depending on the properties of the aikCert key used,
// these fields may be obfuscated. If valid, these MAY be used as an input to risk engines.
//
// See: https://w3c.github.io/webauthn/#sctn-tpm-attestation
return subtle.ConstantTimeCompare(certifyInfo.Name.Buffer, name.Buffer) == 1, nil
}
func tpm2NameDigest(name tpm2.TPM2BName) (alg tpm2.TPMIAlgHash, digest []byte, err error) {
buf := name.Buffer
if len(buf) < 3 {
return 0, nil, fmt.Errorf("name too short")
}
alg = tpm2.TPMIAlgHash(binary.BigEndian.Uint16(buf[:2]))
var hash crypto.Hash
if hash, err = alg.Hash(); err != nil {
return 0, nil, fmt.Errorf("invalid hash algorithm: %w", err)
}
digest = buf[2:]
if len(digest) == 0 {
return 0, nil, fmt.Errorf("name digest is empty")
}
if len(digest) != hash.Size() {
return 0, nil, fmt.Errorf("invalid name digest length: %d", len(digest))
}
return alg, digest, nil
}
type tpm2AttStatement struct {
Version string
Algorithm int64
Signature []byte
CertInfo []byte
PubArea []byte
X5C []any
HasX5C bool
HasValidX5C bool
HasECDAAKeyID bool
HasValidECDAAKeyID bool
ECDAAKeyID []byte
}
func newTPM2AttStatement(raw map[string]any) (statement *tpm2AttStatement, err error) {
var ok bool
statement = &tpm2AttStatement{}
// Given the verification procedure inputs attStmt, authenticatorData
// and clientDataHash, the verification procedure is as follows.
// Verify that attStmt is valid CBOR conforming to the syntax defined
// above and perform CBOR decoding on it to extract the contained fields.
if statement.Version, ok = raw[stmtVersion].(string); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving ver value")
}
if statement.Algorithm, ok = raw[stmtAlgorithm].(int64); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving alg value")
}
if statement.Signature, ok = raw[stmtSignature].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving sig value")
}
if statement.CertInfo, ok = raw[stmtCertInfo].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving certInfo value")
}
if statement.PubArea, ok = raw[stmtPubArea].([]byte); !ok {
return nil, ErrAttestationFormat.WithDetails("Error retrieving pubArea value")
}
var rawX5C, rawECDAAKeyID any
rawX5C, statement.HasX5C = raw[stmtX5C]
statement.X5C, statement.HasValidX5C = rawX5C.([]any)
rawECDAAKeyID, statement.HasECDAAKeyID = raw[stmtECDAAKID]
statement.ECDAAKeyID, statement.HasValidECDAAKeyID = rawECDAAKeyID.([]byte)
return statement, nil
}
// forEachSAN loops through the TPM SAN extension.
//
// RFC 5280, 4.2.1.6
// SubjectAltName ::= GeneralNames
//
// GeneralNames ::= SEQUENCE SIZE (1..MAX) OF GeneralName
//
// GeneralName ::= CHOICE {
// otherName [0] OtherName,
// rfc822Name [1] IA5String,
// dNSName [2] IA5String,
// x400Address [3] ORAddress,
// directoryName [4] Name,
// ediPartyName [5] EDIPartyName,
// uniformResourceIdentifier [6] IA5String,
// iPAddress [7] OCTET STRING,
// registeredID [8] OBJECT IDENTIFIER }
func forEachSAN(extension []byte, callback func(tag int, data []byte) error) error {
var seq asn1.RawValue
rest, err := asn1.Unmarshal(extension, &seq)
if err != nil {
return err
} else if len(rest) != 0 {
return errors.New("x509: trailing data after X.509 extension")
}
if !seq.IsCompound || seq.Tag != 16 || seq.Class != 0 {
return asn1.StructuralError{Msg: "bad SAN sequence"}
}
rest = seq.Bytes
for len(rest) > 0 {
var v asn1.RawValue
rest, err = asn1.Unmarshal(rest, &v)
if err != nil {
return err
}
if err = callback(v.Tag, v.Bytes); err != nil {
return err
}
}
return nil
}
const (
nameTypeDN = 4
)
func parseSANExtension(value []byte) (manufacturer string, model string, version string, err error) {
err = forEachSAN(value, func(tag int, data []byte) error {
if tag == nameTypeDN {
tpmDeviceAttributes := pkix.RDNSequence{}
if _, err = asn1.Unmarshal(data, &tpmDeviceAttributes); err != nil {
return err
}
for _, rdn := range tpmDeviceAttributes {
if len(rdn) == 0 {
continue
}
for _, atv := range rdn {
value, ok := atv.Value.(string)
if !ok {
continue
}
if atv.Type.Equal(oidTCGAtTpmManufacturer) {
manufacturer = strings.TrimPrefix(value, "id:")
}
if atv.Type.Equal(oidTCGAtTpmModel) {
model = value
}
if atv.Type.Equal(oidTCGAtTPMVersion) {
version = strings.TrimPrefix(value, "id:")
}
}
}
}
return nil
})
return
}
type tpmManufacturer struct {
id string
name string
code string
}
// See https://trustedcomputinggroup.org/resource/vendor-id-registry/ for registry contents.
var (
tpmManufacturers = []tpmManufacturer{
{"414D4400", "AMD", "AMD"},
{"414E5400", "Ant Group", "ANT"},
{"41544D4C", "Atmel", "ATML"},
{"4252434D", "Broadcom", "BRCM"},
{"4353434F", "Cisco", "CSCO"},
{"464C5953", "Flyslice Technologies", "FLYS"},
{"524F4343", "Fuzhou Rockchip", "ROCC"},
{"474F4F47", "Google", "GOOG"},
{"48504900", "HPI", "HPI"},
{"48504500", "HPE", "HPE"},
{"48495349", "Huawei", "HISI"},
{"49424d00", "IBM", "IBM"},
{"49424D00", "IBM", "IBM"},
{"49465800", "Infineon", "IFX"},
{"494E5443", "Intel", "INTC"},
{"4C454E00", "Lenovo", "LEN"},
{"4D534654", "Microsoft", "MSFT"},
{"4E534D20", "National Semiconductor", "NSM"},
{"4E545A00", "Nationz", "NTZ"},
{"4E534700", "NSING", "NSG"},
{"4E544300", "Nuvoton Technology", "NTC"},
{"51434F4D", "Qualcomm", "QCOM"},
{"534D534E", "Samsung", "SECE"},
{"53454345", "SecEdge", "SecEdge"},
{"534E5300", "Sinosun", "SNS"},
{"534D5343", "SMSC", "SMSC"},
{"53544D20", "ST Microelectronics", "STM"},
{"54584E00", "Texas Instruments", "TXN"},
{"57454300", "Winbond", "WEC"},
{"5345414C", "Wisekey", "SEAL"},
{"FFFFF1D0", "FIDO Alliance Conformance Testing", "FIDO"},
}
)
func isValidTPMManufacturer(id string) bool {
for _, m := range tpmManufacturers {
if m.id == id {
return true
}
}
return false
}
func tpmParseAIKAttCA(x5c *x509.Certificate, x5cis []*x509.Certificate) (err *Error) {
if err = tpmParseSANExtension(x5c); err != nil {
return err
}
if err = tpmRemoveEKU(x5c); err != nil {
return err
}
for _, parent := range x5cis {
if err = tpmRemoveEKU(parent); err != nil {
return err
}
}
return nil
}
func tpmParseSANExtension(attestation *x509.Certificate) (protoErr *Error) {
var (
manufacturer, model, version string
err error
)
for _, ext := range attestation.Extensions {
if ext.Id.Equal(oidExtensionSubjectAltName) {
if manufacturer, model, version, err = parseSANExtension(ext.Value); err != nil {
return ErrInvalidAttestation.WithDetails("Authenticator with invalid Authenticator Identity Key SAN data encountered during attestation validation.").WithInfo(fmt.Sprintf("Error occurred parsing SAN extension: %s", err.Error())).WithError(err)
}
}
}
if manufacturer == "" || model == "" || version == "" {
return ErrAttestationFormat.WithDetails("Invalid SAN data in AIK certificate.")
}
var unhandled []asn1.ObjectIdentifier
for _, uce := range attestation.UnhandledCriticalExtensions {
if uce.Equal(oidExtensionSubjectAltName) {
continue
}
unhandled = append(unhandled, uce)
}
attestation.UnhandledCriticalExtensions = unhandled
return nil
}
type tpmBasicConstraints struct {
IsCA bool `asn1:"optional"`
MaxPathLen int `asn1:"optional,default:-1"`
}
// Remove extension key usage to avoid ExtKeyUsage check failure.
func tpmRemoveEKU(x5c *x509.Certificate) *Error {
var (
unknown []asn1.ObjectIdentifier
hasAiK bool
)
for _, eku := range x5c.UnknownExtKeyUsage {
if eku.Equal(oidTCGKpAIKCertificate) {
hasAiK = true
continue
}
if eku.Equal(oidMicrosoftKpPrivacyCA) {
continue
}
unknown = append(unknown, eku)
}
if !hasAiK {
return ErrAttestationFormat.WithDetails("Attestation Identity Key certificate missing required Extended Key Usage.")
}
x5c.UnknownExtKeyUsage = unknown
return nil
}
func init() {
RegisterAttestationFormat(AttestationFormatTPM, attestationFormatValidationHandlerTPM)
}
@@ -0,0 +1,434 @@
package protocol
import (
"bytes"
"encoding/binary"
"fmt"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
)
const (
minAuthDataLength = 37
minAttestedAuthLength = 55
maxCredentialIDLength = 1023
)
// AuthenticatorResponse represents the IDL with the same name.
//
// Authenticators respond to Relying Party requests by returning an object derived from the AuthenticatorResponse
// interface
//
// Specification: §5.2. Authenticator Responses (https://www.w3.org/TR/webauthn/#iface-authenticatorresponse)
type AuthenticatorResponse struct {
// From the spec https://www.w3.org/TR/webauthn/#dom-authenticatorresponse-clientdatajson
// This attribute contains a JSON serialization of the client data passed to the authenticator
// by the client in its call to either create() or get().
ClientDataJSON URLEncodedBase64 `json:"clientDataJSON"`
}
// AuthenticatorData represents the IDL with the same name.
//
// The authenticator data structure encodes contextual bindings made by the authenticator. These bindings are controlled
// by the authenticator itself, and derive their trust from the WebAuthn Relying Party's assessment of the security
// properties of the authenticator. In one extreme case, the authenticator may be embedded in the client, and its
// bindings may be no more trustworthy than the client data. At the other extreme, the authenticator may be a discrete
// entity with high-security hardware and software, connected to the client over a secure channel. In both cases, the
// Relying Party receives the authenticator data in the same format, and uses its knowledge of the authenticator to make
// trust decisions.
//
// The authenticator data has a compact but extensible encoding. This is desired since authenticators can be devices
// with limited capabilities and low power requirements, with much simpler software stacks than the client platform.
//
// Specification: §6.1. Authenticator Data (https://www.w3.org/TR/webauthn/#sctn-authenticator-data)
type AuthenticatorData struct {
RPIDHash []byte `json:"rpid"`
Flags AuthenticatorFlags `json:"flags"`
Counter uint32 `json:"sign_count"`
AttData AttestedCredentialData `json:"att_data"`
ExtData []byte `json:"ext_data"`
}
// AttestedCredentialData is a variable-length byte array added to the authenticator data when generating an attestation
// object for a credential.
//
// Specification: §6.5.2. Attested Credential Data (https://www.w3.org/TR/webauthn/#sctn-attested-credential-data)
type AttestedCredentialData struct {
// AAGUID is the 16-byte Authenticator Attestation GUID, a unique identifier indicating the type of the
// authenticator (i.e. make and model).
AAGUID []byte `json:"aaguid"`
// CredentialID is the credential identifier whose length is prepended as a 16-bit unsigned big-endian integer.
CredentialID []byte `json:"credential_id"`
// CredentialPublicKey is the CBOR-encoded credential public key using the COSE_Key format defined in
// Section 7 of [RFC9052].
CredentialPublicKey []byte `json:"public_key"`
}
// CredentialMediationRequirement represents mediation requirements for clients. When making a request via get(options)
// or create(options), developers can set a case-by-case requirement for user mediation by choosing the appropriate
// CredentialMediationRequirement enum value.
//
// See https://www.w3.org/TR/credential-management-1/#mediation-requirements
type CredentialMediationRequirement string
const (
// MediationDefault lets the browser choose the mediation flow completely as if it wasn't specified at all.
MediationDefault CredentialMediationRequirement = ""
// MediationSilent indicates user mediation is suppressed for the given operation. If the operation can be performed
// without user involvement, wonderful. If user involvement is necessary, then the operation will return null rather
// than involving the user.
MediationSilent CredentialMediationRequirement = "silent"
// MediationOptional indicates if credentials can be handed over for a given operation without user mediation, they
// will be. If user mediation is required, then the user agent will involve the user in the decision.
MediationOptional CredentialMediationRequirement = "optional"
// MediationConditional indicates for get(), discovered credentials are presented to the user in a non-modal dialog
// along with an indication of the origin which is requesting credentials. If the user makes a gesture outside of
// the dialog, the dialog closes without resolving or rejecting the Promise returned by the get() method and without
// causing a user-visible error condition. If the user makes a gesture that selects a credential, that credential is
// returned to the caller. The prevent silent access flag is treated as being true regardless of its actual value:
// the conditional behavior always involves user mediation of some sort if applicable credentials are discovered.
MediationConditional CredentialMediationRequirement = "conditional"
// MediationRequired indicates the user agent will not hand over credentials without user mediation, even if the
// prevent silent access flag is unset for an origin.
MediationRequired CredentialMediationRequirement = "required"
)
// AuthenticatorAttachment represents the IDL enum of the same name, and is used as part of the Authenticator Selection
// Criteria.
//
// This enumerations values describe authenticators' attachment modalities. Relying Parties use this to express a
// preferred authenticator attachment modality when calling navigator.credentials.create() to create a credential.
//
// If this member is present, eligible authenticators are filtered to only authenticators attached with the specified
// §5.4.5 Authenticator Attachment Enumeration (enum AuthenticatorAttachment). The value SHOULD be a member of
// AuthenticatorAttachment but client platforms MUST ignore unknown values, treating an unknown value as if the member
// does not exist.
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dom-authenticatorselectioncriteria-authenticatorattachment)
//
// Specification: §5.4.5. Authenticator Attachment Enumeration (https://www.w3.org/TR/webauthn/#enum-attachment)
type AuthenticatorAttachment string
const (
// Platform represents a platform authenticator is attached using a client device-specific transport, called
// platform attachment, and is usually not removable from the client device. A public key credential bound to a
// platform authenticator is called a platform credential.
Platform AuthenticatorAttachment = "platform"
// CrossPlatform represents a roaming authenticator is attached using cross-platform transports, called
// cross-platform attachment. Authenticators of this class are removable from, and can "roam" among, client devices.
// A public key credential bound to a roaming authenticator is called a roaming credential.
CrossPlatform AuthenticatorAttachment = "cross-platform"
)
// ResidentKeyRequirement represents the IDL of the same name.
//
// This enumerations values describe the Relying Party's requirements for client-side discoverable credentials
// (formerly known as resident credentials or resident keys).
//
// Specifies the extent to which the Relying Party desires to create a client-side discoverable credential. For
// historical reasons the naming retains the deprecated “resident” terminology. The value SHOULD be a member of
// ResidentKeyRequirement but client platforms MUST ignore unknown values, treating an unknown value as if the member
// does not exist. If no value is given then the effective value is required if requireResidentKey is true or
// discouraged if it is false or absent.
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dom-authenticatorselectioncriteria-residentkey)
//
// Specification: §5.4.6. Resident Key Requirement Enumeration (https://www.w3.org/TR/webauthn/#enumdef-residentkeyrequirement)
type ResidentKeyRequirement string
const (
// ResidentKeyRequirementDiscouraged indicates the Relying Party prefers creating a server-side credential, but will
// accept a client-side discoverable credential. This is the default.
ResidentKeyRequirementDiscouraged ResidentKeyRequirement = "discouraged"
// ResidentKeyRequirementPreferred indicates to the client we would prefer a discoverable credential.
ResidentKeyRequirementPreferred ResidentKeyRequirement = "preferred"
// ResidentKeyRequirementRequired indicates the Relying Party requires a client-side discoverable credential, and is
// prepared to receive an error if a client-side discoverable credential cannot be created.
ResidentKeyRequirementRequired ResidentKeyRequirement = "required"
)
// AuthenticatorTransport represents the IDL enum with the same name.
//
// Authenticators may implement various transports for communicating with clients. This enumeration defines hints as to
// how clients might communicate with a particular authenticator in order to obtain an assertion for a specific
// credential. Note that these hints represent the WebAuthn Relying Party's best belief as to how an authenticator may
// be reached. A Relying Party will typically learn of the supported transports for a public key credential via
// getTransports().
//
// Specification: §5.8.4. Authenticator Transport Enumeration (https://www.w3.org/TR/webauthn/#enumdef-authenticatortransport)
type AuthenticatorTransport string
const (
// USB indicates the respective authenticator can be contacted over removable USB.
USB AuthenticatorTransport = "usb"
// NFC indicates the respective authenticator can be contacted over Near Field Communication (NFC).
NFC AuthenticatorTransport = "nfc"
// BLE indicates the respective authenticator can be contacted over Bluetooth Smart (Bluetooth Low Energy / BLE).
BLE AuthenticatorTransport = "ble"
// SmartCard indicates the respective authenticator can be contacted over ISO/IEC 7816 smart card with contacts.
//
// WebAuthn Level 3.
SmartCard AuthenticatorTransport = "smart-card"
// Hybrid indicates the respective authenticator can be contacted using a combination of (often separate)
// data-transport and proximity mechanisms. This supports, for example, authentication on a desktop computer using
// a smartphone.
//
// WebAuthn Level 3.
Hybrid AuthenticatorTransport = "hybrid"
// Internal indicates the respective authenticator is contacted using a client device-specific transport, i.e., it
// is a platform authenticator. These authenticators are not removable from the client device.
Internal AuthenticatorTransport = "internal"
)
// UserVerificationRequirement is a representation of the UserVerificationRequirement IDL enum.
//
// A WebAuthn Relying Party may require user verification for some of its operations but not for others,
// and may use this type to express its needs.
//
// Specification: §5.8.6. User Verification Requirement Enumeration (https://www.w3.org/TR/webauthn/#enum-userVerificationRequirement)
type UserVerificationRequirement string
const (
// VerificationRequired User verification is required to create/release a credential.
VerificationRequired UserVerificationRequirement = "required"
// VerificationPreferred User verification is preferred to create/release a credential.
VerificationPreferred UserVerificationRequirement = "preferred" // This is the default.
// VerificationDiscouraged The authenticator should not verify the user for the credential.
VerificationDiscouraged UserVerificationRequirement = "discouraged"
)
// AuthenticatorFlags A byte of information returned during during ceremonies in the
// authenticatorData that contains bits that give us information about the
// whether the user was present and/or verified during authentication, and whether
// there is attestation or extension data present. Bit 0 is the least significant bit.
//
// Specification: §6.1. Authenticator Data - Flags (https://www.w3.org/TR/webauthn/#flags)
type AuthenticatorFlags byte
// The bits that do not have flags are reserved for future use.
const (
// FlagUserPresent Bit 00000001 in the byte sequence. Tells us if user is present. Also referred to as the UP flag.
FlagUserPresent AuthenticatorFlags = 1 << iota // Referred to as UP.
// FlagRFU1 is a reserved for future use flag.
FlagRFU1
// FlagUserVerified Bit 00000100 in the byte sequence. Tells us if user is verified
// by the authenticator using a biometric or PIN. Also referred to as the UV flag.
FlagUserVerified
// FlagBackupEligible Bit 00001000 in the byte sequence. Tells us if a backup is eligible for device. Also referred
// to as the BE flag.
FlagBackupEligible // Referred to as BE.
// FlagBackupState Bit 00010000 in the byte sequence. Tells us if a backup state for device. Also referred to as the
// BS flag.
FlagBackupState
// FlagRFU2 is a reserved for future use flag.
FlagRFU2
// FlagAttestedCredentialData Bit 01000000 in the byte sequence. Indicates whether
// the authenticator added attested credential data. Also referred to as the AT flag.
FlagAttestedCredentialData
// FlagHasExtensions Bit 10000000 in the byte sequence. Indicates if the authenticator data has extensions. Also
// referred to as the ED flag.
FlagHasExtensions
)
// UserPresent returns if the UP flag was set.
func (flag AuthenticatorFlags) UserPresent() bool {
return flag.HasUserPresent()
}
// UserVerified returns if the UV flag was set.
func (flag AuthenticatorFlags) UserVerified() bool {
return flag.HasUserVerified()
}
// HasUserPresent returns if the UP flag was set.
func (flag AuthenticatorFlags) HasUserPresent() bool {
return (flag & FlagUserPresent) == FlagUserPresent
}
// HasUserVerified returns if the UV flag was set.
func (flag AuthenticatorFlags) HasUserVerified() bool {
return (flag & FlagUserVerified) == FlagUserVerified
}
// HasAttestedCredentialData returns if the AT flag was set.
func (flag AuthenticatorFlags) HasAttestedCredentialData() bool {
return (flag & FlagAttestedCredentialData) == FlagAttestedCredentialData
}
// HasExtensions returns if the ED flag was set.
func (flag AuthenticatorFlags) HasExtensions() bool {
return (flag & FlagHasExtensions) == FlagHasExtensions
}
// HasBackupEligible returns if the BE flag was set.
func (flag AuthenticatorFlags) HasBackupEligible() bool {
return (flag & FlagBackupEligible) == FlagBackupEligible
}
// HasBackupState returns if the BS flag was set.
func (flag AuthenticatorFlags) HasBackupState() bool {
return (flag & FlagBackupState) == FlagBackupState
}
// Unmarshal will take the raw Authenticator Data and marshals it into AuthenticatorData for further validation.
// The authenticator data has a compact but extensible encoding. This is desired since authenticators can be
// devices with limited capabilities and low power requirements, with much simpler software stacks than the client platform.
// The authenticator data structure is a byte array of 37 bytes or more, and is laid out in this table:
// https://www.w3.org/TR/webauthn/#table-authData
func (a *AuthenticatorData) Unmarshal(rawAuthData []byte) (err error) {
if minAuthDataLength > len(rawAuthData) {
return ErrBadRequest.
WithDetails("Authenticator data length too short").
WithInfo(fmt.Sprintf("Expected data greater than %d bytes. Got %d bytes", minAuthDataLength, len(rawAuthData)))
}
a.RPIDHash = rawAuthData[:32]
a.Flags = AuthenticatorFlags(rawAuthData[32])
a.Counter = binary.BigEndian.Uint32(rawAuthData[33:37])
remaining := len(rawAuthData) - minAuthDataLength
if a.Flags.HasAttestedCredentialData() {
if len(rawAuthData) > minAttestedAuthLength {
if err = a.unmarshalAttestedData(rawAuthData); err != nil {
return err
}
attDataLen := len(a.AttData.AAGUID) + 2 + len(a.AttData.CredentialID) + len(a.AttData.CredentialPublicKey)
remaining -= attDataLen
} else {
return ErrBadRequest.WithDetails("Attested credential flag set but data is missing")
}
} else {
if !a.Flags.HasExtensions() && len(rawAuthData) != 37 {
return ErrBadRequest.WithDetails("Attested credential flag not set")
}
}
if a.Flags.HasExtensions() {
if remaining != 0 {
a.ExtData = rawAuthData[len(rawAuthData)-remaining:]
remaining -= len(a.ExtData)
} else {
return ErrBadRequest.WithDetails("Extensions flag set but extensions data is missing")
}
}
if remaining != 0 {
return ErrBadRequest.WithDetails("Leftover bytes decoding AuthenticatorData")
}
return nil
}
// If Attestation Data is present, unmarshall that into the appropriate public key structure.
func (a *AuthenticatorData) unmarshalAttestedData(rawAuthData []byte) (err error) {
a.AttData.AAGUID = rawAuthData[37:53]
idLength := binary.BigEndian.Uint16(rawAuthData[53:55])
if len(rawAuthData) < int(55+idLength) {
return ErrBadRequest.WithDetails("Authenticator attestation data length too short")
}
if idLength > maxCredentialIDLength {
return ErrBadRequest.WithDetails("Authenticator attestation data credential id length too long")
}
a.AttData.CredentialID = rawAuthData[55 : 55+idLength]
a.AttData.CredentialPublicKey, err = unmarshalCredentialPublicKey(rawAuthData[55+idLength:])
if err != nil {
return ErrBadRequest.WithDetails(fmt.Sprintf("Could not unmarshal Credential Public Key: %v", err)).WithError(err)
}
return nil
}
// Unmarshall the credential's Public Key into CBOR encoding.
func unmarshalCredentialPublicKey(keyBytes []byte) (rawBytes []byte, err error) {
var m any
if err = webauthncbor.Unmarshal(keyBytes, &m); err != nil {
return nil, err
}
if rawBytes, err = webauthncbor.Marshal(m); err != nil {
return nil, err
}
return rawBytes, nil
}
// ResidentKeyRequired - Require that the key be private key resident to the client device.
func ResidentKeyRequired() *bool {
required := true
return &required
}
// ResidentKeyNotRequired - Do not require that the private key be resident to the client device.
func ResidentKeyNotRequired() *bool {
required := false
return &required
}
// Verify on AuthenticatorData handles Steps 13 through 15 & 17 for Registration
// and Steps 15 through 18 for Assertion.
func (a *AuthenticatorData) Verify(rpIdHash []byte, appIDHash []byte, userVerificationRequired bool, userPresenceRequired bool) (err error) {
// Registration Step 13 & Assertion Step 15
// Verify that the RP ID hash in authData is indeed the SHA-256
// hash of the RP ID expected by the RP.
if !bytes.Equal(a.RPIDHash, rpIdHash) && !bytes.Equal(a.RPIDHash, appIDHash) {
return ErrVerification.WithInfo(fmt.Sprintf("RP Hash mismatch. Expected %x and Received %x", a.RPIDHash, rpIdHash))
}
// Registration Step 15 & Assertion Step 16
// Verify that the User Present bit of the flags in authData is set.
if userPresenceRequired && !a.Flags.UserPresent() {
return ErrVerification.WithInfo("User presence required but flag not set by authenticator")
}
// Registration Step 15 & Assertion Step 17
// If user verification is required for this assertion, verify that
// the User Verified bit of the flags in authData is set.
if userVerificationRequired && !a.Flags.UserVerified() {
return ErrVerification.WithInfo("User verification required but flag not set by authenticator")
}
// Registration Step 17 & Assertion Step 18
// Verify that the values of the client extension outputs in clientExtensionResults
// and the authenticator extension outputs in the extensions in authData are as
// expected, considering the client extension input values that were given as the
// extensions option in the create() call. In particular, any extension identifier
// values in the clientExtensionResults and the extensions in authData MUST be also be
// present as extension identifier values in the extensions member of options, i.e., no
// extensions are present that were not requested. In the general case, the meaning
// of "are as expected" is specific to the Relying Party and which extensions are in use.
// This is not yet fully implemented by the spec or by browsers.
return nil
}
@@ -0,0 +1,52 @@
package protocol
import (
"bytes"
"encoding/base64"
"reflect"
)
// URLEncodedBase64 represents a byte slice holding URL-encoded base64 data.
// When fields of this type are unmarshalled from JSON, the data is base64
// decoded into a byte slice.
type URLEncodedBase64 []byte
func (e URLEncodedBase64) String() string {
return base64.RawURLEncoding.EncodeToString(e)
}
// UnmarshalJSON base64 decodes a URL-encoded value, storing the result in the
// provided byte slice.
func (e *URLEncodedBase64) UnmarshalJSON(data []byte) error {
if bytes.Equal(data, []byte("null")) {
return nil
}
// Trim the leading and trailing quotes from raw JSON data (the whole value part).
data = bytes.Trim(data, `"`)
// Trim the trailing equal characters.
data = bytes.TrimRight(data, "=")
out := make([]byte, base64.RawURLEncoding.DecodedLen(len(data)))
n, err := base64.RawURLEncoding.Decode(out, data)
if err != nil {
return err
}
v := reflect.ValueOf(e).Elem()
v.SetBytes(out[:n])
return nil
}
// MarshalJSON base64 encodes a non URL-encoded value, storing the result in the
// provided byte slice.
func (e URLEncodedBase64) MarshalJSON() ([]byte, error) {
if e == nil {
return []byte("null"), nil
}
return []byte(`"` + base64.RawURLEncoding.EncodeToString(e) + `"`), nil
}
@@ -0,0 +1,20 @@
package protocol
import (
"crypto/rand"
)
// ChallengeLength - Length of bytes to generate for a challenge.
const ChallengeLength = DefaultChallengeLength
// CreateChallenge creates a new challenge that should be signed and returned by the authenticator. The spec recommends
// using at least 16 bytes with 100 bits of entropy. We use 32 bytes.
func CreateChallenge() (challenge URLEncodedBase64, err error) {
challenge = make([]byte, ChallengeLength)
if _, err = rand.Read(challenge); err != nil {
return nil, err
}
return challenge, nil
}
@@ -0,0 +1,321 @@
package protocol
import (
"crypto/subtle"
"fmt"
"net/url"
"strings"
)
// CollectedClientData represents the contextual bindings of both the WebAuthn Relying Party
// and the client. It is a key-value mapping whose keys are strings. Values can be any type
// that has a valid encoding in JSON. Its structure is defined by the following Web IDL.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dictdef-collectedclientdata)
type CollectedClientData struct {
// Type contains the string "webauthn.create" when creating new credentials, and "webauthn.get" when getting an
// assertion from an existing credential. The purpose of this member is to prevent certain types of signature
// confusion attacks (where an attacker substitutes one legitimate signature for another).
Type CeremonyType `json:"type"`
// Challenge contains the base64url encoding of the challenge provided by the Relying Party.
Challenge string `json:"challenge"`
// Origin contains the fully qualified origin of the requester, as provided to the authenticator by the client.
Origin string `json:"origin"`
// TopOrigin contains the fully qualified top-level origin of the requester when the client is cross-origin.
// This is only present when CrossOrigin is true.
//
// WebAuthn Level 3.
TopOrigin string `json:"topOrigin,omitempty"`
// CrossOrigin indicates whether the calling context is an iframe that is not same-origin with its ancestor.
//
// WebAuthn Level 3.
CrossOrigin bool `json:"crossOrigin,omitempty"`
// TokenBinding contains information about the state of the Token Binding protocol.
TokenBinding *TokenBinding `json:"tokenBinding,omitempty"`
// Hint is an opaque field that may be added by the client. Chromium-based browsers include this field to remind
// implementers not to perform string comparison on the clientDataJSON.
Hint string `json:"new_keys_may_be_added_here,omitempty"`
}
// CeremonyType represents the type of WebAuthn ceremony being performed.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-type)
type CeremonyType string
const (
// CreateCeremony is the ceremony type for credential registration ("webauthn.create").
CreateCeremony CeremonyType = "webauthn.create"
// AssertCeremony is the ceremony type for authentication assertion ("webauthn.get").
AssertCeremony CeremonyType = "webauthn.get"
)
// TokenBinding contains information about the state of the Token Binding protocol used when communicating with the
// Relying Party. Its absence indicates that the client doesn't support token binding.
//
// Specification: §5.8.1. Client Data Used in WebAuthn Signatures (https://www.w3.org/TR/webauthn/#dom-collectedclientdata-tokenbinding)
type TokenBinding struct {
Status TokenBindingStatus `json:"status"`
ID string `json:"id,omitempty"`
}
// TokenBindingStatus represents the state of Token Binding between the client and the Relying Party.
type TokenBindingStatus string
const (
// Present indicates token binding was used when communicating with the
// Relying Party. In this case, the id member MUST be present.
Present TokenBindingStatus = "present"
// Supported indicates the client supports token binding, but it was not
// negotiated when communicating with the Relying Party.
Supported TokenBindingStatus = "supported"
// NotSupported indicates token binding not supported
// when communicating with the Relying Party.
NotSupported TokenBindingStatus = "not-supported"
)
// FullyQualifiedOrigin returns the origin per the HTML spec: (scheme)://(host)[:(port)].
func FullyQualifiedOrigin(rawOrigin string) (fqOrigin string, err error) {
if strings.HasPrefix(rawOrigin, "android:apk-key-hash:") {
return rawOrigin, nil
}
var origin *url.URL
if origin, err = url.ParseRequestURI(rawOrigin); err != nil {
return "", err
}
if origin.Host == "" {
return "", fmt.Errorf("url '%s' does not have a host", rawOrigin)
}
origin.Path, origin.RawPath, origin.RawQuery, origin.User = "", "", "", nil
return origin.String(), nil
}
// Verify handles steps 3 through 6 of verifying the registering client data of a
// new credential and steps 7 through 10 of verifying an authentication assertion
// See https://www.w3.org/TR/webauthn/#registering-a-new-credential
// and https://www.w3.org/TR/webauthn/#verifying-assertion
//
// Note: the rpTopOriginsVerify parameter does not accept the TopOriginVerificationMode value of
// TopOriginDefaultVerificationMode as it's expected this value is updated by the config validation process.
//
//nolint:gocyclo
func (c *CollectedClientData) Verify(storedChallenge string, ceremony CeremonyType, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin bool) (err error) {
// Registration Step 3. Verify that the value of C.type is webauthn.create.
// Assertion Step 7. Verify that the value of C.type is the string webauthn.get.
if c.Type != ceremony {
return ErrVerification.WithDetails("Error validating ceremony type").WithInfo(fmt.Sprintf("Expected Value: %s, Received: %s", ceremony, c.Type))
}
// Registration Step 4. Verify that the value of C.challenge matches the challenge
// that was sent to the authenticator in the create() call.
// Assertion Step 8. Verify that the value of C.challenge matches the challenge
// that was sent to the authenticator in the PublicKeyCredentialRequestOptions
// passed to the get() call.
challenge := c.Challenge
if subtle.ConstantTimeCompare([]byte(storedChallenge), []byte(challenge)) != 1 {
return ErrVerification.
WithDetails("Error validating challenge").
WithInfo(fmt.Sprintf("Expected b Value: %#v\nReceived b: %#v\n", storedChallenge, challenge))
}
// Registration Step 5 & Assertion Step 9. Verify that the value of C.origin matches
// the Relying Party's origin.
if !IsOriginInHaystack(c.Origin, rpOrigins) {
return ErrVerification.
WithDetails("Error validating origin").
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", rpOrigins, c.Origin))
}
if !allowCrossOrigin && c.CrossOrigin {
return ErrVerification.
WithDetails("Error validating cross origin flag").
WithInfo("The cross origin flag is invalid due to the configuration.")
}
switch len(c.TopOrigin) {
case 0:
break
default:
if !c.CrossOrigin {
return ErrVerification.
WithDetails("Error validating topOrigin").
WithInfo("The topOrigin can't have values unless crossOrigin is true.")
}
var possibleTopOrigins []string
switch rpTopOriginsVerify {
case TopOriginExplicitVerificationMode:
possibleTopOrigins = rpTopOrigins
case TopOriginAutoVerificationMode:
possibleTopOrigins = make([]string, 0, len(rpTopOrigins)+len(rpOrigins))
possibleTopOrigins = append(possibleTopOrigins, rpTopOrigins...)
possibleTopOrigins = append(possibleTopOrigins, rpOrigins...)
case TopOriginImplicitVerificationMode:
possibleTopOrigins = rpOrigins
default:
return ErrNotImplemented.WithDetails("Error handling unknown Top Origin verification mode")
}
if !IsOriginInHaystack(c.TopOrigin, possibleTopOrigins) {
return ErrVerification.
WithDetails("Error validating top origin").
WithInfo(fmt.Sprintf("Expected Values: %s, Received: %s", possibleTopOrigins, c.TopOrigin))
}
}
// Registration Step 6 and Assertion Step 10. Verify that the value of C.tokenBinding.status
// matches the state of Token Binding for the TLS connection over which the assertion was
// obtained. If Token Binding was used on that TLS connection, also verify that C.tokenBinding.id
// matches the base64url encoding of the Token Binding ID for the connection.
if c.TokenBinding != nil {
if c.TokenBinding.Status == "" {
return ErrParsingData.WithDetails("Error decoding clientData, token binding present without status")
}
if c.TokenBinding.Status != Present && c.TokenBinding.Status != Supported && c.TokenBinding.Status != NotSupported {
return ErrParsingData.
WithDetails("Error decoding clientData, token binding present with invalid status").
WithInfo(fmt.Sprintf("Got: %s", c.TokenBinding.Status))
}
}
// Not yet fully implemented by the spec, browsers, and me.
return nil
}
// TopOriginVerificationMode determines how the Relying Party validates the topOrigin field in
// [CollectedClientData]. This is relevant for cross-origin iframe scenarios where the top-level browsing context's
// origin differs from the embedded origin making the WebAuthn API call.
//
// WebAuthn Level 3.
type TopOriginVerificationMode int
const (
// TopOriginDefaultVerificationMode is the zero value of [TopOriginVerificationMode] and has no matching rule in
// the verifier; passing it directly to [CollectedClientData.Verify] returns an "unknown Top Origin verification
// mode" error. High-level callers using [webauthn.Config] have this value coerced to
// [TopOriginExplicitVerificationMode] by config validation, which is the recommended default.
TopOriginDefaultVerificationMode TopOriginVerificationMode = iota
// TopOriginAutoVerificationMode accepts the Top Origin if it matches any entry in either the allowed Top Origins
// list or the allowed Origins list. The two lists are unioned (RPTopOrigins RPOrigins). This is the most
// permissive of the three active modes and should only be used when an RP deliberately wants cross-origin and
// same-origin embeddings to share an allow-list.
TopOriginAutoVerificationMode
// TopOriginImplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Origins
// list (RPOrigins). The RPTopOrigins list is ignored in this mode.
TopOriginImplicitVerificationMode
// TopOriginExplicitVerificationMode accepts the Top Origin only if it matches an entry in the allowed Top Origins
// list (RPTopOrigins). The RPOrigins list is ignored in this mode. This is the strictest mode and the one
// [webauthn.Config] coerces the zero value to.
TopOriginExplicitVerificationMode
)
// IsOriginInHaystack checks if the needle is in the haystack using the mechanism to determine origin equality defined
// in HTML5 Section 5.3 and RFC3986 Section 6.2.1.
//
// Specifically if the needle value has the 'http://' or 'https://' prefix (case-insensitive) and can be parsed as a
// URL; we check each item in the haystack to see if it matches the same rules, and then if the scheme and host (with
// a normalized port) components match case-insensitively then they're considered a match.
//
// If the needle value does not have the 'http://' or 'https://' prefix (case-insensitive) or can't be parsed as a URL
// equality is determined using simple string comparison.
//
// It is important to note that this function completely ignores Apple Associated Domains entirely as Apple is using
// an unassigned Well-Known URI in breech of Well-Known Uniform Resource Identifiers (RFC8615).
//
// See (Origin Definition): https://www.w3.org/TR/2011/WD-html5-20110525/origin-0.html
//
// See (Simple String Comparison Definition): https://datatracker.ietf.org/doc/html/rfc3986#section-6.2.1
//
// See (Apple Associated Domains): https://developer.apple.com/documentation/xcode/supporting-associated-domains
//
// See (IANA Well Known URI Assignments): https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml
//
// See (Well-Known Uniform Resource Identifiers): https://datatracker.ietf.org/doc/html/rfc8615
func IsOriginInHaystack(needle string, haystack []string) bool {
needleURI := parseOriginURI(needle)
if needleURI != nil {
for _, hay := range haystack {
if hayURI := parseOriginURI(hay); hayURI != nil {
if isOriginEqual(needleURI, hayURI) {
return true
}
}
}
} else {
for _, hay := range haystack {
if needle == hay {
return true
}
}
}
return false
}
func isOriginEqual(a *url.URL, b *url.URL) bool {
if !strings.EqualFold(a.Scheme, b.Scheme) {
return false
}
if !strings.EqualFold(a.Host, b.Host) {
return false
}
return true
}
func parseOriginURI(raw string) *url.URL {
if !isPossibleFQDN(raw) {
return nil
}
// We can ignore the error here because it's effectively not a FQDN if this fails.
uri, _ := url.Parse(raw)
if uri == nil {
return nil
}
// Normalize the port if necessary.
switch uri.Scheme {
case "http":
if uri.Port() == "80" {
uri.Host = uri.Hostname()
}
case "https":
if uri.Port() == "443" {
uri.Host = uri.Hostname()
}
}
return uri
}
func isPossibleFQDN(raw string) bool {
normalized := strings.ToLower(raw)
return strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://")
}
+239
View File
@@ -0,0 +1,239 @@
package protocol
import (
"encoding/asn1"
)
const (
none = "none"
stmtFmtNone = none
stmtTypNone = none
stmtAttStmt = "attStmt"
stmtFmt = "fmt"
stmtX5C = "x5c"
stmtSignature = "sig"
stmtAlgorithm = "alg"
stmtVersion = "ver"
stmtECDAAKID = "ecdaaKeyId"
stmtCertInfo = "certInfo"
stmtPubArea = "pubArea"
)
const (
versionTPM20 = "2.0"
)
const (
attStatementAndroidSafetyNetHostname = "attest.android.com"
)
const (
// MinimumChallengeLength defines the minimum length of the challenge.
MinimumChallengeLength = 16
// DefaultChallengeLength defines the default length of the challenge.
DefaultChallengeLength = 32
)
var (
// internalRemappedAuthenticatorTransport handles remapping of AuthenticatorTransport values. Specifically it is
// intentional on remapping only transports that never made recommendation but are being used in the wild. It
// should not be used to handle transports that were ratified.
internalRemappedAuthenticatorTransport = map[string]AuthenticatorTransport{
// The Authenticator Transport 'hybrid' was previously named 'cable'; even if it was for a short period.
"cable": Hybrid,
}
)
const (
/*
Apple Anonymous Attestation Root 1 in PEM form.
Source: https://www.apple.com/certificateauthority/Apple_WebAuthn_Root_CA.pem
SHA256 Fingerprints:
Root 1: 09:15:DD:5C:07:A2:8D:B5:49:D1:F6:77:BB:5A:75:D4:BF:BE:95:61:A7:73:42:43:27:76:2E:9E:02:F9:BB:29
*/
certificateAppleRoot1 = `-----BEGIN CERTIFICATE-----
MIICEjCCAZmgAwIBAgIQaB0BbHo84wIlpQGUKEdXcTAKBggqhkjOPQQDAzBLMR8w
HQYDVQQDDBZBcHBsZSBXZWJBdXRobiBSb290IENBMRMwEQYDVQQKDApBcHBsZSBJ
bmMuMRMwEQYDVQQIDApDYWxpZm9ybmlhMB4XDTIwMDMxODE4MjEzMloXDTQ1MDMx
NTAwMDAwMFowSzEfMB0GA1UEAwwWQXBwbGUgV2ViQXV0aG4gUm9vdCBDQTETMBEG
A1UECgwKQXBwbGUgSW5jLjETMBEGA1UECAwKQ2FsaWZvcm5pYTB2MBAGByqGSM49
AgEGBSuBBAAiA2IABCJCQ2pTVhzjl4Wo6IhHtMSAzO2cv+H9DQKev3//fG59G11k
xu9eI0/7o6V5uShBpe1u6l6mS19S1FEh6yGljnZAJ+2GNP1mi/YK2kSXIuTHjxA/
pcoRf7XkOtO4o1qlcaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUJtdk
2cV4wlpn0afeaxLQG2PxxtcwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2cA
MGQCMFrZ+9DsJ1PW9hfNdBywZDsWDbWFp28it1d/5w2RPkRX3Bbn/UbDTNLx7Jr3
jAGGiQIwHFj+dJZYUJR786osByBelJYsVZd2GbHQu209b5RCmGQ21gpSAk9QZW4B
1bWeT0vT
-----END CERTIFICATE-----`
)
const (
/*
Google Hardware Attestation Root 1 through Root 5 in PEM form.
Source: https://developer.android.com/training/articles/security-key-attestation#root_certificate
SHA256 Fingerprints:
Root 1: CE:DB:1C:B6:DC:89:6A:E5:EC:79:73:48:BC:E9:28:67:53:C2:B3:8E:E7:1C:E0:FB:E3:4A:9A:12:48:80:0D:FC
Root 2: 6D:9D:B4:CE:6C:5C:0B:29:31:66:D0:89:86:E0:57:74:A8:77:6C:EB:52:5D:9E:43:29:52:0D:E1:2B:A4:BC:C0
Root 3: C1:98:4A:3E:F4:5C:1E:2A:91:85:51:DE:10:60:3C:86:F7:05:1B:22:49:C4:89:1C:AE:32:30:EA:BD:0C:97:D5
Root 4: 1E:F1:A0:4B:8B:A5:8A:B9:45:89:AC:49:8C:89:82:A7:83:F2:4E:A7:30:7E:01:59:A0:C3:A7:3B:37:7D:87:CC
Root 5: AB:66:41:17:8A:36:E1:79:AA:0C:1C:DD:DF:9A:16:EB:45:FA:20:94:3E:2B:8C:D7:C7:C0:5C:26:CF:8B:48:7A
*/
certificateAndroidKeyRoot1 = `-----BEGIN CERTIFICATE-----
MIIFHDCCAwSgAwIBAgIJAPHBcqaZ6vUdMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjIwMzIwMTgwNzQ4WhcNNDIwMzE1MTgw
NzQ4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQB8cMqTllHc8U+qCrOlg3H7
174lmaCsbo/bJ0C17JEgMLb4kvrqsXZs01U3mB/qABg/1t5Pd5AORHARs1hhqGIC
W/nKMav574f9rZN4PC2ZlufGXb7sIdJpGiO9ctRhiLuYuly10JccUZGEHpHSYM2G
tkgYbZba6lsCPYAAP83cyDV+1aOkTf1RCp/lM0PKvmxYN10RYsK631jrleGdcdkx
oSK//mSQbgcWnmAEZrzHoF1/0gso1HZgIn0YLzVhLSA/iXCX4QT2h3J5z3znluKG
1nv8NQdxei2DIIhASWfu804CA96cQKTTlaae2fweqXjdN1/v2nqOhngNyz1361mF
mr4XmaKH/ItTwOe72NI9ZcwS1lVaCvsIkTDCEXdm9rCNPAY10iTunIHFXRh+7KPz
lHGewCq/8TOohBRn0/NNfh7uRslOSZ/xKbN9tMBtw37Z8d2vvnXq/YWdsm1+JLVw
n6yYD/yacNJBlwpddla8eaVMjsF6nBnIgQOf9zKSe06nSTqvgwUHosgOECZJZ1Eu
zbH4yswbt02tKtKEFhx+v+OTge/06V+jGsqTWLsfrOCNLuA8H++z+pUENmpqnnHo
vaI47gC+TNpkgYGkkBT6B/m/U01BuOBBTzhIlMEZq9qkDWuM2cA5kW5V3FJUcfHn
w1IdYIg2Wxg7yHcQZemFQg==
-----END CERTIFICATE-----`
certificateAndroidKeyRoot2 = `-----BEGIN CERTIFICATE-----
MIICIjCCAaigAwIBAgIRAISp0Cl7DrWK5/8OgN52BgUwCgYIKoZIzj0EAwMwUjEc
MBoGA1UEAwwTS2V5IEF0dGVzdGF0aW9uIENBMTEQMA4GA1UECwwHQW5kcm9pZDET
MBEGA1UECgwKR29vZ2xlIExMQzELMAkGA1UEBhMCVVMwHhcNMjUwNzE3MjIzMjE4
WhcNMzUwNzE1MjIzMjE4WjBSMRwwGgYDVQQDDBNLZXkgQXR0ZXN0YXRpb24gQ0Ex
MRAwDgYDVQQLDAdBbmRyb2lkMRMwEQYDVQQKDApHb29nbGUgTExDMQswCQYDVQQG
EwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABCPaI3FO3z5bBQo8cuiEas4HjqCt
G/mLFfRT0MsIssPBEEU5Cfbt6sH5yOAxqEi5QagpU1yX4HwnGb7OtBYpDTB57uH5
Eczm34A5FNijV3s0/f0UPl7zbJcTx6xwqMIRq6NCMEAwDwYDVR0TAQH/BAUwAwEB
/zAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFFIyuyz7RkOb3NaBqQ5lZuA0QepA
MAoGCCqGSM49BAMDA2gAMGUCMETfjPO/HwqReR2CS7p0ZWoD/LHs6hDi422opifH
EUaYLxwGlT9SLdjkVpz0UUOR5wIxAIoGyxGKRHVTpqpGRFiJtQEOOTp/+s1GcxeY
uR2zh/80lQyu9vAFCj6E4AXc+osmRg==
-----END CERTIFICATE-----`
certificateAndroidKeyRoot3 = `-----BEGIN CERTIFICATE-----
MIIFYDCCA0igAwIBAgIJAOj6GWMU0voYMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMTYwNTI2MTYyODUyWhcNMjYwNTI0MTYy
ODUyWjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
AGMCAwEAAaOBpjCBozAdBgNVHQ4EFgQUNmHhAHyIBQlRi0RsR/8aTMnqTxIwHwYD
VR0jBBgwFoAUNmHhAHyIBQlRi0RsR/8aTMnqTxIwDwYDVR0TAQH/BAUwAwEB/zAO
BgNVHQ8BAf8EBAMCAYYwQAYDVR0fBDkwNzA1oDOgMYYvaHR0cHM6Ly9hbmRyb2lk
Lmdvb2dsZWFwaXMuY29tL2F0dGVzdGF0aW9uL2NybC8wDQYJKoZIhvcNAQELBQAD
ggIBACDIw41L3KlXG0aMiS//cqrG+EShHUGo8HNsw30W1kJtjn6UBwRM6jnmiwfB
Pb8VA91chb2vssAtX2zbTvqBJ9+LBPGCdw/E53Rbf86qhxKaiAHOjpvAy5Y3m00m
qC0w/Zwvju1twb4vhLaJ5NkUJYsUS7rmJKHHBnETLi8GFqiEsqTWpG/6ibYCv7rY
DBJDcR9W62BW9jfIoBQcxUCUJouMPH25lLNcDc1ssqvC2v7iUgI9LeoM1sNovqPm
QUiG9rHli1vXxzCyaMTjwftkJLkf6724DFhuKug2jITV0QkXvaJWF4nUaHOTNA4u
JU9WDvZLI1j83A+/xnAJUucIv/zGJ1AMH2boHqF8CY16LpsYgBt6tKxxWH00XcyD
CdW2KlBCeqbQPcsFmWyWugxdcekhYsAWyoSf818NUsZdBWBaR/OukXrNLfkQ79Iy
ZohZbvabO/X+MVT3rriAoKc8oE2Uws6DF+60PV7/WIPjNvXySdqspImSN78mflxD
qwLqRBYkA3I75qppLGG9rp7UCdRjxMl8ZDBld+7yvHVgt1cVzJx9xnyGCC23Uaic
MDSXYrB4I4WHXPGjxhZuCuPBLTdOLU8YRvMYdEvYebWHMpvwGCF6bAx3JBpIeOQ1
wDB5y0USicV3YgYGmi+NZfhA4URSh77Yd6uuJOJENRaNVTzk
-----END CERTIFICATE-----`
certificateAndroidKeyRoot4 = `-----BEGIN CERTIFICATE-----
MIIFHDCCAwSgAwIBAgIJANUP8luj8tazMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMTkxMTIyMjAzNzU4WhcNMzQxMTE4MjAz
NzU4WjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQBOMaBc8oumXb2voc7XCWnu
XKhBBK3e2KMGz39t7lA3XXRe2ZLLAkLM5y3J7tURkf5a1SutfdOyXAmeE6SRo83U
h6WszodmMkxK5GM4JGrnt4pBisu5igXEydaW7qq2CdC6DOGjG+mEkN8/TA6p3cno
L/sPyz6evdjLlSeJ8rFBH6xWyIZCbrcpYEJzXaUOEaxxXxgYz5/cTiVKN2M1G2ok
QBUIYSY6bjEL4aUN5cfo7ogP3UvliEo3Eo0YgwuzR2v0KR6C1cZqZJSTnghIC/vA
D32KdNQ+c3N+vl2OTsUVMC1GiWkngNx1OO1+kXW+YTnnTUOtOIswUP/Vqd5SYgAI
mMAfY8U9/iIgkQj6T2W6FsScy94IN9fFhE1UtzmLoBIuUFsVXJMTz+Jucth+IqoW
Fua9v1R93/k98p41pjtFX+H8DslVgfP097vju4KDlqN64xV1grw3ZLl4CiOe/A91
oeLm2UHOq6wn3esB4r2EIQKb6jTVGu5sYCcdWpXr0AUVqcABPdgL+H7qJguBw09o
jm6xNIrw2OocrDKsudk/okr/AwqEyPKw9WnMlQgLIKw1rODG2NvU9oR3GVGdMkUB
ZutL8VuFkERQGt6vQ2OCw0sV47VMkuYbacK/xyZFiRcrPJPb41zgbQj9XAEyLKCH
ex0SdDrx+tWUDqG8At2JHA==
-----END CERTIFICATE-----`
certificateAndroidKeyRoot5 = `-----BEGIN CERTIFICATE-----
MIIFHDCCAwSgAwIBAgIJAMNrfES5rhgxMA0GCSqGSIb3DQEBCwUAMBsxGTAXBgNV
BAUTEGY5MjAwOWU4NTNiNmIwNDUwHhcNMjExMTE3MjMxMDQyWhcNMzYxMTEzMjMx
MDQyWjAbMRkwFwYDVQQFExBmOTIwMDllODUzYjZiMDQ1MIICIjANBgkqhkiG9w0B
AQEFAAOCAg8AMIICCgKCAgEAr7bHgiuxpwHsK7Qui8xUFmOr75gvMsd/dTEDDJdS
Sxtf6An7xyqpRR90PL2abxM1dEqlXnf2tqw1Ne4Xwl5jlRfdnJLmN0pTy/4lj4/7
tv0Sk3iiKkypnEUtR6WfMgH0QZfKHM1+di+y9TFRtv6y//0rb+T+W8a9nsNL/ggj
nar86461qO0rOs2cXjp3kOG1FEJ5MVmFmBGtnrKpa73XpXyTqRxB/M0n1n/W9nGq
C4FSYa04T6N5RIZGBN2z2MT5IKGbFlbC8UrW0DxW7AYImQQcHtGl/m00QLVWutHQ
oVJYnFPlXTcHYvASLu+RhhsbDmxMgJJ0mcDpvsC4PjvB+TxywElgS70vE0XmLD+O
JtvsBslHZvPBKCOdT0MS+tgSOIfga+z1Z1g7+DVagf7quvmag8jfPioyKvxnK/Eg
sTUVi2ghzq8wm27ud/mIM7AY2qEORR8Go3TVB4HzWQgpZrt3i5MIlCaY504LzSRi
igHCzAPlHws+W0rB5N+er5/2pJKnfBSDiCiFAVtCLOZ7gLiMm0jhO2B6tUXHI/+M
RPjy02i59lINMRRev56GKtcd9qO/0kUJWdZTdA2XoS82ixPvZtXQpUpuL12ab+9E
aDK8Z4RHJYYfCT3Q5vNAXaiWQ+8PTWm2QgBR/bkwSWc+NpUFgNPN9PvQi8WEg5Um
AGMCAwEAAaNjMGEwHQYDVR0OBBYEFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMB8GA1Ud
IwQYMBaAFDZh4QB8iAUJUYtEbEf/GkzJ6k8SMA8GA1UdEwEB/wQFMAMBAf8wDgYD
VR0PAQH/BAQDAgIEMA0GCSqGSIb3DQEBCwUAA4ICAQBTNNZe5cuf8oiq+jV0itTG
zWVhSTjOBEk2FQvh11J3o3lna0o7rd8RFHnN00q4hi6TapFhh4qaw/iG6Xg+xOan
63niLWIC5GOPFgPeYXM9+nBb3zZzC8ABypYuCusWCmt6Tn3+Pjbz3MTVhRGXuT/T
QH4KGFY4PhvzAyXwdjTOCXID+aHud4RLcSySr0Fq/L+R8TWalvM1wJJPhyRjqRCJ
erGtfBagiALzvhnmY7U1qFcS0NCnKjoO7oFedKdWlZz0YAfu3aGCJd4KHT0MsGiL
Zez9WP81xYSrKMNEsDK+zK5fVzw6jA7cxmpXcARTnmAuGUeI7VVDhDzKeVOctf3a
0qQLwC+d0+xrETZ4r2fRGNw2YEs2W8Qj6oDcfPvq9JySe7pJ6wcHnl5EZ0lwc4xH
7Y4Dx9RA1JlfooLMw3tOdJZH0enxPXaydfAD3YifeZpFaUzicHeLzVJLt9dvGB0b
HQLE4+EqKFgOZv2EoP686DQqbVS1u+9k0p2xbMA105TBIk7npraa8VM0fnrRKi7w
lZKwdH+aNAyhbXRW9xsnODJ+g8eF452zvbiKKngEKirK5LGieoXBX7tZ9D1GNBH2
Ob3bKOwwIWdEFle/YF/h6zWgdeoaNGDqVBrLr2+0DtWoiB1aDEjLWl9FmyIUyUm7
mD/vFDkzF+wm7cyWpQpCVQ==
-----END CERTIFICATE-----`
)
var (
oidExtensionAppleAnonymousAttestation = asn1.ObjectIdentifier{1, 2, 840, 113635, 100, 8, 2}
oidExtensionAndroidKeystore = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 11129, 2, 1, 17}
oidExtensionSubjectAltName = asn1.ObjectIdentifier{2, 5, 29, 17}
oidExtensionExtendedKeyUsage = asn1.ObjectIdentifier{2, 5, 29, 37}
oidExtensionBasicConstraints = asn1.ObjectIdentifier{2, 5, 29, 19}
oidFIDOGenCeAAGUID = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 45724, 1, 1, 4}
oidMicrosoftKpPrivacyCA = asn1.ObjectIdentifier{1, 3, 6, 1, 4, 1, 311, 21, 36}
oidTCGKpAIKCertificate = asn1.ObjectIdentifier{2, 23, 133, 8, 3}
oidTCGAtTpmManufacturer = asn1.ObjectIdentifier{2, 23, 133, 2, 1}
oidTCGAtTpmModel = asn1.ObjectIdentifier{2, 23, 133, 2, 2}
oidTCGAtTPMVersion = asn1.ObjectIdentifier{2, 23, 133, 2, 3}
)
@@ -0,0 +1,283 @@
package protocol
import (
"crypto/sha256"
"encoding/base64"
"io"
"net/http"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// Credential is the basic credential type from the Credential Management specification that is inherited by WebAuthn's
// PublicKeyCredential type.
//
// Specification: Credential Management §2.2. The Credential Interface (https://www.w3.org/TR/credential-management/#credential)
type Credential struct {
// ID is The credentials identifier. The requirements for the
// identifier are distinct for each type of credential. It might
// represent a username for username/password tuples, for example.
ID string `json:"id"`
// Type is the value of the objects interface object's [[type]] slot,
// which specifies the credential type represented by this object.
// This should be type "public-key" for Webauthn credentials.
Type string `json:"type"`
}
// ParsedCredential is the parsed PublicKeyCredential interface, inherits from Credential, and contains
// the attributes that are returned to the caller when a new credential is created, or a new assertion is requested.
type ParsedCredential struct {
ID string `cbor:"id"`
Type string `cbor:"type"`
}
// PublicKeyCredential represents the IDL of the same name and contains the raw response returned to the Relying Party
// from the client's call to navigator.credentials.create() or navigator.credentials.get().
//
// Specification: §5.1. PublicKeyCredential Interface (https://www.w3.org/TR/webauthn/#iface-pkcredential)
type PublicKeyCredential struct {
Credential
RawID URLEncodedBase64 `json:"rawId"`
ClientExtensionResults AuthenticationExtensionsClientOutputs `json:"clientExtensionResults,omitempty"`
AuthenticatorAttachment string `json:"authenticatorAttachment,omitempty"`
}
// ParsedPublicKeyCredential is the parsed form of [PublicKeyCredential] with typed fields.
type ParsedPublicKeyCredential struct {
ParsedCredential
RawID []byte `json:"rawId"`
ClientExtensionResults AuthenticationExtensionsClientOutputs `json:"clientExtensionResults,omitempty"`
AuthenticatorAttachment AuthenticatorAttachment `json:"authenticatorAttachment,omitempty"`
}
// CredentialCreationResponse is the raw response returned to the Relying Party from the client for a credential
// registration ceremony. It contains the [AuthenticatorAttestationResponse] which holds the attestation object
// and client data.
//
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#sctn-credentialcreationoptions-extension)
type CredentialCreationResponse struct {
PublicKeyCredential
AttestationResponse AuthenticatorAttestationResponse `json:"response"`
}
// ParsedCredentialCreationData is the parsed form of [CredentialCreationResponse]. It is the result of parsing the
// raw response from the authenticator and can be used with [ParsedCredentialCreationData.Verify] to complete the
// registration ceremony verification.
type ParsedCredentialCreationData struct {
ParsedPublicKeyCredential
Response ParsedAttestationResponse
Raw CredentialCreationResponse
}
// ParseCredentialCreationResponse parses a registration/attestation response from a [*http.Request]. The request body
// is automatically drained and closed after parsing.
//
// This is the standard entry point when using [net/http]. For implementations that don't use [net/http], see
// [ParseCredentialCreationResponseBody] (accepts an [io.Reader]) or [ParseCredentialCreationResponseBytes] (accepts a
// []byte).
func ParseCredentialCreationResponse(request *http.Request) (*ParsedCredentialCreationData, error) {
if request == nil || request.Body == nil {
return nil, ErrBadRequest.WithDetails("No response given")
}
defer func() {
_, _ = io.Copy(io.Discard, request.Body)
_ = request.Body.Close()
}()
return ParseCredentialCreationResponseBody(request.Body)
}
// ParseCredentialCreationResponseBody parses a registration/attestation response from an [io.Reader]. The caller is
// responsible for closing the reader if applicable.
//
// This is the framework-agnostic variant of [ParseCredentialCreationResponse]. For a [*http.Request] use
// [ParseCredentialCreationResponse] instead. For raw bytes use [ParseCredentialCreationResponseBytes].
func ParseCredentialCreationResponseBody(body io.Reader) (pcc *ParsedCredentialCreationData, err error) {
var ccr CredentialCreationResponse
if err = decodeBody(body, &ccr); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo(err.Error()).WithError(err)
}
return ccr.Parse()
}
// ParseCredentialCreationResponseBytes parses a registration/attestation response from raw bytes.
//
// See also [ParseCredentialCreationResponse] (for [*http.Request]) and [ParseCredentialCreationResponseBody] (for
// [io.Reader]).
func ParseCredentialCreationResponseBytes(data []byte) (pcc *ParsedCredentialCreationData, err error) {
var ccr CredentialCreationResponse
if err = decodeBytes(data, &ccr); err != nil {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo(err.Error()).WithError(err)
}
return ccr.Parse()
}
// Parse validates and parses the CredentialCreationResponse into a ParsedCredentialCreationData. This receiver
// is unlikely to be expressly guaranteed under the versioning policy. Users looking for this guarantee should see
// ParseCredentialCreationResponseBody instead, and this receiver should only be used if that function is inadequate
// for their use case.
func (ccr CredentialCreationResponse) Parse() (pcc *ParsedCredentialCreationData, err error) {
if ccr.ID == "" {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Missing ID")
}
testB64, err := base64.RawURLEncoding.DecodeString(ccr.ID)
if err != nil || len(testB64) == 0 {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("ID not base64.RawURLEncoded")
}
if ccr.Type == "" {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Missing type")
}
if ccr.Type != string(PublicKeyCredentialType) {
return nil, ErrBadRequest.WithDetails("Parse error for Registration").WithInfo("Type not public-key")
}
response, err := ccr.AttestationResponse.Parse()
if err != nil {
return nil, ErrParsingData.WithDetails("Error parsing attestation response")
}
var attachment AuthenticatorAttachment
switch ccr.AuthenticatorAttachment {
case "platform":
attachment = Platform
case "cross-platform":
attachment = CrossPlatform
}
return &ParsedCredentialCreationData{
ParsedPublicKeyCredential{
ParsedCredential{ccr.ID, ccr.Type}, ccr.RawID, ccr.ClientExtensionResults, attachment,
},
*response,
ccr,
}, nil
}
// Verify the Client and Attestation data.
//
// Specification: §7.1. Registering a New Credential (https://www.w3.org/TR/webauthn/#sctn-registering-a-new-credential)
func (pcc *ParsedCredentialCreationData) Verify(storedChallenge string, relyingPartyID string, rpOrigins, rpTopOrigins []string, rpTopOriginsVerify TopOriginVerificationMode, allowCrossOrigin, verifyUser, verifyUserPresence bool, mds metadata.Provider, credParams []CredentialParameter) (clientDataHash []byte, err error) {
// Handles steps 3 through 6 - Verifying the Client Data against the Relying Party's stored data.
if err = pcc.Response.CollectedClientData.Verify(storedChallenge, CreateCeremony, rpOrigins, rpTopOrigins, rpTopOriginsVerify, allowCrossOrigin); err != nil {
return nil, err
}
// Step 7. Compute the hash of response.clientDataJSON using SHA-256.
sum := sha256.Sum256(pcc.Raw.AttestationResponse.ClientDataJSON)
clientDataHash = sum[:]
// Step 8. Perform CBOR decoding on the attestationObject field of the AuthenticatorAttestationResponse
// structure to obtain the attestation statement format fmt, the authenticator data authData, and the
// attestation statement attStmt.
// We do the above step while parsing and decoding the CredentialCreationResponse
// Handle steps 9 through 14 - This verifies the attestation object.
if err = pcc.Response.AttestationObject.Verify(relyingPartyID, clientDataHash, verifyUser, verifyUserPresence, mds, credParams); err != nil {
return clientDataHash, err
}
// Step 15. If validation is successful, obtain a list of acceptable trust anchors (attestation root
// certificates or ECDAA-Issuer public keys) for that attestation type and attestation statement
// format fmt, from a trusted source or from policy. For example, the FIDO Metadata Service provides
// one way to obtain such information, using the AAGUID in the attestedCredentialData in authData.
// [https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-metadata-service-v2.0-id-20180227.html]
// TODO: There are no valid AAGUIDs yet or trust sources supported. We could implement policy for the RP in
// the future, however.
// Step 16. Assess the attestation trustworthiness using outputs of the verification procedure in step 14, as follows:
// - If self attestation was used, check if self attestation is acceptable under Relying Party policy.
// - If ECDAA was used, verify that the identifier of the ECDAA-Issuer public key used is included in
// the set of acceptable trust anchors obtained in step 15.
// - Otherwise, use the X.509 certificates returned by the verification procedure to verify that the
// attestation public key correctly chains up to an acceptable root certificate.
// TODO: We're not supporting trust anchors, self-attestation policy, or acceptable root certs yet.
// Step 17. Check that the credentialId is not yet registered to any other user. If registration is
// requested for a credential that is already registered to a different user, the Relying Party SHOULD
// fail this registration ceremony, or it MAY decide to accept the registration, i.e. while deleting
// the older registration.
// TODO: We can't support this in the code's current form, the Relying Party would need to check for this
// against their database.
// Step 18 If the attestation statement attStmt verified successfully and is found to be trustworthy, then
// register the new credential with the account that was denoted in the options.user passed to create(), by
// associating it with the credentialId and credentialPublicKey in the attestedCredentialData in authData, as
// appropriate for the Relying Party's system.
// Step 19. If the attestation statement attStmt successfully verified but is not trustworthy per step 16 above,
// the Relying Party SHOULD fail the registration ceremony.
// TODO: Not implemented for the reasons mentioned under Step 16.
return clientDataHash, nil
}
// GetAppID takes a AuthenticationExtensions object or nil. It then performs the following checks in order:
//
// 1. Check that the Session Data's AuthenticationExtensions has been provided and if it hasn't return an error.
// 2. Check that the AuthenticationExtensionsClientOutputs contains the extensions output and return an empty string if it doesn't.
// 3. Check that the Credential AttestationFormat is `fido-u2f` and return an empty string if it isn't.
// 4. Check that the AuthenticationExtensionsClientOutputs contains the appid key and if it doesn't return an empty string.
// 5. Check that the AuthenticationExtensionsClientOutputs appid is a bool and if it isn't return an error.
// 6. Check that the appid output is true and if it isn't return an empty string.
// 7. Check that the Session Data has an appid extension defined and if it doesn't return an error.
// 8. Check that the appid extension in Session Data is a string and if it isn't return an error.
// 9. Return the appid extension value from the Session data.
func (ppkc ParsedPublicKeyCredential) GetAppID(authExt AuthenticationExtensions, credentialAttestationFormat string) (appID string, err error) {
var (
value, clientValue interface{}
enableAppID, ok bool
)
if authExt == nil {
return "", nil
}
if ppkc.ClientExtensionResults == nil {
return "", nil
}
// If the credential is not in the fido-u2f attestation FORMAT it is assumed to NOT be a fido-u2f credential.
// https://www.w3.org/TR/webauthn/#sctn-fido-u2f-attestation
if credentialAttestationFormat != string(AttestationFormatFIDOUniversalSecondFactor) {
return "", nil
}
if clientValue, ok = ppkc.ClientExtensionResults[ExtensionAppID]; !ok {
return "", nil
}
if enableAppID, ok = clientValue.(bool); !ok {
return "", ErrBadRequest.WithDetails("Client Output appid did not have the expected type")
}
if !enableAppID {
return "", nil
}
if value, ok = authExt[ExtensionAppID]; !ok {
return "", ErrBadRequest.WithDetails("Session Data does not have an appid but Client Output indicates it should be set")
}
if appID, ok = value.(string); !ok {
return "", ErrBadRequest.WithDetails("Session Data appid did not have the expected type")
}
return appID, nil
}
@@ -0,0 +1,40 @@
package protocol
import (
"bytes"
"encoding/json"
"errors"
"io"
)
func decodeBody(body io.Reader, v any) (err error) {
decoder := json.NewDecoder(body)
if err = decoder.Decode(v); err != nil {
return err
}
_, err = decoder.Token()
if !errors.Is(err, io.EOF) {
return errors.New("body contains trailing data")
}
return nil
}
func decodeBytes(data []byte, v any) (err error) {
decoder := json.NewDecoder(bytes.NewReader(data))
if err = decoder.Decode(v); err != nil {
return err
}
_, err = decoder.Token()
if !errors.Is(err, io.EOF) {
return errors.New("body contains trailing data")
}
return nil
}
@@ -0,0 +1,8 @@
// Package protocol contains data structures and validation functionality
// outlined in the Web Authentication specification (https://www.w3.org/TR/webauthn).
// The data structures here attempt to conform as much as possible to their definitions,
// but some structs (like those that are used as part of validation steps) contain
// additional fields that help us unpack and validate the data we unmarshall.
// When implementing this library, most developers will primarily be using the API
// outlined in the webauthn package.
package protocol
@@ -0,0 +1,46 @@
package protocol
// CredentialEntity represents the PublicKeyCredentialEntity IDL and it describes a user account, or a WebAuthn Relying
// Party with which a public key credential is associated.
//
// Specification: §5.4.1. Public Key Entity Description (https://www.w3.org/TR/webauthn/#dictionary-pkcredentialentity)
type CredentialEntity struct {
// A human-palatable name for the entity. Its function depends on what the PublicKeyCredentialEntity represents:
//
// When inherited by PublicKeyCredentialRpEntity it is a human-palatable identifier for the Relying Party,
// intended only for display. For example, "ACME Corporation", "Wonderful Widgets, Inc." or "ОАО Примертех".
//
// When inherited by PublicKeyCredentialUserEntity, it is a human-palatable identifier for a user account. It is
// intended only for display, i.e., aiding the user in determining the difference between user accounts with similar
// displayNames. For example, "alexm", "alex.p.mueller@example.com" or "+14255551234".
Name string `json:"name"`
}
// The RelyingPartyEntity represents the PublicKeyCredentialRpEntity IDL and is used to supply additional Relying Party
// attributes when creating a new credential.
//
// Specification: §5.4.2. Relying Party Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dictionary-rp-credential-params)
type RelyingPartyEntity struct {
CredentialEntity
// A unique identifier for the Relying Party entity, which sets the RP ID.
ID string `json:"id"`
}
// The UserEntity represents the PublicKeyCredentialUserEntity IDL and is used to supply additional user account
// attributes when creating a new credential.
//
// Specification: §5.4.3 User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialuserentity)
type UserEntity struct {
CredentialEntity
// A human-palatable name for the user account, intended only for display.
// For example, "Alex P. Müller" or "田中 倫". The Relying Party SHOULD let
// the user choose this, and SHOULD NOT restrict the choice more than necessary.
DisplayName string `json:"displayName"`
// ID is the user handle of the user account entity. To ensure secure operation,
// authentication and authorization decisions MUST be made on the basis of this id
// member, not the displayName nor name members. See Section 6.1 of
// [RFC8266](https://www.w3.org/TR/webauthn/#biblio-rfc8266).
ID any `json:"id"`
}
@@ -0,0 +1,154 @@
package protocol
// Error is a struct that describes specific error conditions in a structured format.
type Error struct {
// Short name for the type of error that has occurred.
Type string `json:"type"`
// Additional details about the error.
Details string `json:"error"`
// Information to help debug the error.
DevInfo string `json:"debug"`
// Inner error.
Err error `json:"-"`
}
func (e *Error) Error() string {
return e.Details
}
func (e *Error) Unwrap() error {
return e.Err
}
func (e *Error) WithDetails(details string) *Error {
err := *e
err.Details = details
return &err
}
func (e *Error) WithInfo(info string) *Error {
err := *e
err.DevInfo = info
return &err
}
func (e *Error) WithError(err error) *Error {
errCopy := *e
errCopy.Err = err
return &errCopy
}
// ErrorUnknownCredential is a special Error which signals the fact the provided credential is unknown. The reason this
// specific error type is useful is so that the relying-party can send a signal to the Authenticator that the
// credential has been removed.
type ErrorUnknownCredential struct {
Err *Error
}
func (e *ErrorUnknownCredential) Error() string {
return e.Err.Error()
}
func (e *ErrorUnknownCredential) Unwrap() error {
return e.Err
}
func (e *ErrorUnknownCredential) copy() ErrorUnknownCredential {
err := *e.Err
return ErrorUnknownCredential{Err: &err}
}
func (e *ErrorUnknownCredential) WithDetails(details string) *ErrorUnknownCredential {
err := e.copy()
err.Err.Details = details
return &err
}
func (e *ErrorUnknownCredential) WithInfo(info string) *ErrorUnknownCredential {
err := e.copy()
err.Err.DevInfo = info
return &err
}
func (e *ErrorUnknownCredential) WithError(err error) *ErrorUnknownCredential {
errCopy := e.copy()
errCopy.Err.Err = err
return &errCopy
}
var (
ErrBadRequest = &Error{
Type: "invalid_request",
Details: "Error reading the request data",
}
ErrPolicyRestriction = &Error{
Type: "policy_restriction",
Details: "Policy restriction prevented the operation from completing",
}
ErrChallengeMismatch = &Error{
Type: "challenge_mismatch",
Details: "Stored challenge and received challenge do not match",
}
ErrParsingData = &Error{
Type: "parse_error",
Details: "Error parsing the authenticator response",
}
ErrAuthData = &Error{
Type: "auth_data",
Details: "Error verifying the authenticator data",
}
ErrVerification = &Error{
Type: "verification_error",
Details: "Error validating the authenticator response",
}
ErrAttestation = &Error{
Type: "attestation_error",
Details: "Error validating the attestation data provided",
}
ErrInvalidAttestation = &Error{
Type: "invalid_attestation",
Details: "Invalid attestation data",
}
ErrMetadata = &Error{
Type: "invalid_metadata",
Details: "",
}
ErrAttestationFormat = &Error{
Type: "invalid_attestation",
Details: "Invalid attestation format",
}
ErrAttestationCertificate = &Error{
Type: "invalid_certificate",
Details: "Invalid attestation certificate",
}
ErrAssertionSignature = &Error{
Type: "invalid_signature",
Details: "Assertion Signature against auth data and client hash is not valid",
}
ErrUnsupportedKey = &Error{
Type: "invalid_key_type",
Details: "Unsupported Public Key Type",
}
ErrUnsupportedAlgorithm = &Error{
Type: "unsupported_key_algorithm",
Details: "Unsupported public key algorithm",
}
ErrNotSpecImplemented = &Error{
Type: "spec_unimplemented",
Details: "This field is not yet supported by the WebAuthn spec",
}
ErrNotImplemented = &Error{
Type: "not_implemented",
Details: "This field is not yet supported by this library",
}
)
@@ -0,0 +1,26 @@
package protocol
// Extensions are discussed in §9. WebAuthn Extensions (https://www.w3.org/TR/webauthn/#extensions).
// For a list of commonly supported extensions, see §10. Defined Extensions
// (https://www.w3.org/TR/webauthn/#sctn-defined-extensions).
// AuthenticationExtensionsClientOutputs represents the IDL of the same name. It is a map of extension identifier
// strings to their output values, returned by the client after a create() or get() call.
//
// Specification: §5.9. Authentication Extensions Client Outputs (https://www.w3.org/TR/webauthn/#iface-authentication-extensions-client-outputs)
type AuthenticationExtensionsClientOutputs map[string]any
const (
// ExtensionAppID is the FIDO AppID Extension identifier. It is used during authentication to allow credentials
// registered via the legacy FIDO U2F JavaScript API to be used with WebAuthn.
//
// Specification: §10.1. FIDO AppID Extension (https://www.w3.org/TR/webauthn/#sctn-appid-extension)
ExtensionAppID = "appid"
// ExtensionAppIDExclude is the FIDO AppID Exclusion Extension identifier. It is used during registration to
// exclude credentials previously registered via the legacy FIDO U2F JavaScript API.
//
// Specification: §10.2. FIDO AppID Exclusion Extension (https://www.w3.org/TR/webauthn/#sctn-appid-exclude-extension)
ExtensionAppIDExclude = "appidExclude"
)
@@ -0,0 +1,30 @@
package protocol
import (
"crypto/x509"
)
func init() {
initAndroidKeyHardwareRoots()
initAppleHardwareRoots()
}
func initAndroidKeyHardwareRoots() {
if attAndroidKeyHardwareRootsCertPool == nil {
attAndroidKeyHardwareRootsCertPool = x509.NewCertPool()
}
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot1)))
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot2)))
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot3)))
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot4)))
attAndroidKeyHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAndroidKeyRoot5)))
}
func initAppleHardwareRoots() {
if attAppleHardwareRootsCertPool == nil {
attAppleHardwareRootsCertPool = x509.NewCertPool()
}
attAppleHardwareRootsCertPool.AddCert(mustParseX509CertificatePEM([]byte(certificateAppleRoot1)))
}
@@ -0,0 +1,81 @@
package protocol
// isISO3166Alpha2 reports whether code is a valid ISO 3166-1 alpha-2 country code.
// Officially-assigned codes and user-assigned codes (AA, QMQZ, XAXZ, ZZ) are both
// accepted; the W3C WebAuthn test vectors use AA, so rejecting user-assigned codes
// would fail §16 conformance. Codes of the wrong length, wrong case, or containing
// non-letters are rejected.
func isISO3166Alpha2(code string) bool {
if _, ok := iso3166Alpha2Codes[code]; ok {
return true
}
return isISO3166Alpha2UserAssigned(code)
}
// isISO3166Alpha2UserAssigned reports whether code is a user-assignable code per
// ISO 3166-1 (AA, QMQZ, XAXZ, ZZ).
func isISO3166Alpha2UserAssigned(code string) bool {
if len(code) != 2 {
return false
}
switch code {
case "AA", "ZZ":
return true
}
switch code[0] {
case 'Q':
return code[1] >= 'M' && code[1] <= 'Z'
case 'X':
return code[1] >= 'A' && code[1] <= 'Z'
}
return false
}
var iso3166Alpha2Codes = map[string]struct{}{
"AD": {}, "AE": {}, "AF": {}, "AG": {}, "AI": {}, "AL": {}, "AM": {}, "AO": {},
"AQ": {}, "AR": {}, "AS": {}, "AT": {}, "AU": {}, "AW": {}, "AX": {}, "AZ": {},
"BA": {}, "BB": {}, "BD": {}, "BE": {}, "BF": {}, "BG": {}, "BH": {}, "BI": {},
"BJ": {}, "BL": {}, "BM": {}, "BN": {}, "BO": {}, "BQ": {}, "BR": {}, "BS": {},
"BT": {}, "BV": {}, "BW": {}, "BY": {}, "BZ": {},
"CA": {}, "CC": {}, "CD": {}, "CF": {}, "CG": {}, "CH": {}, "CI": {}, "CK": {},
"CL": {}, "CM": {}, "CN": {}, "CO": {}, "CR": {}, "CU": {}, "CV": {}, "CW": {},
"CX": {}, "CY": {}, "CZ": {},
"DE": {}, "DJ": {}, "DK": {}, "DM": {}, "DO": {}, "DZ": {},
"EC": {}, "EE": {}, "EG": {}, "EH": {}, "ER": {}, "ES": {}, "ET": {},
"FI": {}, "FJ": {}, "FK": {}, "FM": {}, "FO": {}, "FR": {},
"GA": {}, "GB": {}, "GD": {}, "GE": {}, "GF": {}, "GG": {}, "GH": {}, "GI": {},
"GL": {}, "GM": {}, "GN": {}, "GP": {}, "GQ": {}, "GR": {}, "GS": {}, "GT": {},
"GU": {}, "GW": {}, "GY": {},
"HK": {}, "HM": {}, "HN": {}, "HR": {}, "HT": {}, "HU": {},
"ID": {}, "IE": {}, "IL": {}, "IM": {}, "IN": {}, "IO": {}, "IQ": {}, "IR": {},
"IS": {}, "IT": {},
"JE": {}, "JM": {}, "JO": {}, "JP": {},
"KE": {}, "KG": {}, "KH": {}, "KI": {}, "KM": {}, "KN": {}, "KP": {}, "KR": {},
"KW": {}, "KY": {}, "KZ": {},
"LA": {}, "LB": {}, "LC": {}, "LI": {}, "LK": {}, "LR": {}, "LS": {}, "LT": {},
"LU": {}, "LV": {}, "LY": {},
"MA": {}, "MC": {}, "MD": {}, "ME": {}, "MF": {}, "MG": {}, "MH": {}, "MK": {},
"ML": {}, "MM": {}, "MN": {}, "MO": {}, "MP": {}, "MQ": {}, "MR": {}, "MS": {},
"MT": {}, "MU": {}, "MV": {}, "MW": {}, "MX": {}, "MY": {}, "MZ": {},
"NA": {}, "NC": {}, "NE": {}, "NF": {}, "NG": {}, "NI": {}, "NL": {}, "NO": {},
"NP": {}, "NR": {}, "NU": {}, "NZ": {},
"OM": {},
"PA": {}, "PE": {}, "PF": {}, "PG": {}, "PH": {}, "PK": {}, "PL": {}, "PM": {},
"PN": {}, "PR": {}, "PS": {}, "PT": {}, "PW": {}, "PY": {},
"QA": {},
"RE": {}, "RO": {}, "RS": {}, "RU": {}, "RW": {},
"SA": {}, "SB": {}, "SC": {}, "SD": {}, "SE": {}, "SG": {}, "SH": {}, "SI": {},
"SJ": {}, "SK": {}, "SL": {}, "SM": {}, "SN": {}, "SO": {}, "SR": {}, "SS": {},
"ST": {}, "SV": {}, "SX": {}, "SY": {}, "SZ": {},
"TC": {}, "TD": {}, "TF": {}, "TG": {}, "TH": {}, "TJ": {}, "TK": {}, "TL": {},
"TM": {}, "TN": {}, "TO": {}, "TR": {}, "TT": {}, "TV": {}, "TW": {}, "TZ": {},
"UA": {}, "UG": {}, "UM": {}, "US": {}, "UY": {}, "UZ": {},
"VA": {}, "VC": {}, "VE": {}, "VG": {}, "VI": {}, "VN": {}, "VU": {},
"WF": {}, "WS": {},
"YE": {}, "YT": {},
"ZA": {}, "ZM": {}, "ZW": {},
}
@@ -0,0 +1,132 @@
package protocol
import (
"context"
"crypto/x509"
"fmt"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
)
// ValidateMetadata validates the metadata for the given authenticator.
//
//nolint:gocyclo
func ValidateMetadata(ctx context.Context, mds metadata.Provider, aaguid uuid.UUID, attestationType, attestationFormat string, x5cs []any) (protoErr *Error) {
if mds == nil {
return nil
}
if AttestationFormat(attestationFormat) == AttestationFormatNone {
return nil
}
var (
entry *metadata.Entry
err error
)
if entry, err = mds.GetEntry(ctx, aaguid); err != nil {
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. Error occurred retrieving the metadata entry: %+v", aaguid, err))
}
if entry == nil {
if aaguid == uuid.Nil && mds.GetValidateEntryPermitZeroAAGUID(ctx) {
return nil
}
if mds.GetValidateEntry(ctx) {
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. The authenticator has no registered metadata.", aaguid))
}
return nil
}
if attestationType != "" && attestationType != stmtTypNone && mds.GetValidateAttestationTypes(ctx) {
found := false
for _, atype := range entry.MetadataStatement.AttestationTypes {
if string(atype) == attestationType {
found = true
break
}
}
if !found {
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. The attestation type '%s' is not known to be used by this authenticator.", aaguid.String(), attestationType))
}
}
if mds.GetValidateStatus(ctx) {
if err = mds.ValidateStatusReports(ctx, entry.StatusReports); err != nil {
return ErrMetadata.WithInfo(fmt.Sprintf("Failed to validate authenticator metadata for Authenticator Attestation GUID '%s'. Error occurred validating the authenticator status: %+v", aaguid, err))
}
}
if mds.GetValidateTrustAnchor(ctx) {
if len(x5cs) == 0 {
return nil
}
var (
x5c, parsed *x509.Certificate
x5cis []*x509.Certificate
raw []byte
ok bool
)
for i, x5cAny := range x5cs {
if raw, ok = x5cAny.([]byte); !ok {
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to parse attestation certificate from x5c during attestation validation for Authenticator Attestation GUID '%s'.", aaguid)).WithInfo(fmt.Sprintf("The %s certificate in the attestation was type '%T' but '[]byte' was expected", loopOrdinalNumber(i), x5cAny))
}
if parsed, err = x509.ParseCertificate(raw); err != nil {
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to parse attestation certificate from x5c during attestation validation for Authenticator Attestation GUID '%s'.", aaguid)).WithInfo(fmt.Sprintf("Error returned from x509.ParseCertificate: %+v", err)).WithError(err)
}
if x5c == nil {
x5c = parsed
} else {
x5cis = append(x5cis, parsed)
}
}
if attestationType == string(metadata.AttCA) {
if protoErr = tpmParseAIKAttCA(x5c, x5cis); protoErr != nil {
return ErrMetadata.WithDetails(protoErr.Details).WithInfo(protoErr.DevInfo).WithError(protoErr)
}
}
if x5c != nil && x5c.Subject.CommonName != x5c.Issuer.CommonName {
if !entry.MetadataStatement.AttestationTypes.HasBasicFull() {
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to validate attestation statement signature during attestation validation for Authenticator Attestation GUID '%s'. Attestation was provided in the full format but the authenticator doesn't support the full attestation format.", aaguid))
}
if _, err = x5c.Verify(entry.MetadataStatement.Verifier(x5cis)); err != nil {
return ErrMetadata.WithDetails(fmt.Sprintf("Failed to validate attestation statement signature during attestation validation for Authenticator Attestation GUID '%s'. The attestation certificate could not be verified due to an error validating the trust chain against the Metadata Service.", aaguid)).WithError(err)
}
}
}
return nil
}
func loopOrdinalNumber(n int) string {
n++
if n > 9 && n < 20 {
return fmt.Sprintf("%dth", n)
}
switch n % 10 {
case 1:
return fmt.Sprintf("%dst", n)
case 2:
return fmt.Sprintf("%dnd", n)
case 3:
return fmt.Sprintf("%drd", n)
default:
return fmt.Sprintf("%dth", n)
}
}
@@ -0,0 +1,305 @@
package protocol
// CredentialCreation is the top-level request object for credential registration. It wraps
// [PublicKeyCredentialCreationOptions] and an optional mediation requirement. This is the object that should be
// serialized and sent to the client to initiate the navigator.credentials.create() call.
//
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#dictionary-makecredentialoptions)
type CredentialCreation struct {
Response PublicKeyCredentialCreationOptions `json:"publicKey"`
Mediation CredentialMediationRequirement `json:"mediation,omitempty"`
}
// CredentialAssertion is the top-level request object for credential assertion (login). It wraps
// [PublicKeyCredentialRequestOptions] and an optional mediation requirement. This is the object that should be
// serialized and sent to the client to initiate the navigator.credentials.get() call.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
type CredentialAssertion struct {
Response PublicKeyCredentialRequestOptions `json:"publicKey"`
Mediation CredentialMediationRequirement `json:"mediation,omitempty"`
}
// PublicKeyCredentialCreationOptions represents the IDL of the same name.
//
// In order to create a Credential via create(), the caller specifies a few parameters in a
// PublicKeyCredentialCreationOptions object.
//
// WebAuthn Level 3: hints,attestationFormats.
//
// Specification: §5.4. Options for Credential Creation (https://www.w3.org/TR/webauthn/#dictionary-makecredentialoptions)
type PublicKeyCredentialCreationOptions struct {
RelyingParty RelyingPartyEntity `json:"rp"`
User UserEntity `json:"user"`
Challenge URLEncodedBase64 `json:"challenge"`
Parameters []CredentialParameter `json:"pubKeyCredParams,omitempty"`
Timeout int `json:"timeout,omitempty"`
CredentialExcludeList []CredentialDescriptor `json:"excludeCredentials,omitempty"`
AuthenticatorSelection AuthenticatorSelection `json:"authenticatorSelection,omitempty"`
Hints []PublicKeyCredentialHints `json:"hints,omitempty"`
Attestation ConveyancePreference `json:"attestation,omitempty"`
AttestationFormats []AttestationFormat `json:"attestationFormats,omitempty"`
Extensions AuthenticationExtensions `json:"extensions,omitempty"`
}
// The PublicKeyCredentialRequestOptions dictionary supplies get() with the data it needs to generate an assertion.
// Its challenge member MUST be present, while its other members are OPTIONAL.
//
// WebAuthn Level 3: hints.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
type PublicKeyCredentialRequestOptions struct {
Challenge URLEncodedBase64 `json:"challenge"`
Timeout int `json:"timeout,omitempty"`
RelyingPartyID string `json:"rpId,omitempty"`
AllowedCredentials []CredentialDescriptor `json:"allowCredentials,omitempty"`
UserVerification UserVerificationRequirement `json:"userVerification,omitempty"`
Hints []PublicKeyCredentialHints `json:"hints,omitempty"`
Extensions AuthenticationExtensions `json:"extensions,omitempty"`
}
// CredentialDescriptor represents the PublicKeyCredentialDescriptor IDL.
//
// This dictionary contains the attributes that are specified by a caller when referring to a public key credential as
// an input parameter to the create() or get() methods. It mirrors the fields of the PublicKeyCredential object returned
// by the latter methods.
//
// Specification: §5.10.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#credential-dictionary)
type CredentialDescriptor struct {
// The valid credential types.
Type CredentialType `json:"type"`
// CredentialID The ID of a credential to allow/disallow.
CredentialID URLEncodedBase64 `json:"id"`
// The authenticator transports that can be used.
Transport []AuthenticatorTransport `json:"transports,omitempty"`
// AttestationType is the attestation type from the originating Credential (one of "basic_full",
// "basic_surrogate", "attca", "anonca", "ecdaa", "none"). Used internally only; not serialized.
AttestationType string `json:"-"`
// AttestationFormat is the attestation statement format from the originating Credential (one of "packed",
// "tpm", "android-key", "android-safetynet", "fido-u2f", "apple", "compound", "none"). Used internally only;
// not serialized. Prior releases overloaded [CredentialDescriptor.AttestationType] with this value; callers
// that construct descriptors directly should populate this field instead.
AttestationFormat string `json:"-"`
}
func (c CredentialDescriptor) SignalUnknownCredential(rpid string) *SignalUnknownCredential {
return &SignalUnknownCredential{
CredentialID: c.CredentialID,
RPID: rpid,
}
}
// CredentialType represents the PublicKeyCredentialType IDL and is used with the CredentialDescriptor IDL.
//
// This enumeration defines the valid credential types. It is an extension point; values can be added to it in the
// future, as more credential types are defined. The values of this enumeration are used for versioning the
// Authentication Assertion and attestation structures according to the type of the authenticator.
//
// Currently one credential type is defined, namely "public-key".
//
// Specification: §5.8.2. Credential Type Enumeration (https://www.w3.org/TR/webauthn/#enumdef-publickeycredentialtype)
//
// Specification: §5.8.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#dictionary-credential-descriptor)
type CredentialType string
const (
// PublicKeyCredentialType - Currently one credential type is defined, namely "public-key".
PublicKeyCredentialType CredentialType = "public-key"
)
// AuthenticationExtensions represents the AuthenticationExtensionsClientInputs IDL. This member contains additional
// parameters requesting additional processing by the client and authenticator.
//
// Specification: §5.7.1. Authentication Extensions Client Inputs (https://www.w3.org/TR/webauthn/#iface-authentication-extensions-client-inputs)
type AuthenticationExtensions map[string]any
// AuthenticatorSelection represents the AuthenticatorSelectionCriteria IDL.
//
// WebAuthn Relying Parties may use the AuthenticatorSelectionCriteria dictionary to specify their requirements
// regarding authenticator attributes.
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictionary-authenticatorSelection)
type AuthenticatorSelection struct {
// AuthenticatorAttachment If this member is present, eligible authenticators are filtered to only
// authenticators attached with the specified AuthenticatorAttachment enum.
AuthenticatorAttachment AuthenticatorAttachment `json:"authenticatorAttachment,omitempty"`
// RequireResidentKey this member describes the Relying Party's requirements regarding resident
// credentials. If the parameter is set to true, the authenticator MUST create a client-side-resident
// public key credential source when creating a public key credential.
RequireResidentKey *bool `json:"requireResidentKey,omitempty"`
// ResidentKey this member describes the Relying Party's requirements regarding resident
// credentials per Webauthn Level 2.
ResidentKey ResidentKeyRequirement `json:"residentKey,omitempty"`
// UserVerification This member describes the Relying Party's requirements regarding user verification for
// the create() operation. Eligible authenticators are filtered to only those capable of satisfying this
// requirement.
UserVerification UserVerificationRequirement `json:"userVerification,omitempty"`
}
// ConveyancePreference is the type representing the AttestationConveyancePreference IDL.
//
// WebAuthn Relying Parties may use AttestationConveyancePreference to specify their preference regarding attestation
// conveyance during credential generation.
//
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#enum-attestation-convey)
type ConveyancePreference string
const (
// PreferNoAttestation is a ConveyancePreference value.
//
// This value indicates that the Relying Party is not interested in authenticator attestation. For example, in order
// to potentially avoid having to obtain user consent to relay identifying information to the Relying Party, or to
// save a round trip to an Attestation CA or Anonymization CA.
//
// This is the default value.
//
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-none)
PreferNoAttestation ConveyancePreference = none
// PreferIndirectAttestation is a ConveyancePreference value.
//
// This value indicates that the Relying Party prefers an attestation conveyance yielding verifiable attestation
// statements, but allows the client to decide how to obtain such attestation statements. The client MAY replace the
// authenticator-generated attestation statements with attestation statements generated by an Anonymization CA, in
// order to protect the users privacy, or to assist Relying Parties with attestation verification in a
// heterogeneous ecosystem.
//
// Note: There is no guarantee that the Relying Party will obtain a verifiable attestation statement in this case.
// For example, in the case that the authenticator employs self attestation.
//
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-indirect)
PreferIndirectAttestation ConveyancePreference = "indirect"
// PreferDirectAttestation is a ConveyancePreference value.
//
// This value indicates that the Relying Party wants to receive the attestation statement as generated by the
// authenticator.
//
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-direct)
PreferDirectAttestation ConveyancePreference = "direct"
// PreferEnterpriseAttestation is a ConveyancePreference value.
//
// This value indicates that the Relying Party wants to receive an attestation statement that may include uniquely
// identifying information. This is intended for controlled deployments within an enterprise where the organization
// wishes to tie registrations to specific authenticators. User agents MUST NOT provide such an attestation unless
// the user agent or authenticator configuration permits it for the requested RP ID.
//
// If permitted, the user agent SHOULD signal to the authenticator (at invocation time) that enterprise
// attestation is requested, and convey the resulting AAGUID and attestation statement, unaltered, to the Relying
// Party.
//
// Specification: §5.4.7. Attestation Conveyance Preference Enumeration (https://www.w3.org/TR/webauthn/#dom-attestationconveyancepreference-enterprise)
PreferEnterpriseAttestation ConveyancePreference = "enterprise"
)
// AttestationFormat is an internal representation of the relevant inputs for registration.
//
// Specification: §5.4 Options for Credential Creation (https://w3c.github.io/webauthn/#dom-publickeycredentialcreationoptions-attestationformats)
// Registry: https://www.iana.org/assignments/webauthn/webauthn.xhtml
type AttestationFormat string
const (
// AttestationFormatPacked is the "packed" attestation statement format is a WebAuthn-optimized format for
// attestation. It uses a very compact but still extensible encoding method. This format is implementable by
// authenticators with limited resources (i.e., secure elements).
AttestationFormatPacked AttestationFormat = "packed"
// AttestationFormatTPM is the TPM attestation statement format returns an attestation statement in the same format
// as the packed attestation statement format, although the rawData and signature fields are computed differently.
AttestationFormatTPM AttestationFormat = "tpm"
// AttestationFormatAndroidKey is the attestation statement format for platform authenticators on versions "N", and
// later, which may provide this proprietary "hardware attestation" statement.
AttestationFormatAndroidKey AttestationFormat = "android-key"
// AttestationFormatAndroidSafetyNet is the attestation statement format that Android-based platform authenticators
// MAY produce an attestation statement based on the Android SafetyNet API.
AttestationFormatAndroidSafetyNet AttestationFormat = "android-safetynet"
// AttestationFormatFIDOUniversalSecondFactor is the attestation statement format that is used with FIDO U2F
// authenticators.
AttestationFormatFIDOUniversalSecondFactor AttestationFormat = "fido-u2f"
// AttestationFormatApple is the attestation statement format that is used with Apple devices' platform
// authenticators.
AttestationFormatApple AttestationFormat = "apple"
// AttestationFormatCompound is used to pass multiple, self-contained attestation statements in a single ceremony.
AttestationFormatCompound AttestationFormat = "compound"
// AttestationFormatNone is the attestation statement format that is used to replace any authenticator-provided
// attestation statement when a WebAuthn Relying Party indicates it does not wish to receive attestation information.
AttestationFormatNone AttestationFormat = none
)
type PublicKeyCredentialHints string
const (
// PublicKeyCredentialHintSecurityKey is a PublicKeyCredentialHint that indicates that the Relying Party believes
// that users will satisfy this request with a physical security key. For example, an enterprise Relying Party may
// set this hint if they have issued security keys to their employees and will only accept those authenticators for
// registration and authentication.
//
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
// authenticatorAttachment SHOULD be set to cross-platform.
PublicKeyCredentialHintSecurityKey PublicKeyCredentialHints = "security-key"
// PublicKeyCredentialHintClientDevice is a PublicKeyCredentialHint that indicates that the Relying Party believes
// that users will satisfy this request with a platform authenticator attached to the client device.
//
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
// authenticatorAttachment SHOULD be set to platform.
PublicKeyCredentialHintClientDevice PublicKeyCredentialHints = "client-device"
// PublicKeyCredentialHintHybrid is a PublicKeyCredentialHint that indicates that the Relying Party believes that
// users will satisfy this request with general-purpose authenticators such as smartphones. For example, a consumer
// Relying Party may believe that only a small fraction of their customers possesses dedicated security keys. This
// option also implies that the local platform authenticator should not be promoted in the UI.
//
// For compatibility with older user agents, when this hint is used in PublicKeyCredentialCreationOptions, the
// authenticatorAttachment SHOULD be set to cross-platform.
PublicKeyCredentialHintHybrid PublicKeyCredentialHints = "hybrid"
)
func (a *PublicKeyCredentialRequestOptions) GetAllowedCredentialIDs() [][]byte {
var allowedCredentialIDs = make([][]byte, len(a.AllowedCredentials))
for i, credential := range a.AllowedCredentials {
allowedCredentialIDs[i] = credential.CredentialID
}
return allowedCredentialIDs
}
// Extensions is a generic type for WebAuthn extensions. The actual contents are defined by each individual extension.
//
// Specification: §9. WebAuthn Extensions (https://www.w3.org/TR/webauthn/#extensions)
type Extensions any
// ServerResponse is a response from a FIDO conformance server.
type ServerResponse struct {
// Status indicates whether the operation succeeded or failed.
Status ServerResponseStatus `json:"status"`
// Message provides additional details about an error if Status is "failed".
Message string `json:"errorMessage"`
}
// ServerResponseStatus is the status code returned by a FIDO conformance server.
type ServerResponseStatus string
const (
// StatusOk indicates the server operation was successful.
StatusOk ServerResponseStatus = "ok"
// StatusFailed indicates the server operation failed.
StatusFailed ServerResponseStatus = "failed"
)
@@ -0,0 +1,16 @@
package protocol
import "gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
//go:generate msgp
//msgp:replace webauthncose.COSEAlgorithmIdentifier with:int
//msgp:replace CredentialType with:string
//msgp:clearomitted
// CredentialParameter is the credential type and algorithm
// that the relying party wants the authenticator to create.
type CredentialParameter struct {
Type CredentialType `json:"type" msg:"typ,omitempty"`
Algorithm webauthncose.COSEAlgorithmIdentifier `json:"alg" msg:"alg,omitempty"`
}
@@ -0,0 +1,223 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package protocol
import (
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *CredentialParameter) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 2 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "typ":
{
var zb0002 string
zb0002, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Type")
return
}
z.Type = CredentialType(zb0002)
}
zb0001Mask |= 0x1
case "alg":
{
var zb0003 int
zb0003, err = dc.ReadInt()
if err != nil {
err = msgp.WrapError(err, "Algorithm")
return
}
z.Algorithm = webauthncose.COSEAlgorithmIdentifier(zb0003)
}
zb0001Mask |= 0x2
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x3 {
if (zb0001Mask & 0x1) == 0 {
z.Type = ""
}
if (zb0001Mask & 0x2) == 0 {
z.Algorithm = 0
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z CredentialParameter) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(2)
var zb0001Mask uint8 /* 2 bits */
_ = zb0001Mask
if z.Type == "" {
zb0001Len--
zb0001Mask |= 0x1
}
if z.Algorithm == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// write "typ"
err = en.Append(0xa3, 0x74, 0x79, 0x70)
if err != nil {
return
}
err = en.WriteString(string(z.Type))
if err != nil {
err = msgp.WrapError(err, "Type")
return
}
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "alg"
err = en.Append(0xa3, 0x61, 0x6c, 0x67)
if err != nil {
return
}
err = en.WriteInt(int(z.Algorithm))
if err != nil {
err = msgp.WrapError(err, "Algorithm")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z CredentialParameter) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(2)
var zb0001Mask uint8 /* 2 bits */
_ = zb0001Mask
if z.Type == "" {
zb0001Len--
zb0001Mask |= 0x1
}
if z.Algorithm == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// string "typ"
o = append(o, 0xa3, 0x74, 0x79, 0x70)
o = msgp.AppendString(o, string(z.Type))
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "alg"
o = append(o, 0xa3, 0x61, 0x6c, 0x67)
o = msgp.AppendInt(o, int(z.Algorithm))
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *CredentialParameter) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 2 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "typ":
{
var zb0002 string
zb0002, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Type")
return
}
z.Type = CredentialType(zb0002)
}
zb0001Mask |= 0x1
case "alg":
{
var zb0003 int
zb0003, bts, err = msgp.ReadIntBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Algorithm")
return
}
z.Algorithm = webauthncose.COSEAlgorithmIdentifier(zb0003)
}
zb0001Mask |= 0x2
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x3 {
if (zb0001Mask & 0x1) == 0 {
z.Type = ""
}
if (zb0001Mask & 0x2) == 0 {
z.Algorithm = 0
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z CredentialParameter) Msgsize() (s int) {
s = 1 + 4 + msgp.StringPrefixSize + len(string(z.Type)) + 4 + msgp.IntSize
return
}
@@ -0,0 +1,51 @@
package protocol
// NewSignalAllAcceptedCredentials creates a new SignalAllAcceptedCredentials struct that can simply be encoded with
// json.Marshal.
func NewSignalAllAcceptedCredentials(rpid string, user AllAcceptedCredentialsUser) *SignalAllAcceptedCredentials {
if user == nil {
return nil
}
credentials := user.WebAuthnCredentialIDs()
ids := make([]URLEncodedBase64, len(credentials))
for i, id := range credentials {
ids[i] = id
}
return &SignalAllAcceptedCredentials{
AllAcceptedCredentialIDs: ids,
RPID: rpid,
UserID: user.WebAuthnID(),
}
}
// SignalAllAcceptedCredentials is a struct which represents the CDDL of the same name.
type SignalAllAcceptedCredentials struct {
AllAcceptedCredentialIDs []URLEncodedBase64 `json:"allAcceptedCredentialIds"`
RPID string `json:"rpId"`
UserID URLEncodedBase64 `json:"userId"`
}
// SignalCurrentUserDetails is a struct which represents the CDDL of the same name.
type SignalCurrentUserDetails struct {
DisplayName string `json:"displayName"`
Name string `json:"name"`
RPID string `json:"rpId"`
UserID URLEncodedBase64 `json:"userId"`
}
// SignalUnknownCredential is a struct which represents the CDDL of the same name.
type SignalUnknownCredential struct {
CredentialID URLEncodedBase64 `json:"credentialId"`
RPID string `json:"rpId"`
}
// AllAcceptedCredentialsUser is an interface that can be implemented by a user to provide information about their
// accepted credentials.
type AllAcceptedCredentialsUser interface {
WebAuthnID() []byte
WebAuthnCredentialIDs() [][]byte
}
+270
View File
@@ -0,0 +1,270 @@
package protocol
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"net/url"
"strings"
"time"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
func mustParseX509Certificate(der []byte) *x509.Certificate {
cert, err := x509.ParseCertificate(der)
if err != nil {
panic(err)
}
return cert
}
func mustParseX509CertificatePEM(raw []byte) *x509.Certificate {
block, rest := pem.Decode(raw)
if len(rest) > 0 || block == nil || block.Type != "CERTIFICATE" {
panic("Invalid PEM Certificate")
}
return mustParseX509Certificate(block.Bytes)
}
func attStatementParseX5CS(attStatement map[string]any, key string) (x5c []any, x5cs []*x509.Certificate, err error) {
var ok bool
if x5c, ok = attStatement[key].([]any); !ok {
return nil, nil, ErrAttestationFormat.WithDetails("Error retrieving x5c value")
}
if len(x5c) == 0 {
return nil, nil, ErrAttestationFormat.WithDetails("Error retrieving x5c value: empty array")
}
if x5cs, err = parseX5C(x5c); err != nil {
return nil, nil, ErrAttestationFormat.WithDetails("Error retrieving x5c value: error occurred parsing values").WithError(err)
}
return x5c, x5cs, nil
}
func parseX5C(x5c []any) (x5cs []*x509.Certificate, err error) {
x5cs = make([]*x509.Certificate, len(x5c))
var (
raw []byte
ok bool
)
for i, t := range x5c {
if raw, ok = t.([]byte); !ok {
return nil, fmt.Errorf("x5c[%d] is not a byte array", i)
}
if x5cs[i], err = x509.ParseCertificate(raw); err != nil {
return nil, fmt.Errorf("x5c[%d] is not a valid certificate: %w", i, err)
}
}
return x5cs, nil
}
// attStatementCertChainVerify allows verifying an attestation statement certificate chain and optionally allows
// mangling the not after value for purpose of just validating the attestation lineage. If you set mangleNotAfter to
// true this function should only be considered safe for determining lineage, and not hte validity of a chain in
// general.
//
// WARNING: Setting mangleNotAfter=true weakens security by accepting expired certificates.
func attStatementCertChainVerify(certs []*x509.Certificate, roots *x509.CertPool, mangleNotAfter bool, mangleNotAfterSafeTime time.Time) (chains [][]*x509.Certificate, err error) {
if len(certs) == 0 {
return nil, errors.New("empty chain")
}
leaf := certs[0]
for _, cert := range certs {
if !cert.IsCA {
leaf = certInsecureConditionalNotAfterMangle(cert, mangleNotAfter, mangleNotAfterSafeTime)
break
}
}
var (
intermediates *x509.CertPool
)
staticRoots := roots != nil
intermediates = x509.NewCertPool()
if roots == nil {
if roots, err = x509.SystemCertPool(); err != nil || roots == nil {
roots = x509.NewCertPool()
}
}
for _, cert := range certs {
if cert == leaf {
continue
}
if isSelfSigned(cert) && !staticRoots {
roots.AddCert(certInsecureConditionalNotAfterMangle(cert, mangleNotAfter, mangleNotAfterSafeTime))
} else {
intermediates.AddCert(certInsecureConditionalNotAfterMangle(cert, mangleNotAfter, mangleNotAfterSafeTime))
}
}
opts := x509.VerifyOptions{
Roots: roots,
Intermediates: intermediates,
}
return leaf.Verify(opts)
}
func isSelfSigned(c *x509.Certificate) bool {
if !c.IsCA {
return false
}
return c.CheckSignatureFrom(c) == nil
}
// This function is used to intentionally but conditionally mangle the certificate not after value to exclude it from
// the verification process. This should only be used in instances where all you care about is which certificates
// performed the signing.
//
// WARNING: Setting mangle=true weakens security by accepting expired certificates.
func certInsecureConditionalNotAfterMangle(cert *x509.Certificate, mangle bool, safe time.Time) (out *x509.Certificate) {
if !mangle || cert.NotAfter.After(time.Now().Add(time.Minute)) {
return cert
}
out = &x509.Certificate{}
*out = *cert
out.NotAfter = safe
return out
}
func verifyAttestationECDSAPublicKeyMatch(att AttestationObject, cert *x509.Certificate) (attPublicKeyData webauthncose.EC2PublicKeyData, err error) {
var (
key any
ok bool
publicKey, attPublicKey *ecdsa.PublicKey
)
if key, err = webauthncose.ParsePublicKey(att.AuthData.AttData.CredentialPublicKey); err != nil {
return attPublicKeyData, ErrInvalidAttestation.WithDetails(fmt.Sprintf("Error parsing public key: %+v", err)).WithError(err)
}
if attPublicKeyData, ok = key.(webauthncose.EC2PublicKeyData); !ok {
return attPublicKeyData, ErrInvalidAttestation.WithDetails("Attestation public key is not ECDSA")
}
if publicKey, ok = cert.PublicKey.(*ecdsa.PublicKey); !ok {
return attPublicKeyData, ErrInvalidAttestation.WithDetails("Credential public key is not ECDSA")
}
if attPublicKey, err = attPublicKeyData.ToECDSA(); err != nil {
return attPublicKeyData, ErrInvalidAttestation.WithDetails("Error converting public key to ECDSA").WithError(err)
}
if !attPublicKey.Equal(publicKey) {
return attPublicKeyData, ErrInvalidAttestation.WithDetails("Certificate public key does not match public key in authData")
}
return attPublicKeyData, nil
}
// ValidateRPID performs non-exhaustive checks to ensure the string is most likely a domain string as
// relying-party ID's are required to be. Effectively this can be an IP, localhost, or a string that contains a period.
// The relying-party ID must not contain scheme, port, path, query, or fragment components.
//
// See: https://www.w3.org/TR/webauthn/#rp-id
//
//nolint:gocyclo
func ValidateRPID(value string) (err error) {
if len(value) == 0 {
return errors.New("empty value provided")
}
if ip := net.ParseIP(value); ip != nil {
return nil
}
var rpid *url.URL
if rpid, err = url.Parse(value); err != nil {
return err
}
if rpid.Scheme != "" && rpid.Opaque != "" && rpid.Path == "" {
return errors.New("the port component must be empty")
}
if rpid.Scheme != "" {
if rpid.Host != "" && rpid.Path != "" {
return errors.New("the path component must be empty")
}
if rpid.Host != "" && rpid.RawQuery != "" {
return errors.New("the query component must be empty")
}
if rpid.Host != "" && rpid.Fragment != "" {
return errors.New("the fragment component must be empty")
}
if rpid.Host != "" && rpid.Port() != "" {
return errors.New("the port component must be empty")
}
return errors.New("the scheme component must be empty")
}
if rpid.RawQuery != "" {
return errors.New("the query component must be empty")
}
if rpid.RawFragment != "" || rpid.Fragment != "" {
return errors.New("the fragment component must be empty")
}
if rpid.Host == "" {
if strings.Contains(rpid.Path, "/") {
return errors.New("the path component must be empty")
}
}
if value != "localhost" && !strings.Contains(rpid.Path, ".") {
return errors.New("the domain component must actually be a domain")
}
return nil
}
// IsAttestationFormatString reports whether s is one of the WebAuthn-defined attestation statement format
// identifiers. Used to detect and migrate records from prior releases which stored
// the format string in the AttestationType field.
func IsAttestationFormatString(s string) bool {
switch AttestationFormat(s) {
case AttestationFormatPacked,
AttestationFormatTPM,
AttestationFormatAndroidKey,
AttestationFormatAndroidSafetyNet,
AttestationFormatFIDOUniversalSecondFactor,
AttestationFormatApple,
AttestationFormatCompound,
AttestationFormatNone:
return true
default:
return false
}
}
@@ -0,0 +1,33 @@
package webauthncbor
import "github.com/fxamacker/cbor/v2"
const nestedLevelsAllowed = 4
// ctap2CBORDecMode is the cbor.DecMode following the CTAP2 canonical CBOR encoding form
// (https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#message-encoding)
var ctap2CBORDecMode, _ = cbor.DecOptions{
DupMapKey: cbor.DupMapKeyEnforcedAPF,
MaxNestedLevels: nestedLevelsAllowed,
IndefLength: cbor.IndefLengthForbidden,
TagsMd: cbor.TagsForbidden,
}.DecMode()
var ctap2CBOREncMode, _ = cbor.CTAP2EncOptions().EncMode()
// Unmarshal parses the CBOR-encoded data into the value pointed to by v
// following the CTAP2 canonical CBOR encoding form.
// (https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#message-encoding)
func Unmarshal(data []byte, v any) error {
// TODO (james-d-elliott): investigate the specific use case for Unmarshal vs UnmarshalFirst to determine the edge cases where this may be useful.
_, err := ctap2CBORDecMode.UnmarshalFirst(data, v)
return err
}
// Marshal encodes the value pointed to by v
// following the CTAP2 canonical CBOR encoding form.
// (https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#message-encoding)
func Marshal(v any) ([]byte, error) {
return ctap2CBOREncMode.Marshal(v)
}
@@ -0,0 +1,137 @@
package webauthncose
const (
keyCannotDisplay = "Cannot display key"
)
const ecCoordSize = 32
// COSEAlgorithmIdentifier is a number identifying a cryptographic algorithm. The algorithm identifiers SHOULD be values
// registered in the IANA COSE Algorithms registry [https://www.w3.org/TR/webauthn/#biblio-iana-cose-algs-reg], for
// instance, -7 for "ES256" and -257 for "RS256".
//
// Specification: §5.8.5. Cryptographic Algorithm Identifier (https://www.w3.org/TR/webauthn/#sctn-alg-identifier)
type COSEAlgorithmIdentifier int
const (
// AlgES256 ECDSA with SHA-256.
AlgES256 COSEAlgorithmIdentifier = -7
// AlgEdDSA EdDSA.
AlgEdDSA COSEAlgorithmIdentifier = -8
// AlgESP256 is ECDSA using P-256 curve with pre-hashed SHA-256 input.
AlgESP256 COSEAlgorithmIdentifier = -9
// AlgEd25519 is EdDSA using the Ed25519 curve specifically. Unlike [AlgEdDSA] which is the generic EdDSA
// identifier, this explicitly specifies the Ed25519 curve.
AlgEd25519 COSEAlgorithmIdentifier = -19
// AlgES384 ECDSA with SHA-384.
AlgES384 COSEAlgorithmIdentifier = -35
// AlgES512 ECDSA with SHA-512.
AlgES512 COSEAlgorithmIdentifier = -36
// AlgPS256 RSASSA-PSS with SHA-256.
AlgPS256 COSEAlgorithmIdentifier = -37
// AlgPS384 RSASSA-PSS with SHA-384.
AlgPS384 COSEAlgorithmIdentifier = -38
// AlgPS512 RSASSA-PSS with SHA-512.
AlgPS512 COSEAlgorithmIdentifier = -39
// AlgES256K is ECDSA using secp256k1 curve and SHA-256.
AlgES256K COSEAlgorithmIdentifier = -47
// AlgMLDSA44 is ML-DSA with parameter set ML-DSA-44 (FIPS 204).
AlgMLDSA44 COSEAlgorithmIdentifier = -48
// AlgMLDSA65 is ML-DSA with parameter set ML-DSA-65 (FIPS 204).
AlgMLDSA65 COSEAlgorithmIdentifier = -49
// AlgMLDSA87 is ML-DSA with parameter set ML-DSA-87 (FIPS 204).
AlgMLDSA87 COSEAlgorithmIdentifier = -50
// AlgESP384 is ECDSA using P-384 curve with pre-hashed SHA-384 input.
AlgESP384 COSEAlgorithmIdentifier = -51
// AlgESP512 is ECDSA using P-521 curve with pre-hashed SHA-512 input.
AlgESP512 COSEAlgorithmIdentifier = -52
// AlgRS256 RSASSA-PKCS1-v1_5 with SHA-256.
AlgRS256 COSEAlgorithmIdentifier = -257
// AlgRS384 RSASSA-PKCS1-v1_5 with SHA-384.
AlgRS384 COSEAlgorithmIdentifier = -258
// AlgRS512 RSASSA-PKCS1-v1_5 with SHA-512.
AlgRS512 COSEAlgorithmIdentifier = -259
// AlgRS1 RSASSA-PKCS1-v1_5 with SHA-1.
AlgRS1 COSEAlgorithmIdentifier = -65535
)
// COSEKeyType is The Key type derived from the IANA COSE AuthData.
type COSEKeyType int
const (
// KeyTypeReserved is a reserved value.
KeyTypeReserved COSEKeyType = iota
// OctetKey is an Octet Key.
OctetKey
// EllipticKey is an Elliptic Curve Public Key.
EllipticKey
// RSAKey is an RSA Public Key.
RSAKey
// Symmetric Keys.
Symmetric
// HSSLMS is the public key for HSS/LMS hash-based digital signature.
HSSLMS
// WalnutDSA is the public key for Walnut Digital Signature Algorithm.
WalnutDSA
// AKP is the key type for algorithm key pairs (i.e. ML-DSA).
AKP
)
// COSEEllipticCurve is an enumerator that represents the COSE Elliptic Curves.
//
// Specification: https://www.iana.org/assignments/cose/cose.xhtml#elliptic-curves
type COSEEllipticCurve int
const (
// EllipticCurveReserved is the COSE EC Reserved value.
EllipticCurveReserved COSEEllipticCurve = iota
// P256 represents NIST P-256 also known as secp256r1.
P256
// P384 represents NIST P-384 also known as secp384r1.
P384
// P521 represents NIST P-521 also known as secp521r1.
P521
// X25519 for use w/ ECDH only.
X25519
// X448 for use w/ ECDH only.
X448
// Ed25519 for use w/ EdDSA only.
Ed25519
// Ed448 for use w/ EdDSA only.
Ed448
// Secp256k1 is the SECG secp256k1 curve.
Secp256k1
)
@@ -0,0 +1,10 @@
package webauthncose
import (
"crypto/ed25519"
"crypto/x509"
)
func marshalEd25519PublicKey(pub ed25519.PublicKey) ([]byte, error) {
return x509.MarshalPKIXPublicKey(pub)
}
@@ -0,0 +1,7 @@
package webauthncose
import "math/big"
type ECDSASignature struct {
R, S *big.Int
}
@@ -0,0 +1,13 @@
package webauthncose
import "sync/atomic"
var allowBERIntegers atomic.Bool
// SetExperimentalInsecureAllowBERIntegers allows credentials which have BER integer encoding for their signatures
// which do not conform to the specification. This is an experimental option that may be removed without any notice
// and could potentially lead to zero-day exploits due to the ambiguity of encoding practices. This is not a recommended
// option.
func SetExperimentalInsecureAllowBERIntegers(value bool) {
allowBERIntegers.Store(value)
}
@@ -0,0 +1,492 @@
package webauthncose
import (
"crypto"
"crypto/ecdh"
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"hash"
"math"
"math/big"
"github.com/go-webauthn/x/encoding/asn1"
"github.com/google/go-tpm/tpm2"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
)
// PublicKeyData The public key portion of a Relying Party-specific credential key pair, generated
// by an authenticator and returned to a Relying Party at registration time. We unpack this object
// using fxamacker's cbor library ("github.com/fxamacker/cbor/v2") which is why there are cbor tags
// included. The tag field values correspond to the IANA COSE keys that give their respective
// values.
//
// Specification: §6.4.1.1. Examples of credentialPublicKey Values Encoded in COSE_Key Format (https://www.w3.org/TR/webauthn/#sctn-encoded-credPubKey-examples)
type PublicKeyData struct {
// Decode the results to int by default.
_struct bool `cbor:",keyasint" json:"public_key"` //nolint:govet,staticcheck
// The type of key created. Should be OKP, EC2, or RSA.
KeyType int64 `cbor:"1,keyasint" json:"kty"`
// A COSEAlgorithmIdentifier for the algorithm used to derive the key signature.
Algorithm int64 `cbor:"3,keyasint" json:"alg"`
}
type EC2PublicKeyData struct {
PublicKeyData
// If the key type is EC2, the curve on which we derive the signature from.
Curve int64 `cbor:"-1,keyasint,omitempty" json:"crv"`
// A byte string 32 bytes in length that holds the x coordinate of the key.
XCoord []byte `cbor:"-2,keyasint,omitempty" json:"x"`
// A byte string 32 bytes in length that holds the y coordinate of the key.
YCoord []byte `cbor:"-3,keyasint,omitempty" json:"y"`
}
type RSAPublicKeyData struct {
PublicKeyData
// Represents the modulus parameter for the RSA algorithm.
Modulus []byte `cbor:"-1,keyasint,omitempty" json:"n"`
// Represents the exponent parameter for the RSA algorithm.
Exponent []byte `cbor:"-2,keyasint,omitempty" json:"e"`
}
type OKPPublicKeyData struct {
PublicKeyData
Curve int64
// A byte string that holds the x coordinate of the key.
XCoord []byte `cbor:"-2,keyasint,omitempty" json:"x"`
}
// Verify Octet Key Pair (OKP) Public Key Signature.
func (k *OKPPublicKeyData) Verify(data []byte, sig []byte) (bool, error) {
if err := validateOKPPublicKey(k); err != nil {
return false, err
}
var key ed25519.PublicKey = make([]byte, ed25519.PublicKeySize)
copy(key, k.XCoord)
return ed25519.Verify(key, data, sig), nil
}
// Verify Elliptic Curve Public Key Signature.
func (k *EC2PublicKeyData) Verify(data []byte, sig []byte) (valid bool, err error) {
if err = validateEC2PublicKey(k); err != nil {
return false, err
}
pubkey := &ecdsa.PublicKey{
Curve: ec2AlgCurve(k.Algorithm),
X: big.NewInt(0).SetBytes(k.XCoord),
Y: big.NewInt(0).SetBytes(k.YCoord),
}
h := HasherFromCOSEAlg(COSEAlgorithmIdentifier(k.Algorithm))
h.Write(data)
e := &ECDSASignature{}
var opts []asn1.UnmarshalOpt
if allowBERIntegers.Load() {
opts = append(opts, asn1.WithUnmarshalAllowBERIntegers(true))
}
if _, err = asn1.Unmarshal(sig, e, opts...); err != nil {
return false, ErrSigNotProvidedOrInvalid
}
return ecdsa.Verify(pubkey, h.Sum(nil), e.R, e.S), nil
}
// ToECDSA converts the EC2PublicKeyData to an ecdsa.PublicKey.
func (k *EC2PublicKeyData) ToECDSA() (key *ecdsa.PublicKey, err error) {
if err = validateEC2PublicKey(k); err != nil {
return nil, err
}
return &ecdsa.PublicKey{
Curve: ec2AlgCurve(k.Algorithm),
X: big.NewInt(0).SetBytes(k.XCoord),
Y: big.NewInt(0).SetBytes(k.YCoord),
}, nil
}
// Verify RSA Public Key Signature.
func (k *RSAPublicKeyData) Verify(data []byte, sig []byte) (valid bool, err error) {
if err = validateRSAPublicKey(k); err != nil {
return false, err
}
e, _ := parseRSAPublicKeyDataExponent(k)
pubkey := &rsa.PublicKey{
N: big.NewInt(0).SetBytes(k.Modulus),
E: e,
}
coseAlg := COSEAlgorithmIdentifier(k.Algorithm)
algDetail, ok := COSESignatureAlgorithmDetails[coseAlg]
if !ok {
return false, ErrUnsupportedAlgorithm
}
hash := algDetail.hash
h := hash.New()
h.Write(data)
switch coseAlg {
case AlgPS256, AlgPS384, AlgPS512:
err = rsa.VerifyPSS(pubkey, hash, h.Sum(nil), sig, nil)
return err == nil, err
case AlgRS1, AlgRS256, AlgRS384, AlgRS512:
err = rsa.VerifyPKCS1v15(pubkey, hash, h.Sum(nil), sig)
return err == nil, err
default:
return false, ErrUnsupportedAlgorithm
}
}
// ParsePublicKey figures out what kind of COSE material was provided and create the data for the new key.
func ParsePublicKey(keyBytes []byte) (publicKey any, err error) {
pk := PublicKeyData{}
if err = webauthncbor.Unmarshal(keyBytes, &pk); err != nil {
return nil, ErrUnsupportedKey
}
switch COSEKeyType(pk.KeyType) {
case OctetKey:
var o OKPPublicKeyData
if err = webauthncbor.Unmarshal(keyBytes, &o); err != nil {
return nil, err
}
o.PublicKeyData = pk
if err = validateOKPPublicKey(&o); err != nil {
return nil, err
}
return o, nil
case EllipticKey:
var e EC2PublicKeyData
if err = webauthncbor.Unmarshal(keyBytes, &e); err != nil {
return nil, err
}
e.PublicKeyData = pk
if err = validateEC2PublicKey(&e); err != nil {
return nil, err
}
return e, nil
case RSAKey:
var r RSAPublicKeyData
if err = webauthncbor.Unmarshal(keyBytes, &r); err != nil {
return nil, err
}
r.PublicKeyData = pk
if err = validateRSAPublicKey(&r); err != nil {
return nil, err
}
return r, nil
default:
return nil, ErrUnsupportedKey
}
}
// ParseFIDOPublicKey is only used when the appID extension is configured by the assertion response.
func ParseFIDOPublicKey(keyBytes []byte) (data EC2PublicKeyData, err error) {
key, err := ecdh.P256().NewPublicKey(keyBytes)
if err != nil {
return data, fmt.Errorf("failed to parse FIDO public key: %w", err)
}
// Raw bytes for an uncompressed P-256 point: 0x04 || x(32) || y(32).
raw := key.Bytes()
return EC2PublicKeyData{
PublicKeyData: PublicKeyData{
KeyType: int64(EllipticKey),
Algorithm: int64(AlgES256),
},
Curve: int64(P256),
XCoord: raw[1 : 1+ecCoordSize],
YCoord: raw[1+ecCoordSize:],
}, nil
}
func VerifySignature(key any, data []byte, sig []byte) (bool, error) {
switch k := key.(type) {
case OKPPublicKeyData:
return k.Verify(data, sig)
case EC2PublicKeyData:
return k.Verify(data, sig)
case RSAPublicKeyData:
return k.Verify(data, sig)
default:
return false, ErrUnsupportedKey
}
}
func DisplayPublicKey(cpk []byte) string {
parsedKey, err := ParsePublicKey(cpk)
if err != nil {
return keyCannotDisplay
}
var data []byte
switch k := parsedKey.(type) {
case RSAPublicKeyData:
var e int
if e, err = parseRSAPublicKeyDataExponent(&k); err != nil {
return keyCannotDisplay
}
rKey := &rsa.PublicKey{
N: big.NewInt(0).SetBytes(k.Modulus),
E: e,
}
if data, err = x509.MarshalPKIXPublicKey(rKey); err != nil {
return keyCannotDisplay
}
case EC2PublicKeyData:
curve := ec2AlgCurve(k.Algorithm)
if curve == nil {
return keyCannotDisplay
}
eKey := &ecdsa.PublicKey{
Curve: curve,
X: big.NewInt(0).SetBytes(k.XCoord),
Y: big.NewInt(0).SetBytes(k.YCoord),
}
if data, err = x509.MarshalPKIXPublicKey(eKey); err != nil {
return keyCannotDisplay
}
case OKPPublicKeyData:
if len(k.XCoord) != ed25519.PublicKeySize {
return keyCannotDisplay
}
var oKey ed25519.PublicKey = make([]byte, ed25519.PublicKeySize)
copy(oKey, k.XCoord)
if data, err = marshalEd25519PublicKey(oKey); err != nil {
return keyCannotDisplay
}
default:
return "Cannot display key of this type"
}
pemBytes := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: data,
})
return string(pemBytes)
}
func (k *EC2PublicKeyData) TPMCurveID() tpm2.TPMECCCurve {
switch COSEEllipticCurve(k.Curve) {
case P256:
return tpm2.TPMECCNistP256 // TPM_ECC_NIST_P256.
case P384:
return tpm2.TPMECCNistP384 // TPM_ECC_NIST_P384.
case P521:
return tpm2.TPMECCNistP521 // TPM_ECC_NIST_P521.
default:
return tpm2.TPMECCNone // TPM_ECC_NONE.
}
}
func ec2AlgCurve(coseAlg int64) elliptic.Curve {
switch COSEAlgorithmIdentifier(coseAlg) {
case AlgES512, AlgESP512:
return elliptic.P521()
case AlgES384, AlgESP384:
return elliptic.P384()
case AlgES256, AlgESP256:
return elliptic.P256()
default:
return nil
}
}
// SigAlgFromCOSEAlg return which signature algorithm is being used from the COSE Key.
func SigAlgFromCOSEAlg(coseAlg COSEAlgorithmIdentifier) x509.SignatureAlgorithm {
d, ok := COSESignatureAlgorithmDetails[coseAlg]
if !ok {
return x509.UnknownSignatureAlgorithm
}
return d.sigAlg
}
// HasherFromCOSEAlg returns the Hashing interface to be used for a given COSE Algorithm.
func HasherFromCOSEAlg(coseAlg COSEAlgorithmIdentifier) hash.Hash {
d, ok := COSESignatureAlgorithmDetails[coseAlg]
if !ok {
// default to SHA256? Why not.
return crypto.SHA256.New()
}
return d.hash.New()
}
var COSESignatureAlgorithmDetails = map[COSEAlgorithmIdentifier]struct {
name string
hash crypto.Hash
sigAlg x509.SignatureAlgorithm
}{
AlgRS1: {"SHA1-RSA", crypto.SHA1, x509.SHA1WithRSA},
AlgRS256: {"SHA256-RSA", crypto.SHA256, x509.SHA256WithRSA},
AlgRS384: {"SHA384-RSA", crypto.SHA384, x509.SHA384WithRSA},
AlgRS512: {"SHA512-RSA", crypto.SHA512, x509.SHA512WithRSA},
AlgPS256: {"SHA256-RSAPSS", crypto.SHA256, x509.SHA256WithRSAPSS},
AlgPS384: {"SHA384-RSAPSS", crypto.SHA384, x509.SHA384WithRSAPSS},
AlgPS512: {"SHA512-RSAPSS", crypto.SHA512, x509.SHA512WithRSAPSS},
AlgES256: {"ECDSA-SHA256", crypto.SHA256, x509.ECDSAWithSHA256},
AlgESP256: {"ECDSA-SHA256-Prehashed", crypto.SHA256, x509.ECDSAWithSHA256},
AlgES384: {"ECDSA-SHA384", crypto.SHA384, x509.ECDSAWithSHA384},
AlgESP384: {"ECDSA-SHA384-Prehashed", crypto.SHA384, x509.ECDSAWithSHA384},
AlgES512: {"ECDSA-SHA512", crypto.SHA512, x509.ECDSAWithSHA512},
AlgESP512: {"ECDSA-SHA512-Prehashed", crypto.SHA512, x509.ECDSAWithSHA512},
AlgEdDSA: {"EdDSA", crypto.SHA512, x509.PureEd25519},
AlgEd25519: {"Ed25519", crypto.SHA512, x509.PureEd25519},
}
type Error struct {
// Short name for the type of error that has occurred.
Type string `json:"type"`
// Additional details about the error.
Details string `json:"error"`
// Information to help debug the error.
DevInfo string `json:"debug"`
}
var (
ErrUnsupportedKey = &Error{
Type: "invalid_key_type",
Details: "Unsupported Public Key Type",
}
ErrUnsupportedAlgorithm = &Error{
Type: "unsupported_key_algorithm",
Details: "Unsupported public key algorithm",
}
ErrSigNotProvidedOrInvalid = &Error{
Type: "signature_not_provided_or_invalid",
Details: "Signature invalid or not provided",
}
)
func (err *Error) Error() string {
return err.Details
}
func (passedError *Error) WithDetails(details string) *Error {
err := *passedError
err.Details = details
return &err
}
func validateOKPPublicKey(k *OKPPublicKeyData) error {
if len(k.XCoord) != ed25519.PublicKeySize {
return ErrUnsupportedKey.WithDetails(fmt.Sprintf("OKP key x coordinate has invalid length %d, expected %d", len(k.XCoord), ed25519.PublicKeySize))
}
return nil
}
func validateEC2PublicKey(k *EC2PublicKeyData) error {
curve := ec2AlgCurve(k.Algorithm)
if curve == nil {
return ErrUnsupportedAlgorithm.WithDetails("Unsupported EC2 algorithm")
}
byteLen := (curve.Params().BitSize + 7) / 8
if len(k.XCoord) != byteLen || len(k.YCoord) != byteLen {
return ErrUnsupportedKey.WithDetails("EC2 key x or y coordinate has invalid length")
}
x := new(big.Int).SetBytes(k.XCoord)
y := new(big.Int).SetBytes(k.YCoord)
if !curve.IsOnCurve(x, y) {
return ErrUnsupportedKey.WithDetails("EC2 key point is not on curve")
}
return nil
}
func validateRSAPublicKey(k *RSAPublicKeyData) error {
n := new(big.Int).SetBytes(k.Modulus)
if n.Sign() <= 0 {
return ErrUnsupportedKey.WithDetails("RSA key contains zero or empty modulus")
}
if _, err := parseRSAPublicKeyDataExponent(k); err != nil {
return ErrUnsupportedKey.WithDetails(fmt.Sprintf("RSA key contains invalid exponent: %v", err))
}
return nil
}
func parseRSAPublicKeyDataExponent(k *RSAPublicKeyData) (exp int, err error) {
if k == nil {
return 0, fmt.Errorf("invalid key")
}
if len(k.Exponent) == 0 {
return 0, fmt.Errorf("invalid exponent length")
}
for _, b := range k.Exponent {
if exp > (math.MaxInt >> 8) {
return 0, ErrUnsupportedKey
}
exp = (exp << 8) | int(b)
}
if exp <= 0 {
return 0, ErrUnsupportedKey
}
return exp, nil
}
@@ -0,0 +1,67 @@
package webauthn
import (
"gamertan.com/web/internal/webauthnvendored/protocol"
)
//go:generate msgp
//msgp:replace protocol.AuthenticatorAttachment with:string
//msgp:clearomitted
// Authenticator represents a specific authenticator in the context of a [Credential].
type Authenticator struct {
// The AAGUID of the authenticator. An AAGUID is defined as an array containing the globally unique
// identifier of the authenticator model being sought.
AAGUID []byte `json:"AAGUID,omitempty" msg:"aaguid,omitempty"`
// SignCount is a representation of the number of times the Authenticator or Credential have been used to login.
// Upon a new login operation, the Relying Party compares the stored signature counter value with the new SignCount
// value returned in the assertions authenticator data. If this new SignCount value is less than or equal to the
// stored value, a cloned authenticator may exist, or the authenticator may be malfunctioning.
SignCount uint32 `json:"signCount,omitempty" msg:"sc,omitempty"`
// CloneWarning is a signal that the authenticator may be cloned, i.e. at least two copies of the
// credential private key may exist and are being used in parallel. Relying Parties should incorporate
// this information into their risk scoring. Whether the Relying Party updates the stored signature
// counter value in this case, or not, or fails the authentication ceremony or not, is Relying Party-specific.
CloneWarning bool `json:"cloneWarning,omitempty" msg:"cw,omitempty"`
// Attachment is the authenticatorAttachment value returned by the request.
Attachment protocol.AuthenticatorAttachment `json:"attachment,omitempty" msg:"aa,omitempty"`
}
// SelectAuthenticator is a convenience function that constructs a [protocol.AuthenticatorSelection] from individual
// string and boolean parameters. Use [protocol.ResidentKeyRequired] or [protocol.ResidentKeyNotRequired] for the rrk
// parameter.
func SelectAuthenticator(att string, rrk *bool, uv string) protocol.AuthenticatorSelection {
return protocol.AuthenticatorSelection{
AuthenticatorAttachment: protocol.AuthenticatorAttachment(att),
RequireResidentKey: rrk,
UserVerification: protocol.UserVerificationRequirement(uv),
}
}
// UpdateCounter updates the authenticator and either sets the clone warning value or the sign count.
//
// Step 17 of §7.2. about verifying attestation. If the signature counter value authData.signCount
// is nonzero or the value stored in conjunction with credentials id attribute is nonzero, then
// run the following sub-step:
//
// If the signature counter value authData.signCount is
//
// → Greater than the signature counter value stored in conjunction with credentials id attribute.
// Update the stored signature counter value, associated with credentials id attribute, to be the value of
// authData.signCount.
//
// → Less than or equal to the signature counter value stored in conjunction with credentials id attribute.
// This is a signal that the authenticator may be cloned, see CloneWarning above for more information.
func (a *Authenticator) UpdateCounter(authDataCount uint32) {
if authDataCount <= a.SignCount && (authDataCount != 0 || a.SignCount != 0) {
a.CloneWarning = true
return
}
a.SignCount = authDataCount
}
@@ -0,0 +1,305 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"gamertan.com/web/internal/webauthnvendored/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *Authenticator) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "aaguid":
z.AAGUID, err = dc.ReadBytes(z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
zb0001Mask |= 0x1
case "sc":
z.SignCount, err = dc.ReadUint32()
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
zb0001Mask |= 0x2
case "cw":
z.CloneWarning, err = dc.ReadBool()
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
zb0001Mask |= 0x4
case "aa":
{
var zb0002 string
zb0002, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
z.Attachment = protocol.AuthenticatorAttachment(zb0002)
}
zb0001Mask |= 0x8
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0xf {
if (zb0001Mask & 0x1) == 0 {
z.AAGUID = nil
}
if (zb0001Mask & 0x2) == 0 {
z.SignCount = 0
}
if (zb0001Mask & 0x4) == 0 {
z.CloneWarning = false
}
if (zb0001Mask & 0x8) == 0 {
z.Attachment = ""
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *Authenticator) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(4)
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
if z.AAGUID == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.SignCount == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
if z.CloneWarning == false {
zb0001Len--
zb0001Mask |= 0x4
}
if z.Attachment == "" {
zb0001Len--
zb0001Mask |= 0x8
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// write "aaguid"
err = en.Append(0xa6, 0x61, 0x61, 0x67, 0x75, 0x69, 0x64)
if err != nil {
return
}
err = en.WriteBytes(z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "sc"
err = en.Append(0xa2, 0x73, 0x63)
if err != nil {
return
}
err = en.WriteUint32(z.SignCount)
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "cw"
err = en.Append(0xa2, 0x63, 0x77)
if err != nil {
return
}
err = en.WriteBool(z.CloneWarning)
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "aa"
err = en.Append(0xa2, 0x61, 0x61)
if err != nil {
return
}
err = en.WriteString(string(z.Attachment))
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *Authenticator) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(4)
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
if z.AAGUID == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.SignCount == 0 {
zb0001Len--
zb0001Mask |= 0x2
}
if z.CloneWarning == false {
zb0001Len--
zb0001Mask |= 0x4
}
if z.Attachment == "" {
zb0001Len--
zb0001Mask |= 0x8
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// string "aaguid"
o = append(o, 0xa6, 0x61, 0x61, 0x67, 0x75, 0x69, 0x64)
o = msgp.AppendBytes(o, z.AAGUID)
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "sc"
o = append(o, 0xa2, 0x73, 0x63)
o = msgp.AppendUint32(o, z.SignCount)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "cw"
o = append(o, 0xa2, 0x63, 0x77)
o = msgp.AppendBool(o, z.CloneWarning)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "aa"
o = append(o, 0xa2, 0x61, 0x61)
o = msgp.AppendString(o, string(z.Attachment))
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Authenticator) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 4 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "aaguid":
z.AAGUID, bts, err = msgp.ReadBytesBytes(bts, z.AAGUID)
if err != nil {
err = msgp.WrapError(err, "AAGUID")
return
}
zb0001Mask |= 0x1
case "sc":
z.SignCount, bts, err = msgp.ReadUint32Bytes(bts)
if err != nil {
err = msgp.WrapError(err, "SignCount")
return
}
zb0001Mask |= 0x2
case "cw":
z.CloneWarning, bts, err = msgp.ReadBoolBytes(bts)
if err != nil {
err = msgp.WrapError(err, "CloneWarning")
return
}
zb0001Mask |= 0x4
case "aa":
{
var zb0002 string
zb0002, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Attachment")
return
}
z.Attachment = protocol.AuthenticatorAttachment(zb0002)
}
zb0001Mask |= 0x8
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0xf {
if (zb0001Mask & 0x1) == 0 {
z.AAGUID = nil
}
if (zb0001Mask & 0x2) == 0 {
z.SignCount = 0
}
if (zb0001Mask & 0x4) == 0 {
z.CloneWarning = false
}
if (zb0001Mask & 0x8) == 0 {
z.Attachment = ""
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *Authenticator) Msgsize() (s int) {
s = 1 + 7 + msgp.BytesPrefixSize + len(z.AAGUID) + 3 + msgp.Uint32Size + 3 + msgp.BoolSize + 3 + msgp.StringPrefixSize + len(string(z.Attachment))
return
}
@@ -0,0 +1,15 @@
package webauthn
import (
"time"
)
const (
errFmtFieldNotValidDomainString = "field '%s' is not a valid domain string: %w"
errFmtConfigValidate = "error occurred validating the configuration: %w"
)
const (
defaultTimeoutUVD = time.Millisecond * 120000
defaultTimeout = time.Millisecond * 300000
)
@@ -0,0 +1,330 @@
package webauthn
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
//go:generate msgp
//msgp:replace protocol.AuthenticatorTransport with:string
//msgp:shim CredentialFlags as:byte using:(CredentialFlags).MsgpByte/CredentialFlagsFromMsgpByte
//msgp:clearomitted
// NewCredential returns a [*Credential] from a successfully validated registration response. The returned Credential
// includes a populated [CredentialAttestation] containing the raw attestation data needed for future verification;
// see the [CredentialAttestation] documentation for why these values must be persisted.
func NewCredential(clientDataHash []byte, c *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
credential = &Credential{
ID: c.Response.AttestationObject.AuthData.AttData.CredentialID,
PublicKey: c.Response.AttestationObject.AuthData.AttData.CredentialPublicKey,
AttestationType: c.Response.AttestationObject.Type,
AttestationFormat: c.Response.AttestationObject.Format,
Transport: c.Response.Transports,
Flags: NewCredentialFlags(c.Response.AttestationObject.AuthData.Flags),
Authenticator: Authenticator{
AAGUID: c.Response.AttestationObject.AuthData.AttData.AAGUID,
SignCount: c.Response.AttestationObject.AuthData.Counter,
Attachment: c.AuthenticatorAttachment,
},
Attestation: CredentialAttestation{
ClientDataJSON: c.Raw.AttestationResponse.ClientDataJSON,
ClientDataHash: clientDataHash,
AuthenticatorData: c.Raw.AttestationResponse.AuthenticatorData,
PublicKeyAlgorithm: c.Raw.AttestationResponse.PublicKeyAlgorithm,
Object: c.Raw.AttestationResponse.AttestationObject,
},
}
return credential, nil
}
// Credential contains all needed information about a WebAuthn credential for storage. This struct is effectively the
// Credential Record as described in the specification.
//
// Provided this data structure is preserved properly, a Credential can be verified against the FIDO Metadata Service
// at a later date using the [Credential.Verify] method with a [metadata.Provider].
//
// It is strongly recommended for the best security that a [Credential] is encrypted at rest with the exception of the
// ID and the value you use to lookup the user. This prevents a person with access to the database being able to
// compromise privacy by being able to view this data, as well as prevents them being able to compromise security by
// adding or modifying a Credential without them also having access to the encryption key.
//
// For consolidated persistence guidance; recommended schema shape, required lookup columns, and which fields
// must be written back on every successful FinishLogin / ValidateLogin; see the [Storage] section of the
// [gamertan.com/web/internal/webauthnvendored/webauthn] package documentation.
//
// See: §4. Terminology: Credential Record (https://www.w3.org/TR/webauthn-3/#credential-record)
//
// [Storage]: https://pkg.go.dev/gamertan.com/web/internal/webauthnvendored/webauthn#hdr-Storage
type Credential struct {
// The ID is the ID of the public key credential source. Described by the Credential Record 'id' field.
ID []byte `json:"id" msg:"id"`
// The credential public key of the public key credential source. Described by the Credential Record 'publicKey'
// field.
PublicKey []byte `json:"publicKey" msg:"pk"`
// AttestationType is the attestation type as conveyed by the authenticator during the registration ceremonyl
// one of the values defined by [metadata.AuthenticatorAttestationType] ("basic_full", "basic_surrogate",
// "attca", "anonca", "ecdaa", "none"). Prior releases incorrectly stored the attestation FORMAT here; see the
// custom [Credential.UnmarshalJSON] for the backward-compatibility migration applied when decoding such
// records.
AttestationType string `json:"attestationType,omitempty" msg:"atttype,omitempty"`
// AttestationFormat is the attestation statement format identifier ("packed", "tpm", "android-key",
// "android-safetynet", "fido-u2f", "apple", "compound", "none"); see §8 of the WebAuthn specification and
// the AttestationFormat constants in the protocol package.
AttestationFormat string `json:"attestationFormat,omitempty" msg:"attfmt,omitempty"`
// Transport types the authenticator supports. Described by the Credential Record 'transports' field.
Transport []protocol.AuthenticatorTransport `json:"transport,omitempty" msg:"t,omitempty"`
// Flags represent the commonly stored flags.
Flags CredentialFlags `json:"flags" msg:"flg"`
// The Authenticator information for a given Credential.
Authenticator Authenticator `json:"authenticator" msg:"a"`
// The attestation values that can be used to validate this Credential via the MDS3 at a later date.
Attestation CredentialAttestation `json:"attestation" msg:"att"`
}
// UnmarshalJSON decodes a [Credential] from JSON, applying a backward-compatibility migration for records produced
// by earlier versions of this library: if the decoded record has no AttestationFormat and the AttestationType value
// is a recognised attestation FORMAT identifier (i.e. "packed", "tpm", "none"), the value is moved to
// AttestationFormat and AttestationType is cleared so callers can re-derive the true attestation type by calling
// [Credential.Verify]. Records that already carry an AttestationFormat are untouched.
func (c *Credential) UnmarshalJSON(data []byte) error {
type credentialAlias Credential
var tmp credentialAlias
if err := json.Unmarshal(data, &tmp); err != nil {
return err
}
*c = Credential(tmp)
if c.AttestationFormat == "" && protocol.IsAttestationFormatString(c.AttestationType) {
c.AttestationFormat = c.AttestationType
c.AttestationType = ""
}
return nil
}
// SignalUnknownCredential creates a struct that can easily be marshaled to JSON which indicates this is an unknown
// Credential.
func (c *Credential) SignalUnknownCredential(rpid string) *protocol.SignalUnknownCredential {
return c.Descriptor().SignalUnknownCredential(rpid)
}
// Descriptor converts a [Credential] into a [protocol.CredentialDescriptor].
func (c *Credential) Descriptor() (descriptor protocol.CredentialDescriptor) {
return protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: c.ID,
Transport: c.Transport,
AttestationType: c.AttestationType,
AttestationFormat: c.AttestationFormat,
}
}
// Verify re-runs the full attestation verification for this credential against the given [metadata.Provider]. The
// stored raw attestation bytes are re-parsed, the attestation signature is re-verified, and the authenticator is
// validated against the MDS via [protocol.AttestationObject.VerifyAttestation] (which internally dispatches
// [protocol.ValidateMetadata]). This is the canonical audit path and is at least as strong as the original
// registration-time verification; call it on a schedule (i.e. on login or periodically) to catch MDS status changes
// such as a newly-revoked authenticator model or a compromise advisory published after registration.
//
// Requirements:
//
// - The mds argument must be a non-nil [metadata.Provider]; a nil provider returns an error.
//
// - [CredentialAttestation.ClientDataJSON] must be preserved byte-for-byte; it is re-parsed for its collected
// client data fields and re-hashed when [CredentialAttestation.ClientDataHash] is absent.
//
// - [CredentialAttestation.Object] must be preserved byte-for-byte; it is the raw CBOR attestation object and
// is decoded to recover the authenticator data, statement format, and statement for full re-verification.
//
// - [Credential.PublicKey] must be populated with the CBOR-encoded COSE key as emitted by the authenticator at
// registration. As an integrity check, Verify compares this value byte-for-byte against the credential public
// key carried inside the attestation object and returns an error on mismatch.
//
// - [CredentialAttestation.ClientDataHash] is optional; if empty it is recomputed as the SHA-256 of
// ClientDataJSON.
//
// - [Credential.Transport], [CredentialAttestation.AuthenticatorData], and [CredentialAttestation.PublicKeyAlgorithm]
// are not read by the current Verify implementation (the authenticator data is re-derived from the attestation
// object, and the top-level AuthenticatorData / PublicKeyAlgorithm convenience fields are informational). They
// are still stored so future versions of this library, or alternative verification paths, can consume them;
// see [CredentialAttestation] for why every field should be persisted.
//
// As a side-effect, a successful Verify call will populate [Credential.AttestationType] from the re-derived value
// when the field is empty (i.e. on a record migrated from a pre-split JSON layout by [Credential.UnmarshalJSON]);
// the next marshal of the Credential will then carry the correct attestation type. For this reason Verify uses a
// pointer receiver.
//
// See [CredentialAttestation] for guidance on persisting these raw values securely.
func (c *Credential) Verify(mds metadata.Provider) (err error) {
if mds == nil {
return fmt.Errorf("error verifying credential: the metadata provider must be provided but it's nil")
}
raw := c.toAuthenticatorAttestationResponse()
var attestation *protocol.ParsedAttestationResponse
if attestation, err = raw.Parse(); err != nil {
return fmt.Errorf("error verifying credential: error parsing attestation: %w", err)
}
if !bytes.Equal(c.PublicKey, attestation.AttestationObject.AuthData.AttData.CredentialPublicKey) {
return fmt.Errorf("error verifying credential: stored public key does not match the credential public key embedded in the attestation object")
}
clientDataHash := c.Attestation.ClientDataHash
if len(clientDataHash) == 0 {
sum := sha256.Sum256(c.Attestation.ClientDataJSON)
clientDataHash = sum[:]
}
if err = attestation.AttestationObject.VerifyAttestation(clientDataHash, mds); err != nil {
return fmt.Errorf("error verifying credential: error verifying attestation: %w", err)
}
if c.AttestationType == "" {
c.AttestationType = attestation.AttestationObject.Type
}
return nil
}
func (c *Credential) toAuthenticatorAttestationResponse() *protocol.AuthenticatorAttestationResponse {
raw := &protocol.AuthenticatorAttestationResponse{
AuthenticatorResponse: protocol.AuthenticatorResponse{
ClientDataJSON: c.Attestation.ClientDataJSON,
},
Transports: make([]string, len(c.Transport)),
AuthenticatorData: c.Attestation.AuthenticatorData,
PublicKey: c.PublicKey,
PublicKeyAlgorithm: c.Attestation.PublicKeyAlgorithm,
AttestationObject: c.Attestation.Object,
}
for i, transport := range c.Transport {
raw.Transports[i] = string(transport)
}
return raw
}
// Credentials is a decorator type which allows easily converting a [Credential] slice into a
// [protocol.CredentialDescriptor] slice by utilizing the [Credentials.CredentialDescriptors] method. This will be the
// type used globally for the library in a future release.
type Credentials []Credential
// CredentialDescriptors returns the [protocol.CredentialDescriptor] slice for this [Credentials] type.
func (c Credentials) CredentialDescriptors() (descriptors []protocol.CredentialDescriptor) {
descriptors = make([]protocol.CredentialDescriptor, len(c))
for i, credential := range c {
descriptors[i] = credential.Descriptor()
}
return descriptors
}
// NewCredentialFlags is a utility function that is used to derive the [Credential]'s Flags field given a
// [protocol.AuthenticatorFlags]. This allows implementers to solely save the Raw field of the [CredentialFlags] to
// restore them appropriately for appropriate processing without concern that changes forced upon implementers by the
// W3C will introduce breaking changes.
func NewCredentialFlags(flags protocol.AuthenticatorFlags) CredentialFlags {
return CredentialFlags{
UserPresent: flags.HasUserPresent(),
UserVerified: flags.HasUserVerified(),
BackupEligible: flags.HasBackupEligible(),
BackupState: flags.HasBackupState(),
raw: flags,
}
}
// CredentialFlagsFromMsgpByte reconstructs a [CredentialFlags] from the single-byte representation produced by
// [CredentialFlags.MsgpByte]. It is intended for use by the msgp-generated serialization layer; normal callers
// should prefer [NewCredentialFlags].
func CredentialFlagsFromMsgpByte(b byte) CredentialFlags {
return NewCredentialFlags(protocol.AuthenticatorFlags(b))
}
// CredentialFlags contains the boolean flags derived from the authenticator data during registration or login.
// These flags indicate the state of user presence, user verification, and backup eligibility/state at the time
// the credential was used.
type CredentialFlags struct {
// Flag UP indicates the users presence.
UserPresent bool `json:"userPresent"`
// Flag UV indicates the user performed verification.
UserVerified bool `json:"userVerified"`
// Flag BE indicates the credential is able to be backed up and/or sync'd between devices. This should NEVER change.
BackupEligible bool `json:"backupEligible"`
// Flag BS indicates the credential has been backed up and/or sync'd. This value can change but it's recommended
// that RP's keep track of this value.
BackupState bool `json:"backupState"`
raw protocol.AuthenticatorFlags
}
// ProtocolValue returns the underlying [protocol.AuthenticatorFlags] provided this [CredentialFlags] was created using
// NewCredentialFlags.
func (f CredentialFlags) ProtocolValue() protocol.AuthenticatorFlags {
return f.raw
}
// MsgpByte returns the [CredentialFlags] encoded as a single byte, equivalent to the raw
// [protocol.AuthenticatorFlags] value. It is intended for use by the msgp-generated serialization layer (see the
// //msgp:shim directive in this file); normal callers should prefer [CredentialFlags.ProtocolValue].
func (f CredentialFlags) MsgpByte() byte {
return byte(f.raw)
}
// CredentialAttestation holds the raw attestation data from a registration ceremony. These values are intentionally
// stored in their original unparsed form rather than as parsed structures. This is critical because:
//
// - It enables the [Credential] to be verified against the FIDO Metadata Service at a later date using
// [Credential.Verify], even long after the registration ceremony has completed.
// - The WebAuthn specification evolves over time, introducing new validation procedures. Preserving the raw data
// ensures that credentials created today can be re-validated against future rules without requiring re-registration.
// - Raw data serves as an auditable record of exactly what the authenticator and client provided during registration,
// independent of how the library parsed it at that point in time.
//
// Implementers MUST persist all fields of this struct.
type CredentialAttestation struct {
// ClientDataJSON is the raw JSON-encoded client data from the registration response. This is the verbatim value
// provided by the client and is used to recompute the client data hash during later verification.
ClientDataJSON []byte `json:"clientDataJSON,omitempty" msg:"cdj,omitempty"`
// ClientDataHash is the SHA-256 hash of ClientDataJSON computed during registration verification. If empty,
// [Credential.Verify] will recompute it from ClientDataJSON.
ClientDataHash []byte `json:"clientDataHash,omitempty" msg:"cdh,omitempty"`
// AuthenticatorData is the raw authenticator data from the registration response as provided in the
// RegistrationResponseJSON. This is the unparsed byte representation that can be re-parsed for future validation.
AuthenticatorData []byte `json:"authenticatorData,omitempty" msg:"data,omitempty"`
// PublicKeyAlgorithm is the COSE algorithm identifier for the credential's public key.
PublicKeyAlgorithm int64 `json:"publicKeyAlgorithm,omitempty" msg:"alg,omitempty"`
// Object is the raw CBOR-encoded attestation object from the registration response. This contains the attestation
// statement, format, and authenticator data needed by [Credential.Verify] to re-perform attestation verification.
Object []byte `json:"object,omitempty" msg:"obj,omitempty"`
}
@@ -0,0 +1,914 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"gamertan.com/web/internal/webauthnvendored/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *Credential) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 3 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "id":
z.ID, err = dc.ReadBytes(z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
case "pk":
z.PublicKey, err = dc.ReadBytes(z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
case "atttype":
z.AttestationType, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
zb0001Mask |= 0x1
case "attfmt":
z.AttestationFormat, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
zb0001Mask |= 0x2
case "t":
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
if cap(z.Transport) >= int(zb0002) {
z.Transport = (z.Transport)[:zb0002]
} else {
z.Transport = make([]protocol.AuthenticatorTransport, zb0002)
}
for za0001 := range z.Transport {
{
var zb0003 string
zb0003, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
z.Transport[za0001] = protocol.AuthenticatorTransport(zb0003)
}
}
zb0001Mask |= 0x4
case "flg":
{
var zb0004 byte
zb0004, err = dc.ReadByte()
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
z.Flags = CredentialFlagsFromMsgpByte(zb0004)
}
case "a":
err = z.Authenticator.DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
case "att":
err = z.Attestation.DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7 {
if (zb0001Mask & 0x1) == 0 {
z.AttestationType = ""
}
if (zb0001Mask & 0x2) == 0 {
z.AttestationFormat = ""
}
if (zb0001Mask & 0x4) == 0 {
z.Transport = nil
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *Credential) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(8)
var zb0001Mask uint8 /* 8 bits */
_ = zb0001Mask
if z.AttestationType == "" {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AttestationFormat == "" {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Transport == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
// write "id"
err = en.Append(0xa2, 0x69, 0x64)
if err != nil {
return
}
err = en.WriteBytes(z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
// write "pk"
err = en.Append(0xa2, 0x70, 0x6b)
if err != nil {
return
}
err = en.WriteBytes(z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "atttype"
err = en.Append(0xa7, 0x61, 0x74, 0x74, 0x74, 0x79, 0x70, 0x65)
if err != nil {
return
}
err = en.WriteString(z.AttestationType)
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "attfmt"
err = en.Append(0xa6, 0x61, 0x74, 0x74, 0x66, 0x6d, 0x74)
if err != nil {
return
}
err = en.WriteString(z.AttestationFormat)
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// write "t"
err = en.Append(0xa1, 0x74)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.Transport)))
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
for za0001 := range z.Transport {
err = en.WriteString(string(z.Transport[za0001]))
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
}
}
// write "flg"
err = en.Append(0xa3, 0x66, 0x6c, 0x67)
if err != nil {
return
}
err = en.WriteByte((CredentialFlags).MsgpByte(z.Flags))
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
// write "a"
err = en.Append(0xa1, 0x61)
if err != nil {
return
}
err = z.Authenticator.EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
// write "att"
err = en.Append(0xa3, 0x61, 0x74, 0x74)
if err != nil {
return
}
err = z.Attestation.EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *Credential) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(8)
var zb0001Mask uint8 /* 8 bits */
_ = zb0001Mask
if z.AttestationType == "" {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AttestationFormat == "" {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Transport == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
// string "id"
o = append(o, 0xa2, 0x69, 0x64)
o = msgp.AppendBytes(o, z.ID)
// string "pk"
o = append(o, 0xa2, 0x70, 0x6b)
o = msgp.AppendBytes(o, z.PublicKey)
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "atttype"
o = append(o, 0xa7, 0x61, 0x74, 0x74, 0x74, 0x79, 0x70, 0x65)
o = msgp.AppendString(o, z.AttestationType)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "attfmt"
o = append(o, 0xa6, 0x61, 0x74, 0x74, 0x66, 0x6d, 0x74)
o = msgp.AppendString(o, z.AttestationFormat)
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// string "t"
o = append(o, 0xa1, 0x74)
o = msgp.AppendArrayHeader(o, uint32(len(z.Transport)))
for za0001 := range z.Transport {
o = msgp.AppendString(o, string(z.Transport[za0001]))
}
}
// string "flg"
o = append(o, 0xa3, 0x66, 0x6c, 0x67)
o = msgp.AppendByte(o, (CredentialFlags).MsgpByte(z.Flags))
// string "a"
o = append(o, 0xa1, 0x61)
o, err = z.Authenticator.MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
// string "att"
o = append(o, 0xa3, 0x61, 0x74, 0x74)
o, err = z.Attestation.MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Credential) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 3 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "id":
z.ID, bts, err = msgp.ReadBytesBytes(bts, z.ID)
if err != nil {
err = msgp.WrapError(err, "ID")
return
}
case "pk":
z.PublicKey, bts, err = msgp.ReadBytesBytes(bts, z.PublicKey)
if err != nil {
err = msgp.WrapError(err, "PublicKey")
return
}
case "atttype":
z.AttestationType, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AttestationType")
return
}
zb0001Mask |= 0x1
case "attfmt":
z.AttestationFormat, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AttestationFormat")
return
}
zb0001Mask |= 0x2
case "t":
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Transport")
return
}
if cap(z.Transport) >= int(zb0002) {
z.Transport = (z.Transport)[:zb0002]
} else {
z.Transport = make([]protocol.AuthenticatorTransport, zb0002)
}
for za0001 := range z.Transport {
{
var zb0003 string
zb0003, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Transport", za0001)
return
}
z.Transport[za0001] = protocol.AuthenticatorTransport(zb0003)
}
}
zb0001Mask |= 0x4
case "flg":
{
var zb0004 byte
zb0004, bts, err = msgp.ReadByteBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Flags")
return
}
z.Flags = CredentialFlagsFromMsgpByte(zb0004)
}
case "a":
bts, err = z.Authenticator.UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "Authenticator")
return
}
case "att":
bts, err = z.Attestation.UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "Attestation")
return
}
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7 {
if (zb0001Mask & 0x1) == 0 {
z.AttestationType = ""
}
if (zb0001Mask & 0x2) == 0 {
z.AttestationFormat = ""
}
if (zb0001Mask & 0x4) == 0 {
z.Transport = nil
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *Credential) Msgsize() (s int) {
s = 1 + 3 + msgp.BytesPrefixSize + len(z.ID) + 3 + msgp.BytesPrefixSize + len(z.PublicKey) + 8 + msgp.StringPrefixSize + len(z.AttestationType) + 7 + msgp.StringPrefixSize + len(z.AttestationFormat) + 2 + msgp.ArrayHeaderSize
for za0001 := range z.Transport {
s += msgp.StringPrefixSize + len(string(z.Transport[za0001]))
}
s += 4 + msgp.ByteSize + 2 + z.Authenticator.Msgsize() + 4 + z.Attestation.Msgsize()
return
}
// DecodeMsg implements msgp.Decodable
func (z *CredentialAttestation) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "cdj":
z.ClientDataJSON, err = dc.ReadBytes(z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
zb0001Mask |= 0x1
case "cdh":
z.ClientDataHash, err = dc.ReadBytes(z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
zb0001Mask |= 0x2
case "data":
z.AuthenticatorData, err = dc.ReadBytes(z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
zb0001Mask |= 0x4
case "alg":
z.PublicKeyAlgorithm, err = dc.ReadInt64()
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
zb0001Mask |= 0x8
case "obj":
z.Object, err = dc.ReadBytes(z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
zb0001Mask |= 0x10
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x1f {
if (zb0001Mask & 0x1) == 0 {
z.ClientDataJSON = nil
}
if (zb0001Mask & 0x2) == 0 {
z.ClientDataHash = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AuthenticatorData = nil
}
if (zb0001Mask & 0x8) == 0 {
z.PublicKeyAlgorithm = 0
}
if (zb0001Mask & 0x10) == 0 {
z.Object = nil
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *CredentialAttestation) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(5)
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
if z.ClientDataJSON == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.ClientDataHash == nil {
zb0001Len--
zb0001Mask |= 0x2
}
if z.AuthenticatorData == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.PublicKeyAlgorithm == 0 {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Object == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// write "cdj"
err = en.Append(0xa3, 0x63, 0x64, 0x6a)
if err != nil {
return
}
err = en.WriteBytes(z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "cdh"
err = en.Append(0xa3, 0x63, 0x64, 0x68)
if err != nil {
return
}
err = en.WriteBytes(z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "data"
err = en.Append(0xa4, 0x64, 0x61, 0x74, 0x61)
if err != nil {
return
}
err = en.WriteBytes(z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "alg"
err = en.Append(0xa3, 0x61, 0x6c, 0x67)
if err != nil {
return
}
err = en.WriteInt64(z.PublicKeyAlgorithm)
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// write "obj"
err = en.Append(0xa3, 0x6f, 0x62, 0x6a)
if err != nil {
return
}
err = en.WriteBytes(z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *CredentialAttestation) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(5)
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
if z.ClientDataJSON == nil {
zb0001Len--
zb0001Mask |= 0x1
}
if z.ClientDataHash == nil {
zb0001Len--
zb0001Mask |= 0x2
}
if z.AuthenticatorData == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.PublicKeyAlgorithm == 0 {
zb0001Len--
zb0001Mask |= 0x8
}
if z.Object == nil {
zb0001Len--
zb0001Mask |= 0x10
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
if (zb0001Mask & 0x1) == 0 { // if not omitted
// string "cdj"
o = append(o, 0xa3, 0x63, 0x64, 0x6a)
o = msgp.AppendBytes(o, z.ClientDataJSON)
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "cdh"
o = append(o, 0xa3, 0x63, 0x64, 0x68)
o = msgp.AppendBytes(o, z.ClientDataHash)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "data"
o = append(o, 0xa4, 0x64, 0x61, 0x74, 0x61)
o = msgp.AppendBytes(o, z.AuthenticatorData)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "alg"
o = append(o, 0xa3, 0x61, 0x6c, 0x67)
o = msgp.AppendInt64(o, z.PublicKeyAlgorithm)
}
if (zb0001Mask & 0x10) == 0 { // if not omitted
// string "obj"
o = append(o, 0xa3, 0x6f, 0x62, 0x6a)
o = msgp.AppendBytes(o, z.Object)
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *CredentialAttestation) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 5 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "cdj":
z.ClientDataJSON, bts, err = msgp.ReadBytesBytes(bts, z.ClientDataJSON)
if err != nil {
err = msgp.WrapError(err, "ClientDataJSON")
return
}
zb0001Mask |= 0x1
case "cdh":
z.ClientDataHash, bts, err = msgp.ReadBytesBytes(bts, z.ClientDataHash)
if err != nil {
err = msgp.WrapError(err, "ClientDataHash")
return
}
zb0001Mask |= 0x2
case "data":
z.AuthenticatorData, bts, err = msgp.ReadBytesBytes(bts, z.AuthenticatorData)
if err != nil {
err = msgp.WrapError(err, "AuthenticatorData")
return
}
zb0001Mask |= 0x4
case "alg":
z.PublicKeyAlgorithm, bts, err = msgp.ReadInt64Bytes(bts)
if err != nil {
err = msgp.WrapError(err, "PublicKeyAlgorithm")
return
}
zb0001Mask |= 0x8
case "obj":
z.Object, bts, err = msgp.ReadBytesBytes(bts, z.Object)
if err != nil {
err = msgp.WrapError(err, "Object")
return
}
zb0001Mask |= 0x10
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x1f {
if (zb0001Mask & 0x1) == 0 {
z.ClientDataJSON = nil
}
if (zb0001Mask & 0x2) == 0 {
z.ClientDataHash = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AuthenticatorData = nil
}
if (zb0001Mask & 0x8) == 0 {
z.PublicKeyAlgorithm = 0
}
if (zb0001Mask & 0x10) == 0 {
z.Object = nil
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *CredentialAttestation) Msgsize() (s int) {
s = 1 + 4 + msgp.BytesPrefixSize + len(z.ClientDataJSON) + 4 + msgp.BytesPrefixSize + len(z.ClientDataHash) + 5 + msgp.BytesPrefixSize + len(z.AuthenticatorData) + 4 + msgp.Int64Size + 4 + msgp.BytesPrefixSize + len(z.Object)
return
}
// DecodeMsg implements msgp.Decodable
func (z *CredentialFlags) DecodeMsg(dc *msgp.Reader) (err error) {
{
var zb0001 byte
zb0001, err = dc.ReadByte()
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = CredentialFlagsFromMsgpByte(zb0001)
}
return
}
// EncodeMsg implements msgp.Encodable
func (z CredentialFlags) EncodeMsg(en *msgp.Writer) (err error) {
err = en.WriteByte((CredentialFlags).MsgpByte(z))
if err != nil {
err = msgp.WrapError(err)
return
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z CredentialFlags) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
o = msgp.AppendByte(o, (CredentialFlags).MsgpByte(z))
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *CredentialFlags) UnmarshalMsg(bts []byte) (o []byte, err error) {
{
var zb0001 byte
zb0001, bts, err = msgp.ReadByteBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
(*z) = CredentialFlagsFromMsgpByte(zb0001)
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z CredentialFlags) Msgsize() (s int) {
s = msgp.ByteSize
return
}
// DecodeMsg implements msgp.Decodable
func (z *Credentials) DecodeMsg(dc *msgp.Reader) (err error) {
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
if cap((*z)) >= int(zb0002) {
(*z) = (*z)[:zb0002]
} else {
(*z) = make(Credentials, zb0002)
}
for zb0001 := range *z {
err = (*z)[zb0001].DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, zb0001)
return
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z Credentials) EncodeMsg(en *msgp.Writer) (err error) {
err = en.WriteArrayHeader(uint32(len(z)))
if err != nil {
err = msgp.WrapError(err)
return
}
for zb0003 := range z {
err = z[zb0003].EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, zb0003)
return
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z Credentials) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
o = msgp.AppendArrayHeader(o, uint32(len(z)))
for zb0003 := range z {
o, err = z[zb0003].MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, zb0003)
return
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *Credentials) UnmarshalMsg(bts []byte) (o []byte, err error) {
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
if cap((*z)) >= int(zb0002) {
(*z) = (*z)[:zb0002]
} else {
(*z) = make(Credentials, zb0002)
}
for zb0001 := range *z {
bts, err = (*z)[zb0001].UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, zb0001)
return
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z Credentials) Msgsize() (s int) {
s = msgp.ArrayHeaderSize
for zb0003 := range z {
s += z[zb0003].Msgsize()
}
return
}
+185
View File
@@ -0,0 +1,185 @@
// Package webauthn contains the API functionality of the library. After creating and configuring a webauthn object,
// users can call the object to create and validate web authentication credentials.
//
// This documentation section highlights key functions within the library which are recommended and often have
// examples attached. Functions which are discouraged due to their lack of functionality are expressly not documented
// here, and you're on your own with these functions. Generally speaking, if the function is not documented here, it is
// either used by another function documented here, and it hides one of the arguments or return values, or it is lower
// level logic only intended for advanced use cases.
//
// The [New] function is a key function in creating a new instance of a WebAuthn Relying Party which is required to
// perform most actions.
//
// To start the credential creation ceremony, the [WebAuthn.BeginMediatedRegistration] or [WebAuthn.BeginRegistration]
// functions are used which returns [*SessionData] and a [*protocol.CredentialCreation] struct which can be easily
// serialized as JSON for the frontend library/logic. The [*SessionData] must be saved in a way which allows the
// implementer to restore it later. This [*SessionData] should be safely anchored to a user agent without allowing the
// user agent to modify the contents (i.e. opaque session cookie).
//
// To finish the credential creation ceremony, the [WebAuthn.FinishRegistration] function can be used. This function
// requires a [*http.Request] and performs all the necessary and requested validations. If you have other requirements,
// you can use [protocol.ParseCredentialCreationResponseBody] or [protocol.ParseCredentialCreationResponseBytes] which
// require an [io.Reader] or byte array respectively, then use [WebAuthn.CreateCredential] to
// perform validations against the [*protocol.ParsedCredentialCreationData] and saved [*SessionData] and finalize the
// process. For complete customizability, just produce the [*protocol.ParsedCredentialCreationData] with a custom parser
// and provide it to [WebAuthn.CreateCredential].
//
// To start a Passkey login ceremony, the [WebAuthn.BeginDiscoverableMediatedLogin] or [WebAuthn.BeginDiscoverableLogin]
// functions are used which returns [*SessionData] and a [*protocol.CredentialAssertion] struct which can easily be
// serialized as JSON for the frontend library/logic. The [*SessionData] should be safely handled as previously described.
//
// To finish a Passkey login ceremony, the [WebAuthn.FinishPasskeyLogin] function can be used. This function requires a
// [*http.Request] and performs all the necessary validations. If you have other requirements, you can use the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, then use [WebAuthn.ValidatePasskeyLogin] to perform validations against the
// [*protocol.ParsedCredentialAssertionData] and saved [*SessionData] and finalize the process. For complete customizabilty,
// just produce the [protocol.ParsedCredentialAssertionData] with a custom parser and provide it to
// [WebAuthn.ValidatePasskeyLogin].
//
// To start a Multi-Factor login ceremony, the [WebAuthn.BeginMediatedLogin] or [WebAuthn.BeginLogin]
// functions are used which returns [SessionData] and a [*protocol.CredentialAssertion] struct which can easily be
// serialized as JSON for the frontend library/logic. The [*SessionData] should be safely handled as previously described.
//
// To finish a Multi-Factor login ceremony, the [WebAuthn.FinishLogin] function can be used. This function requires a
// [*http.Request] and performs all the necessary validations. If you have other requirements, you can use the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, then use [WebAuthn.ValidateLogin] to perform validations against the
// [*protocol.ParsedCredentialAssertionData] and saved [*SessionData] and finalize the process. For complete
// customizabilty, just produce the [protocol.ParsedCredentialAssertionData] with a custom parser and provide it to
// [WebAuthn.ValidateLogin].
//
// # Relying Party Usage
//
// This library hadnles the relying party server-side concerns. The browser or other user agent is responsible for
// handling the JSON responses from this library and translating them for the WebAUthn API appropriately. There are two
// primary ways to handle this other than doing so manually:
//
// 1. Using a client side library like [@simplewebauthn/browser].
// 2. Some browsers support the [parseCreationOptionsFromJSON] static method on the WebAuthn object.
//
// [parseCreationOptionsFromJSON]: https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential/parseCreationOptionsFromJSON_static
// [@simplewebauthn/browser]: https://simplewebauthn.dev/docs/packages/browser
//
// # Storage
//
// This section describes how a Relying Party should persist the state produced by the library: the [Credential]
// records returned from registration (which must survive for the lifetime of the credential) and the
// [SessionData] records exchanged between the Begin and Finish/Validate calls of each ceremony (which need only
// live long enough to span the ceremony).
//
// Guidance here assumes PostgreSQL as the backing store; the same shape translates to other SQL engines but the
// column types given below are written against PostgreSQL.
//
// Two persistence shapes are supported for the [Credential] struct and the first is strongly recommended:
//
// 1. Explicit fields (recommended). Map each field of the struct (and for [Credential] the nested
// [Authenticator] and [CredentialAttestation] fields) to its own column, using native types (BYTEA for raw
// bytes, BOOLEAN for each flag, TIMESTAMPTZ for time values, etc.). This gives the database a typed,
// queryable view of each record, allows per-field constraints and indexes, and lets an operator audit or
// migrate individual values without having to decode an opaque blob.
//
// 2. Opaque serialized value. Serialize the whole struct into a single BYTEA (or JSONB) column. Both
// encoding/json and MessagePack are supported via the struct tags on every field (the `msg:` tags drive the
// msgp-generated code in *_gen.go, and the `json:` tags drive encoding/json); either encoding will
// round-trip a [Credential]. Prefer this only when the explicit-field approach genuinely
// does not fit; you lose the ability to query, index, or update individual fields in the database.
//
// One persistence shape is supported for the [SessionData] struct which is to store it as bytes via encoding/json or
// using MessagePack as bytes in whatever storage system you're using for user sessions. This data MUST be definitively
// anchored to a user's active session, and it must be restored between the ceremony steps.
//
// Regardless of which shape is chosen, the following values MUST be persisted as their own columns so records
// can be located and scoped correctly without first decoding attestation or key material. The User Handle in
// particular is per-user state (one value shared by every credential that user owns) and MUST NOT be stored on
// the credential row; store it once per user on a separate table (`webauthn_users` in the example below) and
// link credentials to that row via the application user identifier.
//
// On each [Credential] row:
//
// - Credential ID; [Credential.ID], the identifier returned by the authenticator and echoed in every
// assertion. This is the primary lookup key at login.
// - Relying Party ID; the RP ID the credential was registered against. Credentials must be partitioned by
// RP ID and the stored value must match the RP ID in effect at authentication time.
// - Application user identifier; your application's own unique user id (the primary key used elsewhere in
// your schema to reference the user). This is what ties a credential back to the user record and,
// transitively via `webauthn_users`, to the User Handle.
//
// On a separate per-user row (`webauthn_users` or equivalent), keyed uniquely by (RP ID, application user id)
// and also uniquely by (RP ID, User Handle):
//
// - Relying Party ID; same scoping rules as above; a user may have distinct User Handles under different
// RP IDs, so the RP ID must be part of both unique keys on this table.
// - Application user identifier; the same value stored on each of that user's credential rows; this is
// the join column between `webauthn_users` and `webauthn_credentials`.
// - User Handle; the opaque per-user byte sequence returned by [User.WebAuthnID], equivalently
// [SessionData.UserID]. This is the value exchanged with the authenticator and is what
// discoverable-credential flows return at login. It MUST be stable for the lifetime of the account and
// MUST be the same across every credential that user owns; storing it once, per user, is what enforces
// that. It is NOT the same as the application user identifier: the User Handle is an opaque WebAuthn
// value emitted to authenticators, whereas the application user identifier is your schema's primary
// key for the user. Keeping the two as separate columns lets you resolve from either direction.
//
// A minimal PostgreSQL schema covering the above plus the remaining [Credential], [Authenticator], and
// [CredentialAttestation] fields is shown below.
//
// Example users table:
//
// CREATE TABLE webauthn_users (
// id UUID PRIMARY KEY DEFAULT uuidv7(),
// created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// rpid VARCHAR(512) NOT NULL, -- Relying Party ID
// user_id UUID NOT NULL, -- Application-side unique user id (FK to your users table)
// handle BYTEA NOT NULL -- User.WebAuthnID (WebAuthn User Handle); stable per (rpid, user_id)
// );
//
// CREATE UNIQUE INDEX webauthn_users_user_id_key ON webauthn_users (rpid, user_id);
// CREATE UNIQUE INDEX webauthn_users_handle_key ON webauthn_users (rpid, handle);
//
// Example credentials table:
//
// CREATE TABLE webauthn_credentials (
// id UUID PRIMARY KEY DEFAULT uuidv7(),
// created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
// last_used_at TIMESTAMPTZ NULL,
// rpid VARCHAR(512) NOT NULL, -- Relying Party ID
// user_id UUID NOT NULL, -- Application-side unique user id
// kid BYTEA NOT NULL, -- Credential.ID
// aaguid BYTEA NULL, -- Authenticator.AAGUID
// public_key BYTEA NOT NULL, -- Credential.PublicKey (encrypt at rest)
// attestation_type VARCHAR(32) NOT NULL, -- CredentialAttestation.AttestationType
// attestation_format VARCHAR(32) NOT NULL, -- CredentialAttestation.AttestationFormat
// attestation BYTEA NULL DEFAULT NULL, -- CredentialAttestation serialized as Message Pack or JSON (encrypt at rest)
// transport VARCHAR(64) NOT NULL DEFAULT '', -- Credential.Transport serialized as a comma-separated value
// sign_count BIGINT NOT NULL DEFAULT 0, -- Authenticator.SignCount
// clone_warning BOOLEAN NOT NULL DEFAULT FALSE, -- Authenticator.CloneWarning
// attachment VARCHAR(64) NOT NULL DEFAULT '', -- Authenticator.Attachment
// flags BYTEA NOT NULL, -- Value of Flags.ProtocolValue (a single octet), restored with NewCredentialFlags, could also be SMALLINT
// present BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.UserPresent, optionally stored so you can either display it to the user or for filtering credentials
// verified BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.UserVerified, optionally stored so you can either display it to the user or for filtering credentials
// backup_eligible BOOLEAN NOT NULL DEFAULT FALSE, -- Flags.BackupEligible, optionally stored so you can either display it to the user or for filtering credentials
// backup_state BOOLEAN NOT NULL DEFAULT FALSE -- Flags.BackupState, optionally stored so you can either display it to the user or for filtering credentials
// );
//
// CREATE UNIQUE INDEX webauthn_credentials_kid_key ON webauthn_credentials (rpid, kid);
// CREATE INDEX webauthn_credentials_user_id ON webauthn_credentials (rpid, user_id);
//
// With that shape, the two login lookup paths resolve as:
//
// - Credential-ID-first (allowCredentials flows): match `webauthn_credentials.kid` to the credential ID
// returned by the authenticator, then optionally join `webauthn_users` on (rpid, user_id) to compare the
// authenticator-supplied User Handle against the stored one.
// - User-Handle-first (discoverable / passkey flows): match `webauthn_users.handle` under the current
// RP ID to resolve the application user id, then load that user's credentials from
// `webauthn_credentials`.
//
// Fields that change across assertions; [Authenticator.SignCount], [Authenticator.CloneWarning], and
// [CredentialFlags.BackupState] when [CredentialFlags.BackupEligible] is true MUST be written back to storage
// on every successful FinishLogin / ValidateLogin so the next ceremony observes the current values.
//
// For [SessionData] stored in a database (rather than a server-side session store), use the same persistence
// shapes described above. The User Handle on a [SessionData] row is per-session ceremony state rather than
// per-user state, so it is fine to keep [SessionData.UserID] on the session row itself; the per-user-table
// rule applies to [Credential] storage, not to [SessionData]. Additionally index the challenge (unique) and
// the expiry timestamp so sessions can be looked up by challenge at Finish time and expired rows reaped
// cheaply. Stored sessions must only be consumed by a Finish call operating under the same RP ID.
package webauthn
+387
View File
@@ -0,0 +1,387 @@
package webauthn
import (
"bytes"
"context"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
// LoginOption is a functional option that modifies the [protocol.PublicKeyCredentialRequestOptions] sent to the
// client during a login ceremony. Use the With* functions in this package (i.e. [WithUserVerification],
// [WithAllowedCredentials]) to create login options.
type LoginOption func(*protocol.PublicKeyCredentialRequestOptions)
// DiscoverableUserHandler is a callback function that the Relying Party must provide when performing a discoverable
// (passkey) login. It is called with the rawID of the credential and the userHandle from the authenticator response,
// and must return the [User] who owns the credential. This is necessary because in discoverable login flows, the
// Relying Party does not know which user is authenticating until the authenticator response is received.
type DiscoverableUserHandler func(rawID, userHandle []byte) (user User, err error)
// BeginLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent for beginning
// the login/assertion process. This function is used to perform a login when the identity of the user is known such as
// multifactor authentications, to specify a conditional mediation requirement use [WebAuthn.BeginMediatedLogin], to
// perform a login when the identity of the user is not known see [WebAuthn.BeginDiscoverableLogin] and
// [WebAuthn.BeginDiscoverableMediatedLogin] instead. The format of this data can be seen in §5.5 of the WebAuthn
// specification. These default values can be amended by providing additional [LoginOption] parameters. This function
// also returns [SessionData], that must be stored by the RP in a secure manner and then provided to the
// [WebAuthn.FinishLogin] function. This data helps us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginLogin(user User, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.BeginMediatedLogin(user, protocol.MediationDefault, opts...)
}
// BeginDiscoverableLogin creates the [*protocol.CredentialAssertion] data payload that should be sent to the user agent
// for beginning the login/assertion process. This function is used to perform a client-side discoverable login when the
// identity of the user is not known such as passwordless or usernameless authentication, to specify a conditional
// mediation requirement use [WebAuthn.BeginDiscoverableMediatedLogin], to perform logins where the identity of the user
// is known such as multifactor authentication see [WebAuthn.BeginLogin] and [WebAuthn.BeginMediatedLogin] instead.
// The format of this data can be seen in §5.5 of the WebAuthn specification. These default values can be amended by
// providing additional [LoginOption] parameters. This function also returns [SessionData], that
// must be stored by the RP in a secure manner and then provided to the [WebAuthn.FinishLogin] function. This data helps
// us verify the ownership of the credential being retrieved.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dictionary-assertion-options)
func (webauthn *WebAuthn) BeginDiscoverableLogin(opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, protocol.MediationDefault, opts...)
}
// BeginMediatedLogin is similar to [WebAuthn.BeginLogin] however it also allows specifying a credential mediation
// requirement.
func (webauthn *WebAuthn) BeginMediatedLogin(user User, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
credentials := user.WebAuthnCredentials()
if len(credentials) == 0 { // If the user does not have any credentials, we cannot perform an assertion.
return nil, nil, protocol.ErrBadRequest.WithDetails("Found no credentials for user")
}
var allowedCredentials = make([]protocol.CredentialDescriptor, len(credentials))
for i, credential := range credentials {
allowedCredentials[i] = credential.Descriptor()
}
return webauthn.beginLogin(user.WebAuthnID(), allowedCredentials, mediation, opts...)
}
// BeginDiscoverableMediatedLogin is similar to [WebAuthn.BeginDiscoverableLogin] however it also allows specifying a
// credential mediation requirement.
func (webauthn *WebAuthn) BeginDiscoverableMediatedLogin(mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (*protocol.CredentialAssertion, *SessionData, error) {
return webauthn.beginLogin(nil, nil, mediation, opts...)
}
func (webauthn *WebAuthn) beginLogin(userID []byte, allowedCredentials []protocol.CredentialDescriptor, mediation protocol.CredentialMediationRequirement, opts ...LoginOption) (assertion *protocol.CredentialAssertion, session *SessionData, err error) {
if err = webauthn.Config.validate(); err != nil {
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}
assertion = &protocol.CredentialAssertion{
Response: protocol.PublicKeyCredentialRequestOptions{
RelyingPartyID: webauthn.Config.RPID,
UserVerification: webauthn.Config.AuthenticatorSelection.UserVerification,
AllowedCredentials: allowedCredentials,
},
Mediation: mediation,
}
for _, opt := range opts {
opt(&assertion.Response)
}
if len(assertion.Response.Challenge) == 0 {
var challenge protocol.URLEncodedBase64
if challenge, err = protocol.CreateChallenge(); err != nil {
return nil, nil, err
}
assertion.Response.Challenge = challenge
}
if len(assertion.Response.Challenge) < protocol.MinimumChallengeLength {
return nil, nil, fmt.Errorf("error generating assertion: the challenge must be at least 16 bytes")
}
if len(assertion.Response.RelyingPartyID) == 0 {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id must be provided via the configuration or a functional option for a login")
} else if err = protocol.ValidateRPID(assertion.Response.RelyingPartyID); err != nil {
return nil, nil, fmt.Errorf("error generating assertion: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
}
if assertion.Response.Timeout == 0 {
switch assertion.Response.UserVerification {
case protocol.VerificationDiscouraged:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.TimeoutUVD.Milliseconds())
default:
assertion.Response.Timeout = int(webauthn.Config.Timeouts.Login.Timeout.Milliseconds())
}
}
session = &SessionData{
Challenge: assertion.Response.Challenge.String(),
RelyingPartyID: assertion.Response.RelyingPartyID,
UserID: userID,
AllowedCredentialIDs: assertion.Response.GetAllowedCredentialIDs(),
UserVerification: assertion.Response.UserVerification,
Extensions: assertion.Response.Extensions,
}
if webauthn.Config.Timeouts.Login.Enforce {
session.Expires = time.Now().Add(time.Millisecond * time.Duration(assertion.Response.Timeout))
}
return assertion, session, nil
}
// FinishLogin takes the response from the client and validates it against the user credentials and stored session data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidateLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in the [User]'s
// [Credential] list.
func (webauthn *WebAuthn) FinishLogin(user User, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateLogin(user, session, parsedResponse)
}
// FinishDiscoverableLogin takes the response from the client and validates it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions, this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, err
}
return webauthn.ValidateDiscoverableLogin(handler, session, parsedResponse)
}
// FinishPasskeyLogin takes the response from the client and validate it against the handler and stored session data.
// The handler helps to find out which user must be used to validate the response. This is a function defined in your
// business code that will retrieve the user from your persistent data.
//
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialRequestResponseBody] or [protocol.ParseCredentialRequestResponseBytes] which require an
// io.Reader or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialAssertionData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [DiscoverableUserHandler], [*SessionData],
// and [*protocol.ParsedCredentialAssertionData] can then be used with the [WebAuthn.ValidatePasskeyLogin] function.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) FinishPasskeyLogin(handler DiscoverableUserHandler, session SessionData, response *http.Request) (user User, credential *Credential, err error) {
var parsedResponse *protocol.ParsedCredentialAssertionData
if parsedResponse, err = protocol.ParseCredentialRequestResponse(response); err != nil {
return nil, nil, err
}
return webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
}
// ValidateLogin takes a parsed response and validates it against the user credentials and session data.
//
// If you wish to skip performing the step required to parse the *protocol.ParsedCredentialAssertionData and
// you're using net/http then you can use [WebAuthn.FinishLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] provided does not contain
// a [Credential] with the same ID byte array provided all [Credential]'s in the [SessionData] exist in
// the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
return webauthn.validateLogin(user, session, parsedResponse)
}
// ValidateDiscoverableLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials. It's
// recommended that [WebAuthn.ValidatePasskeyLogin] is used instead.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishDiscoverableLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
//
// Note: this is just a backwards compatibility layer over [WebAuthn.ValidatePasskeyLogin] which returns more information.
func (webauthn *WebAuthn) ValidateDiscoverableLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (credential *Credential, err error) {
_, credential, err = webauthn.ValidatePasskeyLogin(handler, session, parsedResponse)
return credential, err
}
// ValidatePasskeyLogin is similar to [WebAuthn.ValidateLogin] that allows for discoverable credentials.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialAssertionData] and
// you're using net/http then you can use [WebAuthn.FinishPasskeyLogin] instead.
//
// This function will return the [protocol.ErrorUnknownCredential] error type when the [User] returned by the
// handler does not contain a [Credential] with the same ID byte array provided all [Credential]'s
// in the [SessionData] exist in the [User]'s [Credential] list.
func (webauthn *WebAuthn) ValidatePasskeyLogin(handler DiscoverableUserHandler, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (user User, credential *Credential, err error) {
if len(session.UserID) != 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session was not initiated as a client-side discoverable login")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
if len(parsedResponse.Response.UserHandle) == 0 {
return nil, nil, protocol.ErrBadRequest.WithDetails("Client-side Discoverable Assertion was attempted with a blank User Handle")
}
if user, err = handler(parsedResponse.RawID, parsedResponse.Response.UserHandle); err != nil {
return nil, nil, protocol.ErrBadRequest.WithDetails(fmt.Sprintf("Failed to lookup Client-side Discoverable Credential: %s", err)).WithError(err)
}
if user == nil {
return nil, nil, protocol.ErrBadRequest.WithDetails("Failed to lookup Client-side Discoverable Credential: handler returned a nil user")
}
if credential, err = webauthn.validateLogin(user, session, parsedResponse); err != nil {
return nil, nil, err
}
return user, credential, nil
}
// validateLogin takes a parsed response and validates it against the user credentials and session data.
//
//nolint:gocyclo
func (webauthn *WebAuthn) validateLogin(user User, session SessionData, parsedResponse *protocol.ParsedCredentialAssertionData) (*Credential, error) {
// Step 1. If the allowCredentials option was given when this authentication ceremony was initiated,
// verify that credential.id identifies one of the public key credentials that were listed in
// allowCredentials.
// NON-NORMATIVE Prior Step: Verify that the allowCredentials for the session are owned by the user provided.
credentials := user.WebAuthnCredentials()
if len(session.AllowedCredentialIDs) > 0 {
if !isCredentialsAllowedMatchingOwned(session.AllowedCredentialIDs, credentials) {
return nil, protocol.ErrBadRequest.WithDetails("User does not own all credentials from the allowed credential list")
}
if !isCredentialIDInCredentials(parsedResponse.RawID, credentials) {
return nil, &protocol.ErrorUnknownCredential{Err: protocol.ErrBadRequest.WithDetails("The credential ID provided is not owned by the user")}
}
if !isByteArrayInSlice(parsedResponse.RawID, session.AllowedCredentialIDs...) {
return nil, protocol.ErrBadRequest.WithDetails("The credential ID provided is not in the sessions allowed credential list")
}
}
// Step 2. If credential.response.userHandle is present, verify that the user identified by this value is
// the owner of the public key credential identified by credential.id. This is in part handled by our Step 1.
userHandle := parsedResponse.Response.UserHandle
if len(userHandle) > 0 {
if !bytes.Equal(userHandle, user.WebAuthnID()) {
return nil, protocol.ErrBadRequest.WithDetails("User handle and User ID do not match")
}
}
var (
found bool
credential Credential
)
// Step 3. Using credentials id attribute (or the corresponding rawId, if base64url encoding is inappropriate
// for your use case), look up the corresponding credential public key.
for _, credential = range credentials {
if bytes.Equal(credential.ID, parsedResponse.RawID) {
found = true
break
}
}
if !found {
return nil, protocol.ErrBadRequest.WithDetails("Unable to find the credential for the returned credential ID")
}
var (
appID string
err error
)
// Ensure authenticators with a bad status are not used.
if webauthn.Config.MDS != nil {
var aaguid uuid.UUID
if len(credential.Authenticator.AAGUID) == 0 {
aaguid = uuid.Nil
} else if aaguid, err = uuid.FromBytes(credential.Authenticator.AAGUID); err != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to decode AAGUID").WithInfo(fmt.Sprintf("Error occurred decoding AAGUID from the credential record: %s", err)).WithError(err)
}
if e := protocol.ValidateMetadata(context.Background(), webauthn.Config.MDS, aaguid, credential.AttestationType, credential.AttestationFormat, nil); e != nil {
return nil, protocol.ErrBadRequest.WithDetails("Failed to validate credential record metadata").WithInfo(e.DevInfo).WithError(e)
}
}
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
shouldVerifyUserPresence := true
rpID := webauthn.Config.RPID
rpOrigins := webauthn.Config.RPOrigins
rpTopOrigins := webauthn.Config.RPTopOrigins
if appID, err = parsedResponse.GetAppID(session.Extensions, credential.AttestationFormat); err != nil {
return nil, err
}
// Handle steps 4 through 16.
if err = parsedResponse.Verify(session.Challenge, rpID, appID, rpOrigins, rpTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, credential.PublicKey); err != nil {
return nil, err
}
// Check if the BackupEligible flag has changed.
if credential.Flags.BackupEligible != parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() {
return nil, protocol.ErrBadRequest.WithDetails("Backup Eligible flag inconsistency detected during login validation")
}
// Check for the invalid combination BE=0 and BS=1.
if !parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible() && parsedResponse.Response.AuthenticatorData.Flags.HasBackupState() {
return nil, protocol.ErrBadRequest.WithDetails("Backup State Flag is true but Backup Eligible flag is false which is invalid")
}
// Handle step 17.
credential.Authenticator.UpdateCounter(parsedResponse.Response.AuthenticatorData.Counter)
// Update flags from response data.
credential.Flags = NewCredentialFlags(parsedResponse.Response.AuthenticatorData.Flags)
return &credential, nil
}
@@ -0,0 +1,101 @@
package webauthn
import "gamertan.com/web/internal/webauthnvendored/protocol"
// WithChallenge overrides the random challenge that [WebAuthn.BeginLogin] would otherwise generate for this
// ceremony. The supplied value is used verbatim.
//
// The only safe reason to call this is when the relying party needs to record the challenge in a server-side store
// before the ceremony is initiated; for example to maintain a set of previously-issued challenges so it can
// reject a replay that reuses one. Generating the challenge inside a separate step lets the RP persist it
// atomically before it is ever handed to the client.
//
// If you have that need, the supplied challenge MUST be produced by [protocol.CreateChallenge] (32 bytes from
// crypto/rand). Do not use timestamps, counters, UUIDs, hashed user inputs, or any other deterministic or
// partially-predictable source; these defeat the cryptographic guarantees the challenge provides and open the
// ceremony to replay and guessing attacks. [WebAuthn.BeginLogin] enforces a minimum length of
// [protocol.MinimumChallengeLength] bytes, but that check is a backstop only and is not a substitute for using a
// CSPRNG.
//
// If you do not have a specific persistence requirement, do not use this function; let the library generate the
// challenge automatically.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-challenge)
//
// Specification: §13.4.3. Cryptographic Challenges (https://www.w3.org/TR/webauthn/#sctn-cryptographic-challenges)
func WithChallenge(challenge []byte) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Challenge = challenge
}
}
// WithLoginRelyingPartyID sets the Relying Party ID for this particular login.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-rpid)
func WithLoginRelyingPartyID(id string) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.RelyingPartyID = id
}
}
// WithAllowedCredentials adjusts the allowed credentials via a slice of [protocol.CredentialDescriptor] values,
// discussed in the included specification sections with user-supplied values.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-allowcredentials)
//
// Specification: §5.10.3. Credential Descriptor (https://www.w3.org/TR/webauthn/#dictdef-publickeycredentialdescriptor)
func WithAllowedCredentials(allowList []protocol.CredentialDescriptor) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.AllowedCredentials = allowList
}
}
// WithUserVerification adjusts the user verification preference by providing a [protocol.UserVerificationRequirement].
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-userverification)
func WithUserVerification(userVerification protocol.UserVerificationRequirement) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.UserVerification = userVerification
}
}
// WithAssertionPublicKeyCredentialHints adjusts the non-default hints for credential types to select during login by
// providing a slice of [protocol.PublicKeyCredentialHints].
//
// WebAuthn Level 3.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialrequestoptions-hints)
func WithAssertionPublicKeyCredentialHints(hints []protocol.PublicKeyCredentialHints) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Hints = hints
}
}
// WithAssertionExtensions adjusts the requested extensions by providing a [protocol.AuthenticationExtensions].
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-extensions)
func WithAssertionExtensions(extensions protocol.AuthenticationExtensions) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
cco.Extensions = extensions
}
}
// WithAppIdExtension automatically includes the specified appid if the AllowedCredentials contains a credential
// with the type `fido-u2f`.
//
// Specification: §5.5. Options for Assertion Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialrequestoptions-extensions)
func WithAppIdExtension(appid string) LoginOption {
return func(cco *protocol.PublicKeyCredentialRequestOptions) {
for _, credential := range cco.AllowedCredentials {
if credential.AttestationFormat == string(protocol.AttestationFormatFIDOUniversalSecondFactor) {
if cco.Extensions == nil {
cco.Extensions = map[string]any{}
}
cco.Extensions[protocol.ExtensionAppID] = appid
break
}
}
}
}
@@ -0,0 +1,230 @@
package webauthn
import (
"bytes"
"fmt"
"net/http"
"time"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
// RegistrationOption is a functional option that modifies the [protocol.PublicKeyCredentialCreationOptions] sent
// to the client during a registration ceremony. Use the With* functions in this package (i.e.
// [WithConveyancePreference], [WithExclusions], [WithAuthenticatorSelection]) to create registration options.
type RegistrationOption func(*protocol.PublicKeyCredentialCreationOptions)
// BeginRegistration generates a new set of registration data to be sent to the client and authenticator. To set a
// conditional mediation requirement for the registration see [WebAuthn.BeginMediatedRegistration].
func (webauthn *WebAuthn) BeginRegistration(user User, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) {
return webauthn.BeginMediatedRegistration(user, protocol.MediationDefault, opts...)
}
// BeginMediatedRegistration is similar to [WebAuthn.BeginRegistration] however it also allows specifying a credential
// mediation requirement.
func (webauthn *WebAuthn) BeginMediatedRegistration(user User, mediation protocol.CredentialMediationRequirement, opts ...RegistrationOption) (creation *protocol.CredentialCreation, session *SessionData, err error) {
if err = webauthn.Config.validate(); err != nil {
return nil, nil, fmt.Errorf(errFmtConfigValidate, err)
}
var (
challenge protocol.URLEncodedBase64
entityUserID any
)
if challenge, err = protocol.CreateChallenge(); err != nil {
return nil, nil, err
}
if webauthn.Config.EncodeUserIDAsString {
entityUserID = string(user.WebAuthnID())
} else {
entityUserID = protocol.URLEncodedBase64(user.WebAuthnID())
}
entityUser := protocol.UserEntity{
ID: entityUserID,
DisplayName: user.WebAuthnDisplayName(),
CredentialEntity: protocol.CredentialEntity{
Name: user.WebAuthnName(),
},
}
entityRelyingParty := protocol.RelyingPartyEntity{
ID: webauthn.Config.RPID,
CredentialEntity: protocol.CredentialEntity{
Name: webauthn.Config.RPDisplayName,
},
}
credentialParams := CredentialParametersDefault()
creation = &protocol.CredentialCreation{
Response: protocol.PublicKeyCredentialCreationOptions{
RelyingParty: entityRelyingParty,
User: entityUser,
Challenge: challenge,
Parameters: credentialParams,
AuthenticatorSelection: webauthn.Config.AuthenticatorSelection,
Attestation: webauthn.Config.AttestationPreference,
},
Mediation: mediation,
}
for _, opt := range opts {
opt(&creation.Response)
}
if len(creation.Response.RelyingParty.ID) == 0 {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party id must be provided via the configuration or a functional option for a creation")
} else if err = protocol.ValidateRPID(creation.Response.RelyingParty.ID); err != nil {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party id failed to validate as it's not a valid domain string with error: %w", err)
}
if len(creation.Response.RelyingParty.Name) == 0 {
return nil, nil, fmt.Errorf("error generating credential creation: the relying party display name must be provided via the configuration or a functional option for a creation")
}
if len(creation.Response.Challenge) < protocol.MinimumChallengeLength {
return nil, nil, fmt.Errorf("error generating credential creation: the challenge must be at least 16 bytes")
}
if creation.Response.Timeout == 0 {
switch creation.Response.AuthenticatorSelection.UserVerification {
case protocol.VerificationDiscouraged:
creation.Response.Timeout = int(webauthn.Config.Timeouts.Registration.TimeoutUVD.Milliseconds())
default:
creation.Response.Timeout = int(webauthn.Config.Timeouts.Registration.Timeout.Milliseconds())
}
}
session = &SessionData{
Challenge: creation.Response.Challenge.String(),
RelyingPartyID: creation.Response.RelyingParty.ID,
UserID: user.WebAuthnID(),
UserVerification: creation.Response.AuthenticatorSelection.UserVerification,
CredParams: creation.Response.Parameters,
Mediation: creation.Mediation,
}
if webauthn.Config.Timeouts.Registration.Enforce {
session.Expires = time.Now().Add(time.Millisecond * time.Duration(creation.Response.Timeout))
}
return creation, session, nil
}
// FinishRegistration takes the response from the authenticator and client and verify the credential against the user's
// credentials and session data.
//
// As with all Finish functions this function requires a [*http.Request] but you can perform the same steps with the
// [protocol.ParseCredentialCreationResponseBody] or [protocol.ParseCredentialCreationResponseBytes] which require an
// [io.Reader] or byte array respectively, you can also use an arbitrary [*protocol.ParsedCredentialCreationData] which is
// returned from all of these functions i.e. by implementing a custom parser. The [User], [*SessionData], and
// [*protocol.ParsedCredentialCreationData] can then be used with the [WebAuthn.CreateCredential] function.
func (webauthn *WebAuthn) FinishRegistration(user User, session SessionData, request *http.Request) (credential *Credential, err error) {
parsedResponse, err := protocol.ParseCredentialCreationResponse(request)
if err != nil {
return nil, err
}
return webauthn.CreateCredential(user, session, parsedResponse)
}
// CreateCredential verifies a parsed response against the user's credentials and session data.
//
// If you wish to skip performing the step required to parse the [*protocol.ParsedCredentialCreationData] and
// you're using net/http then you can use [WebAuthn.FinishRegistration] instead.
func (webauthn *WebAuthn) CreateCredential(user User, session SessionData, parsedResponse *protocol.ParsedCredentialCreationData) (credential *Credential, err error) {
if !bytes.Equal(user.WebAuthnID(), session.UserID) {
return nil, protocol.ErrBadRequest.WithDetails("ID mismatch for User and Session")
}
if !session.Expires.IsZero() && session.Expires.Before(time.Now()) {
return nil, protocol.ErrBadRequest.WithDetails("Session has Expired")
}
shouldVerifyUser := session.UserVerification == protocol.VerificationRequired
shouldVerifyUserPresence := session.Mediation != protocol.MediationConditional
var clientDataHash []byte
if clientDataHash, err = parsedResponse.Verify(session.Challenge, webauthn.Config.RPID, webauthn.Config.RPOrigins, webauthn.Config.RPTopOrigins, webauthn.Config.RPTopOriginVerificationMode, webauthn.Config.RPAllowCrossOrigin, shouldVerifyUser, shouldVerifyUserPresence, webauthn.Config.MDS, session.CredParams); err != nil {
return nil, err
}
if credential, err = NewCredential(clientDataHash, parsedResponse); err != nil {
return nil, err
}
if err = ValidateFilteredCredential(credential, webauthn.Config.Filtering); err != nil {
return nil, err
}
return credential, nil
}
// ValidateFilteredCredential applies the supplied [FilteringConfig] to a freshly-created [Credential]
// and returns a non-nil error when the credential violates any configured filtering rule (backup-eligibility
// prohibition, permitted-AAGUID allow-list, prohibited-AAGUID deny-list). A nil filtering argument is treated
// as "no filtering" and the function returns nil.
//
// The zero AAGUID ([uuid.Nil]) is never excluded by the permitted list, preserving the documented
// [FilteringConfig] contract for authenticators that report no AAGUID.
//
// This function is invoked automatically by [WebAuthn.CreateCredential] using the [Config.Filtering] value;
// relying parties may also call it directly (e.g. to pre-validate a credential before persistence) with any
// FilteringConfig value of their choosing.
//
// The credential argument must be non-nil.
func ValidateFilteredCredential(credential *Credential, filtering *FilteringConfig) (err error) {
if filtering == nil {
return nil
}
if credential == nil {
return protocol.ErrBadRequest.WithInfo("Credential is nil")
}
if filtering.ProhibitBackupEligibility && credential.Flags.BackupEligible {
return protocol.ErrPolicyRestriction.WithInfo("Credential is Backup Eligible")
}
var aaguid uuid.UUID
if err = aaguid.UnmarshalBinary(credential.Authenticator.AAGUID); err != nil {
return protocol.ErrBadRequest.WithInfo("The AAGUID of the credential is not a valid UUID")
}
if len(filtering.PermittedAAGUIDs) != 0 {
var success = false
if aaguid == uuid.Nil {
success = true
} else {
for _, permitted := range filtering.PermittedAAGUIDs {
if permitted == aaguid {
success = true
break
}
}
}
if !success {
return protocol.ErrPolicyRestriction.WithInfo("Credential has an AAGUID which is not permitted")
}
}
if len(filtering.ProhibitedAAGUIDs) != 0 {
for _, prohibited := range filtering.ProhibitedAAGUIDs {
if prohibited == aaguid {
return protocol.ErrPolicyRestriction.WithInfo("Credential has an AAGUID which is prohibited")
}
}
}
return nil
}
@@ -0,0 +1,121 @@
package webauthn
import (
"gamertan.com/web/internal/webauthnvendored/protocol"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
)
// CredentialParametersDefault returns the default list of acceptable credential algorithms. This includes ES256,
// ES384, ES512, RS256, RS384, RS512, PS256, PS384, PS512, and EdDSA. The order indicates preference.
func CredentialParametersDefault() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
}
}
// CredentialParametersRecommendedL3 returns the WebAuthn Level 3 recommended credential algorithm list: EdDSA,
// ES256, and RS256 (in that order). This is the minimal set recommended by the specification for broad
// authenticator compatibility.
func CredentialParametersRecommendedL3() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
}
}
// CredentialParametersExtendedL3 returns the WebAuthn Level 3 recommended credential algorithm list (EdDSA, ES256,
// RS256) extended with all other algorithms supported by this library (ES384, ES512, RS384, RS512, PS256, PS384,
// PS512). The Level 3 recommended algorithms appear first to indicate preference.
func CredentialParametersExtendedL3() []protocol.CredentialParameter {
return []protocol.CredentialParameter{
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgEdDSA,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgES512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgRS512,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS256,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS384,
},
{
Type: protocol.PublicKeyCredentialType,
Algorithm: webauthncose.AlgPS512,
},
}
}
@@ -0,0 +1,132 @@
package webauthn
import "gamertan.com/web/internal/webauthnvendored/protocol"
// WithCredentialParameters adjusts the credential parameters in the registration options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-pubkeycredparams)
func WithCredentialParameters(credentialParams []protocol.CredentialParameter) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Parameters = credentialParams
}
}
// WithExclusions adjusts the non-default parameters regarding credentials to exclude from registration.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-excludecredentials)
func WithExclusions(excludeList []protocol.CredentialDescriptor) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.CredentialExcludeList = excludeList
}
}
// WithAuthenticatorSelection adjusts the non-default parameters regarding the authenticator to select during
// registration.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-authenticatorselection)
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictdef-authenticatorselectioncriteria)
func WithAuthenticatorSelection(authenticatorSelection protocol.AuthenticatorSelection) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AuthenticatorSelection = authenticatorSelection
}
}
// WithResidentKeyRequirement sets both the resident key and require resident key protocol options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-authenticatorselection)
//
// Specification: §5.4.4. Authenticator Selection Criteria (https://www.w3.org/TR/webauthn/#dictdef-authenticatorselectioncriteria)
func WithResidentKeyRequirement(requirement protocol.ResidentKeyRequirement) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AuthenticatorSelection.ResidentKey = requirement
switch requirement {
case protocol.ResidentKeyRequirementRequired:
cco.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyRequired()
default:
cco.AuthenticatorSelection.RequireResidentKey = protocol.ResidentKeyNotRequired()
}
}
}
// WithPublicKeyCredentialHints adjusts the non-default hints for credential types to select during registration.
//
// WebAuthn Level 3.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-hints)
func WithPublicKeyCredentialHints(hints []protocol.PublicKeyCredentialHints) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Hints = hints
}
}
// WithConveyancePreference adjusts the non-default parameters regarding whether the authenticator should attest to the
// credential.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialcreationoptions-attestation)
func WithConveyancePreference(preference protocol.ConveyancePreference) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Attestation = preference
}
}
// WithAttestationFormats adjusts the non-default formats for credential types to select during registration.
//
// WebAuthn Level 3.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-attestationformats)
func WithAttestationFormats(formats []protocol.AttestationFormat) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.AttestationFormats = formats
}
}
// WithExtensions adjusts the extension parameter in the registration options.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions)
//
// Specification: §9. Extensions (https://www.w3.org/TR/webauthn/#webauthn-extensions)
func WithExtensions(extension protocol.AuthenticationExtensions) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.Extensions = extension
}
}
// WithAppIdExcludeExtension automatically includes the specified appid if the CredentialExcludeList contains a credential
// with the type `fido-u2f`.
//
// Specification: §5.4. Parameters for Credential Generation (https://www.w3.org/TR/webauthn-3/#dom-publickeycredentialcreationoptions-extensions)
//
// Specification: §9. Extensions (https://www.w3.org/TR/webauthn/#webauthn-extensions)
//
// Specification: §10.1.2. FIDO AppID Exclusion Extension (https://www.w3.org/TR/webauthn/#sctn-appid-exclude-extension)
func WithAppIdExcludeExtension(appid string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
for _, credential := range cco.CredentialExcludeList {
if credential.AttestationFormat == string(protocol.AttestationFormatFIDOUniversalSecondFactor) {
if cco.Extensions == nil {
cco.Extensions = map[string]any{}
}
cco.Extensions[protocol.ExtensionAppIDExclude] = appid
break
}
}
}
}
// WithRegistrationRelyingPartyID sets the relying party id for the registration.
func WithRegistrationRelyingPartyID(id string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.RelyingParty.ID = id
}
}
// WithRegistrationRelyingPartyName sets the relying party name for the registration.
func WithRegistrationRelyingPartyName(name string) RegistrationOption {
return func(cco *protocol.PublicKeyCredentialCreationOptions) {
cco.RelyingParty.Name = name
}
}
+251
View File
@@ -0,0 +1,251 @@
package webauthn
import (
"fmt"
"time"
"github.com/google/uuid"
"gamertan.com/web/internal/webauthnvendored/metadata"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
// New creates a new [WebAuthn] instance from the provided [Config]. The configuration is validated before the
// instance is returned.
func New(config *Config) (*WebAuthn, error) {
if err := config.validate(); err != nil {
return nil, fmt.Errorf(errFmtConfigValidate, err)
}
return &WebAuthn{
config,
}, nil
}
// WebAuthn is the primary interface of this package. It provides methods to begin and finish both registration and
// login ceremonies. Create an instance using [New] and then call the appropriate Begin/Finish methods for your
// use case. See the package documentation for detailed ceremony flows.
type WebAuthn struct {
Config *Config
}
// Config represents the Relying Party configuration for WebAuthn operations. At minimum, RPID and RPOrigins must
// be configured. The RPID should be the effective domain of the Relying Party (i.e. "example.com") and RPOrigins
// should contain the fully qualified origins that are permitted (i.e. "https://example.com").
type Config struct {
// RPID configures the Relying Party Server ID. This should generally be the origin without a scheme and port.
RPID string
// RPDisplayName configures the display name for the Relying Party Server. This can be any string.
RPDisplayName string
// RPOrigins configures the list of Relying Party Server Origins that are permitted. The provided origins can either
// be fully qualified origins or strings for simple string comparison. The strings are matched using canonical
// origin matching semantics specifically if they start with 'http://' or 'https://' if the provided origin has a
// case-insensitive equal scheme and host component they are equal, otherwise simple string comparison is utilized
// to determine equality.
RPOrigins []string
// RPTopOrigins configures the list of Relying Party Server Top Origins that are permitted. The provided origins can
// either be fully qualified origins or strings for simple string comparison. The strings are matched using
// canonical origin matching semantics specifically if they start with 'http://' or 'https://' if the provided
// origin has a case-insensitive equal scheme and host component they are equal, otherwise simple string comparison
// is utilized to determine equality.
RPTopOrigins []string
// RPTopOriginVerificationMode determines the verification mode for the Top Origin value used in cross-origin
// ceremonies. When the zero value ([protocol.TopOriginDefaultVerificationMode]) is provided, the config
// validator coerces this field to [protocol.TopOriginExplicitVerificationMode]; i.e. any Top Origin supplied
// by the client must appear in [Config.RPTopOrigins]. Set this field explicitly to
// [protocol.TopOriginAutoVerificationMode] or [protocol.TopOriginImplicitVerificationMode] if you need
// different matching semantics; there is no longer a mode that disables verification entirely.
RPTopOriginVerificationMode protocol.TopOriginVerificationMode
// RPAllowCrossOrigin determines whether the RP is allowed to be used in cross-origin contexts. This is disabled
// by default.
RPAllowCrossOrigin bool
// AttestationPreference sets the default attestation conveyance preferences.
AttestationPreference protocol.ConveyancePreference
// AuthenticatorSelection sets the default authenticator selection options.
AuthenticatorSelection protocol.AuthenticatorSelection
// Debug enables various debug options.
Debug bool
// EncodeUserIDAsString ensures the user.id value during registrations is encoded as a raw UTF8 string. This is
// useful when you only use printable ASCII characters for the random user.id but the browser library does not
// decode the URL Safe Base64 data.
EncodeUserIDAsString bool
// Timeouts configures various timeouts.
Timeouts TimeoutsConfig
// MDS configures a FIDO Metadata Service provider for authenticator trust validation. When set, the library
// validates attestation statements against known authenticator metadata including trust anchors, attestation
// types, and authenticator status. Use the providers in [gamertan.com/web/internal/webauthnvendored/metadata/providers/memory]
// or [gamertan.com/web/internal/webauthnvendored/metadata/providers/cached] to create a provider instance.
MDS metadata.Provider
// Filtering configures the filtering of authenticators based on their AAGUIDs. This is useful for enforcing
// policy on the authenticators that are available to be registered with the Relying Party.
Filtering *FilteringConfig
validated bool
}
// FilteringConfig configures the filtering of authenticators based on their AAGUIDs. This is useful for enforcing
// policy on the authenticators that are available to be registered with the Relying Party.
type FilteringConfig struct {
// ProhibitBackupEligibility if set will prohibit the use of authenticators with the backup eligible flag set.
ProhibitBackupEligibility bool
// PermittedAAGUIDs if set is used to filter authenticators by their AAGUID only allowing specific values. This
// option is mutually exclusive with ProhibitedAAGUIDs and will never exclude a zero AAGUID. To prohibit the use
// of Zero AAGUIDs, use [Config.MDS] or [FilteringConfig.ProhibitedAAGUIDs].
PermittedAAGUIDs []uuid.UUID
// ProhibitedAAGUIDs if set is used to filter authenticators by their AAGUID only prohibiting specific values. This
// option is mutually exclusive with PermittedAAGUIDs.
ProhibitedAAGUIDs []uuid.UUID
}
// TimeoutsConfig configures the timeout durations for both login and registration ceremonies. These values are sent
// to the client as the timeout field in the credential request/creation options and optionally enforced server-side.
type TimeoutsConfig struct {
Login TimeoutConfig
Registration TimeoutConfig
}
// TimeoutConfig configures timeout behavior for a specific WebAuthn ceremony (registration or login).
type TimeoutConfig struct {
// Enforce the timeouts at the Relying Party / Server. This means if enabled and the user takes too long that even
// if the browser does not enforce the timeout the Relying Party / Server will.
Enforce bool
// Timeout is the timeout for logins/registrations when the UserVerificationRequirement is set to anything other
// than discouraged.
Timeout time.Duration
// TimeoutUVD is the timeout for logins/registrations when the UserVerificationRequirement is set to discouraged.
TimeoutUVD time.Duration
}
// Validate that the config flags in Config are properly set.
func (config *Config) validate() (err error) {
if config.validated {
return nil
}
if len(config.RPID) != 0 {
if err = protocol.ValidateRPID(config.RPID); err != nil {
return fmt.Errorf(errFmtFieldNotValidDomainString, "RPID", err)
}
}
defaultTimeoutConfig := defaultTimeout
defaultTimeoutUVDConfig := defaultTimeoutUVD
if config.Timeouts.Login.Timeout.Milliseconds() == 0 {
config.Timeouts.Login.Timeout = defaultTimeoutConfig
}
if config.Timeouts.Login.TimeoutUVD.Milliseconds() == 0 {
config.Timeouts.Login.TimeoutUVD = defaultTimeoutUVDConfig
}
if config.Timeouts.Registration.Timeout.Milliseconds() == 0 {
config.Timeouts.Registration.Timeout = defaultTimeoutConfig
}
if config.Timeouts.Registration.TimeoutUVD.Milliseconds() == 0 {
config.Timeouts.Registration.TimeoutUVD = defaultTimeoutUVDConfig
}
if len(config.RPOrigins) == 0 {
return fmt.Errorf("must provide at least one value to the 'RPOrigins' field")
}
if config.RPTopOriginVerificationMode == protocol.TopOriginDefaultVerificationMode {
config.RPTopOriginVerificationMode = protocol.TopOriginExplicitVerificationMode
}
if config.Filtering != nil {
if len(config.Filtering.PermittedAAGUIDs) > 0 && len(config.Filtering.ProhibitedAAGUIDs) > 0 {
return fmt.Errorf("cannot set both 'PermittedAAGUIDs' and 'ProhibitedAAGUIDs' in the filtering config")
}
}
config.validated = true
return nil
}
// GetRPID returns the configured Relying Party ID.
func (c *Config) GetRPID() string {
return c.RPID
}
// GetOrigins returns the configured Relying Party Origins.
func (c *Config) GetOrigins() []string {
return c.RPOrigins
}
// GetTopOrigins returns the configured Relying Party Top Origins.
func (c *Config) GetTopOrigins() []string {
return c.RPTopOrigins
}
// GetTopOriginVerificationMode returns the configured Top Origin verification mode.
func (c *Config) GetTopOriginVerificationMode() protocol.TopOriginVerificationMode {
return c.RPTopOriginVerificationMode
}
// GetMetaDataProvider returns the configured FIDO Metadata Service provider.
func (c *Config) GetMetaDataProvider() metadata.Provider {
return c.MDS
}
// ConfigProvider is an interface that provides access to the WebAuthn [Config] values. This is useful for
// implementations that wish to provide configuration from alternative sources.
type ConfigProvider interface {
GetRPID() string
GetOrigins() []string
GetTopOrigins() []string
GetTopOriginVerificationMode() protocol.TopOriginVerificationMode
GetMetaDataProvider() metadata.Provider
}
// User is an interface with the Relying Party's User entry and provides the fields and methods needed for WebAuthn
// registration operations.
type User interface {
// WebAuthnID provides the user handle of the user account. A user handle is an opaque byte sequence with a maximum
// size of 64 bytes, and is not meant to be displayed to the user.
//
// To ensure secure operation, authentication and authorization decisions MUST be made on the basis of this id
// member, not the displayName nor name members. See Section 6.1 of [RFC8266].
//
// It's recommended this value is completely random and uses the entire 64 bytes.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dom-publickeycredentialuserentity-id)
WebAuthnID() []byte
// WebAuthnName provides the name attribute of the user account during registration and is a human-palatable name
// for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party SHOULD
// let the user choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentity)
WebAuthnName() string
// WebAuthnDisplayName provides the name attribute of the user account during registration and is a human-palatable
// name for the user account, intended only for display. For example, "Alex Müller" or "田中倫". The Relying Party
// SHOULD let the user choose this, and SHOULD NOT restrict the choice more than necessary.
//
// Specification: §5.4.3. User Account Parameters for Credential Generation (https://www.w3.org/TR/webauthn/#dom-publickeycredentialuserentity-displayname)
WebAuthnDisplayName() string
// WebAuthnCredentials provides the slice of [Credential] objects owned by the user. This generally should be all
// the [Credential] objects owned by the user regardless of which flow is being used.
WebAuthnCredentials() []Credential
}
@@ -0,0 +1,43 @@
package webauthn
import (
"time"
"gamertan.com/web/internal/webauthnvendored/protocol"
)
//go:generate msgp
//msgp:replace protocol.UserVerificationRequirement with:string
//msgp:replace protocol.AuthenticationExtensions with:map[string]any
//msgp:replace protocol.CredentialMediationRequirement with:string
//msgp:clearomitted
// SessionData is the data that must be stored by the Relying Party between the Begin and Finish steps of a WebAuthn
// ceremony. It contains the challenge and other parameters needed to verify the authenticator's response.
//
// The Relying Party must store this data securely and associate it with the user's session. It should not be
// modifiable by the client (i.e. store it server-side or in a signed, opaque cookie). After the ceremony completes,
// the session data should be discarded.
//
// Every field returned by the Begin* functions must be delivered to the matching Finish* / Validate* call with
// the same values; if anything is dropped or reshaped in transit, verification will fail. Treat [SessionData] as
// an atomic record between those two calls.
//
// For consolidated persistence guidance; recommended schema shape, required lookup columns, and the rules
// that also apply to [Credential] records; see the [Storage] section of the
// [gamertan.com/web/internal/webauthnvendored/webauthn] package documentation.
//
// [Storage]: https://pkg.go.dev/gamertan.com/web/internal/webauthnvendored/webauthn#hdr-Storage
type SessionData struct {
Challenge string `json:"challenge" msg:"c"`
RelyingPartyID string `json:"rpId,omitempty" msg:"r,omitempty"`
UserID []byte `json:"user_id,omitempty" msg:"u,omitempty"`
AllowedCredentialIDs [][]byte `json:"allowed_credentials,omitempty" msg:"allow,omitempty"`
Expires time.Time `json:"expires" msg:"exp"`
UserVerification protocol.UserVerificationRequirement `json:"userVerification,omitempty" msg:"uv,omitempty"`
Extensions protocol.AuthenticationExtensions `json:"extensions,omitempty" msg:"exts,omitempty"`
CredParams []protocol.CredentialParameter `json:"credParams,omitempty" msg:"params,omitempty"`
Mediation protocol.CredentialMediationRequirement `json:"mediation,omitempty" msg:"cmr,omitempty"`
}
@@ -0,0 +1,653 @@
// Code generated by github.com/tinylib/msgp DO NOT EDIT.
package webauthn
import (
"gamertan.com/web/internal/webauthnvendored/protocol"
"github.com/tinylib/msgp/msgp"
)
// DecodeMsg implements msgp.Decodable
func (z *SessionData) DecodeMsg(dc *msgp.Reader) (err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 7 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, err = dc.ReadMapKeyPtr()
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "c":
z.Challenge, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
case "r":
z.RelyingPartyID, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
zb0001Mask |= 0x1
case "u":
z.UserID, err = dc.ReadBytes(z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
zb0001Mask |= 0x2
case "allow":
var zb0002 uint32
zb0002, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
if cap(z.AllowedCredentialIDs) >= int(zb0002) {
z.AllowedCredentialIDs = (z.AllowedCredentialIDs)[:zb0002]
} else {
z.AllowedCredentialIDs = make([][]byte, zb0002)
}
for za0003 := range z.AllowedCredentialIDs {
z.AllowedCredentialIDs[za0003], err = dc.ReadBytes(z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
zb0001Mask |= 0x4
case "exp":
z.Expires, err = dc.ReadTime()
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
case "uv":
{
var zb0003 string
zb0003, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
z.UserVerification = protocol.UserVerificationRequirement(zb0003)
}
zb0001Mask |= 0x8
case "exts":
var zb0004 uint32
zb0004, err = dc.ReadMapHeader()
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
if z.Extensions == nil {
z.Extensions = make(map[string]interface{}, zb0004)
} else if len(z.Extensions) > 0 {
clear(z.Extensions)
}
for zb0004 > 0 {
zb0004--
var za0004 string
za0004, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
var za0005 interface{}
za0005, err = dc.ReadIntf()
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
z.Extensions[za0004] = za0005
}
zb0001Mask |= 0x10
case "params":
var zb0005 uint32
zb0005, err = dc.ReadArrayHeader()
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
if cap(z.CredParams) >= int(zb0005) {
z.CredParams = (z.CredParams)[:zb0005]
} else {
z.CredParams = make([]protocol.CredentialParameter, zb0005)
}
for za0006 := range z.CredParams {
err = z.CredParams[za0006].DecodeMsg(dc)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
zb0001Mask |= 0x20
case "cmr":
{
var zb0006 string
zb0006, err = dc.ReadString()
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
z.Mediation = protocol.CredentialMediationRequirement(zb0006)
}
zb0001Mask |= 0x40
default:
err = dc.Skip()
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7f {
if (zb0001Mask & 0x1) == 0 {
z.RelyingPartyID = ""
}
if (zb0001Mask & 0x2) == 0 {
z.UserID = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AllowedCredentialIDs = nil
}
if (zb0001Mask & 0x8) == 0 {
z.UserVerification = ""
}
if (zb0001Mask & 0x10) == 0 {
z.Extensions = nil
}
if (zb0001Mask & 0x20) == 0 {
z.CredParams = nil
}
if (zb0001Mask & 0x40) == 0 {
z.Mediation = ""
}
}
return
}
// EncodeMsg implements msgp.Encodable
func (z *SessionData) EncodeMsg(en *msgp.Writer) (err error) {
// check for omitted fields
zb0001Len := uint32(9)
var zb0001Mask uint16 /* 9 bits */
_ = zb0001Mask
if z.RelyingPartyID == "" {
zb0001Len--
zb0001Mask |= 0x2
}
if z.UserID == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AllowedCredentialIDs == nil {
zb0001Len--
zb0001Mask |= 0x8
}
if z.UserVerification == "" {
zb0001Len--
zb0001Mask |= 0x20
}
if z.Extensions == nil {
zb0001Len--
zb0001Mask |= 0x40
}
if z.CredParams == nil {
zb0001Len--
zb0001Mask |= 0x80
}
if z.Mediation == "" {
zb0001Len--
zb0001Mask |= 0x100
}
// variable map header, size zb0001Len
err = en.Append(0x80 | uint8(zb0001Len))
if err != nil {
return
}
// skip if no fields are to be emitted
if zb0001Len != 0 {
// write "c"
err = en.Append(0xa1, 0x63)
if err != nil {
return
}
err = en.WriteString(z.Challenge)
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
if (zb0001Mask & 0x2) == 0 { // if not omitted
// write "r"
err = en.Append(0xa1, 0x72)
if err != nil {
return
}
err = en.WriteString(z.RelyingPartyID)
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// write "u"
err = en.Append(0xa1, 0x75)
if err != nil {
return
}
err = en.WriteBytes(z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// write "allow"
err = en.Append(0xa5, 0x61, 0x6c, 0x6c, 0x6f, 0x77)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.AllowedCredentialIDs)))
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
for za0003 := range z.AllowedCredentialIDs {
err = en.WriteBytes(z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
}
// write "exp"
err = en.Append(0xa3, 0x65, 0x78, 0x70)
if err != nil {
return
}
err = en.WriteTime(z.Expires)
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
if (zb0001Mask & 0x20) == 0 { // if not omitted
// write "uv"
err = en.Append(0xa2, 0x75, 0x76)
if err != nil {
return
}
err = en.WriteString(string(z.UserVerification))
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
}
if (zb0001Mask & 0x40) == 0 { // if not omitted
// write "exts"
err = en.Append(0xa4, 0x65, 0x78, 0x74, 0x73)
if err != nil {
return
}
err = en.WriteMapHeader(uint32(len(z.Extensions)))
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
for za0004, za0005 := range z.Extensions {
err = en.WriteString(za0004)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
err = en.WriteIntf(za0005)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
}
}
if (zb0001Mask & 0x80) == 0 { // if not omitted
// write "params"
err = en.Append(0xa6, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73)
if err != nil {
return
}
err = en.WriteArrayHeader(uint32(len(z.CredParams)))
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
for za0006 := range z.CredParams {
err = z.CredParams[za0006].EncodeMsg(en)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
}
if (zb0001Mask & 0x100) == 0 { // if not omitted
// write "cmr"
err = en.Append(0xa3, 0x63, 0x6d, 0x72)
if err != nil {
return
}
err = en.WriteString(string(z.Mediation))
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
}
}
return
}
// MarshalMsg implements msgp.Marshaler
func (z *SessionData) MarshalMsg(b []byte) (o []byte, err error) {
o = msgp.Require(b, z.Msgsize())
// check for omitted fields
zb0001Len := uint32(9)
var zb0001Mask uint16 /* 9 bits */
_ = zb0001Mask
if z.RelyingPartyID == "" {
zb0001Len--
zb0001Mask |= 0x2
}
if z.UserID == nil {
zb0001Len--
zb0001Mask |= 0x4
}
if z.AllowedCredentialIDs == nil {
zb0001Len--
zb0001Mask |= 0x8
}
if z.UserVerification == "" {
zb0001Len--
zb0001Mask |= 0x20
}
if z.Extensions == nil {
zb0001Len--
zb0001Mask |= 0x40
}
if z.CredParams == nil {
zb0001Len--
zb0001Mask |= 0x80
}
if z.Mediation == "" {
zb0001Len--
zb0001Mask |= 0x100
}
// variable map header, size zb0001Len
o = append(o, 0x80|uint8(zb0001Len))
// skip if no fields are to be emitted
if zb0001Len != 0 {
// string "c"
o = append(o, 0xa1, 0x63)
o = msgp.AppendString(o, z.Challenge)
if (zb0001Mask & 0x2) == 0 { // if not omitted
// string "r"
o = append(o, 0xa1, 0x72)
o = msgp.AppendString(o, z.RelyingPartyID)
}
if (zb0001Mask & 0x4) == 0 { // if not omitted
// string "u"
o = append(o, 0xa1, 0x75)
o = msgp.AppendBytes(o, z.UserID)
}
if (zb0001Mask & 0x8) == 0 { // if not omitted
// string "allow"
o = append(o, 0xa5, 0x61, 0x6c, 0x6c, 0x6f, 0x77)
o = msgp.AppendArrayHeader(o, uint32(len(z.AllowedCredentialIDs)))
for za0003 := range z.AllowedCredentialIDs {
o = msgp.AppendBytes(o, z.AllowedCredentialIDs[za0003])
}
}
// string "exp"
o = append(o, 0xa3, 0x65, 0x78, 0x70)
o = msgp.AppendTime(o, z.Expires)
if (zb0001Mask & 0x20) == 0 { // if not omitted
// string "uv"
o = append(o, 0xa2, 0x75, 0x76)
o = msgp.AppendString(o, string(z.UserVerification))
}
if (zb0001Mask & 0x40) == 0 { // if not omitted
// string "exts"
o = append(o, 0xa4, 0x65, 0x78, 0x74, 0x73)
o = msgp.AppendMapHeader(o, uint32(len(z.Extensions)))
for za0004, za0005 := range z.Extensions {
o = msgp.AppendString(o, za0004)
o, err = msgp.AppendIntf(o, za0005)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
}
}
if (zb0001Mask & 0x80) == 0 { // if not omitted
// string "params"
o = append(o, 0xa6, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73)
o = msgp.AppendArrayHeader(o, uint32(len(z.CredParams)))
for za0006 := range z.CredParams {
o, err = z.CredParams[za0006].MarshalMsg(o)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
}
if (zb0001Mask & 0x100) == 0 { // if not omitted
// string "cmr"
o = append(o, 0xa3, 0x63, 0x6d, 0x72)
o = msgp.AppendString(o, string(z.Mediation))
}
}
return
}
// UnmarshalMsg implements msgp.Unmarshaler
func (z *SessionData) UnmarshalMsg(bts []byte) (o []byte, err error) {
var field []byte
_ = field
var zb0001 uint32
zb0001, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
var zb0001Mask uint8 /* 7 bits */
_ = zb0001Mask
for zb0001 > 0 {
zb0001--
field, bts, err = msgp.ReadMapKeyZC(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
switch msgp.UnsafeString(field) {
case "c":
z.Challenge, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Challenge")
return
}
case "r":
z.RelyingPartyID, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "RelyingPartyID")
return
}
zb0001Mask |= 0x1
case "u":
z.UserID, bts, err = msgp.ReadBytesBytes(bts, z.UserID)
if err != nil {
err = msgp.WrapError(err, "UserID")
return
}
zb0001Mask |= 0x2
case "allow":
var zb0002 uint32
zb0002, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs")
return
}
if cap(z.AllowedCredentialIDs) >= int(zb0002) {
z.AllowedCredentialIDs = (z.AllowedCredentialIDs)[:zb0002]
} else {
z.AllowedCredentialIDs = make([][]byte, zb0002)
}
for za0003 := range z.AllowedCredentialIDs {
z.AllowedCredentialIDs[za0003], bts, err = msgp.ReadBytesBytes(bts, z.AllowedCredentialIDs[za0003])
if err != nil {
err = msgp.WrapError(err, "AllowedCredentialIDs", za0003)
return
}
}
zb0001Mask |= 0x4
case "exp":
z.Expires, bts, err = msgp.ReadTimeBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Expires")
return
}
case "uv":
{
var zb0003 string
zb0003, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "UserVerification")
return
}
z.UserVerification = protocol.UserVerificationRequirement(zb0003)
}
zb0001Mask |= 0x8
case "exts":
var zb0004 uint32
zb0004, bts, err = msgp.ReadMapHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
if z.Extensions == nil {
z.Extensions = make(map[string]interface{}, zb0004)
} else if len(z.Extensions) > 0 {
clear(z.Extensions)
}
for zb0004 > 0 {
var za0005 interface{}
zb0004--
var za0004 string
za0004, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions")
return
}
za0005, bts, err = msgp.ReadIntfBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Extensions", za0004)
return
}
z.Extensions[za0004] = za0005
}
zb0001Mask |= 0x10
case "params":
var zb0005 uint32
zb0005, bts, err = msgp.ReadArrayHeaderBytes(bts)
if err != nil {
err = msgp.WrapError(err, "CredParams")
return
}
if cap(z.CredParams) >= int(zb0005) {
z.CredParams = (z.CredParams)[:zb0005]
} else {
z.CredParams = make([]protocol.CredentialParameter, zb0005)
}
for za0006 := range z.CredParams {
bts, err = z.CredParams[za0006].UnmarshalMsg(bts)
if err != nil {
err = msgp.WrapError(err, "CredParams", za0006)
return
}
}
zb0001Mask |= 0x20
case "cmr":
{
var zb0006 string
zb0006, bts, err = msgp.ReadStringBytes(bts)
if err != nil {
err = msgp.WrapError(err, "Mediation")
return
}
z.Mediation = protocol.CredentialMediationRequirement(zb0006)
}
zb0001Mask |= 0x40
default:
bts, err = msgp.Skip(bts)
if err != nil {
err = msgp.WrapError(err)
return
}
}
}
// Clear omitted fields.
if zb0001Mask != 0x7f {
if (zb0001Mask & 0x1) == 0 {
z.RelyingPartyID = ""
}
if (zb0001Mask & 0x2) == 0 {
z.UserID = nil
}
if (zb0001Mask & 0x4) == 0 {
z.AllowedCredentialIDs = nil
}
if (zb0001Mask & 0x8) == 0 {
z.UserVerification = ""
}
if (zb0001Mask & 0x10) == 0 {
z.Extensions = nil
}
if (zb0001Mask & 0x20) == 0 {
z.CredParams = nil
}
if (zb0001Mask & 0x40) == 0 {
z.Mediation = ""
}
}
o = bts
return
}
// Msgsize returns an upper bound estimate of the number of bytes occupied by the serialized message
func (z *SessionData) Msgsize() (s int) {
s = 1 + 2 + msgp.StringPrefixSize + len(z.Challenge) + 2 + msgp.StringPrefixSize + len(z.RelyingPartyID) + 2 + msgp.BytesPrefixSize + len(z.UserID) + 6 + msgp.ArrayHeaderSize
for za0003 := range z.AllowedCredentialIDs {
s += msgp.BytesPrefixSize + len(z.AllowedCredentialIDs[za0003])
}
s += 4 + msgp.TimeSize + 3 + msgp.StringPrefixSize + len(string(z.UserVerification)) + 5 + msgp.MapHeaderSize
if z.Extensions != nil {
for za0004, za0005 := range z.Extensions {
_ = za0005
s += msgp.StringPrefixSize + len(za0004) + msgp.GuessSize(za0005)
}
}
s += 7 + msgp.ArrayHeaderSize
for za0006 := range z.CredParams {
s += z.CredParams[za0006].Msgsize()
}
s += 4 + msgp.StringPrefixSize + len(string(z.Mediation))
return
}
@@ -0,0 +1,40 @@
package webauthn
import "bytes"
func isByteArrayInSlice(needle []byte, haystack ...[]byte) (valid bool) {
for _, hay := range haystack {
if bytes.Equal(needle, hay) {
return true
}
}
return false
}
func isCredentialsAllowedMatchingOwned(allowedCredentialIDs [][]byte, credentials []Credential) (valid bool) {
var credential Credential
allowed:
for _, allowedCredentialID := range allowedCredentialIDs {
for _, credential = range credentials {
if bytes.Equal(credential.ID, allowedCredentialID) {
continue allowed
}
}
return false
}
return true
}
func isCredentialIDInCredentials(credentialID []byte, credentials []Credential) (valid bool) {
for _, credential := range credentials {
if bytes.Equal(credential.ID, credentialID) {
return true
}
}
return false
}
+281
View File
@@ -0,0 +1,281 @@
// SPDX-License-Identifier: MPL-2.0
// Package organizations defines storage-neutral organizations, teams,
// memberships, and single-use invitations.
package organizations
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"io"
"regexp"
"strings"
"time"
)
var (
ErrInvitationNotFound = errors.New("organizations: invitation not found")
ErrMembershipNotFound = errors.New("organizations: membership not found")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
type Organization struct {
ID string
Slug string
Name string
Personal bool
CreatedAt time.Time
}
type Membership struct {
OrganizationID string
UserID string
Status string
JoinedAt time.Time
}
type Team struct {
ID, OrganizationID, Slug, Name string
CreatedAt time.Time
}
type TeamMembership struct {
TeamID, UserID string
JoinedAt time.Time
}
type Project struct {
ID, OrganizationID, Slug, Name string
CreatedAt time.Time
}
type Environment struct {
ID, OrganizationID, ProjectID, Slug, Name string
CreatedAt time.Time
}
type ApplicationService struct {
ID, OrganizationID, ProjectID, EnvironmentID, Slug, Name string
CreatedAt time.Time
}
type Invitation struct {
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
CreatedAt, ExpiresAt, UsedAt time.Time
}
type Repository interface {
CreateOrganization(context.Context, Organization, Membership) error
CreateTeam(context.Context, Team) error
AddTeamMember(context.Context, TeamMembership) error
CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
AcceptInvitation(context.Context, [32]byte, string, time.Time) error
MembershipsForUser(context.Context, string) ([]Membership, error)
TeamsForUser(context.Context, string, string) ([]Team, error)
}
type Options struct {
Random io.Reader
Now func() time.Time
}
type Service struct {
repository Repository
random io.Reader
now func() time.Time
}
func New(repository Repository, options Options) (*Service, error) {
if repository == nil {
return nil, errors.New("organizations: repository is required")
}
if options.Random == nil {
options.Random = rand.Reader
}
if options.Now == nil {
options.Now = time.Now
}
return &Service{repository: repository, random: options.Random, now: options.Now}, nil
}
type CreateOrganization struct {
Slug, Name, OwnerUserID string
Personal bool
}
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) {
return Organization{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Personal: input.Personal, CreatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
if err = service.repository.CreateOrganization(ctx, organization, owner); err != nil {
return Organization{}, err
}
return organization, nil
}
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
value := make([]byte, 6)
if _, err := io.ReadFull(service.random, value); err != nil {
return Organization{}, fmt.Errorf("organizations: secure randomness unavailable: %w", err)
}
suffix := hex.EncodeToString(value)
return service.CreateOrganization(ctx, CreateOrganization{Slug: "personal-" + strings.ToLower(suffix), Name: strings.TrimSpace(displayName) + " — Personal", OwnerUserID: userID, Personal: true})
}
type CreateTeam struct{ OrganizationID, Slug, Name string }
func (service *Service) CreateTeam(ctx context.Context, input CreateTeam) (Team, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Team{}, errors.New("organizations: invalid team")
}
id, err := token(service.random, 18)
if err != nil {
return Team{}, err
}
team := Team{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateTeam(ctx, team); err != nil {
return Team{}, err
}
return team, nil
}
func (service *Service) AddTeamMember(ctx context.Context, teamID, userID string) error {
if !idPattern.MatchString(teamID) || !idPattern.MatchString(userID) {
return errors.New("organizations: invalid team membership")
}
return service.repository.AddTeamMember(ctx, TeamMembership{TeamID: teamID, UserID: userID, JoinedAt: service.now().UTC()})
}
type CreateProject struct{ OrganizationID, Slug, Name string }
func (service *Service) CreateProject(ctx context.Context, input CreateProject) (Project, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Project{}, errors.New("organizations: invalid project")
}
id, err := token(service.random, 18)
if err != nil {
return Project{}, err
}
project := Project{ID: id, OrganizationID: input.OrganizationID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateProject(ctx, project); err != nil {
return Project{}, err
}
return project, nil
}
type CreateEnvironment struct{ OrganizationID, ProjectID, Slug, Name string }
func (service *Service) CreateEnvironment(ctx context.Context, input CreateEnvironment) (Environment, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return Environment{}, errors.New("organizations: invalid environment")
}
id, err := token(service.random, 18)
if err != nil {
return Environment{}, err
}
environment := Environment{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateEnvironment(ctx, environment); err != nil {
return Environment{}, err
}
return environment, nil
}
type CreateApplicationService struct{ OrganizationID, ProjectID, EnvironmentID, Slug, Name string }
func (service *Service) CreateApplicationService(ctx context.Context, input CreateApplicationService) (ApplicationService, error) {
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.ProjectID) || !idPattern.MatchString(input.EnvironmentID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) {
return ApplicationService{}, errors.New("organizations: invalid application service")
}
id, err := token(service.random, 18)
if err != nil {
return ApplicationService{}, err
}
application := ApplicationService{ID: id, OrganizationID: input.OrganizationID, ProjectID: input.ProjectID, EnvironmentID: input.EnvironmentID, Slug: input.Slug, Name: input.Name, CreatedAt: service.now().UTC()}
if err = service.repository.CreateApplicationService(ctx, application); err != nil {
return ApplicationService{}, err
}
return application, nil
}
func (service *Service) Invite(ctx context.Context, organizationID, email, invitedBy string, lifetime time.Duration) (string, Invitation, error) {
email = strings.ToLower(strings.TrimSpace(email))
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour {
return "", Invitation{}, errors.New("organizations: invalid invitation")
}
raw, err := token(service.random, 32)
if err != nil {
return "", Invitation{}, err
}
now := service.now().UTC()
invitation := Invitation{Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, CreatedAt: now, ExpiresAt: now.Add(lifetime)}
if err = service.repository.CreateInvitation(ctx, invitation); err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
}
func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID string) error {
if len(rawToken) < 32 || len(rawToken) > 128 || !idPattern.MatchString(userID) {
return ErrInvitationNotFound
}
digest := sha256.Sum256([]byte(rawToken))
now := service.now().UTC()
if _, err := service.repository.InvitationByDigest(ctx, digest, now); err != nil {
return err
}
return service.repository.AcceptInvitation(ctx, digest, userID, now)
}
func (service *Service) Memberships(ctx context.Context, userID string) ([]Membership, error) {
if !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid user")
}
return service.repository.MembershipsForUser(ctx, userID)
}
func (service *Service) Teams(ctx context.Context, organizationID, userID string) ([]Team, error) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) {
return nil, errors.New("organizations: invalid team query")
}
return service.repository.TeamsForUser(ctx, organizationID, userID)
}
func (service *Service) Repository() Repository { return service.repository }
func token(random io.Reader, size int) (string, error) {
value := make([]byte, size)
if _, err := io.ReadFull(random, value); err != nil {
return "", fmt.Errorf("organizations: secure randomness unavailable: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func bounded(value string, limit int) bool {
return value != "" && len(value) <= limit && !strings.ContainsAny(value, "\x00\r\n")
}
+85
View File
@@ -0,0 +1,85 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"strings"
"testing"
"time"
)
func TestCreateInviteAndAccept(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &repositoryStub{}
service, err := New(repository, Options{Random: strings.NewReader(strings.Repeat("r", 512)), Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
organization, err := service.CreateOrganization(t.Context(), CreateOrganization{Slug: "quiet-systems", Name: "Quiet Systems", OwnerUserID: "user-12345678"})
if err != nil || organization.ID == "" || repository.organization.ID != organization.ID {
t.Fatalf("organization=%+v err=%v", organization, err)
}
raw, invitation, err := service.Invite(t.Context(), organization.ID, "MEMBER@example.test", "user-12345678", time.Hour)
if err != nil || raw == "" || invitation.Email != "member@example.test" {
t.Fatalf("invitation=%+v err=%v", invitation, err)
}
repository.invitation = invitation
if err = service.AcceptInvitation(t.Context(), raw, "user-87654321"); err != nil {
t.Fatal(err)
}
if repository.acceptedUser != "user-87654321" {
t.Fatalf("accepted=%q", repository.acceptedUser)
}
}
func TestInvitationFailsClosed(t *testing.T) {
service, err := New(&repositoryStub{invitationErr: ErrInvitationNotFound}, Options{Random: strings.NewReader(strings.Repeat("x", 256))})
if err != nil {
t.Fatal(err)
}
if err = service.AcceptInvitation(t.Context(), strings.Repeat("x", 43), "user-87654321"); !errors.Is(err, ErrInvitationNotFound) {
t.Fatalf("err=%v", err)
}
if _, _, err = service.Invite(t.Context(), "bad", "person@example.test", "user-12345678", time.Hour); err == nil {
t.Fatal("invalid organization accepted")
}
}
type repositoryStub struct {
organization Organization
invitation Invitation
invitationErr error
acceptedUser string
}
func (repository *repositoryStub) CreateOrganization(_ context.Context, organization Organization, _ Membership) error {
repository.organization = organization
return nil
}
func (*repositoryStub) CreateTeam(context.Context, Team) error { return nil }
func (*repositoryStub) AddTeamMember(context.Context, TeamMembership) error { return nil }
func (*repositoryStub) CreateProject(context.Context, Project) error { return nil }
func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { return nil }
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil
}
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation) error {
repository.invitation = invitation
return nil
}
func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error) {
if repository.invitationErr != nil {
return Invitation{}, repository.invitationErr
}
return repository.invitation, nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time) error {
repository.acceptedUser = userID
return nil
}
func (*repositoryStub) MembershipsForUser(context.Context, string) ([]Membership, error) {
return nil, nil
}
func (*repositoryStub) TeamsForUser(context.Context, string, string) ([]Team, error) { return nil, nil }
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
if grep -Fq 'github.com/go-webauthn/webauthn' go.mod go.sum; then
echo 'go-webauthn must be compiled from the checked internal derivative, not resolved as a module' >&2
exit 1
fi
if grep -Eq '^replace[[:space:]]|^replace[[:space:]]*\(' go.mod; then
echo 'local replacements are forbidden in the public module' >&2
exit 1
fi
compiled_dependencies=$(go list -deps ./...)
if grep -Fxq 'github.com/go-webauthn/webauthn' <<<"$compiled_dependencies"; then
echo 'upstream go-webauthn unexpectedly appears in the compiled dependency graph' >&2
exit 1
fi
grep -Fq 'de0a809e3027957ca15b72b252540317f9ba581b' THIRD_PARTY_NOTICES.md
./scripts/check-vendored-webauthn.sh
./scripts/check-embedded-webauthn.sh
go mod verify
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
source_root=third_party/go-webauthn
embedded_root=internal/webauthnvendored
derived=$(mktemp -d)
trap 'rm -rf "$derived"' EXIT
packages=(
metadata
protocol
protocol/webauthncbor
protocol/webauthncose
webauthn
)
install -D -m 0644 "$source_root/LICENSE" "$derived/LICENSE"
for package in "${packages[@]}"; do
while IFS= read -r source; do
relative=${source#"$source_root"/}
install -D -m 0644 "$source" "$derived/$relative"
done < <(find "$source_root/$package" -maxdepth 1 -type f -name '*.go' ! -name '*_test.go' | sort)
done
while IFS= read -r source; do
sed -i \
's#github.com/go-webauthn/webauthn#gamertan.com/web/internal/webauthnvendored#g' \
"$source"
done < <(find "$derived" -type f -name '*.go' | sort)
cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt "$embedded_root/LICENSE"
if ! diff -ru --no-dereference "$derived" "$embedded_root"; then
echo 'compiled WebAuthn verifier differs from its audited mechanical derivation' >&2
exit 1
fi
test "$(find "$embedded_root" -type f | wc -l)" -eq 57
+4 -1
View File
@@ -6,7 +6,8 @@ cd "$root"
failed=0 failed=0
while IFS= read -r -d '' file; do while IFS= read -r -d '' file; do
case $file in case $file in
./.git/*|./LICENSES/*|./go.sum) continue ;; ./.git|./.git/*|./LICENSES/*|./go.sum) continue ;;
./third_party/go-webauthn/*|./third_party/go-webauthn.SHA256SUMS|./internal/webauthnvendored/*) continue ;;
./starters/*|./examples/*) expected=0BSD ;; ./starters/*|./examples/*) expected=0BSD ;;
./scripts/*|./.gitea/*|./services/*) expected=AGPL-3.0-only ;; ./scripts/*|./.gitea/*|./services/*) expected=AGPL-3.0-only ;;
*) expected=MPL-2.0 ;; *) expected=MPL-2.0 ;;
@@ -15,4 +16,6 @@ while IFS= read -r -d '' file; do
echo "license mismatch: $file expected $expected" >&2; failed=1 echo "license mismatch: $file expected $expected" >&2; failed=1
fi fi
done < <(find . -type f -print0) done < <(find . -type f -print0)
./scripts/check-vendored-webauthn.sh || failed=1
./scripts/check-embedded-webauthn.sh || failed=1
exit "$failed" exit "$failed"
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-only
set -euo pipefail
root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
cd "$root"
test -f third_party/go-webauthn/LICENSE
cmp -s LICENSES/BSD-3-Clause-go-webauthn.txt third_party/go-webauthn/LICENSE
sha256sum -c third_party/go-webauthn.SHA256SUMS >/dev/null
expected=$(sed -n 's# third_party/go-webauthn/.*#&#p' third_party/go-webauthn.SHA256SUMS | wc -l)
actual=$(find third_party/go-webauthn -type f | wc -l)
test "$expected" -eq "$actual"
+10 -3
View File
@@ -11,11 +11,18 @@ cd "$root"
mapfile -t files < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow) mapfile -t files < <(grep -Ev '^[[:space:]]*(#|$)' scripts/public-snapshot.allow)
[[ ${#files[@]} -gt 0 ]] || exit 1 [[ ${#files[@]} -gt 0 ]] || exit 1
for file in "${files[@]}"; do for file in "${files[@]}"; do
[[ $file != /* && $file != *..* && -f $file && ! -L $file ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; } [[ $file != /* && $file != *..* ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; }
git ls-files --error-unmatch -- "$file" >/dev/null if [[ $file = */ ]]; then
directory=${file%/}
[[ -d $directory && ! -L $directory ]] || { echo "invalid allowlisted directory: $file" >&2; exit 1; }
[[ -n $(git ls-files -- "$directory/") ]] || { echo "empty allowlisted directory: $file" >&2; exit 1; }
else
[[ -f $file && ! -L $file ]] || { echo "invalid allowlisted path: $file" >&2; exit 1; }
git ls-files --error-unmatch -- "$file" >/dev/null
fi
done done
mkdir -m 0700 "$output" mkdir -m 0700 "$output"
git archive --format=tar HEAD -- "${files[@]}" | tar -x -C "$output" git archive --format=tar HEAD -- "${files[@]}" | tar -x -C "$output"
find "$output" -type d -exec chmod 0755 {} + find "$output" -type d -exec chmod 0755 {} +
"$output/scripts/check-licenses.sh" "$output/scripts/check-licenses.sh"
echo "exported ${#files[@]} reviewed files" echo "exported ${#files[@]} reviewed paths"
+27
View File
@@ -9,11 +9,15 @@ CONTRIBUTING.md
LICENSES.md LICENSES.md
LICENSES/0BSD.txt LICENSES/0BSD.txt
LICENSES/AGPL-3.0-only.txt LICENSES/AGPL-3.0-only.txt
LICENSES/BSD-3-Clause-go-webauthn.txt
LICENSES/MPL-2.0.txt LICENSES/MPL-2.0.txt
README.md README.md
SECURITY.md SECURITY.md
THIRD_PARTY_NOTICES.md
abuse/abuse.go abuse/abuse.go
abuse/abuse_test.go abuse/abuse_test.go
access/access.go
access/access_test.go
analytics/analytics.go analytics/analytics.go
analytics/analytics_test.go analytics/analytics_test.go
analytics/fuzz_test.go analytics/fuzz_test.go
@@ -25,12 +29,28 @@ auth/password_test.go
auth/service_test.go auth/service_test.go
authhttp/authhttp.go authhttp/authhttp.go
authhttp/authhttp_test.go authhttp/authhttp_test.go
authhttp/passkey.go
authhttp/passkey_test.go
authsqlite/store.go authsqlite/store.go
authsqlite/store_test.go authsqlite/store_test.go
authsqlite/access.go
authsqlite/organizations.go
authsqlite/passkey.go
authsqlite/passkey_test.go
authwebauthn/fuzz_test.go
authwebauthn/service.go
authwebauthn/service_test.go
authwebauthn/types.go
internal/webauthnvendored/
docs/ADOPTION.md docs/ADOPTION.md
docs/ARCHITECTURE.md docs/ARCHITECTURE.md
docs/DEPENDENCIES.md docs/DEPENDENCIES.md
docs/GETTING_STARTED.md
docs/MODULES.md
docs/ORGANIZATIONS.md
docs/PASSKEYS.md
docs/PUBLIC_SNAPSHOT.md docs/PUBLIC_SNAPSHOT.md
docs/SANDWICH_HIME.md
docs/SERVICES_ROADMAP.md docs/SERVICES_ROADMAP.md
docs/THREAT_MODEL.md docs/THREAT_MODEL.md
go.mod go.mod
@@ -41,7 +61,12 @@ requestlog/requestlog_test.go
requestmeta/fuzz_test.go requestmeta/fuzz_test.go
requestmeta/requestmeta.go requestmeta/requestmeta.go
requestmeta/requestmeta_test.go requestmeta/requestmeta_test.go
organizations/organizations.go
organizations/organizations_test.go
scripts/check-licenses.sh scripts/check-licenses.sh
scripts/check-dependencies.sh
scripts/check-vendored-webauthn.sh
scripts/check-embedded-webauthn.sh
scripts/export-public.sh scripts/export-public.sh
scripts/public-snapshot.allow scripts/public-snapshot.allow
scripts/test-public-snapshot.sh scripts/test-public-snapshot.sh
@@ -49,6 +74,8 @@ scripts/verify.sh
starters/basic/.env.example starters/basic/.env.example
starters/basic/README.md starters/basic/README.md
starters/basic/main.go starters/basic/main.go
third_party/go-webauthn.SHA256SUMS
third_party/go-webauthn/
websec/ratelimit.go websec/ratelimit.go
websec/websec.go websec/websec.go
websec/websec_test.go websec/websec_test.go

Some files were not shown because too many files have changed in this diff Show More