diff --git a/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx b/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx index 3b7a72156..f1d57f243 100644 --- a/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx +++ b/apps/docs-website/src/content/docs/guides/operations/identity-login.mdx @@ -5,7 +5,7 @@ description: Configure local accounts, SMTP email flows, external login provider import { Aside, CardGrid, LinkCard, Tabs, TabItem } from "@astrojs/starlight/components"; -Chatto supports local password accounts, email verification and reset flows, external OAuth/OIDC providers, explicit account linking, and owner recovery through verified email addresses. +Chatto supports local password accounts, opt-in passkeys, email verification and reset flows, external OAuth/OIDC providers, explicit account linking, and owner recovery through verified email addresses. ## Choose Your Account Model @@ -16,6 +16,21 @@ Chatto supports local password accounts, email verification and reset flows, ext | Organization SSO | Configure one or more `[[auth.providers]]` entries. | | Mixed community | Allow local accounts and selected external providers. | | Passwordless external login | Ensure users keep at least one linked provider and understand recovery paths. | +| Passkey-first daily login | Enable passkeys and SMTP. Email remains the recovery path while users can omit a password. | + +## Passkeys + +Passkeys are disabled by default. Enable them only after `webserver.url` is the stable public HTTPS URL people use to open Chatto: WebAuthn credentials are scoped to that hostname. `localhost` is accepted for development. + +```toml +[webserver] +url = "https://chat.example.com" + +[auth.passkeys] +enabled = true +``` + +Passkeys accept platform, synced, cross-device, and hardware-key authenticators. Chatto requires a device PIN or biometric verification and does not request device attestation. Users can use a passkey for daily sign-in while retaining a verified email address for password recovery. ## SMTP Email diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx index c63fa40e8..9dc00fd10 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx @@ -782,6 +782,7 @@ Login and registration options exposed before authentication. | `direct_registration_enabled` | `bool` | Whether users can create accounts through the public UI. | | `providers` | repeated [`ProviderMetadata`](#chatto-api-v1-ProviderMetadata) | Configured login providers. | | `authorize_url` | `string` | URL for the legacy authorization flow, when enabled. | +| `passkeys_enabled` | `bool` | Whether this server has enabled WebAuthn passkey login. Older servers omit this field and clients must treat that as unavailable. | diff --git a/apps/docs-website/src/content/docs/reference/environment-variables.mdx b/apps/docs-website/src/content/docs/reference/environment-variables.mdx index ec6b20f7a..e1fd66622 100644 --- a/apps/docs-website/src/content/docs/reference/environment-variables.mdx +++ b/apps/docs-website/src/content/docs/reference/environment-variables.mdx @@ -279,6 +279,10 @@ Only used when `storage_backend` is set to `s3`. See the [S3 Storage guide](/gui Enable direct (email/password) registration. When `false`, the registration page is hidden and the registration API returns `403`. Users can still sign in via configured SSO providers. + + Enable WebAuthn passkeys. Requires a secure public `webserver.url`; `localhost` is allowed for development. The relying-party hostname is derived from that URL. + + Inactivity TTL for bearer auth tokens. Supports durations like `90d`, `2160h`. Successful validation refreshes the TTL; inactive tokens expire automatically. diff --git a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx index 978bb393a..129dbe5e5 100644 --- a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx @@ -4148,6 +4148,7 @@ Login and registration options exposed before authentication. | `direct_registration_enabled` | `bool` | Whether users can create accounts through the public UI. | | `providers` | repeated [`ProviderMetadata`](#chatto-api-v1-ProviderMetadata) | Configured login providers. | | `authorize_url` | `string` | URL for the legacy authorization flow, when enabled. | +| `passkeys_enabled` | `bool` | Whether this server has enabled WebAuthn passkey login. Older servers omit this field and clients must treat that as unavailable. | diff --git a/apps/frontend/src/lib/api-client-tests/server.spec.ts b/apps/frontend/src/lib/api-client-tests/server.spec.ts index eece0355d..59d7ae6bf 100644 --- a/apps/frontend/src/lib/api-client-tests/server.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/server.spec.ts @@ -64,6 +64,7 @@ describe('getPublicServerInfo', () => { version: '9.8.7', authorizeUrl: '/oauth/authorize', directRegistrationEnabled: true, + passkeysEnabled: false, welcomeMessage: 'welcome', description: 'description', iconUrl: 'https://cdn/logo.webp', diff --git a/apps/frontend/src/lib/api-client/server.ts b/apps/frontend/src/lib/api-client/server.ts index bec7fa2db..2c0544005 100644 --- a/apps/frontend/src/lib/api-client/server.ts +++ b/apps/frontend/src/lib/api-client/server.ts @@ -14,6 +14,8 @@ export type PublicServerInfo = { version: string; authorizeUrl: string; directRegistrationEnabled: boolean; + /** Omitted by older server/client fixtures; fetched server responses normalize it to false. */ + passkeysEnabled?: boolean; welcomeMessage: string | null; description: string | null; iconUrl: string | null; @@ -38,6 +40,7 @@ export async function getPublicServerInfo( authorizeUrl: response.login?.authorizeUrl ?? "", directRegistrationEnabled: response.login?.directRegistrationEnabled ?? false, + passkeysEnabled: response.login?.passkeysEnabled ?? false, welcomeMessage: profile.welcomeMessage, description: profile.description, iconUrl: profile.logoUrl, diff --git a/cli/go.mod b/cli/go.mod index accfa1c49..df5029e78 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -31,6 +31,7 @@ require ( github.com/gin-contrib/sessions v1.1.0 github.com/gin-gonic/gin v1.12.0 github.com/go-jose/go-jose/v3 v3.0.5 + github.com/go-webauthn/webauthn v0.17.4 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/gorilla/securecookie v1.1.2 github.com/gorilla/sessions v1.4.0 @@ -107,6 +108,7 @@ require ( github.com/fatih/structtag v1.2.0 // indirect github.com/frostbyte73/core v0.1.1 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gammazero/deque v1.2.1 // indirect github.com/gin-contrib/sse v1.1.1 // indirect @@ -116,6 +118,8 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect @@ -153,6 +157,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.3.3 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/pion/datachannel v1.6.0 // indirect github.com/pion/dtls/v3 v3.1.2 // indirect github.com/pion/ice/v4 v4.2.6 // indirect @@ -182,9 +187,11 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sanity-io/litter v1.5.8 // indirect github.com/sirupsen/logrus v1.9.4 // indirect + github.com/tinylib/msgp v1.6.4 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/wlynxg/anet v0.0.5 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect diff --git a/cli/go.sum b/cli/go.sum index ec6c75087..eebb555cd 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -151,6 +151,8 @@ github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJI github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= @@ -177,6 +179,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= @@ -196,6 +204,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -314,6 +324,8 @@ github.com/otiai10/opengraph/v2 v2.2.0 h1:33L8EVEN1xRKTK+aGOOwU/XSbPYCFaezG0l5gr github.com/otiai10/opengraph/v2 v2.2.0/go.mod h1:tX5Dg72DBqE1dJc0cOKX8RUHbQqJOViu6T+Vlhhqo5U= github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc= github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc= @@ -406,6 +418,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJXP61mNV3/7iuU= github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= @@ -416,6 +430,8 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= github.com/wneessen/go-mail v0.7.3 h1:g3DravXC5SMlVdboFrQA8Jx95A8sOzoBeS5F+vzNRK0= github.com/wneessen/go-mail v0.7.3/go.mod h1:QGhBX0yNbc1J+Mkjcu7z2rpj4B4l+BmDY8gYznPC9sk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= diff --git a/cli/internal/config/config.go b/cli/internal/config/config.go index 148cb9dce..d2c5200d4 100644 --- a/cli/internal/config/config.go +++ b/cli/internal/config/config.go @@ -496,11 +496,39 @@ func IsAllowedAuthProviderType(providerType string) bool { type AuthConfig struct { DirectRegistration *bool `toml:"direct_registration" env:"CHATTO_AUTH_DIRECT_REGISTRATION" comment:"Enable direct (email/password) registration. When false, users can only sign in via SSO providers. Default: true."` + Passkeys PasskeysConfig `toml:"passkeys,commented" comment:"WebAuthn passkey login. Passkeys require a secure webserver.url; enable explicitly after confirming the public URL is stable."` TokenTTL Duration `toml:"token_ttl,commented" env:"CHATTO_AUTH_TOKEN_TTL" comment:"TTL for bearer auth tokens. Supports human-readable durations like '90d', '2160h'. Default: 90d."` EmailOTP EmailOTPConfig `toml:"email_otp,commented" comment:"Email OTP guardrails for registration and email verification."` Providers []AuthProviderConfig `toml:"providers" comment:"External login providers. Configure as repeated [[auth.providers]] tables."` } +// PasskeysConfig controls the WebAuthn passkey feature. The relying-party ID +// is deliberately derived from webserver.url rather than configured separately +// so credentials cannot accidentally be scoped more broadly than the public +// Chatto origin. +type PasskeysConfig struct { + Enabled *bool `toml:"enabled,commented" env:"CHATTO_AUTH_PASSKEYS_ENABLED" comment:"Enable passkey sign-in and account management. Default: false. Requires an HTTPS webserver.url, except localhost development URLs."` +} + +// EnabledOrDefault reports whether passkeys are explicitly enabled. +func (c *PasskeysConfig) EnabledOrDefault() bool { + return c.Enabled != nil && *c.Enabled +} + +// PasskeyRelyingParty returns the exact WebAuthn relying-party ID and origin +// derived from the configured public URL. Callers should use it only after +// Validate has accepted an enabled passkey configuration. +func (c *ChattoConfig) PasskeyRelyingParty() (id string, origin string, ok bool) { + if !c.Auth.Passkeys.EnabledOrDefault() { + return "", "", false + } + u, err := url.Parse(c.Webserver.URL) + if err != nil || u.Hostname() == "" || (u.Scheme != "https" && !isLoopbackHost(u.Hostname())) { + return "", "", false + } + return u.Hostname(), u.Scheme + "://" + u.Host, true +} + // EmailOTPConfig controls registration and email-verification one-time-password guardrails. type EmailOTPConfig struct { ThrottlingEnabled *bool `toml:"throttling_enabled,commented" env:"CHATTO_AUTH_EMAIL_OTP_THROTTLING_ENABLED" comment:"Enable email OTP throttling for registration and email verification. Default: true."` @@ -1012,6 +1040,13 @@ func (c *ChattoConfig) Validate() error { errs = append(errs, err.Error()) } } + if c.Auth.Passkeys.EnabledOrDefault() { + if c.Webserver.URL == "" { + errs = append(errs, "webserver.url is required when passkeys are enabled") + } else if parsed, err := url.Parse(c.Webserver.URL); err != nil || parsed.Hostname() == "" || (parsed.Scheme != "https" && !isLoopbackHost(parsed.Hostname())) { + errs = append(errs, "passkeys require an HTTPS webserver.url (except localhost development URLs)") + } + } if c.NATS.Client.URL != "" { if _, err := url.Parse(c.NATS.Client.URL); err != nil { errs = append(errs, fmt.Sprintf("nats.client.url is invalid: %v", err)) diff --git a/cli/internal/config/config_test.go b/cli/internal/config/config_test.go index 2563bb22a..9de86fa9d 100644 --- a/cli/internal/config/config_test.go +++ b/cli/internal/config/config_test.go @@ -48,6 +48,33 @@ func TestReadConfig_WithoutConfigFile(t *testing.T) { } } +func TestPasskeysRequireSecurePublicURL(t *testing.T) { + enabled := true + for _, tt := range []struct { + name string + url string + wantErr bool + }{ + {name: "missing URL", wantErr: true}, + {name: "plain HTTP public URL", url: "http://chat.example.test", wantErr: true}, + {name: "HTTPS public URL", url: "https://chat.example.test"}, + {name: "localhost development URL", url: "http://localhost:5173"}, + } { + t.Run(tt.name, func(t *testing.T) { + cfg := validTestConfig() + cfg.Auth.Passkeys.Enabled = &enabled + cfg.Webserver.URL = tt.url + err := cfg.Validate() + if tt.wantErr && (err == nil || !strings.Contains(err.Error(), "passkeys")) { + t.Fatalf("Validate() error = %v, want passkeys validation error", err) + } + if !tt.wantErr && err != nil { + t.Fatalf("Validate() error = %v, want nil", err) + } + }) + } +} + func TestReadConfig_WithConfigFile(t *testing.T) { // Create a temp directory with a config file tmpDir := t.TempDir() diff --git a/cli/internal/connectapi/server.go b/cli/internal/connectapi/server.go index 655f86fda..f464dc594 100644 --- a/cli/internal/connectapi/server.go +++ b/cli/internal/connectapi/server.go @@ -28,6 +28,7 @@ func (s *serverDiscoveryService) GetServer(ctx context.Context, _ *connect.Reque Profile: profile, Login: &apiv1.ServerLogin{ DirectRegistrationEnabled: s.api.config.Auth.DirectRegistrationOrDefault(), + PasskeysEnabled: s.api.config.Auth.Passkeys.EnabledOrDefault(), Providers: apiAuthProviders(s.api.config.Auth.PublicProviders()), AuthorizeUrl: "/oauth/authorize", }, diff --git a/cli/internal/core/auth_generation.go b/cli/internal/core/auth_generation.go index 0a6befb52..da0da3eee 100644 --- a/cli/internal/core/auth_generation.go +++ b/cli/internal/core/auth_generation.go @@ -104,6 +104,7 @@ func (c *ChattoCore) waitForUserAuthGenerationCurrent(ctx context.Context, userI if err := c.userModel.waitForUsersCurrent(ctx, "user auth generation", agg.Subject(events.EventUserPasswordHashChanged), agg.Subject(events.EventUserExternalIdentityUnlinked), + agg.Subject(events.EventUserPasskeyUnlinked), agg.Subject(events.EventUserAccountDeleted), ); err != nil { return fmt.Errorf("wait for user auth generation: %w", err) diff --git a/cli/internal/core/core.go b/cli/internal/core/core.go index 370cdbb8a..057007027 100644 --- a/cli/internal/core/core.go +++ b/cli/internal/core/core.go @@ -194,6 +194,12 @@ type ChattoCore struct { // WaitFor from user/account writers. UsersProjector *events.Projector + // Passkeys owns credential-hash lookup and mutable authenticator state. + Passkeys *PasskeyProjection + + // PasskeysProjector runs credential-specific passkey facts. + PasskeysProjector *events.Projector + // ContentKeys holds wrapped per-user DEK epochs used by encrypted // message bodies and durable user PII. ContentKeys *ContentKeyProjection @@ -953,6 +959,9 @@ func NewChattoCore(ctx context.Context, nc *nats.Conn, cfg config.CoreConfig) (* users := newUserProjectionWithDEKResolver(dekResolver) usersProjector := newProjector(users, "users", "Users", users.adminProjectionEstimate) + passkeys := NewPasskeyProjection() + passkeysProjector := newProjector(passkeys, "passkeys", "Passkeys", func() (int64, int64, []ProjectionAdminMetric) { return 0, 0, nil }) + contentKeys := NewContentKeyProjection() contentKeysProjector := newProjector(contentKeys, "content_keys", "Content Keys", contentKeys.adminProjectionEstimate) @@ -1018,6 +1027,8 @@ func NewChattoCore(ctx context.Context, nc *nats.Conn, cfg config.CoreConfig) (* ReactionsProjector: reactionsProjector, Users: users, UsersProjector: usersProjector, + Passkeys: passkeys, + PasskeysProjector: passkeysProjector, ContentKeys: contentKeys, ContentKeysProjector: contentKeysProjector, RBAC: rbac, diff --git a/cli/internal/core/external_identities.go b/cli/internal/core/external_identities.go index 3c3c386a3..07baa635a 100644 --- a/cli/internal/core/external_identities.go +++ b/cli/internal/core/external_identities.go @@ -360,7 +360,7 @@ func (c *ChattoCore) DisconnectExternalIdentity(ctx context.Context, userID, sub if !found { return ErrExternalIdentityNotFound } - if _, hasPassword := c.Users.PasswordHash(userID); !hasPassword && len(identities) <= 1 { + if _, hasPassword := c.Users.PasswordHash(userID); !hasPassword && len(identities) <= 1 && !c.Users.HasVerifiedEmail(userID) && len(c.Users.Passkeys(userID)) < 2 { return ErrExternalIdentityLastMethod } return nil diff --git a/cli/internal/core/passkey_projection.go b/cli/internal/core/passkey_projection.go new file mode 100644 index 000000000..faaa69b69 --- /dev/null +++ b/cli/internal/core/passkey_projection.go @@ -0,0 +1,73 @@ +package core + +import ( + "sync" + + "hmans.de/chatto/internal/events" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +// PasskeyProjection owns credential-hash lookup state. Credential material is +// intentionally not duplicated in UserProjection: passkey assertions update it +// frequently and must not contend with unrelated account mutations. +type PasskeyProjection struct { + events.MemoryProjection + sync.RWMutex + credentials map[string]Passkey +} + +func NewPasskeyProjection() *PasskeyProjection { + return &PasskeyProjection{credentials: make(map[string]Passkey)} +} + +func (p *PasskeyProjection) Subjects() []string { + return []string{events.PasskeySubjectFilter(), events.AggregateEventTypeFilter(events.AggregateUser, events.EventUserAccountDeleted)} +} + +func (p *PasskeyProjection) Apply(event *corev1.Event, _ uint64) error { + if event == nil { + return nil + } + p.Lock() + defer p.Unlock() + switch e := event.GetEvent().(type) { + case *corev1.Event_UserAccountDeleted: + if v := e.UserAccountDeleted; v != nil { + for hash, credential := range p.credentials { + if credential.UserID == v.GetUserId() { + delete(p.credentials, hash) + } + } + } + case *corev1.Event_PasskeyCredentialRegistered: + v := e.PasskeyCredentialRegistered + if v != nil && v.GetCredentialHash() != "" && v.GetUserId() != "" && len(v.GetCredentialId()) > 0 && len(v.GetCredential()) > 0 { + p.credentials[v.GetCredentialHash()] = Passkey{UserID: v.GetUserId(), CredentialHash: v.GetCredentialHash(), CredentialID: append([]byte(nil), v.GetCredentialId()...), Credential: append([]byte(nil), v.GetCredential()...)} + } + case *corev1.Event_PasskeyCredentialUpdated: + v := e.PasskeyCredentialUpdated + if v != nil && len(v.GetCredential()) > 0 { + if existing, ok := p.credentials[v.GetCredentialHash()]; ok { + existing.Credential = append([]byte(nil), v.GetCredential()...) + p.credentials[v.GetCredentialHash()] = existing + } + } + case *corev1.Event_PasskeyCredentialRemoved: + if v := e.PasskeyCredentialRemoved; v != nil { + delete(p.credentials, v.GetCredentialHash()) + } + } + return nil +} + +func (p *PasskeyProjection) Get(credentialHash string) (Passkey, bool) { + p.RLock() + defer p.RUnlock() + credential, ok := p.credentials[credentialHash] + if !ok { + return Passkey{}, false + } + credential.CredentialID = append([]byte(nil), credential.CredentialID...) + credential.Credential = append([]byte(nil), credential.Credential...) + return credential, true +} diff --git a/cli/internal/core/passkeys.go b/cli/internal/core/passkeys.go new file mode 100644 index 000000000..c6636790c --- /dev/null +++ b/cli/internal/core/passkeys.go @@ -0,0 +1,198 @@ +package core + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + + "hmans.de/chatto/internal/events" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +var ( + ErrPasskeyNotFound = errors.New("passkey not found") + ErrPasskeyClaimed = errors.New("passkey is linked to another account") +) + +// Passkey is the durable WebAuthn credential material required by the server. +// It deliberately excludes browser ceremony data, which belongs in runtime state. +type Passkey struct { + UserID string + CredentialHash string + CredentialID []byte + Credential []byte + Label string +} + +func passkeyHash(credentialID []byte) string { + sum := sha256.Sum256(credentialID) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func (c *ChattoCore) PasskeysForUser(ctx context.Context, userID string) ([]Passkey, error) { + if err := c.userModel.waitForUsersCurrent(ctx, "passkeys", events.UserAggregate(userID).AllEventsFilter()); err != nil { + return nil, err + } + if _, ok := c.Users.Get(userID); !ok { + return nil, ErrNotFound + } + return c.Users.Passkeys(userID), nil +} + +func (c *ChattoCore) LinkPasskey(ctx context.Context, userID string, credentialID, credential []byte, label string) (Passkey, error) { + userID, label = strings.TrimSpace(userID), strings.TrimSpace(label) + if userID == "" || len(credentialID) == 0 || len(credential) == 0 { + return Passkey{}, ErrInvalidArgument + } + if label == "" { + label = "Passkey" + } + if len(label) > 64 { + return Passkey{}, ErrInvalidArgument + } + hash := passkeyHash(credentialID) + userEvent := newEvent(userID, &corev1.Event{Event: &corev1.Event_UserPasskeyLinked{UserPasskeyLinked: &corev1.UserPasskeyLinkedEvent{UserId: userID, CredentialHash: hash, Label: label}}}) + credentialEvent := newEvent(userID, &corev1.Event{Event: &corev1.Event_PasskeyCredentialRegistered{PasskeyCredentialRegistered: &corev1.PasskeyCredentialRegisteredEvent{UserId: userID, CredentialHash: hash, CredentialId: credentialID, Credential: credential}}}) + passkeyAgg := events.PasskeyAggregate(hash) + for attempt := 0; attempt < maxUserMutationRetries; attempt++ { + userAgg := events.UserAggregate(userID) + userSeq, err := c.EventPublisher.LastSubjectSeq(ctx, userAgg.AllEventsFilter()) + if err != nil { + return Passkey{}, fmt.Errorf("read user passkey OCC: %w", err) + } + credentialSeq, err := c.EventPublisher.LastSubjectSeq(ctx, passkeyAgg.AllEventsFilter()) + if err != nil { + return Passkey{}, fmt.Errorf("read credential passkey OCC: %w", err) + } + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(userAgg.AllEventsFilter(), userSeq)); err != nil { + return Passkey{}, err + } + if _, ok := c.Users.Get(userID); !ok { + return Passkey{}, ErrNotFound + } + if err := c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(passkeyAgg.AllEventsFilter(), credentialSeq)); err != nil { + return Passkey{}, err + } + if existing, ok := c.Passkeys.Get(hash); ok { + if existing.UserID != userID { + return Passkey{}, ErrPasskeyClaimed + } + return existing, nil + } + entries := []events.BatchEntry{{Subject: userAgg.Subject(events.EventUserPasskeyLinked), Event: userEvent, HasOCC: true, ExpectedSeq: userSeq, FilterSubject: userAgg.AllEventsFilter()}, {Subject: passkeyAgg.Subject(events.EventPasskeyCredentialRegistered), Event: credentialEvent, HasOCC: true, ExpectedSeq: credentialSeq, FilterSubject: passkeyAgg.AllEventsFilter()}} + seqs, err := c.EventPublisher.AppendBatch(ctx, entries) + if err == nil { + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(entries[0].Subject, seqs[0])); err != nil { + return Passkey{}, err + } + if err := c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(entries[1].Subject, seqs[1])); err != nil { + return Passkey{}, err + } + return Passkey{UserID: userID, CredentialHash: hash, CredentialID: append([]byte(nil), credentialID...), Credential: append([]byte(nil), credential...), Label: label}, nil + } + if !errors.Is(err, events.ErrConflict) { + return Passkey{}, err + } + } + return Passkey{}, fmt.Errorf("passkey link OCC retry exhausted: %w", events.ErrConflict) +} + +func (c *ChattoCore) UnlinkPasskey(ctx context.Context, userID, credentialHash string) error { + credentialHash = strings.TrimSpace(credentialHash) + if userID == "" || credentialHash == "" { + return ErrInvalidArgument + } + userEvent := newEvent(userID, &corev1.Event{Event: &corev1.Event_UserPasskeyUnlinked{UserPasskeyUnlinked: &corev1.UserPasskeyUnlinkedEvent{UserId: userID, CredentialHash: credentialHash}}}) + credentialEvent := newEvent(userID, &corev1.Event{Event: &corev1.Event_PasskeyCredentialRemoved{PasskeyCredentialRemoved: &corev1.PasskeyCredentialRemovedEvent{CredentialHash: credentialHash}}}) + userAgg, passkeyAgg := events.UserAggregate(userID), events.PasskeyAggregate(credentialHash) + for attempt := 0; attempt < maxUserMutationRetries; attempt++ { + userSeq, err := c.EventPublisher.LastSubjectSeq(ctx, userAgg.AllEventsFilter()) + if err != nil { + return err + } + credentialSeq, err := c.EventPublisher.LastSubjectSeq(ctx, passkeyAgg.AllEventsFilter()) + if err != nil { + return err + } + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(userAgg.AllEventsFilter(), userSeq)); err != nil { + return err + } + if err := c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(passkeyAgg.AllEventsFilter(), credentialSeq)); err != nil { + return err + } + passkeys := c.Users.Passkeys(userID) + found := false + for _, passkey := range passkeys { + if passkey.CredentialHash == credentialHash { + found = true + break + } + } + if !found { + return fmt.Errorf("%w: %s", ErrPasskeyNotFound, credentialHash) + } + if _, hasPassword := c.Users.PasswordHash(userID); !hasPassword && len(c.Users.ExternalIdentities(userID)) == 0 && !c.Users.HasVerifiedEmail(userID) && len(passkeys) <= 2 { + return ErrExternalIdentityLastMethod + } + entries := []events.BatchEntry{{Subject: userAgg.Subject(events.EventUserPasskeyUnlinked), Event: userEvent, HasOCC: true, ExpectedSeq: userSeq, FilterSubject: userAgg.AllEventsFilter()}, {Subject: passkeyAgg.Subject(events.EventPasskeyCredentialRemoved), Event: credentialEvent, HasOCC: true, ExpectedSeq: credentialSeq, FilterSubject: passkeyAgg.AllEventsFilter()}} + seqs, err := c.EventPublisher.AppendBatch(ctx, entries) + if err == nil { + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(entries[0].Subject, seqs[0])); err != nil { + return err + } + return c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(entries[1].Subject, seqs[1])) + } + if !errors.Is(err, events.ErrConflict) { + return err + } + } + return fmt.Errorf("passkey unlink OCC retry exhausted: %w", events.ErrConflict) +} + +// recordUserDeletionAndReleasePasskeys atomically tombstones the account and +// all of its credential aggregates. This releases credential hashes without +// relying on eventual cross-projection cleanup after deletion. +func (c *ChattoCore) recordUserDeletionAndReleasePasskeys(ctx context.Context, userID string, deletedEvent *corev1.Event) error { + userAgg := events.UserAggregate(userID) + for attempt := 0; attempt < maxUserMutationRetries; attempt++ { + userSeq, err := c.EventPublisher.LastSubjectSeq(ctx, userAgg.AllEventsFilter()) + if err != nil { + return err + } + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(userAgg.AllEventsFilter(), userSeq)); err != nil { + return err + } + links := c.Users.Passkeys(userID) + entries := []events.BatchEntry{{Subject: userAgg.Subject(events.EventUserAccountDeleted), Event: deletedEvent, HasOCC: true, ExpectedSeq: userSeq, FilterSubject: userAgg.AllEventsFilter()}} + for _, link := range links { + agg := events.PasskeyAggregate(link.CredentialHash) + seq, err := c.EventPublisher.LastSubjectSeq(ctx, agg.AllEventsFilter()) + if err != nil { + return err + } + if err := c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(agg.AllEventsFilter(), seq)); err != nil { + return err + } + entries = append(entries, events.BatchEntry{Subject: agg.Subject(events.EventPasskeyCredentialRemoved), Event: newEvent(userID, &corev1.Event{Event: &corev1.Event_PasskeyCredentialRemoved{PasskeyCredentialRemoved: &corev1.PasskeyCredentialRemovedEvent{CredentialHash: link.CredentialHash}}}), HasOCC: true, ExpectedSeq: seq, FilterSubject: agg.AllEventsFilter()}) + } + seqs, err := c.EventPublisher.AppendBatch(ctx, entries) + if err == nil { + if err := c.userModel.waitForUsers(ctx, events.SubjectPosition(entries[0].Subject, seqs[0])); err != nil { + return err + } + for i := 1; i < len(entries); i++ { + if err := c.PasskeysProjector.WaitFor(ctx, events.SubjectPosition(entries[i].Subject, seqs[i])); err != nil { + return err + } + } + return nil + } + if !errors.Is(err, events.ErrConflict) { + return err + } + } + return fmt.Errorf("user deletion passkey OCC retry exhausted: %w", events.ErrConflict) +} diff --git a/cli/internal/core/passkeys_test.go b/cli/internal/core/passkeys_test.go new file mode 100644 index 000000000..a5e2837ff --- /dev/null +++ b/cli/internal/core/passkeys_test.go @@ -0,0 +1,76 @@ +package core + +import ( + "errors" + "testing" +) + +func TestChattoCore_PasskeysAreDurableAndUniquelyLinked(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + first, err := core.CreateUser(ctx, SystemActorID, "passkey-first", "Passkey First", "password123") + if err != nil { + t.Fatalf("CreateUser first: %v", err) + } + second, err := core.CreateUser(ctx, SystemActorID, "passkey-second", "Passkey Second", "password123") + if err != nil { + t.Fatalf("CreateUser second: %v", err) + } + + credentialID := []byte("credential-id") + if _, err := core.LinkPasskey(ctx, first.GetId(), credentialID, []byte("serialized-credential"), "Laptop"); err != nil { + t.Fatalf("LinkPasskey: %v", err) + } + passkeys, err := core.PasskeysForUser(ctx, first.GetId()) + if err != nil { + t.Fatalf("PasskeysForUser: %v", err) + } + if len(passkeys) != 1 || passkeys[0].Label != "Laptop" || passkeys[0].CredentialHash == "" { + t.Fatalf("passkeys = %#v", passkeys) + } + if _, err := core.LinkPasskey(ctx, second.GetId(), credentialID, []byte("serialized-credential"), "Duplicate"); !errors.Is(err, ErrPasskeyClaimed) { + t.Fatalf("LinkPasskey duplicate error = %v, want ErrPasskeyClaimed", err) + } + if _, err := core.LinkPasskey(ctx, "Umissing", []byte("orphan"), []byte("serialized-credential"), "Orphan"); !errors.Is(err, ErrNotFound) { + t.Fatalf("LinkPasskey missing user error = %v, want ErrNotFound", err) + } + if err := core.UnlinkPasskey(ctx, first.GetId(), passkeys[0].CredentialHash); err != nil { + t.Fatalf("UnlinkPasskey: %v", err) + } + passkeys, err = core.PasskeysForUser(ctx, first.GetId()) + if err != nil { + t.Fatalf("PasskeysForUser after unlink: %v", err) + } + if len(passkeys) != 0 { + t.Fatalf("passkeys after unlink = %#v, want none", passkeys) + } + if _, ok := core.Passkeys.Get(passkeyHash(credentialID)); ok { + t.Fatal("credential remains in passkey projection after unlink") + } +} + +func TestChattoCore_DeleteUserReleasesPasskeyCredential(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + first, err := core.CreateUser(ctx, SystemActorID, "passkey-delete-first", "Passkey Delete First", "password123") + if err != nil { + t.Fatalf("CreateUser first: %v", err) + } + second, err := core.CreateUser(ctx, SystemActorID, "passkey-delete-second", "Passkey Delete Second", "password123") + if err != nil { + t.Fatalf("CreateUser second: %v", err) + } + credentialID := []byte("deleted-user-credential") + if _, err := core.LinkPasskey(ctx, first.GetId(), credentialID, []byte("credential"), "Laptop"); err != nil { + t.Fatalf("LinkPasskey: %v", err) + } + if err := core.DeleteUser(ctx, SystemActorID, first.GetId()); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if _, ok := core.Passkeys.Get(passkeyHash(credentialID)); ok { + t.Fatal("deleted user's credential remains claimed") + } + if _, err := core.LinkPasskey(ctx, second.GetId(), credentialID, []byte("credential"), "Replacement"); err != nil { + t.Fatalf("LinkPasskey replacement: %v", err) + } +} diff --git a/cli/internal/core/projection_registry_test.go b/cli/internal/core/projection_registry_test.go index 8dac1b2a8..ebd5290e0 100644 --- a/cli/internal/core/projection_registry_test.go +++ b/cli/internal/core/projection_registry_test.go @@ -11,8 +11,8 @@ var registryKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) func TestProjectionRegistryDrivesAdminStates(t *testing.T) { core, _ := setupTestCore(t) - if len(core.projections) != 12 { - t.Fatalf("registered projections = %d, want 12", len(core.projections)) + if len(core.projections) != 13 { + t.Fatalf("registered projections = %d, want 13", len(core.projections)) } registryNames := make(map[string]struct{}, len(core.projections)) @@ -64,6 +64,9 @@ func TestProjectionRegistryDrivesAdminStates(t *testing.T) { if _, ok := registryNames["Mentionables"]; !ok { t.Fatal("Mentionables projection is not registered") } + if _, ok := registryNames["Passkeys"]; !ok { + t.Fatal("Passkeys projection is not registered") + } states, err := core.ProjectionAdminStates(testContext(t)) if err != nil { diff --git a/cli/internal/core/projection_subjects_test.go b/cli/internal/core/projection_subjects_test.go index d0bfb6509..2c38a5155 100644 --- a/cli/internal/core/projection_subjects_test.go +++ b/cli/internal/core/projection_subjects_test.go @@ -95,6 +95,14 @@ func TestProjectionSubjectPolicy(t *testing.T) { got: NewMentionablesProjection(nil, nil).Subjects(), want: []string{events.EventSubjectFilter()}, }, + { + name: "passkeys use credential aggregates with account-deletion cleanup", + got: NewPasskeyProjection().Subjects(), + want: []string{ + events.PasskeySubjectFilter(), + events.AggregateEventTypeFilter(events.AggregateUser, events.EventUserAccountDeleted), + }, + }, } for _, tc := range cases { diff --git a/cli/internal/core/user_projection.go b/cli/internal/core/user_projection.go index 188c555a2..86ec26827 100644 --- a/cli/internal/core/user_projection.go +++ b/cli/internal/core/user_projection.go @@ -39,6 +39,7 @@ type projectedUser struct { authGeneration uint64 verifiedEmail map[string]VerifiedEmail externalIdentities map[string]ExternalIdentity + passkeys map[string]Passkey oauthConsent map[string]struct{} preferences *corev1.ServerUserPreferences loginChanged time.Time @@ -104,6 +105,10 @@ func (p *UserProjection) Apply(event *corev1.Event, seq uint64) error { p.applyExternalIdentityLinked(e.UserExternalIdentityLinked) case *corev1.Event_UserExternalIdentityUnlinked: p.applyExternalIdentityUnlinked(e.UserExternalIdentityUnlinked, seq) + case *corev1.Event_UserPasskeyLinked: + p.applyPasskeyLinked(e.UserPasskeyLinked) + case *corev1.Event_UserPasskeyUnlinked: + p.applyPasskeyUnlinked(e.UserPasskeyUnlinked, seq) case *corev1.Event_UserServerPreferencesChanged: p.applyServerPreferencesChanged(e.UserServerPreferencesChanged) case *corev1.Event_UserLoginCooldownStarted: @@ -142,12 +147,32 @@ func (p *UserProjection) ensureUserLocked(userID string) *projectedUser { if u.externalIdentities == nil { u.externalIdentities = make(map[string]ExternalIdentity) } + if u.passkeys == nil { + u.passkeys = make(map[string]Passkey) + } if u.oauthConsent == nil { u.oauthConsent = make(map[string]struct{}) } return u } +func (p *UserProjection) applyPasskeyLinked(e *corev1.UserPasskeyLinkedEvent) { + if e == nil || e.GetUserId() == "" || e.GetCredentialHash() == "" { + return + } + u := p.ensureUserLocked(e.GetUserId()) + u.passkeys[e.GetCredentialHash()] = Passkey{CredentialHash: e.GetCredentialHash(), CredentialID: append([]byte(nil), e.GetCredentialId()...), Credential: append([]byte(nil), e.GetCredential()...), Label: e.GetLabel()} +} + +func (p *UserProjection) applyPasskeyUnlinked(e *corev1.UserPasskeyUnlinkedEvent, seq uint64) { + if e == nil || e.GetUserId() == "" || e.GetCredentialHash() == "" { + return + } + u := p.ensureUserLocked(e.GetUserId()) + delete(u.passkeys, e.GetCredentialHash()) + u.authGeneration = seq +} + func (p *UserProjection) applyDEKGenerated(e *corev1.UserDEKGeneratedEvent) { if e == nil || e.GetUserId() == "" || e.GetEpoch() <= 0 || e.GetContentKeyRef() == "" { return @@ -626,6 +651,23 @@ func (p *UserProjection) ExternalIdentities(userID string) []ExternalIdentity { return identities } +// Passkeys returns a defensive snapshot of one account's linked credentials. +func (p *UserProjection) Passkeys(userID string) []Passkey { + p.RLock() + defer p.RUnlock() + u := p.users[userID] + if u == nil || u.deleted { + return nil + } + result := make([]Passkey, 0, len(u.passkeys)) + for _, passkey := range u.passkeys { + passkey.CredentialID = append([]byte(nil), passkey.CredentialID...) + passkey.Credential = append([]byte(nil), passkey.Credential...) + result = append(result, passkey) + } + return result +} + func (p *UserProjection) LoginExists(login string) bool { p.RLock() defer p.RUnlock() @@ -770,6 +812,19 @@ func (p *UserProjection) VerifiedUserIDs() []string { return out } +// UserIDs returns stable IDs for all non-deleted projected users. +func (p *UserProjection) UserIDs() []string { + p.RLock() + defer p.RUnlock() + result := make([]string, 0, len(p.users)) + for userID, user := range p.users { + if user != nil && !user.deleted { + result = append(result, userID) + } + } + return result +} + func (p *UserProjection) VerifiedAccountIDs() []string { p.RLock() defer p.RUnlock() diff --git a/cli/internal/core/users.go b/cli/internal/core/users.go index 52e12ac09..39d4d9eec 100644 --- a/cli/internal/core/users.go +++ b/cli/internal/core/users.go @@ -1374,7 +1374,7 @@ func (c *ChattoCore) DeleteUser(ctx context.Context, actorID, userID string) err deletedEvent := newEvent(actorID, &corev1.Event{Event: &corev1.Event_UserAccountDeleted{ UserAccountDeleted: &corev1.UserAccountDeletedEvent{UserId: userID}, }}) - if _, err := c.appendUserEvent(ctx, userID, deletedEvent, "", nil); err != nil { + if err := c.recordUserDeletionAndReleasePasskeys(ctx, userID, deletedEvent); err != nil { return fmt.Errorf("failed to mark user deleted: %w", err) } if _, err := c.RevokeRuntimeCredentialsForUser(ctx, userID, "account_deleted"); err != nil { diff --git a/cli/internal/events/subjects.go b/cli/internal/events/subjects.go index c7cc8b815..98f74bd56 100644 --- a/cli/internal/events/subjects.go +++ b/cli/internal/events/subjects.go @@ -21,14 +21,15 @@ const ( // Aggregate type segments. Stable identifiers; once written, never renamed. const ( - AggregateRoom = "room" - AggregateConfig = "config" - AggregateGroup = "group" - AggregateLayout = "layout" - AggregateUser = "user" - AggregateAsset = "asset" - AggregateRBAC = "rbac" - AggregateAuth = "auth" + AggregateRoom = "room" + AggregateConfig = "config" + AggregateGroup = "group" + AggregateLayout = "layout" + AggregateUser = "user" + AggregateAsset = "asset" + AggregateRBAC = "rbac" + AggregateAuth = "auth" + AggregatePasskey = "passkey" ) // ConfigSingletonID is the sentinel aggregate ID for server-wide config @@ -146,6 +147,11 @@ const ( EventUserOIDCSubjectLinked = "oidc_subject_linked" EventUserExternalIdentityLinked = "external_identity_linked" EventUserExternalIdentityUnlinked = "external_identity_unlinked" + EventUserPasskeyLinked = "passkey_linked" + EventUserPasskeyUnlinked = "passkey_unlinked" + EventPasskeyCredentialRegistered = "credential_registered" + EventPasskeyCredentialUpdated = "credential_updated" + EventPasskeyCredentialRemoved = "credential_removed" EventUserServerPreferencesChanged = "server_preferences_changed" EventUserLoginCooldownStarted = "login_cooldown_started" EventUserLoginCooldownCleared = "login_cooldown_cleared" @@ -339,6 +345,16 @@ func EventTypeOf(e *corev1.Event) string { return EventUserExternalIdentityLinked case *corev1.Event_UserExternalIdentityUnlinked: return EventUserExternalIdentityUnlinked + case *corev1.Event_UserPasskeyLinked: + return EventUserPasskeyLinked + case *corev1.Event_UserPasskeyUnlinked: + return EventUserPasskeyUnlinked + case *corev1.Event_PasskeyCredentialRegistered: + return EventPasskeyCredentialRegistered + case *corev1.Event_PasskeyCredentialUpdated: + return EventPasskeyCredentialUpdated + case *corev1.Event_PasskeyCredentialRemoved: + return EventPasskeyCredentialRemoved case *corev1.Event_UserServerPreferencesChanged: return EventUserServerPreferencesChanged case *corev1.Event_UserLoginCooldownStarted: @@ -525,6 +541,11 @@ func AuthAggregate() Aggregate { return Aggregate{Type: AggregateAuth, ID: AuthServerID} } +// PasskeyAggregate owns one WebAuthn credential, keyed by its opaque hash. +func PasskeyAggregate(credentialHash string) Aggregate { + return Aggregate{Type: AggregatePasskey, ID: credentialHash} +} + // EventSubjectFilter returns the wildcard filter matching every event in the // EVT stream. Use sparingly: most invariants should OCC against a narrower // aggregate namespace, but cross-aggregate invariants may need the stream-wide @@ -572,6 +593,9 @@ func RBACSubjectFilter() string { return SubjectRoot + AggregateRBAC + ".>" } // Pattern: evt.auth.> func AuthSubjectFilter() string { return SubjectRoot + AggregateAuth + ".>" } +// PasskeySubjectFilter returns all credential-specific passkey facts. +func PasskeySubjectFilter() string { return SubjectRoot + AggregatePasskey + ".>" } + // AggregateEventTypeFilter returns a cross-aggregate, event-type-narrow // filter — every event of the given type across every aggregate instance. // Used by projections that only care about a subset of event types and don't diff --git a/cli/internal/passkeys/webauthn.go b/cli/internal/passkeys/webauthn.go new file mode 100644 index 000000000..04976b552 --- /dev/null +++ b/cli/internal/passkeys/webauthn.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 ChattoCorp GmbH +// SPDX-License-Identifier: AGPL-3.0-or-later + +// Package passkeys centralizes WebAuthn relying-party construction. Credential +// persistence and ceremony lifecycle stay in core services; this package keeps +// security-sensitive RP policy identical for every caller. +package passkeys + +import ( + "fmt" + + "github.com/go-webauthn/webauthn/protocol" + "github.com/go-webauthn/webauthn/webauthn" + "hmans.de/chatto/internal/config" +) + +// New creates Chatto's WebAuthn relying-party verifier from the validated +// public server URL. It accepts any authenticator attachment, requires a local +// user-verification gesture, creates discoverable credentials, and asks no +// authenticator to reveal attestation. +func New(cfg config.ChattoConfig, displayName string) (*webauthn.WebAuthn, error) { + rpID, origin, ok := cfg.PasskeyRelyingParty() + if !ok { + return nil, fmt.Errorf("passkeys are disabled or have no valid public relying-party URL") + } + if displayName == "" { + displayName = "Chatto" + } + return webauthn.New(&webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + RPOrigins: []string{origin}, + AttestationPreference: protocol.PreferNoAttestation, + AuthenticatorSelection: protocol.AuthenticatorSelection{ + ResidentKey: protocol.ResidentKeyRequirementRequired, + UserVerification: protocol.VerificationRequired, + }, + }) +} diff --git a/cli/internal/passkeys/webauthn_test.go b/cli/internal/passkeys/webauthn_test.go new file mode 100644 index 000000000..09864ad33 --- /dev/null +++ b/cli/internal/passkeys/webauthn_test.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2026 ChattoCorp GmbH +// SPDX-License-Identifier: AGPL-3.0-or-later + +package passkeys + +import ( + "testing" + + "hmans.de/chatto/internal/config" +) + +func TestNewDerivesExactRelyingPartyFromPublicURL(t *testing.T) { + enabled := true + cfg := config.ChattoConfig{ + Webserver: config.WebserverConfig{URL: "https://chat.example.test:8443"}, + Auth: config.AuthConfig{Passkeys: config.PasskeysConfig{Enabled: &enabled}}, + } + wa, err := New(cfg, "Example Chat") + if err != nil { + t.Fatalf("New() error = %v", err) + } + if wa.Config.RPID != "chat.example.test" { + t.Fatalf("RPID = %q, want chat.example.test", wa.Config.RPID) + } + if got := wa.Config.RPOrigins; len(got) != 1 || got[0] != "https://chat.example.test:8443" { + t.Fatalf("RPOrigins = %#v", got) + } +} + +func TestNewRejectsInsecurePublicOriginWithoutPriorConfigValidation(t *testing.T) { + enabled := true + cfg := config.ChattoConfig{ + Webserver: config.WebserverConfig{URL: "http://chat.example.test"}, + Auth: config.AuthConfig{Passkeys: config.PasskeysConfig{Enabled: &enabled}}, + } + if _, err := New(cfg, "Example Chat"); err == nil { + t.Fatal("New() error = nil, want insecure public origin rejection") + } +} diff --git a/cli/internal/pb/chatto/api/v1/server.pb.go b/cli/internal/pb/chatto/api/v1/server.pb.go index 39d534732..de07d5daa 100644 --- a/cli/internal/pb/chatto/api/v1/server.pb.go +++ b/cli/internal/pb/chatto/api/v1/server.pb.go @@ -120,9 +120,12 @@ type ServerLogin struct { // Configured login providers. Providers []*ProviderMetadata `protobuf:"bytes,2,rep,name=providers,proto3" json:"providers,omitempty"` // URL for the legacy authorization flow, when enabled. - AuthorizeUrl string `protobuf:"bytes,3,opt,name=authorize_url,json=authorizeUrl,proto3" json:"authorize_url,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + AuthorizeUrl string `protobuf:"bytes,3,opt,name=authorize_url,json=authorizeUrl,proto3" json:"authorize_url,omitempty"` + // Whether this server has enabled WebAuthn passkey login. Older servers omit + // this field and clients must treat that as unavailable. + PasskeysEnabled bool `protobuf:"varint,4,opt,name=passkeys_enabled,json=passkeysEnabled,proto3" json:"passkeys_enabled,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ServerLogin) Reset() { @@ -176,6 +179,13 @@ func (x *ServerLogin) GetAuthorizeUrl() string { return "" } +func (x *ServerLogin) GetPasskeysEnabled() bool { + if x != nil { + return x.PasskeysEnabled + } + return false +} + var File_chatto_api_v1_server_proto protoreflect.FileDescriptor const file_chatto_api_v1_server_proto_rawDesc = "" + @@ -192,11 +202,12 @@ const file_chatto_api_v1_server_proto_rawDesc = "" + "\t_logo_urlB\r\n" + "\v_banner_urlB\x12\n" + "\x10_welcome_messageB\x0e\n" + - "\f_description\"\xb1\x01\n" + + "\f_description\"\xdc\x01\n" + "\vServerLogin\x12>\n" + "\x1bdirect_registration_enabled\x18\x01 \x01(\bR\x19directRegistrationEnabled\x12=\n" + "\tproviders\x18\x02 \x03(\v2\x1f.chatto.api.v1.ProviderMetadataR\tproviders\x12#\n" + - "\rauthorize_url\x18\x03 \x01(\tR\fauthorizeUrlB\xa7\x01\n" + + "\rauthorize_url\x18\x03 \x01(\tR\fauthorizeUrl\x12)\n" + + "\x10passkeys_enabled\x18\x04 \x01(\bR\x0fpasskeysEnabledB\xa7\x01\n" + "\x11com.chatto.api.v1B\vServerProtoP\x01Z/hmans.de/chatto/internal/pb/chatto/api/v1;apiv1\xa2\x02\x03CAX\xaa\x02\rChatto.Api.V1\xca\x02\rChatto\\Api\\V1\xe2\x02\x19Chatto\\Api\\V1\\GPBMetadata\xea\x02\x0fChatto::Api::V1b\x06proto3" var ( diff --git a/cli/internal/pb/chatto/core/v1/event.pb.go b/cli/internal/pb/chatto/core/v1/event.pb.go index fabdfafdf..ba0dff31c 100644 --- a/cli/internal/pb/chatto/core/v1/event.pb.go +++ b/cli/internal/pb/chatto/core/v1/event.pb.go @@ -127,6 +127,11 @@ type Event struct { // *Event_UserCustomStatusSet // *Event_UserCustomStatusCleared // *Event_UserExternalIdentityUnlinked + // *Event_UserPasskeyLinked + // *Event_UserPasskeyUnlinked + // *Event_PasskeyCredentialRegistered + // *Event_PasskeyCredentialUpdated + // *Event_PasskeyCredentialRemoved // *Event_RbacRoleCreated // *Event_RbacRoleDisplayNameChanged // *Event_RbacRoleDescriptionChanged @@ -861,6 +866,51 @@ func (x *Event) GetUserExternalIdentityUnlinked() *UserExternalIdentityUnlinkedE return nil } +func (x *Event) GetUserPasskeyLinked() *UserPasskeyLinkedEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserPasskeyLinked); ok { + return x.UserPasskeyLinked + } + } + return nil +} + +func (x *Event) GetUserPasskeyUnlinked() *UserPasskeyUnlinkedEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserPasskeyUnlinked); ok { + return x.UserPasskeyUnlinked + } + } + return nil +} + +func (x *Event) GetPasskeyCredentialRegistered() *PasskeyCredentialRegisteredEvent { + if x != nil { + if x, ok := x.Event.(*Event_PasskeyCredentialRegistered); ok { + return x.PasskeyCredentialRegistered + } + } + return nil +} + +func (x *Event) GetPasskeyCredentialUpdated() *PasskeyCredentialUpdatedEvent { + if x != nil { + if x, ok := x.Event.(*Event_PasskeyCredentialUpdated); ok { + return x.PasskeyCredentialUpdated + } + } + return nil +} + +func (x *Event) GetPasskeyCredentialRemoved() *PasskeyCredentialRemovedEvent { + if x != nil { + if x, ok := x.Event.(*Event_PasskeyCredentialRemoved); ok { + return x.PasskeyCredentialRemoved + } + } + return nil +} + func (x *Event) GetRbacRoleCreated() *RbacRoleCreatedEvent { if x != nil { if x, ok := x.Event.(*Event_RbacRoleCreated); ok { @@ -1466,6 +1516,26 @@ type Event_UserExternalIdentityUnlinked struct { UserExternalIdentityUnlinked *UserExternalIdentityUnlinkedEvent `protobuf:"bytes,717,opt,name=user_external_identity_unlinked,json=userExternalIdentityUnlinked,proto3,oneof"` } +type Event_UserPasskeyLinked struct { + UserPasskeyLinked *UserPasskeyLinkedEvent `protobuf:"bytes,718,opt,name=user_passkey_linked,json=userPasskeyLinked,proto3,oneof"` +} + +type Event_UserPasskeyUnlinked struct { + UserPasskeyUnlinked *UserPasskeyUnlinkedEvent `protobuf:"bytes,719,opt,name=user_passkey_unlinked,json=userPasskeyUnlinked,proto3,oneof"` +} + +type Event_PasskeyCredentialRegistered struct { + PasskeyCredentialRegistered *PasskeyCredentialRegisteredEvent `protobuf:"bytes,720,opt,name=passkey_credential_registered,json=passkeyCredentialRegistered,proto3,oneof"` +} + +type Event_PasskeyCredentialUpdated struct { + PasskeyCredentialUpdated *PasskeyCredentialUpdatedEvent `protobuf:"bytes,721,opt,name=passkey_credential_updated,json=passkeyCredentialUpdated,proto3,oneof"` +} + +type Event_PasskeyCredentialRemoved struct { + PasskeyCredentialRemoved *PasskeyCredentialRemovedEvent `protobuf:"bytes,722,opt,name=passkey_credential_removed,json=passkeyCredentialRemoved,proto3,oneof"` +} + type Event_RbacRoleCreated struct { // ----- RBAC (800-839, durable, evt.rbac.>) ----- RbacRoleCreated *RbacRoleCreatedEvent `protobuf:"bytes,800,opt,name=rbac_role_created,json=rbacRoleCreated,proto3,oneof"` @@ -1746,6 +1816,16 @@ func (*Event_UserCustomStatusCleared) isEvent_Event() {} func (*Event_UserExternalIdentityUnlinked) isEvent_Event() {} +func (*Event_UserPasskeyLinked) isEvent_Event() {} + +func (*Event_UserPasskeyUnlinked) isEvent_Event() {} + +func (*Event_PasskeyCredentialRegistered) isEvent_Event() {} + +func (*Event_PasskeyCredentialUpdated) isEvent_Event() {} + +func (*Event_PasskeyCredentialRemoved) isEvent_Event() {} + func (*Event_RbacRoleCreated) isEvent_Event() {} func (*Event_RbacRoleDisplayNameChanged) isEvent_Event() {} @@ -1814,7 +1894,7 @@ var File_chatto_core_v1_event_proto protoreflect.FileDescriptor const file_chatto_core_v1_event_proto_rawDesc = "" + "\n" + - "\x1achatto/core/v1/event.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/auth_events.proto\x1a!chatto/core/v1/asset_events.proto\x1a#chatto/core/v1/message_events.proto\x1a&chatto/core/v1/moderation_events.proto\x1a chatto/core/v1/rbac_events.proto\x1a$chatto/core/v1/reaction_events.proto\x1a chatto/core/v1/room_events.proto\x1a&chatto/core/v1/room_group_events.proto\x1a\"chatto/core/v1/config_events.proto\x1a\"chatto/core/v1/thread_events.proto\x1a chatto/core/v1/user_events.proto\"\xa0U\n" + + "\x1achatto/core/v1/event.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/auth_events.proto\x1a!chatto/core/v1/asset_events.proto\x1a#chatto/core/v1/message_events.proto\x1a&chatto/core/v1/moderation_events.proto\x1a chatto/core/v1/rbac_events.proto\x1a$chatto/core/v1/reaction_events.proto\x1a chatto/core/v1/room_events.proto\x1a&chatto/core/v1/room_group_events.proto\x1a\"chatto/core/v1/config_events.proto\x1a\"chatto/core/v1/thread_events.proto\x1a chatto/core/v1/user_events.proto\"\xb5Y\n" + "\x05Event\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -1890,7 +1970,12 @@ const file_chatto_core_v1_event_proto_rawDesc = "" + "\x1duser_external_identity_linked\x18\xca\x05 \x01(\v2/.chatto.core.v1.UserExternalIdentityLinkedEventH\x00R\x1auserExternalIdentityLinked\x12`\n" + "\x16user_custom_status_set\x18\xcb\x05 \x01(\v2(.chatto.core.v1.UserCustomStatusSetEventH\x00R\x13userCustomStatusSet\x12l\n" + "\x1auser_custom_status_cleared\x18\xcc\x05 \x01(\v2,.chatto.core.v1.UserCustomStatusClearedEventH\x00R\x17userCustomStatusCleared\x12{\n" + - "\x1fuser_external_identity_unlinked\x18\xcd\x05 \x01(\v21.chatto.core.v1.UserExternalIdentityUnlinkedEventH\x00R\x1cuserExternalIdentityUnlinked\x12S\n" + + "\x1fuser_external_identity_unlinked\x18\xcd\x05 \x01(\v21.chatto.core.v1.UserExternalIdentityUnlinkedEventH\x00R\x1cuserExternalIdentityUnlinked\x12Y\n" + + "\x13user_passkey_linked\x18\xce\x05 \x01(\v2&.chatto.core.v1.UserPasskeyLinkedEventH\x00R\x11userPasskeyLinked\x12_\n" + + "\x15user_passkey_unlinked\x18\xcf\x05 \x01(\v2(.chatto.core.v1.UserPasskeyUnlinkedEventH\x00R\x13userPasskeyUnlinked\x12w\n" + + "\x1dpasskey_credential_registered\x18\xd0\x05 \x01(\v20.chatto.core.v1.PasskeyCredentialRegisteredEventH\x00R\x1bpasskeyCredentialRegistered\x12n\n" + + "\x1apasskey_credential_updated\x18\xd1\x05 \x01(\v2-.chatto.core.v1.PasskeyCredentialUpdatedEventH\x00R\x18passkeyCredentialUpdated\x12n\n" + + "\x1apasskey_credential_removed\x18\xd2\x05 \x01(\v2-.chatto.core.v1.PasskeyCredentialRemovedEventH\x00R\x18passkeyCredentialRemoved\x12S\n" + "\x11rbac_role_created\x18\xa0\x06 \x01(\v2$.chatto.core.v1.RbacRoleCreatedEventH\x00R\x0frbacRoleCreated\x12v\n" + "\x1erbac_role_display_name_changed\x18\xa1\x06 \x01(\v2/.chatto.core.v1.RbacRoleDisplayNameChangedEventH\x00R\x1arbacRoleDisplayNameChanged\x12u\n" + "\x1drbac_role_description_changed\x18\xa2\x06 \x01(\v2/.chatto.core.v1.RbacRoleDescriptionChangedEventH\x00R\x1arbacRoleDescriptionChanged\x12S\n" + @@ -2014,38 +2099,43 @@ var file_chatto_core_v1_event_proto_goTypes = []any{ (*UserCustomStatusSetEvent)(nil), // 70: chatto.core.v1.UserCustomStatusSetEvent (*UserCustomStatusClearedEvent)(nil), // 71: chatto.core.v1.UserCustomStatusClearedEvent (*UserExternalIdentityUnlinkedEvent)(nil), // 72: chatto.core.v1.UserExternalIdentityUnlinkedEvent - (*RbacRoleCreatedEvent)(nil), // 73: chatto.core.v1.RbacRoleCreatedEvent - (*RbacRoleDisplayNameChangedEvent)(nil), // 74: chatto.core.v1.RbacRoleDisplayNameChangedEvent - (*RbacRoleDescriptionChangedEvent)(nil), // 75: chatto.core.v1.RbacRoleDescriptionChangedEvent - (*RbacRoleDeletedEvent)(nil), // 76: chatto.core.v1.RbacRoleDeletedEvent - (*RbacRolesReorderedEvent)(nil), // 77: chatto.core.v1.RbacRolesReorderedEvent - (*RbacRoleAssignedEvent)(nil), // 78: chatto.core.v1.RbacRoleAssignedEvent - (*RbacRoleRevokedEvent)(nil), // 79: chatto.core.v1.RbacRoleRevokedEvent - (*RbacPermissionGrantedEvent)(nil), // 80: chatto.core.v1.RbacPermissionGrantedEvent - (*RbacPermissionDeniedEvent)(nil), // 81: chatto.core.v1.RbacPermissionDeniedEvent - (*RbacPermissionClearedEvent)(nil), // 82: chatto.core.v1.RbacPermissionClearedEvent - (*RbacRolePingableChangedEvent)(nil), // 83: chatto.core.v1.RbacRolePingableChangedEvent - (*RoomMemberBannedEvent)(nil), // 84: chatto.core.v1.RoomMemberBannedEvent - (*RoomMemberUnbannedEvent)(nil), // 85: chatto.core.v1.RoomMemberUnbannedEvent - (*RoomMemberAddedEvent)(nil), // 86: chatto.core.v1.RoomMemberAddedEvent - (*RoomMemberRemovedEvent)(nil), // 87: chatto.core.v1.RoomMemberRemovedEvent - (*RegistrationVerificationCodeIssuedEvent)(nil), // 88: chatto.core.v1.RegistrationVerificationCodeIssuedEvent - (*EmailVerificationCodeIssuedEvent)(nil), // 89: chatto.core.v1.EmailVerificationCodeIssuedEvent - (*PasswordResetLinkIssuedEvent)(nil), // 90: chatto.core.v1.PasswordResetLinkIssuedEvent - (*AccountDeletionConfirmationIssuedEvent)(nil), // 91: chatto.core.v1.AccountDeletionConfirmationIssuedEvent - (*PasswordResetCompletedEvent)(nil), // 92: chatto.core.v1.PasswordResetCompletedEvent - (*LoginSucceededEvent)(nil), // 93: chatto.core.v1.LoginSucceededEvent - (*LoginFailedEvent)(nil), // 94: chatto.core.v1.LoginFailedEvent - (*LogoutSucceededEvent)(nil), // 95: chatto.core.v1.LogoutSucceededEvent - (*AuthCodeIssuedEvent)(nil), // 96: chatto.core.v1.AuthCodeIssuedEvent - (*AuthCodeExchangeSucceededEvent)(nil), // 97: chatto.core.v1.AuthCodeExchangeSucceededEvent - (*AuthCodeExchangeFailedEvent)(nil), // 98: chatto.core.v1.AuthCodeExchangeFailedEvent - (*BearerTokenIssuedEvent)(nil), // 99: chatto.core.v1.BearerTokenIssuedEvent - (*BearerTokenRevokedEvent)(nil), // 100: chatto.core.v1.BearerTokenRevokedEvent - (*OAuthConsentGrantedEvent)(nil), // 101: chatto.core.v1.OAuthConsentGrantedEvent - (*OAuthConsentDeniedEvent)(nil), // 102: chatto.core.v1.OAuthConsentDeniedEvent - (*ReactionAddedEvent)(nil), // 103: chatto.core.v1.ReactionAddedEvent - (*ReactionRemovedEvent)(nil), // 104: chatto.core.v1.ReactionRemovedEvent + (*UserPasskeyLinkedEvent)(nil), // 73: chatto.core.v1.UserPasskeyLinkedEvent + (*UserPasskeyUnlinkedEvent)(nil), // 74: chatto.core.v1.UserPasskeyUnlinkedEvent + (*PasskeyCredentialRegisteredEvent)(nil), // 75: chatto.core.v1.PasskeyCredentialRegisteredEvent + (*PasskeyCredentialUpdatedEvent)(nil), // 76: chatto.core.v1.PasskeyCredentialUpdatedEvent + (*PasskeyCredentialRemovedEvent)(nil), // 77: chatto.core.v1.PasskeyCredentialRemovedEvent + (*RbacRoleCreatedEvent)(nil), // 78: chatto.core.v1.RbacRoleCreatedEvent + (*RbacRoleDisplayNameChangedEvent)(nil), // 79: chatto.core.v1.RbacRoleDisplayNameChangedEvent + (*RbacRoleDescriptionChangedEvent)(nil), // 80: chatto.core.v1.RbacRoleDescriptionChangedEvent + (*RbacRoleDeletedEvent)(nil), // 81: chatto.core.v1.RbacRoleDeletedEvent + (*RbacRolesReorderedEvent)(nil), // 82: chatto.core.v1.RbacRolesReorderedEvent + (*RbacRoleAssignedEvent)(nil), // 83: chatto.core.v1.RbacRoleAssignedEvent + (*RbacRoleRevokedEvent)(nil), // 84: chatto.core.v1.RbacRoleRevokedEvent + (*RbacPermissionGrantedEvent)(nil), // 85: chatto.core.v1.RbacPermissionGrantedEvent + (*RbacPermissionDeniedEvent)(nil), // 86: chatto.core.v1.RbacPermissionDeniedEvent + (*RbacPermissionClearedEvent)(nil), // 87: chatto.core.v1.RbacPermissionClearedEvent + (*RbacRolePingableChangedEvent)(nil), // 88: chatto.core.v1.RbacRolePingableChangedEvent + (*RoomMemberBannedEvent)(nil), // 89: chatto.core.v1.RoomMemberBannedEvent + (*RoomMemberUnbannedEvent)(nil), // 90: chatto.core.v1.RoomMemberUnbannedEvent + (*RoomMemberAddedEvent)(nil), // 91: chatto.core.v1.RoomMemberAddedEvent + (*RoomMemberRemovedEvent)(nil), // 92: chatto.core.v1.RoomMemberRemovedEvent + (*RegistrationVerificationCodeIssuedEvent)(nil), // 93: chatto.core.v1.RegistrationVerificationCodeIssuedEvent + (*EmailVerificationCodeIssuedEvent)(nil), // 94: chatto.core.v1.EmailVerificationCodeIssuedEvent + (*PasswordResetLinkIssuedEvent)(nil), // 95: chatto.core.v1.PasswordResetLinkIssuedEvent + (*AccountDeletionConfirmationIssuedEvent)(nil), // 96: chatto.core.v1.AccountDeletionConfirmationIssuedEvent + (*PasswordResetCompletedEvent)(nil), // 97: chatto.core.v1.PasswordResetCompletedEvent + (*LoginSucceededEvent)(nil), // 98: chatto.core.v1.LoginSucceededEvent + (*LoginFailedEvent)(nil), // 99: chatto.core.v1.LoginFailedEvent + (*LogoutSucceededEvent)(nil), // 100: chatto.core.v1.LogoutSucceededEvent + (*AuthCodeIssuedEvent)(nil), // 101: chatto.core.v1.AuthCodeIssuedEvent + (*AuthCodeExchangeSucceededEvent)(nil), // 102: chatto.core.v1.AuthCodeExchangeSucceededEvent + (*AuthCodeExchangeFailedEvent)(nil), // 103: chatto.core.v1.AuthCodeExchangeFailedEvent + (*BearerTokenIssuedEvent)(nil), // 104: chatto.core.v1.BearerTokenIssuedEvent + (*BearerTokenRevokedEvent)(nil), // 105: chatto.core.v1.BearerTokenRevokedEvent + (*OAuthConsentGrantedEvent)(nil), // 106: chatto.core.v1.OAuthConsentGrantedEvent + (*OAuthConsentDeniedEvent)(nil), // 107: chatto.core.v1.OAuthConsentDeniedEvent + (*ReactionAddedEvent)(nil), // 108: chatto.core.v1.ReactionAddedEvent + (*ReactionRemovedEvent)(nil), // 109: chatto.core.v1.ReactionRemovedEvent } var file_chatto_core_v1_event_proto_depIdxs = []int32{ 1, // 0: chatto.core.v1.Event.created_at:type_name -> google.protobuf.Timestamp @@ -2120,43 +2210,48 @@ var file_chatto_core_v1_event_proto_depIdxs = []int32{ 70, // 69: chatto.core.v1.Event.user_custom_status_set:type_name -> chatto.core.v1.UserCustomStatusSetEvent 71, // 70: chatto.core.v1.Event.user_custom_status_cleared:type_name -> chatto.core.v1.UserCustomStatusClearedEvent 72, // 71: chatto.core.v1.Event.user_external_identity_unlinked:type_name -> chatto.core.v1.UserExternalIdentityUnlinkedEvent - 73, // 72: chatto.core.v1.Event.rbac_role_created:type_name -> chatto.core.v1.RbacRoleCreatedEvent - 74, // 73: chatto.core.v1.Event.rbac_role_display_name_changed:type_name -> chatto.core.v1.RbacRoleDisplayNameChangedEvent - 75, // 74: chatto.core.v1.Event.rbac_role_description_changed:type_name -> chatto.core.v1.RbacRoleDescriptionChangedEvent - 76, // 75: chatto.core.v1.Event.rbac_role_deleted:type_name -> chatto.core.v1.RbacRoleDeletedEvent - 77, // 76: chatto.core.v1.Event.rbac_roles_reordered:type_name -> chatto.core.v1.RbacRolesReorderedEvent - 78, // 77: chatto.core.v1.Event.rbac_role_assigned:type_name -> chatto.core.v1.RbacRoleAssignedEvent - 79, // 78: chatto.core.v1.Event.rbac_role_revoked:type_name -> chatto.core.v1.RbacRoleRevokedEvent - 80, // 79: chatto.core.v1.Event.rbac_permission_granted:type_name -> chatto.core.v1.RbacPermissionGrantedEvent - 81, // 80: chatto.core.v1.Event.rbac_permission_denied:type_name -> chatto.core.v1.RbacPermissionDeniedEvent - 82, // 81: chatto.core.v1.Event.rbac_permission_cleared:type_name -> chatto.core.v1.RbacPermissionClearedEvent - 83, // 82: chatto.core.v1.Event.rbac_role_pingable_changed:type_name -> chatto.core.v1.RbacRolePingableChangedEvent - 84, // 83: chatto.core.v1.Event.room_member_banned:type_name -> chatto.core.v1.RoomMemberBannedEvent - 85, // 84: chatto.core.v1.Event.room_member_unbanned:type_name -> chatto.core.v1.RoomMemberUnbannedEvent - 86, // 85: chatto.core.v1.Event.room_member_added:type_name -> chatto.core.v1.RoomMemberAddedEvent - 87, // 86: chatto.core.v1.Event.room_member_removed:type_name -> chatto.core.v1.RoomMemberRemovedEvent - 88, // 87: chatto.core.v1.Event.registration_verification_code_issued:type_name -> chatto.core.v1.RegistrationVerificationCodeIssuedEvent - 89, // 88: chatto.core.v1.Event.email_verification_code_issued:type_name -> chatto.core.v1.EmailVerificationCodeIssuedEvent - 90, // 89: chatto.core.v1.Event.password_reset_link_issued:type_name -> chatto.core.v1.PasswordResetLinkIssuedEvent - 91, // 90: chatto.core.v1.Event.account_deletion_confirmation_issued:type_name -> chatto.core.v1.AccountDeletionConfirmationIssuedEvent - 92, // 91: chatto.core.v1.Event.password_reset_completed:type_name -> chatto.core.v1.PasswordResetCompletedEvent - 93, // 92: chatto.core.v1.Event.login_succeeded:type_name -> chatto.core.v1.LoginSucceededEvent - 94, // 93: chatto.core.v1.Event.login_failed:type_name -> chatto.core.v1.LoginFailedEvent - 95, // 94: chatto.core.v1.Event.logout_succeeded:type_name -> chatto.core.v1.LogoutSucceededEvent - 96, // 95: chatto.core.v1.Event.auth_code_issued:type_name -> chatto.core.v1.AuthCodeIssuedEvent - 97, // 96: chatto.core.v1.Event.auth_code_exchange_succeeded:type_name -> chatto.core.v1.AuthCodeExchangeSucceededEvent - 98, // 97: chatto.core.v1.Event.auth_code_exchange_failed:type_name -> chatto.core.v1.AuthCodeExchangeFailedEvent - 99, // 98: chatto.core.v1.Event.bearer_token_issued:type_name -> chatto.core.v1.BearerTokenIssuedEvent - 100, // 99: chatto.core.v1.Event.bearer_token_revoked:type_name -> chatto.core.v1.BearerTokenRevokedEvent - 101, // 100: chatto.core.v1.Event.oauth_consent_granted:type_name -> chatto.core.v1.OAuthConsentGrantedEvent - 102, // 101: chatto.core.v1.Event.oauth_consent_denied:type_name -> chatto.core.v1.OAuthConsentDeniedEvent - 103, // 102: chatto.core.v1.Event.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent - 104, // 103: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent - 104, // [104:104] is the sub-list for method output_type - 104, // [104:104] is the sub-list for method input_type - 104, // [104:104] is the sub-list for extension type_name - 104, // [104:104] is the sub-list for extension extendee - 0, // [0:104] is the sub-list for field type_name + 73, // 72: chatto.core.v1.Event.user_passkey_linked:type_name -> chatto.core.v1.UserPasskeyLinkedEvent + 74, // 73: chatto.core.v1.Event.user_passkey_unlinked:type_name -> chatto.core.v1.UserPasskeyUnlinkedEvent + 75, // 74: chatto.core.v1.Event.passkey_credential_registered:type_name -> chatto.core.v1.PasskeyCredentialRegisteredEvent + 76, // 75: chatto.core.v1.Event.passkey_credential_updated:type_name -> chatto.core.v1.PasskeyCredentialUpdatedEvent + 77, // 76: chatto.core.v1.Event.passkey_credential_removed:type_name -> chatto.core.v1.PasskeyCredentialRemovedEvent + 78, // 77: chatto.core.v1.Event.rbac_role_created:type_name -> chatto.core.v1.RbacRoleCreatedEvent + 79, // 78: chatto.core.v1.Event.rbac_role_display_name_changed:type_name -> chatto.core.v1.RbacRoleDisplayNameChangedEvent + 80, // 79: chatto.core.v1.Event.rbac_role_description_changed:type_name -> chatto.core.v1.RbacRoleDescriptionChangedEvent + 81, // 80: chatto.core.v1.Event.rbac_role_deleted:type_name -> chatto.core.v1.RbacRoleDeletedEvent + 82, // 81: chatto.core.v1.Event.rbac_roles_reordered:type_name -> chatto.core.v1.RbacRolesReorderedEvent + 83, // 82: chatto.core.v1.Event.rbac_role_assigned:type_name -> chatto.core.v1.RbacRoleAssignedEvent + 84, // 83: chatto.core.v1.Event.rbac_role_revoked:type_name -> chatto.core.v1.RbacRoleRevokedEvent + 85, // 84: chatto.core.v1.Event.rbac_permission_granted:type_name -> chatto.core.v1.RbacPermissionGrantedEvent + 86, // 85: chatto.core.v1.Event.rbac_permission_denied:type_name -> chatto.core.v1.RbacPermissionDeniedEvent + 87, // 86: chatto.core.v1.Event.rbac_permission_cleared:type_name -> chatto.core.v1.RbacPermissionClearedEvent + 88, // 87: chatto.core.v1.Event.rbac_role_pingable_changed:type_name -> chatto.core.v1.RbacRolePingableChangedEvent + 89, // 88: chatto.core.v1.Event.room_member_banned:type_name -> chatto.core.v1.RoomMemberBannedEvent + 90, // 89: chatto.core.v1.Event.room_member_unbanned:type_name -> chatto.core.v1.RoomMemberUnbannedEvent + 91, // 90: chatto.core.v1.Event.room_member_added:type_name -> chatto.core.v1.RoomMemberAddedEvent + 92, // 91: chatto.core.v1.Event.room_member_removed:type_name -> chatto.core.v1.RoomMemberRemovedEvent + 93, // 92: chatto.core.v1.Event.registration_verification_code_issued:type_name -> chatto.core.v1.RegistrationVerificationCodeIssuedEvent + 94, // 93: chatto.core.v1.Event.email_verification_code_issued:type_name -> chatto.core.v1.EmailVerificationCodeIssuedEvent + 95, // 94: chatto.core.v1.Event.password_reset_link_issued:type_name -> chatto.core.v1.PasswordResetLinkIssuedEvent + 96, // 95: chatto.core.v1.Event.account_deletion_confirmation_issued:type_name -> chatto.core.v1.AccountDeletionConfirmationIssuedEvent + 97, // 96: chatto.core.v1.Event.password_reset_completed:type_name -> chatto.core.v1.PasswordResetCompletedEvent + 98, // 97: chatto.core.v1.Event.login_succeeded:type_name -> chatto.core.v1.LoginSucceededEvent + 99, // 98: chatto.core.v1.Event.login_failed:type_name -> chatto.core.v1.LoginFailedEvent + 100, // 99: chatto.core.v1.Event.logout_succeeded:type_name -> chatto.core.v1.LogoutSucceededEvent + 101, // 100: chatto.core.v1.Event.auth_code_issued:type_name -> chatto.core.v1.AuthCodeIssuedEvent + 102, // 101: chatto.core.v1.Event.auth_code_exchange_succeeded:type_name -> chatto.core.v1.AuthCodeExchangeSucceededEvent + 103, // 102: chatto.core.v1.Event.auth_code_exchange_failed:type_name -> chatto.core.v1.AuthCodeExchangeFailedEvent + 104, // 103: chatto.core.v1.Event.bearer_token_issued:type_name -> chatto.core.v1.BearerTokenIssuedEvent + 105, // 104: chatto.core.v1.Event.bearer_token_revoked:type_name -> chatto.core.v1.BearerTokenRevokedEvent + 106, // 105: chatto.core.v1.Event.oauth_consent_granted:type_name -> chatto.core.v1.OAuthConsentGrantedEvent + 107, // 106: chatto.core.v1.Event.oauth_consent_denied:type_name -> chatto.core.v1.OAuthConsentDeniedEvent + 108, // 107: chatto.core.v1.Event.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent + 109, // 108: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent + 109, // [109:109] is the sub-list for method output_type + 109, // [109:109] is the sub-list for method input_type + 109, // [109:109] is the sub-list for extension type_name + 109, // [109:109] is the sub-list for extension extendee + 0, // [0:109] is the sub-list for field type_name } func init() { file_chatto_core_v1_event_proto_init() } @@ -2247,6 +2342,11 @@ func file_chatto_core_v1_event_proto_init() { (*Event_UserCustomStatusSet)(nil), (*Event_UserCustomStatusCleared)(nil), (*Event_UserExternalIdentityUnlinked)(nil), + (*Event_UserPasskeyLinked)(nil), + (*Event_UserPasskeyUnlinked)(nil), + (*Event_PasskeyCredentialRegistered)(nil), + (*Event_PasskeyCredentialUpdated)(nil), + (*Event_PasskeyCredentialRemoved)(nil), (*Event_RbacRoleCreated)(nil), (*Event_RbacRoleDisplayNameChanged)(nil), (*Event_RbacRoleDescriptionChanged)(nil), diff --git a/cli/internal/pb/chatto/core/v1/user_events.pb.go b/cli/internal/pb/chatto/core/v1/user_events.pb.go index bf1e98c74..50e86b270 100644 --- a/cli/internal/pb/chatto/core/v1/user_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/user_events.pb.go @@ -961,6 +961,307 @@ func (x *UserExternalIdentityUnlinkedEvent) GetSubjectHash() string { return "" } +// UserPasskeyLinkedEvent records a WebAuthn credential linked to an account. +// Credential IDs and public keys are opaque binary values; labels are chosen by +// the account holder for their settings UI. +type UserPasskeyLinkedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + CredentialHash string `protobuf:"bytes,2,opt,name=credential_hash,json=credentialHash,proto3" json:"credential_hash,omitempty"` + CredentialId []byte `protobuf:"bytes,3,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + Credential []byte `protobuf:"bytes,4,opt,name=credential,proto3" json:"credential,omitempty"` + Label string `protobuf:"bytes,5,opt,name=label,proto3" json:"label,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserPasskeyLinkedEvent) Reset() { + *x = UserPasskeyLinkedEvent{} + mi := &file_chatto_core_v1_user_events_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserPasskeyLinkedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserPasskeyLinkedEvent) ProtoMessage() {} + +func (x *UserPasskeyLinkedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_user_events_proto_msgTypes[15] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserPasskeyLinkedEvent.ProtoReflect.Descriptor instead. +func (*UserPasskeyLinkedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{15} +} + +func (x *UserPasskeyLinkedEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserPasskeyLinkedEvent) GetCredentialHash() string { + if x != nil { + return x.CredentialHash + } + return "" +} + +func (x *UserPasskeyLinkedEvent) GetCredentialId() []byte { + if x != nil { + return x.CredentialId + } + return nil +} + +func (x *UserPasskeyLinkedEvent) GetCredential() []byte { + if x != nil { + return x.Credential + } + return nil +} + +func (x *UserPasskeyLinkedEvent) GetLabel() string { + if x != nil { + return x.Label + } + return "" +} + +// UserPasskeyUnlinkedEvent permanently disables one linked WebAuthn credential. +type UserPasskeyUnlinkedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + CredentialHash string `protobuf:"bytes,2,opt,name=credential_hash,json=credentialHash,proto3" json:"credential_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserPasskeyUnlinkedEvent) Reset() { + *x = UserPasskeyUnlinkedEvent{} + mi := &file_chatto_core_v1_user_events_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserPasskeyUnlinkedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserPasskeyUnlinkedEvent) ProtoMessage() {} + +func (x *UserPasskeyUnlinkedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_user_events_proto_msgTypes[16] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserPasskeyUnlinkedEvent.ProtoReflect.Descriptor instead. +func (*UserPasskeyUnlinkedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{16} +} + +func (x *UserPasskeyUnlinkedEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserPasskeyUnlinkedEvent) GetCredentialHash() string { + if x != nil { + return x.CredentialHash + } + return "" +} + +// PasskeyCredentialRegisteredEvent owns durable WebAuthn credential material +// on its credential-hash aggregate. It is paired atomically with a user link. +type PasskeyCredentialRegisteredEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + CredentialHash string `protobuf:"bytes,2,opt,name=credential_hash,json=credentialHash,proto3" json:"credential_hash,omitempty"` + CredentialId []byte `protobuf:"bytes,3,opt,name=credential_id,json=credentialId,proto3" json:"credential_id,omitempty"` + Credential []byte `protobuf:"bytes,4,opt,name=credential,proto3" json:"credential,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PasskeyCredentialRegisteredEvent) Reset() { + *x = PasskeyCredentialRegisteredEvent{} + mi := &file_chatto_core_v1_user_events_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PasskeyCredentialRegisteredEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PasskeyCredentialRegisteredEvent) ProtoMessage() {} + +func (x *PasskeyCredentialRegisteredEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_user_events_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PasskeyCredentialRegisteredEvent.ProtoReflect.Descriptor instead. +func (*PasskeyCredentialRegisteredEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{17} +} + +func (x *PasskeyCredentialRegisteredEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *PasskeyCredentialRegisteredEvent) GetCredentialHash() string { + if x != nil { + return x.CredentialHash + } + return "" +} + +func (x *PasskeyCredentialRegisteredEvent) GetCredentialId() []byte { + if x != nil { + return x.CredentialId + } + return nil +} + +func (x *PasskeyCredentialRegisteredEvent) GetCredential() []byte { + if x != nil { + return x.Credential + } + return nil +} + +// PasskeyCredentialUpdatedEvent replaces mutable authenticator state after a +// successful assertion, such as its signature counter or backup state. +type PasskeyCredentialUpdatedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + CredentialHash string `protobuf:"bytes,1,opt,name=credential_hash,json=credentialHash,proto3" json:"credential_hash,omitempty"` + Credential []byte `protobuf:"bytes,2,opt,name=credential,proto3" json:"credential,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PasskeyCredentialUpdatedEvent) Reset() { + *x = PasskeyCredentialUpdatedEvent{} + mi := &file_chatto_core_v1_user_events_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PasskeyCredentialUpdatedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PasskeyCredentialUpdatedEvent) ProtoMessage() {} + +func (x *PasskeyCredentialUpdatedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_user_events_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PasskeyCredentialUpdatedEvent.ProtoReflect.Descriptor instead. +func (*PasskeyCredentialUpdatedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{18} +} + +func (x *PasskeyCredentialUpdatedEvent) GetCredentialHash() string { + if x != nil { + return x.CredentialHash + } + return "" +} + +func (x *PasskeyCredentialUpdatedEvent) GetCredential() []byte { + if x != nil { + return x.Credential + } + return nil +} + +// PasskeyCredentialRemovedEvent permanently releases a credential hash. +type PasskeyCredentialRemovedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + CredentialHash string `protobuf:"bytes,1,opt,name=credential_hash,json=credentialHash,proto3" json:"credential_hash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PasskeyCredentialRemovedEvent) Reset() { + *x = PasskeyCredentialRemovedEvent{} + mi := &file_chatto_core_v1_user_events_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PasskeyCredentialRemovedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PasskeyCredentialRemovedEvent) ProtoMessage() {} + +func (x *PasskeyCredentialRemovedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_user_events_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PasskeyCredentialRemovedEvent.ProtoReflect.Descriptor instead. +func (*PasskeyCredentialRemovedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{19} +} + +func (x *PasskeyCredentialRemovedEvent) GetCredentialHash() string { + if x != nil { + return x.CredentialHash + } + return "" +} + type UserServerPreferencesChangedEvent struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -971,7 +1272,7 @@ type UserServerPreferencesChangedEvent struct { func (x *UserServerPreferencesChangedEvent) Reset() { *x = UserServerPreferencesChangedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[15] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -983,7 +1284,7 @@ func (x *UserServerPreferencesChangedEvent) String() string { func (*UserServerPreferencesChangedEvent) ProtoMessage() {} func (x *UserServerPreferencesChangedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[15] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -996,7 +1297,7 @@ func (x *UserServerPreferencesChangedEvent) ProtoReflect() protoreflect.Message // Deprecated: Use UserServerPreferencesChangedEvent.ProtoReflect.Descriptor instead. func (*UserServerPreferencesChangedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{15} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{20} } func (x *UserServerPreferencesChangedEvent) GetUserId() string { @@ -1022,7 +1323,7 @@ type UserLoginCooldownStartedEvent struct { func (x *UserLoginCooldownStartedEvent) Reset() { *x = UserLoginCooldownStartedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[16] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1034,7 +1335,7 @@ func (x *UserLoginCooldownStartedEvent) String() string { func (*UserLoginCooldownStartedEvent) ProtoMessage() {} func (x *UserLoginCooldownStartedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[16] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1047,7 +1348,7 @@ func (x *UserLoginCooldownStartedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserLoginCooldownStartedEvent.ProtoReflect.Descriptor instead. func (*UserLoginCooldownStartedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{16} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{21} } func (x *UserLoginCooldownStartedEvent) GetUserId() string { @@ -1066,7 +1367,7 @@ type UserLoginCooldownClearedEvent struct { func (x *UserLoginCooldownClearedEvent) Reset() { *x = UserLoginCooldownClearedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[17] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1078,7 +1379,7 @@ func (x *UserLoginCooldownClearedEvent) String() string { func (*UserLoginCooldownClearedEvent) ProtoMessage() {} func (x *UserLoginCooldownClearedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[17] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1091,7 +1392,7 @@ func (x *UserLoginCooldownClearedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserLoginCooldownClearedEvent.ProtoReflect.Descriptor instead. func (*UserLoginCooldownClearedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{17} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{22} } func (x *UserLoginCooldownClearedEvent) GetUserId() string { @@ -1110,7 +1411,7 @@ type UserAccountDeletedEvent struct { func (x *UserAccountDeletedEvent) Reset() { *x = UserAccountDeletedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[18] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1122,7 +1423,7 @@ func (x *UserAccountDeletedEvent) String() string { func (*UserAccountDeletedEvent) ProtoMessage() {} func (x *UserAccountDeletedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[18] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1135,7 +1436,7 @@ func (x *UserAccountDeletedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserAccountDeletedEvent.ProtoReflect.Descriptor instead. func (*UserAccountDeletedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{18} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{23} } func (x *UserAccountDeletedEvent) GetUserId() string { @@ -1155,7 +1456,7 @@ type UserCustomStatusSetEvent struct { func (x *UserCustomStatusSetEvent) Reset() { *x = UserCustomStatusSetEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[19] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1167,7 +1468,7 @@ func (x *UserCustomStatusSetEvent) String() string { func (*UserCustomStatusSetEvent) ProtoMessage() {} func (x *UserCustomStatusSetEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[19] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1180,7 +1481,7 @@ func (x *UserCustomStatusSetEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserCustomStatusSetEvent.ProtoReflect.Descriptor instead. func (*UserCustomStatusSetEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{19} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{24} } func (x *UserCustomStatusSetEvent) GetUserId() string { @@ -1206,7 +1507,7 @@ type UserCustomStatusClearedEvent struct { func (x *UserCustomStatusClearedEvent) Reset() { *x = UserCustomStatusClearedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[20] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1218,7 +1519,7 @@ func (x *UserCustomStatusClearedEvent) String() string { func (*UserCustomStatusClearedEvent) ProtoMessage() {} func (x *UserCustomStatusClearedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[20] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1231,7 +1532,7 @@ func (x *UserCustomStatusClearedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserCustomStatusClearedEvent.ProtoReflect.Descriptor instead. func (*UserCustomStatusClearedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{20} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{25} } func (x *UserCustomStatusClearedEvent) GetUserId() string { @@ -1254,7 +1555,7 @@ type UserKeyShreddedEvent struct { func (x *UserKeyShreddedEvent) Reset() { *x = UserKeyShreddedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[21] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1266,7 +1567,7 @@ func (x *UserKeyShreddedEvent) String() string { func (*UserKeyShreddedEvent) ProtoMessage() {} func (x *UserKeyShreddedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[21] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1279,7 +1580,7 @@ func (x *UserKeyShreddedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserKeyShreddedEvent.ProtoReflect.Descriptor instead. func (*UserKeyShreddedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{21} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{26} } func (x *UserKeyShreddedEvent) GetUserId() string { @@ -1308,7 +1609,7 @@ type UserDEKGeneratedEvent struct { func (x *UserDEKGeneratedEvent) Reset() { *x = UserDEKGeneratedEvent{} - mi := &file_chatto_core_v1_user_events_proto_msgTypes[22] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1320,7 +1621,7 @@ func (x *UserDEKGeneratedEvent) String() string { func (*UserDEKGeneratedEvent) ProtoMessage() {} func (x *UserDEKGeneratedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_user_events_proto_msgTypes[22] + mi := &file_chatto_core_v1_user_events_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1333,7 +1634,7 @@ func (x *UserDEKGeneratedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use UserDEKGeneratedEvent.ProtoReflect.Descriptor instead. func (*UserDEKGeneratedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{22} + return file_chatto_core_v1_user_events_proto_rawDescGZIP(), []int{27} } func (x *UserDEKGeneratedEvent) GetUserId() string { @@ -1451,7 +1752,32 @@ const file_chatto_core_v1_user_events_proto_rawDesc = "" + "\rprovider_type\x18\x06 \x01(\tR\fproviderType\"_\n" + "!UserExternalIdentityUnlinkedEvent\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12!\n" + - "\fsubject_hash\x18\x02 \x01(\tR\vsubjectHash\"\x85\x01\n" + + "\fsubject_hash\x18\x02 \x01(\tR\vsubjectHash\"\xb5\x01\n" + + "\x16UserPasskeyLinkedEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12'\n" + + "\x0fcredential_hash\x18\x02 \x01(\tR\x0ecredentialHash\x12#\n" + + "\rcredential_id\x18\x03 \x01(\fR\fcredentialId\x12\x1e\n" + + "\n" + + "credential\x18\x04 \x01(\fR\n" + + "credential\x12\x14\n" + + "\x05label\x18\x05 \x01(\tR\x05label\"\\\n" + + "\x18UserPasskeyUnlinkedEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12'\n" + + "\x0fcredential_hash\x18\x02 \x01(\tR\x0ecredentialHash\"\xa9\x01\n" + + " PasskeyCredentialRegisteredEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12'\n" + + "\x0fcredential_hash\x18\x02 \x01(\tR\x0ecredentialHash\x12#\n" + + "\rcredential_id\x18\x03 \x01(\fR\fcredentialId\x12\x1e\n" + + "\n" + + "credential\x18\x04 \x01(\fR\n" + + "credential\"h\n" + + "\x1dPasskeyCredentialUpdatedEvent\x12'\n" + + "\x0fcredential_hash\x18\x01 \x01(\tR\x0ecredentialHash\x12\x1e\n" + + "\n" + + "credential\x18\x02 \x01(\fR\n" + + "credential\"H\n" + + "\x1dPasskeyCredentialRemovedEvent\x12'\n" + + "\x0fcredential_hash\x18\x01 \x01(\tR\x0ecredentialHash\"\x85\x01\n" + "!UserServerPreferencesChangedEvent\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12G\n" + "\vpreferences\x18\x02 \x01(\v2%.chatto.core.v1.ServerUserPreferencesR\vpreferences\"8\n" + @@ -1495,7 +1821,7 @@ func file_chatto_core_v1_user_events_proto_rawDescGZIP() []byte { } var file_chatto_core_v1_user_events_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_chatto_core_v1_user_events_proto_msgTypes = make([]protoimpl.MessageInfo, 23) +var file_chatto_core_v1_user_events_proto_msgTypes = make([]protoimpl.MessageInfo, 28) var file_chatto_core_v1_user_events_proto_goTypes = []any{ (UserDEKPurpose)(0), // 0: chatto.core.v1.UserDEKPurpose (*UserCreatedEvent)(nil), // 1: chatto.core.v1.UserCreatedEvent @@ -1513,29 +1839,34 @@ var file_chatto_core_v1_user_events_proto_goTypes = []any{ (*UserOIDCSubjectLinkedEvent)(nil), // 13: chatto.core.v1.UserOIDCSubjectLinkedEvent (*UserExternalIdentityLinkedEvent)(nil), // 14: chatto.core.v1.UserExternalIdentityLinkedEvent (*UserExternalIdentityUnlinkedEvent)(nil), // 15: chatto.core.v1.UserExternalIdentityUnlinkedEvent - (*UserServerPreferencesChangedEvent)(nil), // 16: chatto.core.v1.UserServerPreferencesChangedEvent - (*UserLoginCooldownStartedEvent)(nil), // 17: chatto.core.v1.UserLoginCooldownStartedEvent - (*UserLoginCooldownClearedEvent)(nil), // 18: chatto.core.v1.UserLoginCooldownClearedEvent - (*UserAccountDeletedEvent)(nil), // 19: chatto.core.v1.UserAccountDeletedEvent - (*UserCustomStatusSetEvent)(nil), // 20: chatto.core.v1.UserCustomStatusSetEvent - (*UserCustomStatusClearedEvent)(nil), // 21: chatto.core.v1.UserCustomStatusClearedEvent - (*UserKeyShreddedEvent)(nil), // 22: chatto.core.v1.UserKeyShreddedEvent - (*UserDEKGeneratedEvent)(nil), // 23: chatto.core.v1.UserDEKGeneratedEvent - (TimeFormat)(0), // 24: chatto.core.v1.TimeFormat - (*DeprecatedAsset)(nil), // 25: chatto.core.v1.DeprecatedAsset - (*ServerUserPreferences)(nil), // 26: chatto.core.v1.ServerUserPreferences - (*CustomUserStatus)(nil), // 27: chatto.core.v1.CustomUserStatus + (*UserPasskeyLinkedEvent)(nil), // 16: chatto.core.v1.UserPasskeyLinkedEvent + (*UserPasskeyUnlinkedEvent)(nil), // 17: chatto.core.v1.UserPasskeyUnlinkedEvent + (*PasskeyCredentialRegisteredEvent)(nil), // 18: chatto.core.v1.PasskeyCredentialRegisteredEvent + (*PasskeyCredentialUpdatedEvent)(nil), // 19: chatto.core.v1.PasskeyCredentialUpdatedEvent + (*PasskeyCredentialRemovedEvent)(nil), // 20: chatto.core.v1.PasskeyCredentialRemovedEvent + (*UserServerPreferencesChangedEvent)(nil), // 21: chatto.core.v1.UserServerPreferencesChangedEvent + (*UserLoginCooldownStartedEvent)(nil), // 22: chatto.core.v1.UserLoginCooldownStartedEvent + (*UserLoginCooldownClearedEvent)(nil), // 23: chatto.core.v1.UserLoginCooldownClearedEvent + (*UserAccountDeletedEvent)(nil), // 24: chatto.core.v1.UserAccountDeletedEvent + (*UserCustomStatusSetEvent)(nil), // 25: chatto.core.v1.UserCustomStatusSetEvent + (*UserCustomStatusClearedEvent)(nil), // 26: chatto.core.v1.UserCustomStatusClearedEvent + (*UserKeyShreddedEvent)(nil), // 27: chatto.core.v1.UserKeyShreddedEvent + (*UserDEKGeneratedEvent)(nil), // 28: chatto.core.v1.UserDEKGeneratedEvent + (TimeFormat)(0), // 29: chatto.core.v1.TimeFormat + (*DeprecatedAsset)(nil), // 30: chatto.core.v1.DeprecatedAsset + (*ServerUserPreferences)(nil), // 31: chatto.core.v1.ServerUserPreferences + (*CustomUserStatus)(nil), // 32: chatto.core.v1.CustomUserStatus } var file_chatto_core_v1_user_events_proto_depIdxs = []int32{ - 24, // 0: chatto.core.v1.ServerUserPreferencesUpdatedEvent.time_format:type_name -> chatto.core.v1.TimeFormat + 29, // 0: chatto.core.v1.ServerUserPreferencesUpdatedEvent.time_format:type_name -> chatto.core.v1.TimeFormat 5, // 1: chatto.core.v1.UserAccountCreatedEvent.encrypted_login:type_name -> chatto.core.v1.EncryptedUserString 5, // 2: chatto.core.v1.UserAccountCreatedEvent.encrypted_display_name:type_name -> chatto.core.v1.EncryptedUserString 5, // 3: chatto.core.v1.UserLoginChangedEvent.encrypted_login:type_name -> chatto.core.v1.EncryptedUserString 5, // 4: chatto.core.v1.UserDisplayNameChangedEvent.encrypted_display_name:type_name -> chatto.core.v1.EncryptedUserString - 25, // 5: chatto.core.v1.UserAvatarSetEvent.avatar:type_name -> chatto.core.v1.DeprecatedAsset + 30, // 5: chatto.core.v1.UserAvatarSetEvent.avatar:type_name -> chatto.core.v1.DeprecatedAsset 5, // 6: chatto.core.v1.UserVerifiedEmailAddedEvent.encrypted_email:type_name -> chatto.core.v1.EncryptedUserString - 26, // 7: chatto.core.v1.UserServerPreferencesChangedEvent.preferences:type_name -> chatto.core.v1.ServerUserPreferences - 27, // 8: chatto.core.v1.UserCustomStatusSetEvent.status:type_name -> chatto.core.v1.CustomUserStatus + 31, // 7: chatto.core.v1.UserServerPreferencesChangedEvent.preferences:type_name -> chatto.core.v1.ServerUserPreferences + 32, // 8: chatto.core.v1.UserCustomStatusSetEvent.status:type_name -> chatto.core.v1.CustomUserStatus 0, // 9: chatto.core.v1.UserDEKGeneratedEvent.purpose:type_name -> chatto.core.v1.UserDEKPurpose 10, // [10:10] is the sub-list for method output_type 10, // [10:10] is the sub-list for method input_type @@ -1557,7 +1888,7 @@ func file_chatto_core_v1_user_events_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_core_v1_user_events_proto_rawDesc), len(file_chatto_core_v1_user_events_proto_rawDesc)), NumEnums: 1, - NumMessages: 23, + NumMessages: 28, NumExtensions: 0, NumServices: 0, }, diff --git a/packages/api-types/src/chatto/api/v1/server_pb.ts b/packages/api-types/src/chatto/api/v1/server_pb.ts index 71d030570..c891c1696 100644 --- a/packages/api-types/src/chatto/api/v1/server_pb.ts +++ b/packages/api-types/src/chatto/api/v1/server_pb.ts @@ -115,6 +115,14 @@ export class ServerLogin extends Message { */ authorizeUrl = ""; + /** + * Whether this server has enabled WebAuthn passkey login. Older servers omit + * this field and clients must treat that as unavailable. + * + * @generated from field: bool passkeys_enabled = 4; + */ + passkeysEnabled = false; + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); @@ -126,6 +134,7 @@ export class ServerLogin extends Message { { no: 1, name: "direct_registration_enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, { no: 2, name: "providers", kind: "message", T: ProviderMetadata, repeated: true }, { no: 3, name: "authorize_url", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 4, name: "passkeys_enabled", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): ServerLogin { diff --git a/proto/chatto/api/v1/server.proto b/proto/chatto/api/v1/server.proto index 8aa4bec41..98ef711e5 100644 --- a/proto/chatto/api/v1/server.proto +++ b/proto/chatto/api/v1/server.proto @@ -28,4 +28,7 @@ message ServerLogin { repeated ProviderMetadata providers = 2; // URL for the legacy authorization flow, when enabled. string authorize_url = 3; + // Whether this server has enabled WebAuthn passkey login. Older servers omit + // this field and clients must treat that as unavailable. + bool passkeys_enabled = 4; } diff --git a/proto/chatto/core/v1/event.proto b/proto/chatto/core/v1/event.proto index 2a2c23808..943b36bea 100644 --- a/proto/chatto/core/v1/event.proto +++ b/proto/chatto/core/v1/event.proto @@ -176,6 +176,11 @@ message Event { UserCustomStatusSetEvent user_custom_status_set = 715; UserCustomStatusClearedEvent user_custom_status_cleared = 716; UserExternalIdentityUnlinkedEvent user_external_identity_unlinked = 717; + UserPasskeyLinkedEvent user_passkey_linked = 718; + UserPasskeyUnlinkedEvent user_passkey_unlinked = 719; + PasskeyCredentialRegisteredEvent passkey_credential_registered = 720; + PasskeyCredentialUpdatedEvent passkey_credential_updated = 721; + PasskeyCredentialRemovedEvent passkey_credential_removed = 722; // ----- RBAC (800-839, durable, evt.rbac.>) ----- RbacRoleCreatedEvent rbac_role_created = 800; diff --git a/proto/chatto/core/v1/user_events.proto b/proto/chatto/core/v1/user_events.proto index 4af491e0a..40458053a 100644 --- a/proto/chatto/core/v1/user_events.proto +++ b/proto/chatto/core/v1/user_events.proto @@ -142,6 +142,44 @@ message UserExternalIdentityUnlinkedEvent { string subject_hash = 2; } +// UserPasskeyLinkedEvent records a WebAuthn credential linked to an account. +// Credential IDs and public keys are opaque binary values; labels are chosen by +// the account holder for their settings UI. +message UserPasskeyLinkedEvent { + string user_id = 1; + string credential_hash = 2; + bytes credential_id = 3; + bytes credential = 4; + string label = 5; +} + +// UserPasskeyUnlinkedEvent permanently disables one linked WebAuthn credential. +message UserPasskeyUnlinkedEvent { + string user_id = 1; + string credential_hash = 2; +} + +// PasskeyCredentialRegisteredEvent owns durable WebAuthn credential material +// on its credential-hash aggregate. It is paired atomically with a user link. +message PasskeyCredentialRegisteredEvent { + string user_id = 1; + string credential_hash = 2; + bytes credential_id = 3; + bytes credential = 4; +} + +// PasskeyCredentialUpdatedEvent replaces mutable authenticator state after a +// successful assertion, such as its signature counter or backup state. +message PasskeyCredentialUpdatedEvent { + string credential_hash = 1; + bytes credential = 2; +} + +// PasskeyCredentialRemovedEvent permanently releases a credential hash. +message PasskeyCredentialRemovedEvent { + string credential_hash = 1; +} + message UserServerPreferencesChangedEvent { string user_id = 1; ServerUserPreferences preferences = 2;