diff --git a/server/go.mod b/server/go.mod index 208b6e6..e75ccc4 100644 --- a/server/go.mod +++ b/server/go.mod @@ -6,12 +6,14 @@ require ( github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 github.com/coreos/go-oidc/v3 v3.21.0 github.com/gin-gonic/gin v1.12.0 + github.com/go-webauthn/webauthn v0.18.1 github.com/google/uuid v1.6.0 github.com/knqyf263/go-apk-version v0.0.0-20200609155635-041fdbb8563f github.com/knqyf263/go-deb-version v0.0.0-20241115132648-6f4aee6ccd23 github.com/knqyf263/go-rpm-version v0.0.0-20260811110310-1815e1f1b790 github.com/modelcontextprotocol/go-sdk v1.7.0 github.com/opencontainers/image-spec v1.1.1 + github.com/pquerna/otp v1.5.0 github.com/redis/go-redis/v9 v9.22.0 github.com/robfig/cron/v3 v3.0.1 github.com/wwt/guac v1.3.2 @@ -25,12 +27,17 @@ require ( require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect github.com/bytedance/gopkg v0.1.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.3.1 // indirect github.com/goccy/go-yaml v1.19.2 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/jsonschema-go v0.4.3 // indirect github.com/hyperboloide/lk v0.0.0-20251220053519-b291812e3216 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/pquerna/otp v1.5.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.62.0 // indirect github.com/samber/lo v1.53.0 // indirect @@ -39,6 +46,8 @@ require ( github.com/segmentio/encoding v0.5.4 // indirect github.com/stretchr/objx v0.5.3 // indirect github.com/stretchr/testify v1.12.1 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.8.6 // indirect go.etcd.io/bbolt v1.5.0 // indirect diff --git a/server/go.sum b/server/go.sum index 706114e..8c7fc70 100644 --- a/server/go.sum +++ b/server/go.sum @@ -1,5 +1,3 @@ -gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0 h1:EtojZ1d3cN9foHpc/CAI3KzBewYGn4sKWdkWs2MV78Q= -gitea.hostxtra.co.uk/vantage/vantage-shared v0.6.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0 h1:wwXvHfDKZB44EEj6BXl9O68hLC3kfPz3eak9wvupIRA= gitea.hostxtra.co.uk/vantage/vantage-shared v0.7.0/go.mod h1:Zo66XhqF8No3dveIowLCepvMxVg8KnhsNMz0k0Xpuck= github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM= @@ -27,6 +25,8 @@ github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= +github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI= github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ= github.com/gin-contrib/sse v1.1.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo= @@ -47,6 +47,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.4 h1:9Rcod2ZPO6mOEG6b4GqyoHE/H6//Ze0RuhOo1hT1x0w= github.com/go-playground/validator/v10 v10.30.4/go.mod h1:numpT+RPLE91R9oYWMY/R9zRgJBewr3IXHko4OISPpk= +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/webauthn v0.18.1 h1:KaQw6M+ODLvxHwddyeo6zFhhmicfLup/BClhigB6A+0= +github.com/go-webauthn/webauthn v0.18.1/go.mod h1:s4rZTQnKHWxIh6G3yEGqlxvtiLceA1jigll8FpqSgQ8= +github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo= +github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -57,6 +63,10 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= @@ -101,6 +111,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY= github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg= @@ -141,12 +153,16 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc= github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4= github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 46fd684..df17f89 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -45,6 +45,8 @@ func RegisterRoutes(r *gin.Engine) { r.POST("/auth/login", auth.HandleLocalLogin) r.POST("/auth/mfa/totp", auth.HandleMFATOTP) r.POST("/auth/mfa/recovery", auth.HandleMFARecovery) + r.POST("/auth/mfa/webauthn/begin", auth.HandleMFAWebAuthnBegin) + r.POST("/auth/mfa/webauthn/finish", auth.HandleMFAWebAuthnFinish) r.POST("/auth/mfa/enrol/totp/setup", auth.HandleEnrolTOTPSetup) r.POST("/auth/mfa/enrol/totp/confirm", auth.HandleEnrolTOTPConfirm) r.POST("/auth/logout", auth.HandleLogout) diff --git a/server/internal/auth/webauthn.go b/server/internal/auth/webauthn.go new file mode 100644 index 0000000..34a1c63 --- /dev/null +++ b/server/internal/auth/webauthn.go @@ -0,0 +1,251 @@ +package auth + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net" + "net/http" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "github.com/gin-gonic/gin" + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "github.com/redis/go-redis/v9" +) + +const ( + ceremonyPrefix = "km:wa:" + ceremonyTTL = 5 * time.Minute +) + +// rpConfig derives the relying party from the request. The RP ID is the host +// without its port - WebAuthn forbids a port there - while the origin keeps it. +// +// This is why the reverse proxy must preserve Host: a proxy rewriting it makes +// every passkey on the instance fail to verify, with no error that says so. +func rpConfig(c *gin.Context) (string, string) { + host := c.Request.Host + rpID := host + if h, _, err := net.SplitHostPort(host); err == nil { + rpID = h + } + scheme := "https" + if c.Request.TLS == nil && c.GetHeader("X-Forwarded-Proto") != "https" { + // Only development is ever plain HTTP; WebAuthn permits it on localhost. + scheme = "http" + } + return rpID, scheme + "://" + host +} + +func webAuthnFor(c *gin.Context) (*webauthn.WebAuthn, error) { + rpID, origin := rpConfig(c) + return webauthn.New(&webauthn.Config{ + RPDisplayName: "Vantage", + RPID: rpID, + RPOrigins: []string{origin}, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + }, + }) +} + +// waUser adapts our records to the library's interface. The handle is random +// and per-user: a resident credential hands its user handle to any origin that +// asks, so the user ID must not be it. +type waUser struct { + handle []byte + name string + credentials []webauthn.Credential +} + +func (u waUser) WebAuthnID() []byte { return u.handle } +func (u waUser) WebAuthnName() string { return u.name } +func (u waUser) WebAuthnDisplayName() string { return u.name } +func (u waUser) WebAuthnCredentials() []webauthn.Credential { return u.credentials } + +func toLibCredential(c models.WebAuthnCredential) webauthn.Credential { + return webauthn.Credential{ + ID: c.CredentialID, + PublicKey: c.PublicKey, + AttestationType: "none", + Authenticator: webauthn.Authenticator{ + AAGUID: c.AAGUID, + SignCount: c.SignCount, + }, + } +} + +func saveCeremony(ctx context.Context, data *webauthn.SessionData) (string, error) { + id, err := randomHex(32) + if err != nil { + return "", err + } + blob, err := json.Marshal(data) + if err != nil { + return "", err + } + if err := rdb.Set(ctx, ceremonyPrefix+id, blob, ceremonyTTL).Err(); err != nil { + return "", err + } + return id, nil +} + +// loadCeremony consumes the challenge: a WebAuthn challenge is single use, so +// it is deleted as it is read. +func loadCeremony(ctx context.Context, id string) (*webauthn.SessionData, error) { + blob, err := rdb.GetDel(ctx, ceremonyPrefix+id).Bytes() + if errors.Is(err, redis.Nil) { + return nil, ErrTicketExpired + } + if err != nil { + return nil, err + } + var data webauthn.SessionData + if err := json.Unmarshal(blob, &data); err != nil { + return nil, ErrTicketExpired + } + return &data, nil +} + +// HandleMFAWebAuthnBegin offers an assertion challenge to a pending sign-in. +// +// @Summary Begin passkey verification during sign-in +// @Tags auth +// @Produce json +// @Success 200 {object} object{publicKey=object,ceremony_id=string} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/webauthn/begin [post] +func HandleMFAWebAuthnBegin(c *gin.Context) { + t, _, ok := ticketFromRequest(c, scopeVerify) + if !ok { + return + } + creds, err := services.ListPasskeys(t.InstanceID, t.UserID) + if err != nil || len(creds) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "no passkey is registered"}) + return + } + handle, err := services.WebAuthnHandle(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + lib := make([]webauthn.Credential, 0, len(creds)) + for _, cr := range creds { + lib = append(lib, toLibCredential(cr)) + } + w, err := webAuthnFor(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + options, sessionData, err := w.BeginLogin(waUser{handle: handle, name: t.Email, credentials: lib}, + webauthn.WithUserVerification(protocol.VerificationRequired)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + id, err := saveCeremony(c.Request.Context(), sessionData) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "could not start verification"}) + return + } + c.JSON(http.StatusOK, gin.H{"publicKey": options.Response, "ceremony_id": id}) +} + +// HandleMFAWebAuthnFinish verifies the assertion and signs the user in. +// +// @Summary Complete sign-in with a passkey +// @Tags auth +// @Accept json +// @Produce json +// @Param body body object{ceremony_id=string,credential=object} true "Assertion" +// @Success 200 {object} object{ok=bool} +// @Failure 401 {object} object{error=string,code=string} +// @Router /auth/mfa/webauthn/finish [post] +func HandleMFAWebAuthnFinish(c *gin.Context) { + t, ticketID, ok := ticketFromRequest(c, scopeVerify) + if !ok { + return + } + cred, err := finishAssertion(c, t.InstanceID, t.UserID, t.Email) + if err != nil { + left, ferr := FailTicket(c.Request.Context(), ticketID) + services.LogEvent(t.InstanceID, "mfa.failed", t.Email, "", "", "factor=webauthn") + if ferr != nil || left == 0 { + abortTicketExpired(c) + return + } + c.JSON(http.StatusUnauthorized, gin.H{ + "error": "that passkey could not be verified", "code": "invalid_assertion", "attempts_left": left, + }) + return + } + _ = services.TouchPasskey(t.InstanceID, cred.ID, cred.Authenticator.SignCount) + u, err := services.GetUserInInstance(t.InstanceID, t.UserID) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"}) + return + } + if err := mintSession(c, u, []string{"pwd", services.FactorWebAuthn}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "session save failed"}) + return + } + _ = DeleteTicket(c.Request.Context(), ticketID) + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// finishAssertion is shared by second-factor sign-in, passwordless sign-in and +// step-up, so the verification rules (user verification, clone detection, +// instance scope) exist once. +func finishAssertion(c *gin.Context, instanceID, userID, email string) (*webauthn.Credential, error) { + var body struct { + CeremonyID string `json:"ceremony_id"` + Credential json.RawMessage `json:"credential"` + } + if err := c.ShouldBindJSON(&body); err != nil || body.CeremonyID == "" { + return nil, errors.New("assertion required") + } + sessionData, err := loadCeremony(c.Request.Context(), body.CeremonyID) + if err != nil { + return nil, err + } + parsed, err := protocol.ParseCredentialRequestResponseBody(bytes.NewReader(body.Credential)) + if err != nil { + return nil, err + } + stored, err := services.GetPasskeyByCredentialID(instanceID, parsed.RawID) + if err != nil { + return nil, err + } + if userID != "" && stored.UserID != userID { + return nil, errors.New("credential belongs to another user") + } + handle, err := services.WebAuthnHandle(instanceID, stored.UserID) + if err != nil { + return nil, err + } + w, err := webAuthnFor(c) + if err != nil { + return nil, err + } + user := waUser{handle: handle, name: email, credentials: []webauthn.Credential{toLibCredential(*stored)}} + cred, err := w.ValidateLogin(user, *sessionData, parsed) + if err != nil { + return nil, err + } + if !cred.Flags.UserVerified { + return nil, errors.New("user verification was not performed") + } + // A counter that fails to advance is the library's clone signal. Zero on + // both sides means the authenticator does not keep one, which is normal. + if cred.Authenticator.CloneWarning { + return nil, errors.New("authenticator may be cloned") + } + return cred, nil +} diff --git a/server/internal/auth/webauthn_test.go b/server/internal/auth/webauthn_test.go new file mode 100644 index 0000000..bf21573 --- /dev/null +++ b/server/internal/auth/webauthn_test.go @@ -0,0 +1,36 @@ +package auth + +import ( + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" +) + +// A passkey is bound to its RP ID. Getting this wrong does not fail loudly - it +// silently makes every existing passkey unusable - so the port-stripping and +// scheme rules are pinned here. +func TestRPConfig(t *testing.T) { + cases := []struct { + name, host, proto string + wantID, wantOrig string + }{ + {"plain host", "acme.vantage.example.com", "https", "acme.vantage.example.com", "https://acme.vantage.example.com"}, + {"host with port", "vantage.acme.com:8443", "https", "vantage.acme.com", "https://vantage.acme.com:8443"}, + {"localhost dev", "localhost:3000", "", "localhost", "http://localhost:3000"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest("POST", "/auth/passkey/begin", nil) + c.Request.Host = tc.host + if tc.proto != "" { + c.Request.Header.Set("X-Forwarded-Proto", tc.proto) + } + id, origin := rpConfig(c) + if id != tc.wantID || origin != tc.wantOrig { + t.Fatalf("rpConfig = (%q, %q), want (%q, %q)", id, origin, tc.wantID, tc.wantOrig) + } + }) + } +} diff --git a/server/internal/services/passkeys.go b/server/internal/services/passkeys.go new file mode 100644 index 0000000..a0aa3ec --- /dev/null +++ b/server/internal/services/passkeys.go @@ -0,0 +1,111 @@ +package services + +import ( + "encoding/hex" + "errors" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" +) + +var ErrNoPasskey = errors.New("no such passkey") + +func ListPasskeys(instanceID, userID string) ([]models.WebAuthnCredential, error) { + ctx, cancel := mfaCtx() + defer cancel() + cur, err := db.Col("webauthn_credentials").Find(ctx, + bson.M{"instance_id": instanceID, "user_id": userID}) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + out := []models.WebAuthnCredential{} + if err := cur.All(ctx, &out); err != nil { + return nil, err + } + return out, nil +} + +// GetPasskeyByCredentialID resolves a credential inside one instance. The +// instance scope is not optional: an unscoped lookup would let a credential +// registered on one tenant assert on another. +func GetPasskeyByCredentialID(instanceID string, credID []byte) (*models.WebAuthnCredential, error) { + ctx, cancel := mfaCtx() + defer cancel() + var c models.WebAuthnCredential + err := db.Col("webauthn_credentials").FindOne(ctx, + bson.M{"instance_id": instanceID, "credential_id": credID}).Decode(&c) + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNoPasskey + } + if err != nil { + return nil, err + } + return &c, nil +} + +func SavePasskey(instanceID, userID, name string, credID, publicKey, aaguid []byte, signCount uint32, transports []string) error { + ctx, cancel := mfaCtx() + defer cancel() + if name == "" { + name = "Passkey" + } + _, err := db.Col("webauthn_credentials").InsertOne(ctx, models.WebAuthnCredential{ + InstanceID: instanceID, + UserID: userID, + CredentialID: credID, + CredentialIDHex: hex.EncodeToString(credID), + PublicKey: publicKey, + AAGUID: aaguid, + SignCount: signCount, + Transports: transports, + Name: name, + CreatedAt: time.Now(), + }) + return err +} + +// TouchPasskey records use and the new signature counter. A counter that fails +// to advance can mean a cloned authenticator, so the caller checks it before +// calling this. +func TouchPasskey(instanceID string, credID []byte, signCount uint32) error { + ctx, cancel := mfaCtx() + defer cancel() + now := time.Now() + _, err := db.Col("webauthn_credentials").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "credential_id": credID}, + bson.M{"$set": bson.M{"sign_count": signCount, "last_used_at": now}}) + return err +} + +func RenamePasskey(instanceID, userID, credIDHex, name string) error { + ctx, cancel := mfaCtx() + defer cancel() + res, err := db.Col("webauthn_credentials").UpdateOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex}, + bson.M{"$set": bson.M{"name": name}}) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return ErrNoPasskey + } + return nil +} + +func DeletePasskey(instanceID, userID, credIDHex string) error { + ctx, cancel := mfaCtx() + defer cancel() + res, err := db.Col("webauthn_credentials").DeleteOne(ctx, + bson.M{"instance_id": instanceID, "user_id": userID, "credential_id_hex": credIDHex}) + if err != nil { + return err + } + if res.DeletedCount == 0 { + return ErrNoPasskey + } + return nil +}