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;