From 690a5c47f5b75fb4cd3ca682e777815bb464e3be Mon Sep 17 00:00:00 2001 From: Leif Johansson Date: Tue, 8 Sep 2026 15:08:30 +0200 Subject: [PATCH] feat(lifecycle): wallet instance suspend/revoke with login gate and self-service (SID-AUTH-06) Implements #195 on the WalletInstance model instead of a parallel per-passkey status, superseding #196. - WalletLifecycleService: validated transitions, audit, and the cascade a deactivation implies. Suspension is reversible and only blocks; revocation is terminal; revoking the last non-revoked instance deactivates the wallet: private data, server-side credentials/presentations and pending challenges are erased, live sessions dropped. Data is never erased while an instance the user could still reactivate remains. The user record and passkeys stay so the refusal is attributable ("wallet revoked", not "user not found"). - Login gate in WebAuthnService.FinishLogin: the instance linked to the passkey must be active; a wallet with every instance revoked refuses every passkey. Both login handlers (legacy and AS) return 403 WALLET_SUSPENDED / WALLET_REVOKED. - Passkey link: WIA generate accepts an optional credential_id, recorded as WalletInstance.CredentialID (the field existed but nothing set it). - Self-service endpoints under /user/session/instances: list, change status of own instance, revoke-all. Admin status changes go through the same service when wired, so both paths share one cascade. - Engine session store wired into the lifecycle service (main.go). Co-Authored-By: Claude Fable 5.1 --- cmd/server/main.go | 5 +- docs/API.md | 49 ++++ internal/api/admin_handlers.go | 7 + internal/api/admin_instance_handlers.go | 20 ++ internal/api/admin_instance_handlers_test.go | 45 ++++ internal/api/handlers.go | 4 + internal/api/instance_handlers.go | 112 +++++++++ internal/api/instance_handlers_test.go | 131 ++++++++++ internal/api/wia_handlers.go | 5 + internal/as/passkey.go | 13 +- internal/as/passkey_test.go | 25 ++ internal/server/providers.go | 8 + internal/service/services.go | 2 + internal/service/wallet_lifecycle.go | 228 ++++++++++++++++++ internal/service/wallet_lifecycle_test.go | 139 +++++++++++ internal/service/webauthn.go | 59 +++++ internal/service/webauthn_lifecycle_test.go | 64 +++++ internal/service/wia.go | 11 +- internal/service/wua_status_claims_test.go | 4 +- internal/storage/memory/wallet_instance.go | 3 + .../storage/memory/wallet_instance_test.go | 35 +++ internal/storage/mongodb/wallet_instance.go | 3 + 22 files changed, 966 insertions(+), 6 deletions(-) create mode 100644 internal/api/instance_handlers.go create mode 100644 internal/api/instance_handlers_test.go create mode 100644 internal/service/wallet_lifecycle.go create mode 100644 internal/service/wallet_lifecycle_test.go create mode 100644 internal/service/webauthn_lifecycle_test.go 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)