diff --git a/cmd/server/main.go b/cmd/server/main.go index 923cef89..51b5d765 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -247,9 +247,12 @@ func main() { } mgr.AddProvider(provider) - // Wire session store into UserService so DeleteUser purges active sessions + // Wire session store into UserService so DeleteUser purges active sessions, + // and into the wallet lifecycle service so suspending or revoking a + // wallet instance drops the user's live sessions (SID-AUTH-06). if backendProvider != nil { backendProvider.Services().User.SetSessionCleaner(provider.SessionStore()) + backendProvider.Services().WalletLifecycle.SetSessionCleaner(provider.SessionStore()) } } diff --git a/docs/API.md b/docs/API.md index f55961da..2e99eb65 100644 --- a/docs/API.md +++ b/docs/API.md @@ -143,6 +143,55 @@ Finish WebAuthn registration. --- +#### Wallet instance lifecycle (SID-AUTH-06) + +A wallet instance is one wallet installation, identified by the JWK thumbprint of +its instance key and registered when it first obtains a Wallet Instance +Attestation. A user can inspect and manage their own instances; a provider +manages them through the admin API (`/admin/tenants/{id}/instances`). Both paths +share one lifecycle: `active` → `suspended` (reversible) or `revoked` +(terminal), `suspended` → `active` or `revoked`. Any change away from `active` +drops the user's live sessions and refuses new WIAs for that instance. Login with +the passkey linked to a suspended or revoked instance is refused with `403 +WALLET_SUSPENDED` / `WALLET_REVOKED`. Revoking the last non-revoked instance +deactivates the wallet: the encrypted private data, server-side credentials, +presentations and pending challenges are erased, every passkey of the user is +refused at login, and a new enrollment is required. + +The passkey link is recorded when the wallet passes its passkey's base64url +credential id as `credential_id` to `POST /wallet-provider/wia/generate`. + +##### GET /user/session/instances + +List the caller's wallet instances in the current tenant. + +**Response:** +```json +{ "instances": [ { "id": "", "status": "active", "wscd_type": "native_android", "last_attested_at": "..." } ] } +``` + +##### PUT /user/session/instances/{instance_id}/status + +Change the status of one of the caller's instances. + +**Request:** +```json +{ "status": "suspended", "reason": "lost phone" } +``` + +**Response:** `200 {"id": "", "status": "suspended"}`; `404` if the instance +is not the caller's; `409` for an invalid transition (e.g. reactivating a +revoked instance). + +##### POST /user/session/instances/revoke-all + +Deactivate the wallet: revoke every instance of the caller and erase the wallet +data. + +**Request (optional):** `{ "reason": "device stolen" }` + +**Response:** `200 {"revoked": 2}` + ### Credential Management All credential endpoints require authentication. diff --git a/internal/api/admin_handlers.go b/internal/api/admin_handlers.go index 20830c39..6af4915c 100644 --- a/internal/api/admin_handlers.go +++ b/internal/api/admin_handlers.go @@ -11,6 +11,7 @@ import ( "github.com/sirosfoundation/go-siros-set/set" "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/service" "github.com/sirosfoundation/go-wallet-backend/internal/storage" "github.com/sirosfoundation/go-wallet-backend/pkg/audit" ) @@ -20,8 +21,14 @@ type AdminHandlers struct { store storage.Store logger *zap.Logger audit *audit.Emitter + // lifecycle, when set, handles wallet instance status changes so the + // admin path shares the self-service cascade (SID-AUTH-06). + lifecycle *service.WalletLifecycleService } +// SetLifecycle wires the shared wallet lifecycle service. +func (h *AdminHandlers) SetLifecycle(l *service.WalletLifecycleService) { h.lifecycle = l } + // NewAdminHandlers creates a new AdminHandlers instance func NewAdminHandlers(store storage.Store, logger *zap.Logger, auditor *audit.Emitter) *AdminHandlers { return &AdminHandlers{ diff --git a/internal/api/admin_instance_handlers.go b/internal/api/admin_instance_handlers.go index f0678e47..3185e30e 100644 --- a/internal/api/admin_instance_handlers.go +++ b/internal/api/admin_instance_handlers.go @@ -9,6 +9,7 @@ import ( "github.com/sirosfoundation/go-siros-set/set" "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/service" "github.com/sirosfoundation/go-wallet-backend/internal/storage" ) @@ -90,6 +91,25 @@ func (h *AdminHandlers) UpdateWalletInstanceStatus(c *gin.Context) { return } + if h.lifecycle != nil { + // Shared lifecycle service: same transition rules, audit and cascade + // (session drop, wallet erasure on last revocation) as self-service. + if _, err := h.lifecycle.ChangeStatus(c.Request.Context(), service.LifecycleActor{Kind: "provider"}, tenantID, instanceID, status, req.Reason); err != nil { + switch { + case errors.Is(err, storage.ErrNotFound): + c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"}) + case errors.Is(err, domain.ErrInvalidStatusTransition): + c.JSON(http.StatusConflict, gin.H{"error": "invalid status transition"}) + default: + h.logger.Error("failed to update wallet instance status", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update wallet instance"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"id": instanceID, "status": req.Status}) + return + } + if err := h.store.WalletInstances().UpdateStatus(c.Request.Context(), instanceID, status, req.Reason); err != nil { if errors.Is(err, storage.ErrNotFound) { c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"}) diff --git a/internal/api/admin_instance_handlers_test.go b/internal/api/admin_instance_handlers_test.go index f3b4b6b5..78c37ea4 100644 --- a/internal/api/admin_instance_handlers_test.go +++ b/internal/api/admin_instance_handlers_test.go @@ -16,6 +16,7 @@ import ( "go.uber.org/zap" "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/service" "github.com/sirosfoundation/go-wallet-backend/internal/storage/memory" "github.com/sirosfoundation/go-wallet-backend/pkg/audit" ) @@ -371,3 +372,47 @@ func TestDeleteWalletInstance_WithAudit(t *testing.T) { t.Fatalf("expected 204, got %d: %s", w.Code, w.Body.String()) } } + +// With the shared lifecycle service wired (as BackendProvider does), an admin +// revocation of the user's last instance runs the SID-AUTH-06 cascade. +func TestUpdateWalletInstanceStatus_LifecycleCascade(t *testing.T) { + gin.SetMode(gin.TestMode) + store := memory.NewStore() + h := NewAdminHandlers(store, zap.NewNop(), testAuditEmitter(t)) + h.SetLifecycle(service.NewWalletLifecycleService(store, zap.NewNop(), nil)) + userID := domain.NewUserID() + if err := store.Users().Create(context.Background(), &domain.User{UUID: userID, PrivateData: []byte("vault")}); err != nil { + t.Fatal(err) + } + seedInstance(t, h, "inst-1", "acme", &userID) + + r := gin.New() + r.PUT("/admin/tenants/:id/instances/:instance_id/status", h.UpdateWalletInstanceStatus) + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/admin/tenants/acme/instances/inst-1/status", strings.NewReader(`{"status":"revoked","reason":"compromised"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d %s", w.Code, w.Body.String()) + } + inst, err := store.WalletInstances().GetByID(context.Background(), "inst-1") + if err != nil || inst.Status != domain.InstanceStatusRevoked { + t.Fatalf("expected revoked, got %v %v", err, inst) + } + user, err := store.Users().GetByID(context.Background(), userID) + if err != nil { + t.Fatal(err) + } + if user.PrivateData != nil { + t.Errorf("revoking the last instance must erase the wallet's private data") + } + + // Revoked is terminal, also via the lifecycle path. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/admin/tenants/acme/instances/inst-1/status", strings.NewReader(`{"status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + r.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d", w.Code) + } +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go index bb6c1161..33d55224 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -282,6 +282,10 @@ func (h *Handlers) FinishWebAuthnLogin(c *gin.Context) { c.JSON(404, gin.H{"error": "Credential not found"}) case errors.Is(err, service.ErrVerificationFailed): c.JSON(401, gin.H{"error": "Authentication failed"}) + case errors.Is(err, service.ErrWalletInstanceSuspended): + c.JSON(403, gin.H{"error": "WALLET_SUSPENDED", "message": "This wallet instance has been suspended"}) + case errors.Is(err, service.ErrWalletInstanceRevoked): + c.JSON(403, gin.H{"error": "WALLET_REVOKED", "message": "This wallet has been deactivated; a new enrollment is required"}) case errors.Is(err, service.ErrTenantAccessDenied): c.JSON(403, gin.H{"error": "Tenant user must use tenant-scoped login endpoint"}) case errors.Is(err, service.ErrIdentityNotBound): diff --git a/internal/api/instance_handlers.go b/internal/api/instance_handlers.go new file mode 100644 index 00000000..496ff91e --- /dev/null +++ b/internal/api/instance_handlers.go @@ -0,0 +1,112 @@ +package api + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/service" + "github.com/sirosfoundation/go-wallet-backend/internal/storage" +) + +// Self-service wallet instance lifecycle (SID-AUTH-06, go-wallet-backend#195): +// a user can see their own wallet instances and suspend, reactivate or revoke +// them; revoking all deactivates the wallet. Provider-side changes go through +// the admin API (admin_instance_handlers.go); both share +// service.WalletLifecycleService, so the cascade is the same. + +type updateMyInstanceStatusRequest struct { + Status string `json:"status" binding:"required,oneof=active suspended revoked"` + Reason string `json:"reason"` +} + +type revokeAllInstancesRequest struct { + Reason string `json:"reason"` +} + +func (h *Handlers) lifecycleActor(c *gin.Context) (service.LifecycleActor, domain.TenantID, bool) { + uid, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Unauthorized"}) + return service.LifecycleActor{}, "", false + } + userID := domain.UserIDFromString(uid.(string)) + tenantID, _ := h.getTenantID(c) + return service.LifecycleActor{Kind: "user", UserID: &userID}, tenantID, true +} + +// ListMyWalletInstances handles GET /user/session/instances. +func (h *Handlers) ListMyWalletInstances(c *gin.Context) { + if h.services.WalletLifecycle == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"}) + return + } + actor, tenantID, ok := h.lifecycleActor(c) + if !ok { + return + } + instances, err := h.services.WalletLifecycle.ListForUser(c.Request.Context(), tenantID, *actor.UserID) + if err != nil { + h.logger.Error("failed to list wallet instances", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list wallet instances"}) + return + } + c.JSON(http.StatusOK, gin.H{"instances": instances}) +} + +// UpdateMyWalletInstanceStatus handles PUT /user/session/instances/:instance_id/status. +func (h *Handlers) UpdateMyWalletInstanceStatus(c *gin.Context) { + if h.services.WalletLifecycle == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"}) + return + } + actor, tenantID, ok := h.lifecycleActor(c) + if !ok { + return + } + var req updateMyInstanceStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: status must be active, suspended, or revoked"}) + return + } + inst, err := h.services.WalletLifecycle.ChangeStatus(c.Request.Context(), actor, tenantID, c.Param("instance_id"), domain.InstanceStatus(req.Status), req.Reason) + if err != nil { + switch { + case errors.Is(err, storage.ErrNotFound), errors.Is(err, service.ErrWalletInstanceNotOwned): + c.JSON(http.StatusNotFound, gin.H{"error": "wallet instance not found"}) + case errors.Is(err, domain.ErrInvalidStatusTransition): + c.JSON(http.StatusConflict, gin.H{"error": "invalid status transition"}) + default: + h.logger.Error("failed to update wallet instance status", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update wallet instance"}) + } + return + } + c.JSON(http.StatusOK, gin.H{"id": inst.ID, "status": string(inst.Status)}) +} + +// RevokeAllMyWalletInstances handles POST /user/session/instances/revoke-all: +// deactivate the wallet. Every instance is revoked and the wallet data erased; +// a new enrollment is required afterwards. +func (h *Handlers) RevokeAllMyWalletInstances(c *gin.Context) { + if h.services.WalletLifecycle == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "LIFECYCLE_NOT_SUPPORTED"}) + return + } + actor, tenantID, ok := h.lifecycleActor(c) + if !ok { + return + } + var req revokeAllInstancesRequest + _ = c.ShouldBindJSON(&req) // body is optional + n, err := h.services.WalletLifecycle.RevokeAllForUser(c.Request.Context(), actor, tenantID, *actor.UserID, req.Reason) + if err != nil { + h.logger.Error("failed to revoke wallet instances", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke wallet instances"}) + return + } + c.JSON(http.StatusOK, gin.H{"revoked": n}) +} diff --git a/internal/api/instance_handlers_test.go b/internal/api/instance_handlers_test.go new file mode 100644 index 00000000..8821c8b9 --- /dev/null +++ b/internal/api/instance_handlers_test.go @@ -0,0 +1,131 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/service" + "github.com/sirosfoundation/go-wallet-backend/internal/storage/memory" + "github.com/sirosfoundation/go-wallet-backend/pkg/config" +) + +// setupLifecycleHandlers is setupTestHandlers with the store exposed on the +// Handlers (NewHandlersWithStore) so the test can seed instances directly. +func setupLifecycleHandlers(t *testing.T) (*Handlers, *gin.Engine) { + t.Helper() + gin.SetMode(gin.TestMode) + logger := zap.NewNop() + cfg := &config.Config{ + Server: config.ServerConfig{Host: "localhost", Port: 8080, RPID: "localhost", RPOrigin: "http://localhost:8080", RPName: "Test Wallet"}, + JWT: config.JWTConfig{Secret: "test-secret", ExpiryHours: 24, Issuer: "test-wallet"}, + } + store := memory.NewStore() + services := service.NewServices(store, cfg, logger) + return NewHandlersWithStore(services, store, cfg, logger, []string{"test"}), gin.New() +} + +func seedUserInstance(t *testing.T, h *Handlers, id string, userID domain.UserID) { + t.Helper() + if err := h.store.WalletInstances().Upsert(context.Background(), &domain.WalletInstance{ + ID: id, TenantID: domain.DefaultTenantID, UserID: &userID, Status: domain.InstanceStatusActive, + }); err != nil { + t.Fatalf("seed instance: %v", err) + } +} + +func TestMyWalletInstances_ListUpdateRevokeAll(t *testing.T) { + handlers, router := setupLifecycleHandlers(t) + me := domain.UserIDFromString("user-123") + other := domain.UserIDFromString("user-456") + if err := handlers.store.Users().Create(context.Background(), &domain.User{UUID: me, PrivateData: []byte("vault")}); err != nil { + t.Fatalf("create user: %v", err) + } + seedUserInstance(t, handlers, "mine-1", me) + seedUserInstance(t, handlers, "mine-2", me) + seedUserInstance(t, handlers, "theirs", other) + + auth := authMiddleware("user-123", "did:example:123") + router.GET("/user/session/instances", auth, handlers.ListMyWalletInstances) + router.PUT("/user/session/instances/:instance_id/status", auth, handlers.UpdateMyWalletInstanceStatus) + router.POST("/user/session/instances/revoke-all", auth, handlers.RevokeAllMyWalletInstances) + + // List: only my instances. + w := httptest.NewRecorder() + router.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/user/session/instances", nil)) + if w.Code != http.StatusOK { + t.Fatalf("list: %d %s", w.Code, w.Body.String()) + } + var listed struct { + Instances []domain.WalletInstance `json:"instances"` + } + if err := json.Unmarshal(w.Body.Bytes(), &listed); err != nil { + t.Fatal(err) + } + if len(listed.Instances) != 2 { + t.Fatalf("expected 2 instances, got %d", len(listed.Instances)) + } + + // Suspend my own instance. + w = httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPut, "/user/session/instances/mine-1/status", strings.NewReader(`{"status":"suspended","reason":"lost"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"suspended"`) { + t.Fatalf("suspend: %d %s", w.Code, w.Body.String()) + } + + // Someone else's instance reads as not found. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/user/session/instances/theirs/status", strings.NewReader(`{"status":"revoked"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("not owned: expected 404, got %d %s", w.Code, w.Body.String()) + } + + // Invalid status value is a 400. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/user/session/instances/mine-1/status", strings.NewReader(`{"status":"deleted"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("bad status: expected 400, got %d", w.Code) + } + + // Deactivate the wallet: everything of mine revoked, data erased, theirs untouched. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, "/user/session/instances/revoke-all", strings.NewReader(`{"reason":"device stolen"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"revoked":2`) { + t.Fatalf("revoke-all: %d %s", w.Code, w.Body.String()) + } + user, err := handlers.store.Users().GetByID(context.Background(), me) + if err != nil { + t.Fatal(err) + } + if user.PrivateData != nil { + t.Errorf("private data must be erased once every instance is revoked") + } + theirs, err := handlers.store.WalletInstances().GetByID(context.Background(), "theirs") + if err != nil || theirs.Status != domain.InstanceStatusActive { + t.Errorf("another user's instance must be untouched: %v %v", err, theirs) + } + + // Revoked is terminal. + w = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPut, "/user/session/instances/mine-1/status", strings.NewReader(`{"status":"active"}`)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + if w.Code != http.StatusConflict { + t.Fatalf("reactivate revoked: expected 409, got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/api/wia_handlers.go b/internal/api/wia_handlers.go index 3b03acb4..a13e52ab 100644 --- a/internal/api/wia_handlers.go +++ b/internal/api/wia_handlers.go @@ -60,6 +60,10 @@ type WIAGenerateRequest struct { ClientID string `json:"client_id,omitempty"` // NativeAttestation is optional platform attestation evidence (App Attest / Play Integrity) NativeAttestation *service.NativeAttestationRequest `json:"native_attestation,omitempty"` + // CredentialID is the base64url WebAuthn credential id of the passkey this + // wallet instance logs in with, so that suspending or revoking the instance + // also refuses login with that passkey (SID-AUTH-06). Optional. + CredentialID string `json:"credential_id,omitempty"` } // WIAGenerate handles POST /wallet-provider/wia/generate @@ -93,6 +97,7 @@ func (h *Handlers) WIAGenerate(c *gin.Context) { Challenge: req.Challenge, ClientID: req.ClientID, NativeAttestation: req.NativeAttestation, + CredentialID: req.CredentialID, }) if err != nil { switch { diff --git a/internal/as/passkey.go b/internal/as/passkey.go index 13484fba..c957be9d 100644 --- a/internal/as/passkey.go +++ b/internal/as/passkey.go @@ -2,6 +2,7 @@ package as import ( "context" + "errors" "net/http" "time" @@ -74,7 +75,17 @@ func (h *PasskeyHandlers) LoginFinish(c *gin.Context) { resp, err := h.webauthn.FinishLogin(c.Request.Context(), &req) if err != nil { h.logger.Warn("passkey login finish failed", zap.Error(err)) - c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"}) + // SID-AUTH-06: a suspended or revoked wallet instance is a distinct, + // stable refusal so the client can tell the user what happened + // instead of retrying a login that can never succeed. + switch { + case errors.Is(err, service.ErrWalletInstanceSuspended): + c.JSON(http.StatusForbidden, gin.H{"error": "WALLET_SUSPENDED"}) + case errors.Is(err, service.ErrWalletInstanceRevoked): + c.JSON(http.StatusForbidden, gin.H{"error": "WALLET_REVOKED"}) + default: + c.JSON(http.StatusUnauthorized, gin.H{"error": "authentication failed"}) + } return } diff --git a/internal/as/passkey_test.go b/internal/as/passkey_test.go index 980569bd..3fc1a858 100644 --- a/internal/as/passkey_test.go +++ b/internal/as/passkey_test.go @@ -175,6 +175,31 @@ func TestPasskeyLoginFinish_AuthError(t *testing.T) { } } +// SID-AUTH-06: a suspended or revoked wallet instance is a distinct 403 with a +// stable code, not the generic 401, so the client can explain instead of retry. +func TestPasskeyLoginFinish_WalletLifecycleRefusals(t *testing.T) { + for _, tc := range []struct { + err error + code string + }{ + {service.ErrWalletInstanceSuspended, "WALLET_SUSPENDED"}, + {service.ErrWalletInstanceRevoked, "WALLET_REVOKED"}, + } { + router, _ := setupPasskeyHandlers(&mockWebAuthn{finishLoginErr: tc.err}) + body, _ := json.Marshal(service.FinishLoginRequest{ChallengeID: "c1"}) + req := httptest.NewRequest(http.MethodPost, "/auth/passkey/login/finish", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Errorf("%s: expected 403, got %d", tc.code, w.Code) + } + if !bytes.Contains(w.Body.Bytes(), []byte(tc.code)) { + t.Errorf("expected body to carry %s, got %s", tc.code, w.Body.String()) + } + } +} + func TestPasskeyLoginFinish_BadRequest(t *testing.T) { mock := &mockWebAuthn{} router, _ := setupPasskeyHandlers(mock) diff --git a/internal/server/providers.go b/internal/server/providers.go index bf04c6c4..a6e44a8d 100644 --- a/internal/server/providers.go +++ b/internal/server/providers.go @@ -149,6 +149,10 @@ func (p *AuthProvider) RegisterRoutes(router *gin.Engine) { session.POST("/webauthn/register-finish", requireTACIfEnforced(p.tokenValidator, "i"), p.handlers.FinishAddWebAuthnCredential) session.POST("/webauthn/credential/:id/rename", requireTACIfEnforced(p.tokenValidator, "w"), p.handlers.RenameWebAuthnCredential) session.POST("/webauthn/credential/:id/delete", requireTACIfEnforced(p.tokenValidator, "d"), p.handlers.DeleteWebAuthnCredential) + // Wallet instance lifecycle, self-service (SID-AUTH-06) + session.GET("/instances", requireTACIfEnforced(p.tokenValidator, "r"), p.handlers.ListMyWalletInstances) + session.PUT("/instances/:instance_id/status", requireTACIfEnforced(p.tokenValidator, "w"), p.handlers.UpdateMyWalletInstanceStatus) + session.POST("/instances/revoke-all", requireTACIfEnforced(p.tokenValidator, "d"), p.handlers.RevokeAllMyWalletInstances) } protected.DELETE("/user/session", requireTACIfEnforced(p.tokenValidator, "d"), p.handlers.DeleteUser) @@ -642,6 +646,10 @@ func (p *BackendProvider) TokenValidator() *tokenvalidator.Validator { // RegisterAdminRoutes implements AdminRouteProvider for BackendProvider. func (p *BackendProvider) RegisterAdminRoutes(adminGroup *gin.RouterGroup) { adminHandlers := api.NewAdminHandlers(p.store, p.logger, p.auditor) + if svcs := p.Services(); svcs != nil { + // Admin status changes share the self-service cascade (SID-AUTH-06). + adminHandlers.SetLifecycle(svcs.WalletLifecycle) + } adminHandlers.RegisterRoutes(adminGroup) // Cache management endpoint — useful in test environments where the diff --git a/internal/service/services.go b/internal/service/services.go index 16ee8ba1..fecd07d7 100644 --- a/internal/service/services.go +++ b/internal/service/services.go @@ -27,6 +27,7 @@ type Services struct { WalletProvider *WalletProviderService WIA *WIAService FIDO2Attestation *FIDO2AttestationService + WalletLifecycle *WalletLifecycleService TokenBlacklist *TokenBlacklist ChallengeCleanup *ChallengeCleanupWorker AAGUIDValidator *AAGUIDValidator @@ -95,6 +96,7 @@ func NewServices(store storage.Store, cfg *config.Config, logger *zap.Logger) *S WalletProvider: wpSvc, WIA: wiaSvc, FIDO2Attestation: NewFIDO2AttestationService(cfg, store.WalletInstances(), store.KeyAttestations(), engine.NewTrustService(cfg, logger), logger), + WalletLifecycle: NewWalletLifecycleService(store, logger, audit.NewFromConfig(cfg, logger)), TokenBlacklist: NewTokenBlacklist(cfg.Security.TokenBlacklist, logger), ChallengeCleanup: NewChallengeCleanupWorker(cfg.Security.ChallengeCleanup, store, logger), AAGUIDValidator: aaguidValidator, diff --git a/internal/service/wallet_lifecycle.go b/internal/service/wallet_lifecycle.go new file mode 100644 index 00000000..361d8cb2 --- /dev/null +++ b/internal/service/wallet_lifecycle.go @@ -0,0 +1,228 @@ +package service + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/sirosfoundation/go-siros-set/set" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/storage" + "github.com/sirosfoundation/go-wallet-backend/pkg/audit" +) + +// ErrWalletInstanceNotOwned is returned when a user-initiated lifecycle change +// names an instance that does not belong to that user. Handlers map it to 404 +// so the existence of other users' instances is not disclosed. +var ErrWalletInstanceNotOwned = errors.New("wallet instance does not belong to this user") + +// LifecycleActor says who asked for a wallet instance status change. +type LifecycleActor struct { + // Kind is "user" for self-service changes and "provider" for admin ones. + Kind string + // UserID is set for self-service changes: the instance must belong to it. + UserID *domain.UserID +} + +// WalletLifecycleService implements SID-AUTH-06 wallet lifecycle management on +// top of the domain.WalletInstance model (go-wallet-backend#195): status +// changes with validated transitions, audit events, and the cascade a +// deactivation implies. +// +// Suspension is reversible and only blocks: sessions are dropped, new WIAs are +// refused (WIAService), and login with the linked passkey is refused +// (WebAuthnService.checkWalletLifecycle). Revocation is terminal. Revoking the +// last non-revoked instance of a user deactivates the wallet: the encrypted +// private data - the only durable custodian of the user's keys - and any +// server-side credentials, presentations and pending challenges are erased, +// and login is refused for every passkey of that user, so re-activation +// requires a full new enrollment. Data is never erased while an instance the +// user could still reactivate remains. +type WalletLifecycleService struct { + store storage.Store + logger *zap.Logger + audit *audit.Emitter + sessionCleaner SessionCleaner +} + +// NewWalletLifecycleService creates a WalletLifecycleService. auditor may be nil. +func NewWalletLifecycleService(store storage.Store, logger *zap.Logger, auditor *audit.Emitter) *WalletLifecycleService { + return &WalletLifecycleService{store: store, logger: logger.Named("wallet-lifecycle"), audit: auditor} +} + +// SetSessionCleaner wires the engine session store so suspend/revoke drop the +// user's live sessions. +func (s *WalletLifecycleService) SetSessionCleaner(sc SessionCleaner) { s.sessionCleaner = sc } + +// ListForUser returns the wallet instances registered for a user in a tenant. +func (s *WalletLifecycleService) ListForUser(ctx context.Context, tenantID domain.TenantID, userID domain.UserID) ([]*domain.WalletInstance, error) { + instances, err := s.store.WalletInstances().GetByUser(ctx, tenantID, userID) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + return nil, fmt.Errorf("list wallet instances: %w", err) + } + if instances == nil { + instances = []*domain.WalletInstance{} + } + return instances, nil +} + +// ChangeStatus moves one instance to target after checking tenant, ownership +// (for a user actor) and the domain transition rules, then runs the cascade. +// Returns storage.ErrNotFound, ErrWalletInstanceNotOwned or +// domain.ErrInvalidStatusTransition for the caller to map. +func (s *WalletLifecycleService) ChangeStatus(ctx context.Context, actor LifecycleActor, tenantID domain.TenantID, instanceID string, target domain.InstanceStatus, reason string) (*domain.WalletInstance, error) { + inst, err := s.store.WalletInstances().GetByID(ctx, instanceID) + if err != nil { + return nil, err + } + if inst.TenantID != tenantID { + return nil, storage.ErrNotFound + } + if actor.UserID != nil && (inst.UserID == nil || *inst.UserID != *actor.UserID) { + return nil, ErrWalletInstanceNotOwned + } + if err := domain.ValidateStatusTransition(inst.Status, target); err != nil { + return nil, err + } + if inst.Status == target { + return inst, nil + } + if err := s.store.WalletInstances().UpdateStatus(ctx, instanceID, target, reason); err != nil { + return nil, err + } + inst.Status = target + inst.UpdatedAt = time.Now().UTC() + s.emitAudit(inst.ID, target, reason, actor) + if target != domain.InstanceStatusActive { + s.cascade(ctx, tenantID, inst) + } + return inst, nil +} + +// RevokeAllForUser revokes every non-revoked instance of the user in the +// tenant - the "deactivate my wallet" action - and returns how many changed. +// The cascade then erases the wallet data, since nothing live remains. +func (s *WalletLifecycleService) RevokeAllForUser(ctx context.Context, actor LifecycleActor, tenantID domain.TenantID, userID domain.UserID, reason string) (int, error) { + instances, err := s.ListForUser(ctx, tenantID, userID) + if err != nil { + return 0, err + } + changed := 0 + var last *domain.WalletInstance + for _, inst := range instances { + if inst.Status == domain.InstanceStatusRevoked { + continue + } + if err := s.store.WalletInstances().UpdateStatus(ctx, inst.ID, domain.InstanceStatusRevoked, reason); err != nil { + return changed, fmt.Errorf("revoke instance %s: %w", inst.ID, err) + } + inst.Status = domain.InstanceStatusRevoked + s.emitAudit(inst.ID, domain.InstanceStatusRevoked, reason, actor) + changed++ + last = inst + } + if last != nil { + s.cascade(ctx, tenantID, last) + } + return changed, nil +} + +// cascade runs after an instance left the active state: drop the user's live +// sessions, and erase the wallet data once no instance the user could +// reactivate remains. +func (s *WalletLifecycleService) cascade(ctx context.Context, tenantID domain.TenantID, inst *domain.WalletInstance) { + if inst.UserID == nil { + return + } + userID := *inst.UserID + if s.sessionCleaner != nil { + if err := s.sessionCleaner.DeleteByUser(ctx, userID.String()); err != nil { + s.logger.Warn("failed to drop sessions after instance status change", zap.Error(err)) + } + } + remaining, err := s.store.WalletInstances().GetByUser(ctx, tenantID, userID) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + s.logger.Warn("failed to list remaining instances; not erasing wallet data", zap.Error(err)) + return + } + for _, other := range remaining { + if other.Status != domain.InstanceStatusRevoked { + return // something is still active or reactivatable + } + } + s.eraseWalletData(ctx, userID) +} + +// eraseWalletData is the SID-AUTH-06 "secure erasure on deactivation": the +// same server-side data UserService.DeleteUser removes, but the user record +// and its passkeys stay so the revocation remains attributable and login can +// be refused with a clear reason rather than "user not found". +func (s *WalletLifecycleService) eraseWalletData(ctx context.Context, userID domain.UserID) { + user, err := s.store.Users().GetByID(ctx, userID) + if err != nil { + s.logger.Warn("failed to load user for wallet erasure", zap.Error(err)) + return + } + user.PrivateData = nil + user.PrivateDataETag = "" + user.UpdatedAt = time.Now() + if err := s.store.Users().Update(ctx, user); err != nil { + s.logger.Warn("failed to clear private data", zap.Error(err)) + } + + tenantIDs, err := s.store.UserTenants().GetUserTenants(ctx, userID) + if err != nil || len(tenantIDs) == 0 { + tenantIDs = []domain.TenantID{domain.DefaultTenantID} + } + if user.DID != "" { + for _, tid := range tenantIDs { + creds, err := s.store.Credentials().GetAllByHolder(ctx, tid, user.DID) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + s.logger.Warn("failed to list credentials for erasure", zap.Error(err)) + } + for _, c := range creds { + if err := s.store.Credentials().Delete(ctx, tid, user.DID, c.CredentialIdentifier); err != nil { + s.logger.Warn("failed to delete credential", zap.Error(err)) + } + } + pres, err := s.store.Presentations().GetAllByHolder(ctx, tid, user.DID) + if err != nil && !errors.Is(err, storage.ErrNotFound) { + s.logger.Warn("failed to list presentations for erasure", zap.Error(err)) + } + for _, p := range pres { + if err := s.store.Presentations().Delete(ctx, tid, user.DID, p.PresentationIdentifier); err != nil { + s.logger.Warn("failed to delete presentation", zap.Error(err)) + } + } + } + } + if err := s.store.Challenges().DeleteByUserID(ctx, userID.String()); err != nil { + s.logger.Warn("failed to delete challenges", zap.Error(err)) + } + s.logger.Info("wallet data erased: last wallet instance revoked", zap.String("user_id", userID.String())) +} + +func (s *WalletLifecycleService) emitAudit(instanceID string, status domain.InstanceStatus, reason string, actor LifecycleActor) { + if s.audit == nil { + return + } + var event set.EventURI + switch status { + case domain.InstanceStatusRevoked: + event = set.EventWIRevoked + case domain.InstanceStatusSuspended: + event = set.EventWISuspended + case domain.InstanceStatusActive: + event = set.EventWICreated // re-activation + default: + event = set.EventWIDeactivated + } + s.audit.EmitWithSubject(event, instanceID, map[string]any{ + "status": string(status), + "reason": reason, + "actor": actor.Kind, + }) +} diff --git a/internal/service/wallet_lifecycle_test.go b/internal/service/wallet_lifecycle_test.go new file mode 100644 index 00000000..4890d32f --- /dev/null +++ b/internal/service/wallet_lifecycle_test.go @@ -0,0 +1,139 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/storage" + "github.com/sirosfoundation/go-wallet-backend/internal/storage/memory" +) + +type fakeSessionCleaner struct{ users []string } + +func (f *fakeSessionCleaner) DeleteByUser(_ context.Context, userID string) error { + f.users = append(f.users, userID) + return nil +} + +// lifecycleFixture seeds a user with private data, a pending challenge and +// the given instances, all in the default tenant. +func lifecycleFixture(t *testing.T, statuses ...domain.InstanceStatus) (*WalletLifecycleService, storage.Store, domain.UserID, *fakeSessionCleaner) { + t.Helper() + store := memory.NewStore() + ctx := context.Background() + userID := domain.NewUserID() + require.NoError(t, store.Users().Create(ctx, &domain.User{ + UUID: userID, + DID: "did:example:" + userID.String(), + PrivateData: []byte("encrypted-vault"), + PrivateDataETag: "etag-1", + })) + require.NoError(t, store.Challenges().Create(ctx, &domain.WebauthnChallenge{ + ID: "chal-1", UserID: userID.String(), Challenge: "c", Action: "login", ExpiresAt: time.Now().Add(time.Minute), + })) + for i, st := range statuses { + require.NoError(t, store.WalletInstances().Upsert(ctx, &domain.WalletInstance{ + ID: "inst-" + string(rune('a'+i)), TenantID: domain.DefaultTenantID, UserID: &userID, Status: domain.InstanceStatusActive, + })) + if st != domain.InstanceStatusActive { + require.NoError(t, store.WalletInstances().UpdateStatus(ctx, "inst-"+string(rune('a'+i)), st, "seed")) + } + } + svc := NewWalletLifecycleService(store, zap.NewNop(), nil) + sc := &fakeSessionCleaner{} + svc.SetSessionCleaner(sc) + return svc, store, userID, sc +} + +func userActor(id domain.UserID) LifecycleActor { return LifecycleActor{Kind: "user", UserID: &id} } + +func TestWalletLifecycle_SuspendBlocksWithoutErasing(t *testing.T) { + svc, store, userID, sc := lifecycleFixture(t, domain.InstanceStatusActive) + ctx := context.Background() + + inst, err := svc.ChangeStatus(ctx, userActor(userID), domain.DefaultTenantID, "inst-a", domain.InstanceStatusSuspended, "lost phone") + require.NoError(t, err) + assert.Equal(t, domain.InstanceStatusSuspended, inst.Status) + assert.Equal(t, []string{userID.String()}, sc.users, "live sessions are dropped") + + user, err := store.Users().GetByID(ctx, userID) + require.NoError(t, err) + assert.Equal(t, []byte("encrypted-vault"), user.PrivateData, "suspension is reversible: nothing is erased") + + // Reactivation by the owner is a valid transition. + inst, err = svc.ChangeStatus(ctx, userActor(userID), domain.DefaultTenantID, "inst-a", domain.InstanceStatusActive, "found it") + require.NoError(t, err) + assert.Equal(t, domain.InstanceStatusActive, inst.Status) +} + +func TestWalletLifecycle_RevokingLastInstanceErasesWalletData(t *testing.T) { + svc, store, userID, _ := lifecycleFixture(t, domain.InstanceStatusActive, domain.InstanceStatusSuspended) + ctx := context.Background() + + // One live (suspended, reactivatable) instance remains: no erasure yet. + _, err := svc.ChangeStatus(ctx, LifecycleActor{Kind: "provider"}, domain.DefaultTenantID, "inst-a", domain.InstanceStatusRevoked, "compromised") + require.NoError(t, err) + user, err := store.Users().GetByID(ctx, userID) + require.NoError(t, err) + assert.NotNil(t, user.PrivateData, "a suspended instance could still be reactivated; data must stay") + + // Revoking the last one deactivates the wallet. + _, err = svc.ChangeStatus(ctx, LifecycleActor{Kind: "provider"}, domain.DefaultTenantID, "inst-b", domain.InstanceStatusRevoked, "compromised") + require.NoError(t, err) + user, err = store.Users().GetByID(ctx, userID) + require.NoError(t, err, "the user record itself is kept") + assert.Nil(t, user.PrivateData) + assert.Empty(t, user.PrivateDataETag) + _, err = store.Challenges().GetByID(ctx, "chal-1") + assert.True(t, errors.Is(err, storage.ErrNotFound), "pending challenges are deleted") +} + +func TestWalletLifecycle_RevokeAllForUser(t *testing.T) { + svc, store, userID, sc := lifecycleFixture(t, domain.InstanceStatusActive, domain.InstanceStatusActive, domain.InstanceStatusRevoked) + ctx := context.Background() + + n, err := svc.RevokeAllForUser(ctx, userActor(userID), domain.DefaultTenantID, userID, "deactivate wallet") + require.NoError(t, err) + assert.Equal(t, 2, n, "already-revoked instances are not counted") + + instances, err := svc.ListForUser(ctx, domain.DefaultTenantID, userID) + require.NoError(t, err) + for _, inst := range instances { + assert.Equal(t, domain.InstanceStatusRevoked, inst.Status) + } + user, err := store.Users().GetByID(ctx, userID) + require.NoError(t, err) + assert.Nil(t, user.PrivateData) + assert.NotEmpty(t, sc.users) +} + +func TestWalletLifecycle_OwnershipAndTransitions(t *testing.T) { + svc, store, userID, _ := lifecycleFixture(t, domain.InstanceStatusActive) + ctx := context.Background() + other := domain.NewUserID() + + _, err := svc.ChangeStatus(ctx, userActor(other), domain.DefaultTenantID, "inst-a", domain.InstanceStatusRevoked, "") + assert.True(t, errors.Is(err, ErrWalletInstanceNotOwned), "another user's instance cannot be changed") + + _, err = svc.ChangeStatus(ctx, userActor(userID), "other-tenant", "inst-a", domain.InstanceStatusRevoked, "") + assert.True(t, errors.Is(err, storage.ErrNotFound), "wrong tenant reads as not found") + + _, err = svc.ChangeStatus(ctx, userActor(userID), domain.DefaultTenantID, "missing", domain.InstanceStatusRevoked, "") + assert.True(t, errors.Is(err, storage.ErrNotFound)) + + _, err = svc.ChangeStatus(ctx, userActor(userID), domain.DefaultTenantID, "inst-a", domain.InstanceStatusRevoked, "") + require.NoError(t, err) + _, err = svc.ChangeStatus(ctx, LifecycleActor{Kind: "provider"}, domain.DefaultTenantID, "inst-a", domain.InstanceStatusActive, "") + assert.True(t, errors.Is(err, domain.ErrInvalidStatusTransition), "revocation is terminal") + + user, err := store.Users().GetByID(ctx, userID) + require.NoError(t, err) + assert.Nil(t, user.PrivateData, "the single instance was revoked, so the wallet is deactivated") +} diff --git a/internal/service/webauthn.go b/internal/service/webauthn.go index 47243da7..75c49b97 100644 --- a/internal/service/webauthn.go +++ b/internal/service/webauthn.go @@ -53,6 +53,15 @@ type WebAuthnService struct { // ErrAAGUIDBlacklisted indicates the authenticator's AAGUID is blocked var ErrAAGUIDBlacklisted = errors.New("authenticator not allowed") +// ErrWalletInstanceSuspended and ErrWalletInstanceRevoked refuse a login whose +// passkey belongs to a suspended or revoked wallet instance, or whose wallet +// has been deactivated (every instance revoked) - SID-AUTH-06 login gate, see +// checkWalletLifecycle and WalletLifecycleService. +var ( + ErrWalletInstanceSuspended = errors.New("wallet instance suspended") + ErrWalletInstanceRevoked = errors.New("wallet instance revoked") +) + // NewWebAuthnService creates a new WebAuthnService func NewWebAuthnService(store storage.Store, cfg *config.Config, logger *zap.Logger) (*WebAuthnService, error) { return NewWebAuthnServiceWithValidator(store, cfg, logger, nil) @@ -1074,6 +1083,14 @@ func (s *WebAuthnService) FinishLogin(ctx context.Context, req *FinishLoginReque return nil, ErrVerificationFailed } + // SID-AUTH-06 login gate: a suspended or revoked wallet instance must not + // be able to log in, and a deactivated wallet (all instances revoked) must + // require a fresh enrollment. Checked only after the assertion verified, + // so an attacker cannot probe lifecycle state with a forged assertion. + if err := s.checkWalletLifecycle(ctx, tenantID, userID, matchedCred.ID); err != nil { + return nil, err + } + // Update the credential's signature count matchedCred.Authenticator.SignCount = credential.Authenticator.SignCount user.UpdatedAt = time.Now() @@ -1758,3 +1775,45 @@ func (u *TenantWebAuthnUser) WebAuthnCredentials() []webauthn.Credential { } return creds } + +// checkWalletLifecycle enforces wallet instance status at login (SID-AUTH-06). +// +// Two rules. The instance linked to this passkey (WalletInstance.CredentialID, +// recorded when the wallet supplies credential_id at WIA generation) must be +// active. And if the user has instances at all, at least one must be +// non-revoked: when every instance is revoked the wallet has been deactivated +// and WalletLifecycleService already erased its data, so every passkey of the +// user is refused until a new enrollment. A suspended instance that is not +// linked to this passkey does not block login - it is still blocked from +// obtaining a WIA (WIAService), and the user must be able to log in from +// another device to manage it. A user with no instances yet is unaffected. +func (s *WebAuthnService) checkWalletLifecycle(ctx context.Context, tenantID domain.TenantID, userID domain.UserID, credentialID string) error { + instances, err := s.store.WalletInstances().GetByUser(ctx, tenantID, userID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil + } + return fmt.Errorf("check wallet lifecycle: %w", err) + } + if len(instances) == 0 { + return nil + } + anyLive := false + for _, inst := range instances { + if inst.CredentialID != "" && inst.CredentialID == credentialID { + switch inst.Status { + case domain.InstanceStatusSuspended: + return ErrWalletInstanceSuspended + case domain.InstanceStatusRevoked: + return ErrWalletInstanceRevoked + } + } + if inst.Status != domain.InstanceStatusRevoked { + anyLive = true + } + } + if !anyLive { + return ErrWalletInstanceRevoked + } + return nil +} diff --git a/internal/service/webauthn_lifecycle_test.go b/internal/service/webauthn_lifecycle_test.go new file mode 100644 index 00000000..34beeee0 --- /dev/null +++ b/internal/service/webauthn_lifecycle_test.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sirosfoundation/go-wallet-backend/internal/domain" + "github.com/sirosfoundation/go-wallet-backend/internal/storage/memory" +) + +func seedLifecycleInstance(t *testing.T, s *WebAuthnService, id string, userID domain.UserID, credentialID string, status domain.InstanceStatus) { + t.Helper() + ctx := context.Background() + require.NoError(t, s.store.WalletInstances().Upsert(ctx, &domain.WalletInstance{ + ID: id, TenantID: domain.DefaultTenantID, UserID: &userID, CredentialID: credentialID, Status: domain.InstanceStatusActive, + })) + if status != domain.InstanceStatusActive { + require.NoError(t, s.store.WalletInstances().UpdateStatus(ctx, id, status, "test")) + } +} + +// The SID-AUTH-06 login gate: which passkeys may still log in given the +// user's wallet instances. +func TestCheckWalletLifecycle(t *testing.T) { + ctx := context.Background() + userID := domain.NewUserID() + + t.Run("no instances yet: allowed", func(t *testing.T) { + s := &WebAuthnService{store: memory.NewStore()} + assert.NoError(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-1")) + }) + + t.Run("linked instance suspended: refused with suspended", func(t *testing.T) { + s := &WebAuthnService{store: memory.NewStore()} + seedLifecycleInstance(t, s, "i1", userID, "pk-1", domain.InstanceStatusSuspended) + seedLifecycleInstance(t, s, "i2", userID, "pk-2", domain.InstanceStatusActive) + assert.ErrorIs(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-1"), ErrWalletInstanceSuspended) + assert.NoError(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-2"), "the other device still logs in") + }) + + t.Run("linked instance revoked: refused with revoked", func(t *testing.T) { + s := &WebAuthnService{store: memory.NewStore()} + seedLifecycleInstance(t, s, "i1", userID, "pk-1", domain.InstanceStatusRevoked) + seedLifecycleInstance(t, s, "i2", userID, "pk-2", domain.InstanceStatusActive) + assert.ErrorIs(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-1"), ErrWalletInstanceRevoked) + }) + + t.Run("unlinked suspended instance does not block other passkeys", func(t *testing.T) { + s := &WebAuthnService{store: memory.NewStore()} + seedLifecycleInstance(t, s, "i1", userID, "", domain.InstanceStatusSuspended) + assert.NoError(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-1"), + "the user must be able to log in from another device to manage a suspended one") + }) + + t.Run("every instance revoked: wallet deactivated, any passkey refused", func(t *testing.T) { + s := &WebAuthnService{store: memory.NewStore()} + seedLifecycleInstance(t, s, "i1", userID, "", domain.InstanceStatusRevoked) + seedLifecycleInstance(t, s, "i2", userID, "", domain.InstanceStatusRevoked) + assert.ErrorIs(t, s.checkWalletLifecycle(ctx, domain.DefaultTenantID, userID, "pk-new"), ErrWalletInstanceRevoked) + }) +} diff --git a/internal/service/wia.go b/internal/service/wia.go index 90540752..a44346d7 100644 --- a/internal/service/wia.go +++ b/internal/service/wia.go @@ -276,6 +276,12 @@ type WIARequest struct { ClientID string `json:"client_id,omitempty"` // NativeAttestation is optional platform attestation evidence NativeAttestation *NativeAttestationRequest `json:"native_attestation,omitempty"` + // CredentialID, when provided, is the base64url WebAuthn credential id of + // the passkey this wallet instance logs in with. Recorded as + // WalletInstance.CredentialID so suspending or revoking the instance also + // refuses login with that passkey (SID-AUTH-06). Optional: without it the + // login gate still enforces whole-wallet deactivation. + CredentialID string `json:"credential_id,omitempty"` } // WIAPopClaims are the expected claims in a WIA-PoP JWT. @@ -358,7 +364,7 @@ func (s *WIAService) GenerateWIA(ctx context.Context, tenantID domain.TenantID, } // Step 4: Generate WIA JWT - return s.signWIA(cnfJWK, jkt, tenantID, userID, attestationSource, req.ClientID) + return s.signWIA(cnfJWK, jkt, tenantID, userID, attestationSource, req.ClientID, req.CredentialID) } // validatePop validates the WIA-PoP JWT and extracts the cnf key. @@ -457,7 +463,7 @@ func (s *WIAService) validatePop(popJWT string, expectedNonce string) (map[strin // signWIA creates the WIA JWT (typ: oauth-client-attestation+jwt). // jkt is the JWK Thumbprint of cnfJWK, precomputed by the caller (GenerateWIA) // so it can also be used for the instance-status guard before signing. -func (s *WIAService) signWIA(cnfJWK map[string]interface{}, jkt string, tenantID domain.TenantID, userID *domain.UserID, attestationSource string, clientID string) (string, error) { +func (s *WIAService) signWIA(cnfJWK map[string]interface{}, jkt string, tenantID domain.TenantID, userID *domain.UserID, attestationSource string, clientID string, credentialID string) (string, error) { now := time.Now() // WIA lifetime, capped by WIA max expiry. Deliberately short (default @@ -601,6 +607,7 @@ func (s *WIAService) signWIA(cnfJWK map[string]interface{}, jkt string, tenantID TenantID: tenantID, UserID: userID, Status: domain.InstanceStatusActive, + CredentialID: credentialID, WSCDType: wscdTypeFromAttestation(attestationSource), AttestationSource: attestationSource, LastAttestedAt: now, diff --git a/internal/service/wua_status_claims_test.go b/internal/service/wua_status_claims_test.go index 83252711..14220667 100644 --- a/internal/service/wua_status_claims_test.go +++ b/internal/service/wua_status_claims_test.go @@ -173,7 +173,7 @@ func TestWIAService_ClientStatus(t *testing.T) { svc.cfg.Server.BaseURL = "https://wp.example.com/" enableStatusList(svc.cfg, "") - wia, err := svc.signWIA(map[string]interface{}{"kty": "EC"}, "test-jkt", "tenant", nil, "backend_attested", "client-id") + wia, err := svc.signWIA(map[string]interface{}{"kty": "EC"}, "test-jkt", "tenant", nil, "backend_attested", "client-id", "") if err != nil { t.Fatalf("signWIA: %v", err) } @@ -197,7 +197,7 @@ func TestWIAService_ClientStatusDisabled(t *testing.T) { svc, _ := newTestWIAService(t) svc.cfg.Server.BaseURL = "https://wp.example.com" - wia, err := svc.signWIA(map[string]interface{}{"kty": "EC"}, "test-jkt", "tenant", nil, "backend_attested", "client-id") + wia, err := svc.signWIA(map[string]interface{}{"kty": "EC"}, "test-jkt", "tenant", nil, "backend_attested", "client-id", "") if err != nil { t.Fatalf("signWIA: %v", err) } diff --git a/internal/storage/memory/wallet_instance.go b/internal/storage/memory/wallet_instance.go index 90ee1e36..052284ff 100644 --- a/internal/storage/memory/wallet_instance.go +++ b/internal/storage/memory/wallet_instance.go @@ -33,6 +33,9 @@ func (s *WalletInstanceStore) Upsert(_ context.Context, instance *domain.WalletI if instance.DeviceInfo != nil { existing.DeviceInfo = instance.DeviceInfo } + if instance.CredentialID != "" { + existing.CredentialID = instance.CredentialID + } } else { instance.AttestationCount = 1 if instance.CreatedAt.IsZero() { diff --git a/internal/storage/memory/wallet_instance_test.go b/internal/storage/memory/wallet_instance_test.go index c7c5a894..b8b8a2f2 100644 --- a/internal/storage/memory/wallet_instance_test.go +++ b/internal/storage/memory/wallet_instance_test.go @@ -272,3 +272,38 @@ func TestWalletInstanceStore_UpdateStatus_Reactivate(t *testing.T) { t.Errorf("deactivation_reason should be empty, got %q", got.DeactivationReason) } } + +func TestWalletInstanceStore_Upsert_RecordsCredentialIDWithoutTouchingStatus(t *testing.T) { + store := NewStore() + ctx := context.Background() + inst := &domain.WalletInstance{ID: "inst-cred", TenantID: "acme", Status: domain.InstanceStatusActive} + if err := store.WalletInstances().Upsert(ctx, inst); err != nil { + t.Fatal(err) + } + if err := store.WalletInstances().UpdateStatus(ctx, "inst-cred", domain.InstanceStatusSuspended, "x"); err != nil { + t.Fatal(err) + } + // A later attestation that now names the passkey records the link but + // must not reactivate the instance. + if err := store.WalletInstances().Upsert(ctx, &domain.WalletInstance{ID: "inst-cred", TenantID: "acme", Status: domain.InstanceStatusActive, CredentialID: "pk-1"}); err != nil { + t.Fatal(err) + } + got, err := store.WalletInstances().GetByID(ctx, "inst-cred") + if err != nil { + t.Fatal(err) + } + if got.CredentialID != "pk-1" { + t.Errorf("credential id not recorded: %q", got.CredentialID) + } + if got.Status != domain.InstanceStatusSuspended { + t.Errorf("status must be untouched by upsert, got %s", got.Status) + } + // An attestation without the id keeps the recorded link. + if err := store.WalletInstances().Upsert(ctx, &domain.WalletInstance{ID: "inst-cred", TenantID: "acme", Status: domain.InstanceStatusActive}); err != nil { + t.Fatal(err) + } + got, _ = store.WalletInstances().GetByID(ctx, "inst-cred") + if got.CredentialID != "pk-1" { + t.Errorf("credential id must persist, got %q", got.CredentialID) + } +} diff --git a/internal/storage/mongodb/wallet_instance.go b/internal/storage/mongodb/wallet_instance.go index bec5dbf9..0fe6eba5 100644 --- a/internal/storage/mongodb/wallet_instance.go +++ b/internal/storage/mongodb/wallet_instance.go @@ -45,6 +45,9 @@ func (s *WalletInstanceStore) Upsert(ctx context.Context, instance *domain.Walle if instance.DeviceInfo != nil { update["$set"].(bson.M)["device_info"] = instance.DeviceInfo } + if instance.CredentialID != "" { + update["$set"].(bson.M)["credential_id"] = instance.CredentialID + } opts := options.Update().SetUpsert(true) _, err := s.collection.UpdateOne(ctx, filter, update, opts)