diff --git a/Cargo.lock b/Cargo.lock index 87341cefa..570d55468 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + [[package]] name = "arrayref" version = "0.3.9" @@ -267,6 +279,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.5.3" @@ -294,6 +312,15 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -788,6 +815,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -917,6 +945,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1275,6 +1315,15 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1804,6 +1853,17 @@ dependencies = [ "redox_syscall 0.9.0", ] +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "libssh2-sys" version = "0.3.2" @@ -2151,6 +2211,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pastey" version = "0.2.3" @@ -3166,6 +3237,35 @@ dependencies = [ "uuid", ] +[[package]] +name = "robonix-keystone" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "base64", + "clap", + "prost", + "protoc-bin-vendored", + "rand 0.9.4", + "robonix-atlas", + "robonix-codegen", + "robonix-scribe", + "rusqlite", + "serde", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", + "uuid", +] + [[package]] name = "robonix-liaison" version = "0.1.0" @@ -3292,6 +3392,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index 9c5070b23..d45a04b38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,13 @@ [workspace] # The 12 Robonix system components live one-per-directory under `system/`. -# Four currently have a Rust implementation: +# Rust implementations currently included in the workspace: # - atlas (capability discovery) # - executor (capability orchestration; embeds sentinel for v0.1) +# - keystone (user identity and access-control core) # - liaison (human-machine interaction) # - pilot (planning + decision + memory + world model) +# - scribe (logging facade) +# - soma (body model and package bring-up) # Repo-side dev tooling lives under `tools/` (rbnx CLI + codegen). # Python-only system components (scene) and reference service implementations # (services/{memsearch,voiceprint,speech}) are managed by uv and not part of @@ -12,6 +15,7 @@ members = [ "system/atlas", "system/executor", + "system/keystone", "system/liaison", "system/pilot", "system/scribe", @@ -60,6 +64,12 @@ protoc-bin-vendored = "3.2" # UUID uuid = { version = "1", features = ["v4"] } +# Identity persistence and authentication +argon2 = "0.5" +base64 = "0.22" +rand = "0.9" +rusqlite = { version = "0.37", features = ["bundled"] } + # OpenAI-compatible VLM client async-openai = "0.34.0" diff --git a/Makefile b/Makefile index 2080b3623..740e79b1f 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # managed by uv from this same root. .PHONY: help build release install clean fmt check pyrightconfig \ - build-atlas build-pilot build-executor build-liaison build-soma build-vitals + build-atlas build-pilot build-executor build-keystone build-liaison build-soma build-vitals .DEFAULT_GOAL := help BUILD_MODE ?= debug @@ -22,6 +22,7 @@ help: @echo " make build-atlas - Install robonix-atlas to ~/.cargo/bin" @echo " make build-pilot - Install robonix-pilot to ~/.cargo/bin" @echo " make build-executor - Install robonix-executor to ~/.cargo/bin" + @echo " make build-keystone - Install robonix-keystone to ~/.cargo/bin" @echo " make build-liaison - Install robonix-liaison to ~/.cargo/bin" @echo " make build-soma - Install robonix-soma to ~/.cargo/bin" @echo " make build-vitals - Install robonix-vitals to ~/.cargo/bin" @@ -51,6 +52,9 @@ build-pilot: build-executor: cargo install --force --path system/executor --bin robonix-executor $(CARGO_FLAGS) +build-keystone: + cargo install --force --path system/keystone --bin robonix-keystone $(CARGO_FLAGS) + build-liaison: cargo install --force --path system/liaison --bin robonix-liaison $(CARGO_FLAGS) @@ -72,6 +76,7 @@ install: cargo install --force --path system/atlas --bin robonix-atlas $(CARGO_FLAGS) cargo install --force --path system/pilot --bin robonix-pilot $(CARGO_FLAGS) cargo install --force --path system/executor --bin robonix-executor $(CARGO_FLAGS) + cargo install --force --path system/keystone --bin robonix-keystone $(CARGO_FLAGS) cargo install --force --path system/liaison --bin robonix-liaison $(CARGO_FLAGS) cargo install --force --path system/soma --bin robonix-soma $(CARGO_FLAGS) cargo install --force --path system/vitals --bin robonix-vitals $(CARGO_FLAGS) diff --git a/capabilities/lib/keystone/msg/User.msg b/capabilities/lib/keystone/msg/User.msg new file mode 100644 index 000000000..81a9c3b95 --- /dev/null +++ b/capabilities/lib/keystone/msg/User.msg @@ -0,0 +1,11 @@ +string user_id +string username +string display_name +string email +bool enabled +string[] roles +bool voice_guard_enabled +bool voiceprint_enrolled +bool password_change_required +uint64 created_at_ms +uint64 updated_at_ms diff --git a/capabilities/lib/keystone/srv/AdminDeleteUser.srv b/capabilities/lib/keystone/srv/AdminDeleteUser.srv new file mode 100644 index 000000000..23177e79b --- /dev/null +++ b/capabilities/lib/keystone/srv/AdminDeleteUser.srv @@ -0,0 +1,3 @@ +string session_token +string target_user_id +--- diff --git a/capabilities/lib/keystone/srv/AdminResetVoiceprint.srv b/capabilities/lib/keystone/srv/AdminResetVoiceprint.srv new file mode 100644 index 000000000..1fc1db425 --- /dev/null +++ b/capabilities/lib/keystone/srv/AdminResetVoiceprint.srv @@ -0,0 +1,4 @@ +string session_token +string target_user_id +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/AdminUpdateUser.srv b/capabilities/lib/keystone/srv/AdminUpdateUser.srv new file mode 100644 index 000000000..88b411e2d --- /dev/null +++ b/capabilities/lib/keystone/srv/AdminUpdateUser.srv @@ -0,0 +1,7 @@ +string session_token +string target_user_id +bool enabled +string[] roles +bool voice_guard_enabled +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/ChangePassword.srv b/capabilities/lib/keystone/srv/ChangePassword.srv new file mode 100644 index 000000000..f02e73871 --- /dev/null +++ b/capabilities/lib/keystone/srv/ChangePassword.srv @@ -0,0 +1,4 @@ +string session_token +string current_password +string new_password +--- diff --git a/capabilities/lib/keystone/srv/GetProfile.srv b/capabilities/lib/keystone/srv/GetProfile.srv new file mode 100644 index 000000000..846daff9d --- /dev/null +++ b/capabilities/lib/keystone/srv/GetProfile.srv @@ -0,0 +1,3 @@ +string session_token +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/GetSystemConfig.srv b/capabilities/lib/keystone/srv/GetSystemConfig.srv new file mode 100644 index 000000000..eeb105b60 --- /dev/null +++ b/capabilities/lib/keystone/srv/GetSystemConfig.srv @@ -0,0 +1,3 @@ +string session_token +--- +bool signup_enabled diff --git a/capabilities/lib/keystone/srv/GetVoiceprintPreview.srv b/capabilities/lib/keystone/srv/GetVoiceprintPreview.srv new file mode 100644 index 000000000..2c44361f0 --- /dev/null +++ b/capabilities/lib/keystone/srv/GetVoiceprintPreview.srv @@ -0,0 +1,6 @@ +string session_token +--- +bool available +uint8[] audio_data +uint32 sample_rate_hz +uint64 updated_at_ms diff --git a/capabilities/lib/keystone/srv/ListUsers.srv b/capabilities/lib/keystone/srv/ListUsers.srv new file mode 100644 index 000000000..4ced9aba6 --- /dev/null +++ b/capabilities/lib/keystone/srv/ListUsers.srv @@ -0,0 +1,3 @@ +string session_token +--- +keystone/User[] users diff --git a/capabilities/lib/keystone/srv/Login.srv b/capabilities/lib/keystone/srv/Login.srv new file mode 100644 index 000000000..e0f9175a4 --- /dev/null +++ b/capabilities/lib/keystone/srv/Login.srv @@ -0,0 +1,6 @@ +string username +string password +--- +string session_token +uint64 expires_at_ms +keystone/User user diff --git a/capabilities/lib/keystone/srv/Logout.srv b/capabilities/lib/keystone/srv/Logout.srv new file mode 100644 index 000000000..d0e06b911 --- /dev/null +++ b/capabilities/lib/keystone/srv/Logout.srv @@ -0,0 +1,2 @@ +string session_token +--- diff --git a/capabilities/lib/keystone/srv/Register.srv b/capabilities/lib/keystone/srv/Register.srv new file mode 100644 index 000000000..33058a7ac --- /dev/null +++ b/capabilities/lib/keystone/srv/Register.srv @@ -0,0 +1,8 @@ +string username +string display_name +string email +string password +--- +string session_token +uint64 expires_at_ms +keystone/User user diff --git a/capabilities/lib/keystone/srv/ReplaceVoiceprint.srv b/capabilities/lib/keystone/srv/ReplaceVoiceprint.srv new file mode 100644 index 000000000..1780c1515 --- /dev/null +++ b/capabilities/lib/keystone/srv/ReplaceVoiceprint.srv @@ -0,0 +1,6 @@ +string session_token +uint8[] audio_data +uint32 sample_rate_hz +string voiceprint_provider_id +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/UnbindVoiceprint.srv b/capabilities/lib/keystone/srv/UnbindVoiceprint.srv new file mode 100644 index 000000000..846daff9d --- /dev/null +++ b/capabilities/lib/keystone/srv/UnbindVoiceprint.srv @@ -0,0 +1,3 @@ +string session_token +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/UpdateProfile.srv b/capabilities/lib/keystone/srv/UpdateProfile.srv new file mode 100644 index 000000000..83e12f7c7 --- /dev/null +++ b/capabilities/lib/keystone/srv/UpdateProfile.srv @@ -0,0 +1,5 @@ +string session_token +string display_name +string email +--- +keystone/User user diff --git a/capabilities/lib/keystone/srv/UpdateSystemConfig.srv b/capabilities/lib/keystone/srv/UpdateSystemConfig.srv new file mode 100644 index 000000000..1ebf9104d --- /dev/null +++ b/capabilities/lib/keystone/srv/UpdateSystemConfig.srv @@ -0,0 +1,4 @@ +string session_token +bool signup_enabled +--- +bool signup_enabled diff --git a/capabilities/lib/keystone/srv/VerifyVoice.srv b/capabilities/lib/keystone/srv/VerifyVoice.srv new file mode 100644 index 000000000..f18e6a08a --- /dev/null +++ b/capabilities/lib/keystone/srv/VerifyVoice.srv @@ -0,0 +1,7 @@ +string session_token +string external_subject_id +float32 confidence +float32 minimum_confidence +--- +keystone/User user +bool verified diff --git a/capabilities/lib/liaison/srv/SetHandsfree.srv b/capabilities/lib/liaison/srv/SetHandsfree.srv index 4ce9d246f..f9f9f8941 100644 --- a/capabilities/lib/liaison/srv/SetHandsfree.srv +++ b/capabilities/lib/liaison/srv/SetHandsfree.srv @@ -2,6 +2,7 @@ bool enabled string mic_provider_id string speaker_provider_id +string session_token # Keystone login session that owns hands-free turns --- bool ok bool enabled diff --git a/capabilities/lib/liaison/srv/StartVoiceSession.srv b/capabilities/lib/liaison/srv/StartVoiceSession.srv index 78db57c1d..e4641d6e6 100644 --- a/capabilities/lib/liaison/srv/StartVoiceSession.srv +++ b/capabilities/lib/liaison/srv/StartVoiceSession.srv @@ -11,5 +11,6 @@ string voiceprint_node_id # "" string tts_node_id # "" string speaker_node_id # "" string context_json # extra fields merged into Task.context_json +string session_token # opaque Keystone login session; never forwarded to Pilot --- liaison/VoiceEvent event # streamed diff --git a/capabilities/lifecycle/driver.v1.toml b/capabilities/lifecycle/driver.v1.toml new file mode 100644 index 000000000..b3e72c212 --- /dev/null +++ b/capabilities/lifecycle/driver.v1.toml @@ -0,0 +1,17 @@ +# Shared lifecycle interface implemented by every managed provider. +# Providers keep their own provider_id and namespace; this contract is shared +# so package authors do not need to duplicate an identical driver TOML. +# Package manifests normally omit Driver; current codegen and runtime select +# this shared contract automatically. Explicit shared and legacy namespace +# selections remain supported. Old generated artifacts may fall back only to +# their exact namespace Driver. No package may declare both forms. +[contract] +id = "robonix/lifecycle/driver" +version = "1" +kind = "service" +idl = "lifecycle/srv/Driver.srv" +description = "Initialize, activate, deactivate, or shut down a managed provider." +cross_namespace = true + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/admin_delete_user.v1.toml b/capabilities/system/keystone/admin_delete_user.v1.toml new file mode 100644 index 000000000..0c75f263c --- /dev/null +++ b/capabilities/system/keystone/admin_delete_user.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/admin_delete_user" +version = "1" +kind = "service" +idl = "keystone/srv/AdminDeleteUser.srv" +description = "Delete a user account as an administrator." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/admin_reset_voiceprint.v1.toml b/capabilities/system/keystone/admin_reset_voiceprint.v1.toml new file mode 100644 index 000000000..a36769306 --- /dev/null +++ b/capabilities/system/keystone/admin_reset_voiceprint.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/admin_reset_voiceprint" +version = "1" +kind = "service" +idl = "keystone/srv/AdminResetVoiceprint.srv" +description = "Remove another user's voiceprint binding as an administrator." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/admin_update_user.v1.toml b/capabilities/system/keystone/admin_update_user.v1.toml new file mode 100644 index 000000000..660c3db17 --- /dev/null +++ b/capabilities/system/keystone/admin_update_user.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/admin_update_user" +version = "1" +kind = "service" +idl = "keystone/srv/AdminUpdateUser.srv" +description = "Update a user's enabled state, roles, and voice policy." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/change_password.v1.toml b/capabilities/system/keystone/change_password.v1.toml new file mode 100644 index 000000000..a814012d0 --- /dev/null +++ b/capabilities/system/keystone/change_password.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/change_password" +version = "1" +kind = "service" +idl = "keystone/srv/ChangePassword.srv" +description = "Change the current user's password." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/get_profile.v1.toml b/capabilities/system/keystone/get_profile.v1.toml new file mode 100644 index 000000000..ba13f22e1 --- /dev/null +++ b/capabilities/system/keystone/get_profile.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/get_profile" +version = "1" +kind = "service" +idl = "keystone/srv/GetProfile.srv" +description = "Resolve the current session to its canonical user profile." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/get_system_config.v1.toml b/capabilities/system/keystone/get_system_config.v1.toml new file mode 100644 index 000000000..2edf340a7 --- /dev/null +++ b/capabilities/system/keystone/get_system_config.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/get_system_config" +version = "1" +kind = "service" +idl = "keystone/srv/GetSystemConfig.srv" +description = "Read Keystone account-system configuration." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/get_voiceprint_preview.v1.toml b/capabilities/system/keystone/get_voiceprint_preview.v1.toml new file mode 100644 index 000000000..eeec523d5 --- /dev/null +++ b/capabilities/system/keystone/get_voiceprint_preview.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/get_voiceprint_preview" +version = "1" +kind = "service" +idl = "keystone/srv/GetVoiceprintPreview.srv" +description = "Return the current account's saved voiceprint preview sample." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/list_users.v1.toml b/capabilities/system/keystone/list_users.v1.toml new file mode 100644 index 000000000..7bbdcc0f2 --- /dev/null +++ b/capabilities/system/keystone/list_users.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/list_users" +version = "1" +kind = "service" +idl = "keystone/srv/ListUsers.srv" +description = "List user accounts for an authenticated administrator." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/login.v1.toml b/capabilities/system/keystone/login.v1.toml new file mode 100644 index 000000000..ccdd9f948 --- /dev/null +++ b/capabilities/system/keystone/login.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/login" +version = "1" +kind = "service" +idl = "keystone/srv/Login.srv" +description = "Authenticate a Robonix account and issue an opaque session." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/logout.v1.toml b/capabilities/system/keystone/logout.v1.toml new file mode 100644 index 000000000..1d09d973a --- /dev/null +++ b/capabilities/system/keystone/logout.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/logout" +version = "1" +kind = "service" +idl = "keystone/srv/Logout.srv" +description = "Revoke the current Robonix account session." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/register.v1.toml b/capabilities/system/keystone/register.v1.toml new file mode 100644 index 000000000..9749a82d1 --- /dev/null +++ b/capabilities/system/keystone/register.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/register" +version = "1" +kind = "service" +idl = "keystone/srv/Register.srv" +description = "Create a Robonix account when public signup is enabled." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/replace_voiceprint.v1.toml b/capabilities/system/keystone/replace_voiceprint.v1.toml new file mode 100644 index 000000000..dda4d77ca --- /dev/null +++ b/capabilities/system/keystone/replace_voiceprint.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/replace_voiceprint" +version = "1" +kind = "service" +idl = "keystone/srv/ReplaceVoiceprint.srv" +description = "Replace the current account's enrolled voiceprint using a new sample." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/unbind_voiceprint.v1.toml b/capabilities/system/keystone/unbind_voiceprint.v1.toml new file mode 100644 index 000000000..13175030b --- /dev/null +++ b/capabilities/system/keystone/unbind_voiceprint.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/unbind_voiceprint" +version = "1" +kind = "service" +idl = "keystone/srv/UnbindVoiceprint.srv" +description = "Remove the current account's voiceprint binding." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/update_profile.v1.toml b/capabilities/system/keystone/update_profile.v1.toml new file mode 100644 index 000000000..22a0103dc --- /dev/null +++ b/capabilities/system/keystone/update_profile.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/update_profile" +version = "1" +kind = "service" +idl = "keystone/srv/UpdateProfile.srv" +description = "Update the current user's editable profile fields." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/update_system_config.v1.toml b/capabilities/system/keystone/update_system_config.v1.toml new file mode 100644 index 000000000..f1c9d8df5 --- /dev/null +++ b/capabilities/system/keystone/update_system_config.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/update_system_config" +version = "1" +kind = "service" +idl = "keystone/srv/UpdateSystemConfig.srv" +description = "Update Keystone account-system configuration as an administrator." + +[mode] +type = "rpc" diff --git a/capabilities/system/keystone/verify_voice.v1.toml b/capabilities/system/keystone/verify_voice.v1.toml new file mode 100644 index 000000000..edc0de399 --- /dev/null +++ b/capabilities/system/keystone/verify_voice.v1.toml @@ -0,0 +1,9 @@ +[contract] +id = "robonix/system/keystone/verify_voice" +version = "1" +kind = "service" +idl = "keystone/srv/VerifyVoice.srv" +description = "Verify that a voiceprint match belongs to the current session." + +[mode] +type = "rpc" diff --git a/examples/webots/robonix_manifest.yaml b/examples/webots/robonix_manifest.yaml index 49e568a86..b6fd2640c 100644 --- a/examples/webots/robonix_manifest.yaml +++ b/examples/webots/robonix_manifest.yaml @@ -13,6 +13,16 @@ system: atlas: listen: 0.0.0.0:50051 log: info + keystone: + # robonix-client discovers Keystone through Atlas and connects directly. + listen: 0.0.0.0:50095 + log: info + # Accounts belong to this robot, not to a disposable source checkout. + # Keep both the identity database and first-boot credential file outside + # rbnx-boot so rebuilding or testing from another worktree does not create + # a second, apparently empty user directory. + database: ${HOME}/.robonix/data/keystone.db + bootstrap_credentials_file: ${HOME}/.robonix/data/keystone-bootstrap-admin.txt # soma+scene layer scene: log: info @@ -33,6 +43,7 @@ system: liaison: listen: 0.0.0.0:50081 log: info + keystone_endpoint: 127.0.0.1:50095 # Disabled until the operator enables it from robonix-client. The client # then supplies its persisted input/output provider selection; no client # address is stored in this deployment. diff --git a/system/keystone/Cargo.toml b/system/keystone/Cargo.toml new file mode 100644 index 000000000..d95aad94f --- /dev/null +++ b/system/keystone/Cargo.toml @@ -0,0 +1,43 @@ +[package] +name = "robonix-keystone" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "Robonix Keystone: user identity, preferences, and access decisions" + +[lib] +name = "robonix_keystone" +path = "src/lib.rs" + +[[bin]] +name = "robonix-keystone" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +argon2.workspace = true +base64.workspace = true +clap.workspace = true +prost.workspace = true +rand.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_yaml.workspace = true +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +tonic.workspace = true +tonic-prost.workspace = true +uuid.workspace = true +robonix-atlas = { path = "../atlas" } +robonix-scribe.workspace = true + +[build-dependencies] +robonix-codegen.workspace = true +protoc-bin-vendored.workspace = true +tonic-build.workspace = true +tonic-prost-build.workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/system/keystone/README.md b/system/keystone/README.md index 4530ee5c4..46196930d 100644 --- a/system/keystone/README.md +++ b/system/keystone/README.md @@ -1,22 +1,86 @@ -# Keystone — identity, configuration, policy +# Keystone -One of the 12 Robonix system components. Stores the body's identity, -its persistent configuration, and the policy decisions that depend on -who is operating it. +Keystone is Robonix's persistent user identity and account service. Liaison +uses it to authenticate text, voice, and hands-free interactions before they +reach Pilot. -**Status — v0.1 stub.** Not yet implemented. +Keystone stores: -Today: deployment manifests in YAML, per-package `package_manifest.yaml`, -and ad-hoc `.env` files. User identity / per-user permissions are not -modelled centrally. The currently implemented user gate lives in Liaison: -text/API tasks use `context_json.user_id`, while voice sessions must pass -voiceprint before Pilot/TTS/action when access control is enabled. +- user accounts, profile fields, enabled state, and `user` / `admin` roles; +- Argon2 password hashes; +- opaque login sessions (only SHA-256 token hashes are persisted); +- per-user voice-guard policy and Voiceprint bindings; +- system account settings such as whether public signup is enabled. -When Keystone lands it will: +The SQLite database uses foreign keys and WAL mode. Its default path is +`$ROBONIX_DATA_DIR/keystone.db`; `rbnx boot` sets `ROBONIX_DATA_DIR` to the +deployment's `rbnx-boot/data` directory. A robot deployment that can move +between source checkouts should set `system.keystone.database` and +`bootstrap_credentials_file` to stable, robot-owned paths such as +`${HOME}/.robonix/data/keystone.db` and +`${HOME}/.robonix/data/keystone-bootstrap-admin.txt`. -- own the canonical key/value config store (read by all components on - boot, hot-reloadable for some keys), -- track identities (operators, deployments, fleets) and the policies - that bind them to capability allow-lists, -- be the source-of-truth that Liaison and future Sentinel policy checks - consult for "is this user allowed to call this skill right now". +## First administrator + +Keystone creates an administrator only when the database contains no users. +Set `ROBONIX_KEYSTONE_BOOTSTRAP_ADMIN_PASSWORD` before the first boot to supply +the initial password explicitly. If it is absent, Keystone generates a random +password and writes the one-time credentials to: + +```text +rbnx-boot/data/keystone-bootstrap-admin.txt +``` + +On Unix the file mode is `0600`. The startup log prints its path, never the +password. The administrator must change the password after the first login. + +## Deployment configuration + +Keystone is a built-in system component: + +```yaml +system: + keystone: + listen: 127.0.0.1:50095 + log: info + database: ${HOME}/.robonix/data/keystone.db + bootstrap_credentials_file: ${HOME}/.robonix/data/keystone-bootstrap-admin.txt + liaison: + listen: 0.0.0.0:50081 + keystone_endpoint: 127.0.0.1:50095 +``` + +Keep the Keystone listener private to the robot host. Clients use the account +API proxied by Liaison; they do not connect to port `50095` directly. + +Optional configuration can be passed in the `system.keystone` mapping: + +| Field | Default | Purpose | +|---|---|---| +| `listen` | `127.0.0.1:50095` | Keystone gRPC listener | +| `database` | `$ROBONIX_DATA_DIR/keystone.db` | SQLite database | +| `bootstrap_credentials_file` | `$ROBONIX_DATA_DIR/keystone-bootstrap-admin.txt` | generated first-login credential file | +| `bootstrap_admin_username` | `admin` | first administrator username | +| `bootstrap_admin_display_name` | `Administrator` | first administrator display name | +| `bootstrap_admin_email` | empty | first administrator email | + +The bootstrap password is intentionally environment-only and is not accepted +from the deployment manifest. + +## Access behavior + +- Text turns require a live session, but do not require a voiceprint. +- When a user's voice guard is off, authenticated voice turns skip Voiceprint. +- When the guard is on, every voice turn must identify as that same logged-in + user above the configured confidence threshold. +- A disabled user cannot log in, and disabling the account revokes its sessions. +- Account and role management requires `admin`; Keystone prevents deleting, + disabling, or demoting the last enabled administrator. +- Changing a password keeps the current session and revokes the user's other + sessions. + +Run the focused tests from the repository root: + +```bash +cargo test -p robonix-keystone -p robonix-liaison +``` diff --git a/system/keystone/build.rs b/system/keystone/build.rs new file mode 100644 index 000000000..d607e41f0 --- /dev/null +++ b/system/keystone/build.rs @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: MulanPSL-2.0 +// Keystone codegen: shared Robonix IDL + capability contracts -> proto -> tonic. + +use std::collections::BTreeSet; +use std::path::PathBuf; + +use robonix_codegen::codegen::{contract_gen, msg_parser, proto_gen}; + +fn main() -> Result<(), Box> { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir + .parent() + .and_then(|path| path.parent()) + .ok_or("could not locate repo root from CARGO_MANIFEST_DIR")? + .to_path_buf(); + let idl_root = repo_root.join("capabilities/lib"); + // Keystone implements the account contracts and consumes Voiceprint + // contracts during enrollment. Generate both from the shared contract tree; + // no private proto definitions are maintained by Keystone. + let contracts_root = repo_root.join("capabilities"); + let proto_out = PathBuf::from(std::env::var("OUT_DIR")?); + + clean_generated_proto_dir(&proto_out)?; + println!("cargo:rerun-if-changed={}", idl_root.display()); + println!("cargo:rerun-if-changed={}", contracts_root.display()); + println!("cargo:rerun-if-changed=build.rs"); + + let mut resolver = msg_parser::MsgResolver::new(std::slice::from_ref(&idl_root))?; + let mut idl_skips = 0usize; + resolver.resolve_all_in_index(false, &mut idl_skips)?; + resolver.resolve_all_srv(false, &mut idl_skips)?; + + let contract_srvs: BTreeSet<(String, String)> = + contract_gen::collect_referenced_srvs(&contracts_root)?; + proto_gen::generate(&resolver, &proto_out, Some(&contract_srvs), false)?; + contract_gen::generate( + &mut resolver, + std::slice::from_ref(&contracts_root), + &proto_out, + false, + )?; + + let protoc = protoc_bin_vendored::protoc_bin_path()?; + // SAFETY: build scripts run single-threaded. + unsafe { + std::env::set_var("PROTOC", protoc); + } + let proto_files: Vec = std::fs::read_dir(&proto_out)? + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "proto") + }) + .collect(); + tonic_prost_build::configure() + .build_server(true) + .build_client(true) + .compile_protos(&proto_files, std::slice::from_ref(&proto_out))?; + Ok(()) +} + +/// Remove only generated proto/Rust files from this crate's Cargo output. +fn clean_generated_proto_dir( + proto_out: &std::path::Path, +) -> Result<(), Box> { + if !proto_out.exists() { + return Ok(()); + } + for entry in std::fs::read_dir(proto_out)? { + let path = entry?.path(); + if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| matches!(extension, "proto" | "rs")) + { + std::fs::remove_file(path)?; + } + } + Ok(()) +} diff --git a/system/keystone/src/config.rs b/system/keystone/src/config.rs new file mode 100644 index 000000000..922d11790 --- /dev/null +++ b/system/keystone/src/config.rs @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MulanPSL-2.0 + +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use clap::Parser; +use serde::Deserialize; + +pub const DEFAULT_ATLAS_ENDPOINT: &str = "127.0.0.1:50051"; +pub const DEFAULT_LISTEN: &str = "0.0.0.0:50095"; +pub const DEFAULT_PROVIDER_ID: &str = "keystone"; +pub const KEYSTONE_NAMESPACE: &str = "robonix/system/keystone"; + +#[derive(Debug, Clone)] +pub struct KeystoneConfig { + pub atlas_endpoint: String, + pub listen: String, + pub provider_id: String, + pub database: PathBuf, + pub bootstrap_credentials_file: PathBuf, + pub bootstrap_admin_username: String, + pub bootstrap_admin_display_name: String, + pub bootstrap_admin_email: String, + pub bootstrap_admin_password: Option, +} + +#[derive(Parser, Debug)] +#[command(name = "robonix-keystone", about = "Robonix user identity service")] +pub struct Args { + #[arg(long, env = "ROBONIX_ATLAS_ENDPOINT")] + pub atlas: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_LISTEN")] + pub listen: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_PROVIDER_ID")] + pub provider_id: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_DATABASE")] + pub database: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_BOOTSTRAP_FILE")] + pub bootstrap_credentials_file: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_BOOTSTRAP_ADMIN_USERNAME")] + pub bootstrap_admin_username: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_BOOTSTRAP_ADMIN_DISPLAY_NAME")] + pub bootstrap_admin_display_name: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_BOOTSTRAP_ADMIN_EMAIL")] + pub bootstrap_admin_email: Option, + + #[arg(long, env = "ROBONIX_KEYSTONE_BOOTSTRAP_ADMIN_PASSWORD")] + pub bootstrap_admin_password: Option, + + #[arg(long, env = "ROBONIX_CONFIG_PATH")] + pub config: Option, + + #[arg(long)] + pub config_json: Option, +} + +#[derive(Debug, Default, Deserialize)] +struct FileConfig { + atlas_endpoint: Option, + listen: Option, + provider_id: Option, + database: Option, + bootstrap_credentials_file: Option, + bootstrap_admin_username: Option, + bootstrap_admin_display_name: Option, + bootstrap_admin_email: Option, +} + +impl KeystoneConfig { + /// Resolve command-line, deployment JSON, YAML, environment, and safe defaults. + pub fn resolve(args: Args) -> Result { + let file = if let Some(path) = &args.config { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("read Keystone config '{}'", path.display()))?; + serde_yaml::from_str::(&raw) + .with_context(|| format!("parse Keystone config '{}'", path.display()))? + } else { + FileConfig::default() + }; + let deployment = args + .config_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .context("parse Keystone deployment config")? + .unwrap_or_default(); + + let data_dir = std::env::var_os("ROBONIX_DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("rbnx-boot/data")); + let database = args + .database + .or(deployment.database) + .or(file.database) + .unwrap_or_else(|| data_dir.join("keystone.db")); + let bootstrap_credentials_file = args + .bootstrap_credentials_file + .or(deployment.bootstrap_credentials_file) + .or(file.bootstrap_credentials_file) + .unwrap_or_else(|| data_dir.join("keystone-bootstrap-admin.txt")); + + Ok(Self { + atlas_endpoint: args + .atlas + .or_else(|| nonempty_env("ROBONIX_ATLAS")) + .or(deployment.atlas_endpoint) + .or(file.atlas_endpoint) + .unwrap_or_else(|| DEFAULT_ATLAS_ENDPOINT.to_string()), + listen: args + .listen + .or(deployment.listen) + .or(file.listen) + .unwrap_or_else(|| DEFAULT_LISTEN.to_string()), + provider_id: args + .provider_id + .or(deployment.provider_id) + .or(file.provider_id) + .unwrap_or_else(|| DEFAULT_PROVIDER_ID.to_string()), + database, + bootstrap_credentials_file, + bootstrap_admin_username: args + .bootstrap_admin_username + .or(deployment.bootstrap_admin_username) + .or(file.bootstrap_admin_username) + .unwrap_or_else(|| "admin".to_string()), + bootstrap_admin_display_name: args + .bootstrap_admin_display_name + .or(deployment.bootstrap_admin_display_name) + .or(file.bootstrap_admin_display_name) + .unwrap_or_else(|| "Administrator".to_string()), + bootstrap_admin_email: args + .bootstrap_admin_email + .or(deployment.bootstrap_admin_email) + .or(file.bootstrap_admin_email) + .unwrap_or_default(), + bootstrap_admin_password: args.bootstrap_admin_password, + }) + } +} + +fn nonempty_env(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} diff --git a/system/keystone/src/lib.rs b/system/keystone/src/lib.rs new file mode 100644 index 000000000..5cfd340c9 --- /dev/null +++ b/system/keystone/src/lib.rs @@ -0,0 +1,1205 @@ +// SPDX-License-Identifier: MulanPSL-2.0 + +//! Persistent identity and account management for Robonix. + +pub mod pb; + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::{Arc, Mutex}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use argon2::Argon2; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use rand::RngCore; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; +use uuid::Uuid; + +const DEFAULT_SESSION_TTL_MS: u64 = 24 * 60 * 60 * 1_000; +const MIN_PASSWORD_LENGTH: usize = 8; +const ROLE_USER: &str = "user"; +const ROLE_ADMIN: &str = "admin"; + +/// Public account data. Password hashes and session identifiers never appear here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct User { + pub user_id: String, + pub username: String, + pub display_name: String, + pub email: String, + pub enabled: bool, + pub roles: Vec, + pub voice_guard_enabled: bool, + pub voiceprint_enrolled: bool, + pub password_change_required: bool, + pub created_at_ms: u64, + pub updated_at_ms: u64, +} + +impl User { + pub fn is_admin(&self) -> bool { + self.roles.iter().any(|role| role == ROLE_ADMIN) + } +} + +/// A newly issued, opaque server-side session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthSession { + pub token: String, + pub expires_at_ms: u64, + pub user: User, +} + +/// A private, owner-readable voiceprint sample used for profile preview. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VoiceprintPreview { + pub audio_data: Vec, + pub sample_rate_hz: u32, + pub updated_at_ms: u64, +} + +/// Errors surfaced by Keystone's storage and authorization boundary. +#[derive(Debug, Error)] +pub enum KeystoneError { + #[error("authentication failed")] + AuthenticationFailed, + #[error("administrator role is required")] + AdminRequired, + #[error("user is disabled")] + UserDisabled, + #[error("user '{0}' does not exist")] + UserNotFound(String), + #[error("username is already registered")] + UsernameExists, + #[error("public signup is disabled")] + SignupDisabled, + #[error("username must be 3-64 characters using letters, numbers, '.', '_' or '-'")] + InvalidUsername, + #[error("display name must not be empty")] + InvalidDisplayName, + #[error("email address is invalid")] + InvalidEmail, + #[error("password must contain at least {MIN_PASSWORD_LENGTH} characters")] + WeakPassword, + #[error("role '{0}' is not supported")] + InvalidRole(String), + #[error("at least one enabled administrator must remain")] + LastAdmin, + #[error("voiceprint subject must not be empty")] + InvalidVoiceprintSubject, + #[error("voiceprint is already bound to another user")] + VoiceprintAlreadyBound, + #[error("voiceprint does not match the logged-in user")] + VoiceprintMismatch, + #[error("voiceprint confidence is below the configured threshold")] + VoiceprintConfidenceLow, + #[error("password hashing failed: {0}")] + PasswordHash(String), + #[error("database lock is poisoned")] + LockPoisoned, + #[error(transparent)] + Database(#[from] rusqlite::Error), +} + +/// Thread-safe SQLite account store shared by the gRPC service. +#[derive(Clone)] +pub struct KeystoneStore { + connection: Arc>, + session_ttl_ms: u64, +} + +impl KeystoneStore { + /// Open or create a persistent Keystone database and apply the current schema. + pub fn open(path: impl AsRef) -> Result { + let connection = Connection::open(path)?; + Self::from_connection(connection) + } + + /// Create an isolated in-memory store for tests. + pub fn open_in_memory() -> Result { + Self::from_connection(Connection::open_in_memory()?) + } + + fn from_connection(connection: Connection) -> Result { + connection.execute_batch( + r#" + PRAGMA foreign_keys = ON; + PRAGMA journal_mode = WAL; + + CREATE TABLE IF NOT EXISTS users ( + user_id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + display_name TEXT NOT NULL, + email TEXT NOT NULL DEFAULT '', + password_hash TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + voice_guard_enabled INTEGER NOT NULL DEFAULT 0, + password_change_required INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + role TEXT NOT NULL, + PRIMARY KEY (user_id, role) + ); + + CREATE TABLE IF NOT EXISTS credentials ( + credential_id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + kind TEXT NOT NULL, + provider_id TEXT NOT NULL, + external_subject_id TEXT NOT NULL, + created_at_ms INTEGER NOT NULL, + UNIQUE (kind, provider_id, external_subject_id), + UNIQUE (user_id, kind, provider_id) + ); + + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + created_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + last_seen_at_ms INTEGER NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS voiceprint_previews ( + user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE, + audio_data BLOB NOT NULL, + sample_rate_hz INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + INSERT OR IGNORE INTO settings(key, value) + VALUES ('signup_enabled', 'true'); + "#, + )?; + Ok(Self { + connection: Arc::new(Mutex::new(connection)), + session_ttl_ms: DEFAULT_SESSION_TTL_MS, + }) + } + + fn connection(&self) -> Result, KeystoneError> { + self.connection + .lock() + .map_err(|_| KeystoneError::LockPoisoned) + } + + /// Create the first administrator exactly once. + pub fn bootstrap_admin( + &self, + username: &str, + display_name: &str, + email: &str, + password: &str, + password_change_required: bool, + ) -> Result, KeystoneError> { + validate_profile(username, display_name, email)?; + validate_password(password)?; + let password_hash = hash_password(password)?; + let now = now_ms(); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + let count: u64 = + transaction.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; + if count != 0 { + return Ok(None); + } + let user_id = new_user_id(); + transaction.execute( + "INSERT INTO users ( + user_id, username, display_name, email, password_hash, enabled, + voice_guard_enabled, password_change_required, created_at_ms, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, ?6, ?7, ?7)", + params![ + user_id, + normalize_username(username), + display_name.trim(), + email.trim(), + password_hash, + password_change_required, + now + ], + )?; + transaction.execute( + "INSERT INTO user_roles(user_id, role) VALUES (?1, ?2), (?1, ?3)", + params![user_id, ROLE_USER, ROLE_ADMIN], + )?; + transaction.commit()?; + drop(connection); + self.get_user(&user_id).map(Some) + } + + /// Register a normal user when public signup is enabled. + pub fn register( + &self, + username: &str, + display_name: &str, + email: &str, + password: &str, + ) -> Result { + validate_profile(username, display_name, email)?; + validate_password(password)?; + if !self.signup_enabled()? { + return Err(KeystoneError::SignupDisabled); + } + let password_hash = hash_password(password)?; + let now = now_ms(); + let user_id = new_user_id(); + { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + let result = transaction.execute( + "INSERT INTO users ( + user_id, username, display_name, email, password_hash, + enabled, voice_guard_enabled, password_change_required, + created_at_ms, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, 0, ?6, ?6)", + params![ + user_id, + normalize_username(username), + display_name.trim(), + email.trim(), + password_hash, + now + ], + ); + match result { + Ok(_) => {} + Err(error) if is_unique_constraint(&error) => { + return Err(KeystoneError::UsernameExists); + } + Err(error) => return Err(error.into()), + } + transaction.execute( + "INSERT INTO user_roles(user_id, role) VALUES (?1, ?2)", + params![user_id, ROLE_USER], + )?; + transaction.commit()?; + } + self.issue_session(&user_id) + } + + /// Validate credentials and issue a new opaque session token. + pub fn login(&self, username: &str, password: &str) -> Result { + let normalized = normalize_username(username); + let (user_id, password_hash, enabled): (String, String, bool) = self + .connection()? + .query_row( + "SELECT user_id, password_hash, enabled FROM users WHERE username = ?1", + params![normalized], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()? + .ok_or(KeystoneError::AuthenticationFailed)?; + if !verify_password(password, &password_hash)? { + return Err(KeystoneError::AuthenticationFailed); + } + if !enabled { + return Err(KeystoneError::UserDisabled); + } + self.issue_session(&user_id) + } + + fn issue_session(&self, user_id: &str) -> Result { + let mut token_bytes = [0_u8; 32]; + rand::rng().fill_bytes(&mut token_bytes); + let token = URL_SAFE_NO_PAD.encode(token_bytes); + let token_hash = hash_session_token(&token); + let created_at_ms = now_ms(); + let expires_at_ms = created_at_ms.saturating_add(self.session_ttl_ms); + self.connection()?.execute( + "INSERT INTO sessions( + token_hash, user_id, created_at_ms, expires_at_ms, last_seen_at_ms, revoked + ) VALUES (?1, ?2, ?3, ?4, ?3, 0)", + params![token_hash, user_id, created_at_ms, expires_at_ms], + )?; + Ok(AuthSession { + token, + expires_at_ms, + user: self.get_user(user_id)?, + }) + } + + /// Resolve a live session and update its last-seen timestamp. + pub fn authenticate(&self, token: &str) -> Result { + let connection = self.connection()?; + authenticate_on(&connection, token) + } + + /// Revoke the presented session. Logout is idempotent. + pub fn logout(&self, token: &str) -> Result<(), KeystoneError> { + self.connection()?.execute( + "UPDATE sessions SET revoked = 1 WHERE token_hash = ?1", + params![hash_session_token(token)], + )?; + Ok(()) + } + + pub fn get_user(&self, user_id: &str) -> Result { + let connection = self.connection()?; + user_by_id(&connection, user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(user_id.to_string())) + } + + /// Update the logged-in user's editable profile fields. + pub fn update_profile( + &self, + token: &str, + display_name: &str, + email: &str, + ) -> Result { + let user = self.authenticate(token)?; + validate_display_name(display_name)?; + validate_email(email)?; + self.connection()?.execute( + "UPDATE users + SET display_name = ?2, email = ?3, updated_at_ms = ?4 + WHERE user_id = ?1", + params![user.user_id, display_name.trim(), email.trim(), now_ms()], + )?; + self.get_user(&user.user_id) + } + + /// Change the logged-in user's password and revoke every other session. + pub fn change_password( + &self, + token: &str, + current_password: &str, + new_password: &str, + ) -> Result<(), KeystoneError> { + validate_password(new_password)?; + let user = self.authenticate(token)?; + let password_hash: String = self.connection()?.query_row( + "SELECT password_hash FROM users WHERE user_id = ?1", + params![user.user_id], + |row| row.get(0), + )?; + if !verify_password(current_password, &password_hash)? { + return Err(KeystoneError::AuthenticationFailed); + } + let new_hash = hash_password(new_password)?; + let current_token_hash = hash_session_token(token); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + transaction.execute( + "UPDATE users + SET password_hash = ?2, password_change_required = 0, updated_at_ms = ?3 + WHERE user_id = ?1", + params![user.user_id, new_hash, now_ms()], + )?; + transaction.execute( + "UPDATE sessions SET revoked = 1 + WHERE user_id = ?1 AND token_hash <> ?2", + params![user.user_id, current_token_hash], + )?; + transaction.commit()?; + Ok(()) + } + + pub fn list_users(&self, admin_token: &str) -> Result, KeystoneError> { + let connection = self.connection()?; + require_admin_on(&connection, admin_token)?; + let mut statement = connection.prepare("SELECT user_id FROM users ORDER BY username")?; + let ids = statement + .query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + ids.into_iter() + .map(|user_id| { + user_by_id(&connection, &user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(user_id)) + }) + .collect() + } + + /// Apply administrator-owned account controls. + pub fn admin_update_user( + &self, + admin_token: &str, + target_user_id: &str, + enabled: bool, + roles: &[String], + voice_guard_enabled: bool, + ) -> Result { + let normalized_roles = normalize_roles(roles)?; + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + require_admin_on(&transaction, admin_token)?; + let target = user_by_id(&transaction, target_user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(target_user_id.to_string()))?; + let removes_admin = target.is_admin() && !normalized_roles.contains(ROLE_ADMIN); + if target.is_admin() && (!enabled || removes_admin) { + ensure_another_enabled_admin_on(&transaction, target_user_id)?; + } + transaction.execute( + "UPDATE users + SET enabled = ?2, voice_guard_enabled = ?3, updated_at_ms = ?4 + WHERE user_id = ?1", + params![target_user_id, enabled, voice_guard_enabled, now_ms()], + )?; + transaction.execute( + "DELETE FROM user_roles WHERE user_id = ?1", + params![target_user_id], + )?; + for role in normalized_roles { + transaction.execute( + "INSERT INTO user_roles(user_id, role) VALUES (?1, ?2)", + params![target_user_id, role], + )?; + } + if !enabled { + transaction.execute( + "UPDATE sessions SET revoked = 1 WHERE user_id = ?1", + params![target_user_id], + )?; + } + transaction.commit()?; + drop(connection); + self.get_user(target_user_id) + } + + pub fn admin_delete_user( + &self, + admin_token: &str, + target_user_id: &str, + ) -> Result<(), KeystoneError> { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + require_admin_on(&transaction, admin_token)?; + let target = user_by_id(&transaction, target_user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(target_user_id.to_string()))?; + if target.is_admin() { + ensure_another_enabled_admin_on(&transaction, target_user_id)?; + } + let changed = transaction.execute( + "DELETE FROM users WHERE user_id = ?1", + params![target_user_id], + )?; + if changed == 0 { + return Err(KeystoneError::UserNotFound(target_user_id.to_string())); + } + transaction.commit()?; + Ok(()) + } + + /// Replace the logged-in user's voiceprint binding. + pub fn bind_voiceprint( + &self, + token: &str, + external_subject_id: &str, + ) -> Result { + let user = self.authenticate(token)?; + self.bind_voiceprint_for_user(&user.user_id, external_subject_id)?; + self.get_user(&user.user_id) + } + + /// Replace a voiceprint binding and its private profile preview atomically. + pub fn replace_voiceprint( + &self, + token: &str, + external_subject_id: &str, + audio_data: &[u8], + sample_rate_hz: u32, + ) -> Result { + let user = self.authenticate(token)?; + let subject = external_subject_id.trim(); + if subject.is_empty() { + return Err(KeystoneError::InvalidVoiceprintSubject); + } + let now = now_ms(); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + transaction.execute( + "DELETE FROM credentials + WHERE user_id = ?1 AND kind = 'voiceprint' AND provider_id = 'voiceprint'", + params![user.user_id], + )?; + let result = transaction.execute( + "INSERT INTO credentials( + credential_id, user_id, kind, provider_id, external_subject_id, created_at_ms + ) VALUES (?1, ?2, 'voiceprint', 'voiceprint', ?3, ?4)", + params![ + format!("credential_{}", Uuid::new_v4().simple()), + user.user_id, + subject, + now + ], + ); + match result { + Ok(_) => {} + Err(error) if is_unique_constraint(&error) => { + return Err(KeystoneError::VoiceprintAlreadyBound); + } + Err(error) => return Err(error.into()), + } + transaction.execute( + "INSERT INTO voiceprint_previews( + user_id, audio_data, sample_rate_hz, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4) + ON CONFLICT(user_id) DO UPDATE SET + audio_data = excluded.audio_data, + sample_rate_hz = excluded.sample_rate_hz, + updated_at_ms = excluded.updated_at_ms", + params![user.user_id, audio_data, sample_rate_hz, now], + )?; + transaction.commit()?; + drop(connection); + self.get_user(&user.user_id) + } + + /// Read only the logged-in account's saved voiceprint preview. + pub fn voiceprint_preview( + &self, + token: &str, + ) -> Result, KeystoneError> { + let user = self.authenticate(token)?; + self.connection()? + .query_row( + "SELECT audio_data, sample_rate_hz, updated_at_ms + FROM voiceprint_previews + WHERE user_id = ?1", + params![user.user_id], + |row| { + Ok(VoiceprintPreview { + audio_data: row.get(0)?, + sample_rate_hz: row.get(1)?, + updated_at_ms: row.get(2)?, + }) + }, + ) + .optional() + .map_err(Into::into) + } + + fn bind_voiceprint_for_user( + &self, + user_id: &str, + external_subject_id: &str, + ) -> Result<(), KeystoneError> { + let subject = external_subject_id.trim(); + if subject.is_empty() { + return Err(KeystoneError::InvalidVoiceprintSubject); + } + let now = now_ms(); + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + transaction.execute( + "DELETE FROM credentials + WHERE user_id = ?1 AND kind = 'voiceprint' AND provider_id = 'voiceprint'", + params![user_id], + )?; + let result = transaction.execute( + "INSERT INTO credentials( + credential_id, user_id, kind, provider_id, external_subject_id, created_at_ms + ) VALUES (?1, ?2, 'voiceprint', 'voiceprint', ?3, ?4)", + params![ + format!("credential_{}", Uuid::new_v4().simple()), + user_id, + subject, + now + ], + ); + match result { + Ok(_) => transaction.commit()?, + Err(error) if is_unique_constraint(&error) => { + return Err(KeystoneError::VoiceprintAlreadyBound); + } + Err(error) => return Err(error.into()), + } + Ok(()) + } + + pub fn unbind_voiceprint(&self, token: &str) -> Result { + let user = self.authenticate(token)?; + self.delete_voiceprint_for_user(&user.user_id)?; + self.get_user(&user.user_id) + } + + pub fn admin_reset_voiceprint( + &self, + admin_token: &str, + target_user_id: &str, + ) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + require_admin_on(&transaction, admin_token)?; + user_by_id(&transaction, target_user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(target_user_id.to_string()))?; + transaction.execute( + "DELETE FROM credentials + WHERE user_id = ?1 AND kind = 'voiceprint' AND provider_id = 'voiceprint'", + params![target_user_id], + )?; + transaction.execute( + "DELETE FROM voiceprint_previews WHERE user_id = ?1", + params![target_user_id], + )?; + transaction.commit()?; + drop(connection); + self.get_user(target_user_id) + } + + fn delete_voiceprint_for_user(&self, user_id: &str) -> Result<(), KeystoneError> { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + transaction.execute( + "DELETE FROM credentials + WHERE user_id = ?1 AND kind = 'voiceprint' AND provider_id = 'voiceprint'", + params![user_id], + )?; + transaction.execute( + "DELETE FROM voiceprint_previews WHERE user_id = ?1", + params![user_id], + )?; + transaction.commit()?; + Ok(()) + } + + /// Enforce the per-user voice guard for one voice turn. + pub fn verify_voice( + &self, + token: &str, + external_subject_id: &str, + confidence: f32, + minimum_confidence: f32, + ) -> Result<(User, bool), KeystoneError> { + let user = self.authenticate(token)?; + if !user.voice_guard_enabled { + return Ok((user, false)); + } + if confidence < minimum_confidence { + return Err(KeystoneError::VoiceprintConfidenceLow); + } + let owner: Option = self + .connection()? + .query_row( + "SELECT user_id FROM credentials + WHERE kind = 'voiceprint' + AND provider_id = 'voiceprint' + AND external_subject_id = ?1", + params![external_subject_id], + |row| row.get(0), + ) + .optional()?; + if owner.as_deref() != Some(user.user_id.as_str()) { + return Err(KeystoneError::VoiceprintMismatch); + } + Ok((user, true)) + } + + pub fn signup_enabled(&self) -> Result { + let value: String = self.connection()?.query_row( + "SELECT value FROM settings WHERE key = 'signup_enabled'", + [], + |row| row.get(0), + )?; + Ok(value == "true") + } + + pub fn set_signup_enabled( + &self, + admin_token: &str, + enabled: bool, + ) -> Result { + let mut connection = self.connection()?; + let transaction = connection.transaction()?; + require_admin_on(&transaction, admin_token)?; + transaction.execute( + "INSERT INTO settings(key, value) VALUES ('signup_enabled', ?1) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![if enabled { "true" } else { "false" }], + )?; + transaction.commit()?; + Ok(enabled) + } +} + +fn authenticate_on(connection: &Connection, token: &str) -> Result { + if token.is_empty() { + return Err(KeystoneError::AuthenticationFailed); + } + let token_hash = hash_session_token(token); + let now = now_ms(); + let user_id: String = connection + .query_row( + "SELECT s.user_id + FROM sessions s + JOIN users u ON u.user_id = s.user_id + WHERE s.token_hash = ?1 + AND s.revoked = 0 + AND s.expires_at_ms > ?2 + AND u.enabled = 1", + params![token_hash, now], + |row| row.get(0), + ) + .optional()? + .ok_or(KeystoneError::AuthenticationFailed)?; + connection.execute( + "UPDATE sessions SET last_seen_at_ms = ?2 WHERE token_hash = ?1", + params![token_hash, now], + )?; + user_by_id(connection, &user_id)? + .ok_or_else(|| KeystoneError::UserNotFound(user_id.to_string())) +} + +fn require_admin_on(connection: &Connection, token: &str) -> Result { + let user = authenticate_on(connection, token)?; + if !user.is_admin() { + return Err(KeystoneError::AdminRequired); + } + Ok(user) +} + +fn ensure_another_enabled_admin_on( + connection: &Connection, + excluded_user_id: &str, +) -> Result<(), KeystoneError> { + let count: u64 = connection.query_row( + "SELECT COUNT(DISTINCT u.user_id) + FROM users u + JOIN user_roles r ON r.user_id = u.user_id + WHERE u.enabled = 1 AND r.role = ?1 AND u.user_id <> ?2", + params![ROLE_ADMIN, excluded_user_id], + |row| row.get(0), + )?; + if count == 0 { + return Err(KeystoneError::LastAdmin); + } + Ok(()) +} + +fn user_by_id(connection: &Connection, user_id: &str) -> Result, KeystoneError> { + type UserRow = (String, String, String, String, bool, bool, bool, u64, u64); + let row: Option = connection + .query_row( + "SELECT user_id, username, display_name, email, enabled, + voice_guard_enabled, password_change_required, + created_at_ms, updated_at_ms + FROM users WHERE user_id = ?1", + params![user_id], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + }, + ) + .optional()?; + let Some(( + user_id, + username, + display_name, + email, + enabled, + voice_guard_enabled, + password_change_required, + created_at_ms, + updated_at_ms, + )) = row + else { + return Ok(None); + }; + let mut statement = + connection.prepare("SELECT role FROM user_roles WHERE user_id = ?1 ORDER BY role")?; + let roles = statement + .query_map(params![user_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + let voiceprint_enrolled: bool = connection.query_row( + "SELECT EXISTS( + SELECT 1 FROM credentials + WHERE user_id = ?1 AND kind = 'voiceprint' AND provider_id = 'voiceprint' + )", + params![user_id], + |row| row.get(0), + )?; + Ok(Some(User { + user_id, + username, + display_name, + email, + enabled, + roles, + voice_guard_enabled, + voiceprint_enrolled, + password_change_required, + created_at_ms, + updated_at_ms, + })) +} + +fn normalize_username(username: &str) -> String { + username.trim().to_ascii_lowercase() +} + +fn validate_profile(username: &str, display_name: &str, email: &str) -> Result<(), KeystoneError> { + let username = username.trim(); + if !(3..=64).contains(&username.len()) + || !username + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) + { + return Err(KeystoneError::InvalidUsername); + } + validate_display_name(display_name)?; + validate_email(email) +} + +fn validate_display_name(display_name: &str) -> Result<(), KeystoneError> { + if display_name.trim().is_empty() { + return Err(KeystoneError::InvalidDisplayName); + } + Ok(()) +} + +fn validate_email(email: &str) -> Result<(), KeystoneError> { + let email = email.trim(); + if !email.is_empty() + && (email.contains(char::is_whitespace) + || !email.contains('@') + || email.starts_with('@') + || email.ends_with('@')) + { + return Err(KeystoneError::InvalidEmail); + } + Ok(()) +} + +fn validate_password(password: &str) -> Result<(), KeystoneError> { + if password.chars().count() < MIN_PASSWORD_LENGTH { + return Err(KeystoneError::WeakPassword); + } + Ok(()) +} + +fn normalize_roles(roles: &[String]) -> Result, KeystoneError> { + let mut normalized = BTreeSet::new(); + for role in roles { + let role = role.trim().to_ascii_lowercase(); + if !matches!(role.as_str(), ROLE_USER | ROLE_ADMIN) { + return Err(KeystoneError::InvalidRole(role)); + } + normalized.insert(role); + } + normalized.insert(ROLE_USER.to_string()); + Ok(normalized) +} + +fn hash_password(password: &str) -> Result { + let mut salt_bytes = [0_u8; 16]; + rand::rng().fill_bytes(&mut salt_bytes); + let salt = SaltString::encode_b64(&salt_bytes) + .map_err(|error| KeystoneError::PasswordHash(error.to_string()))?; + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|error| KeystoneError::PasswordHash(error.to_string())) +} + +fn verify_password(password: &str, encoded: &str) -> Result { + let parsed = PasswordHash::new(encoded) + .map_err(|error| KeystoneError::PasswordHash(error.to_string()))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .is_ok()) +} + +fn hash_session_token(token: &str) -> String { + let digest = Sha256::digest(token.as_bytes()); + URL_SAFE_NO_PAD.encode(digest) +} + +fn new_user_id() -> String { + format!("user_{}", Uuid::new_v4().simple()) +} + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn is_unique_constraint(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(code, _) + if code.code == rusqlite::ErrorCode::ConstraintViolation + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ADMIN_PASSWORD: &str = "admin-password"; + const USER_PASSWORD: &str = "user-password"; + + fn store_with_admin() -> (KeystoneStore, AuthSession) { + let store = KeystoneStore::open_in_memory().expect("store"); + store + .bootstrap_admin( + "admin", + "Administrator", + "admin@example.com", + ADMIN_PASSWORD, + true, + ) + .expect("bootstrap") + .expect("created"); + let admin = store.login("admin", ADMIN_PASSWORD).expect("admin login"); + (store, admin) + } + + #[test] + fn register_login_profile_and_restart_persist() { + let directory = tempfile::tempdir().expect("tempdir"); + let database = directory.path().join("keystone.db"); + let user_id; + { + let store = KeystoneStore::open(&database).expect("open"); + store + .bootstrap_admin("admin", "Admin", "", ADMIN_PASSWORD, true) + .expect("bootstrap"); + let session = store + .register("alice", "Alice", "alice@example.com", USER_PASSWORD) + .expect("register"); + user_id = session.user.user_id.clone(); + let updated = store + .update_profile(&session.token, "Alice Smith", "new@example.com") + .expect("update"); + assert_eq!(updated.display_name, "Alice Smith"); + assert_eq!(updated.email, "new@example.com"); + } + let reopened = KeystoneStore::open(&database).expect("reopen"); + let user = reopened.get_user(&user_id).expect("persisted user"); + assert_eq!(user.display_name, "Alice Smith"); + assert!(reopened.login("alice", USER_PASSWORD).is_ok()); + } + + #[test] + fn normal_user_cannot_administer_accounts() { + let (store, _) = store_with_admin(); + let user = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("register"); + let result = store.list_users(&user.token); + assert!(matches!(result, Err(KeystoneError::AdminRequired))); + } + + #[test] + fn admin_can_promote_user_but_cannot_remove_last_admin() { + let (store, admin) = store_with_admin(); + let user = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("register"); + let promoted = store + .admin_update_user( + &admin.token, + &user.user.user_id, + true, + &["admin".to_string()], + true, + ) + .expect("promote"); + assert!(promoted.is_admin()); + assert!(promoted.voice_guard_enabled); + + let demoted_admin = store + .admin_update_user( + &admin.token, + &admin.user.user_id, + true, + &["user".to_string()], + false, + ) + .expect("another admin remains"); + assert!(!demoted_admin.is_admin()); + + let promoted_login = store.login("alice", USER_PASSWORD).expect("login"); + let error = store + .admin_update_user( + &promoted_login.token, + &promoted.user_id, + false, + &["user".to_string()], + false, + ) + .expect_err("last admin protected"); + assert!(matches!(error, KeystoneError::LastAdmin)); + } + + #[test] + fn concurrent_admin_demotion_keeps_one_enabled_administrator() { + use std::sync::{Arc, Barrier}; + + let (store, admin) = store_with_admin(); + let user = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("register"); + let promoted = store + .admin_update_user( + &admin.token, + &user.user.user_id, + true, + &["admin".to_string()], + false, + ) + .expect("promote"); + let promoted_session = store.login("alice", USER_PASSWORD).expect("promoted login"); + let barrier = Arc::new(Barrier::new(3)); + + let first_store = store.clone(); + let first_barrier = barrier.clone(); + let first_token = admin.token.clone(); + let first_target = admin.user.user_id.clone(); + let first = std::thread::spawn(move || { + first_barrier.wait(); + first_store.admin_update_user( + &first_token, + &first_target, + true, + &["user".to_string()], + false, + ) + }); + + let second_store = store.clone(); + let second_barrier = barrier.clone(); + let second_token = promoted_session.token; + let second_target = promoted.user_id; + let second = std::thread::spawn(move || { + second_barrier.wait(); + second_store.admin_update_user( + &second_token, + &second_target, + true, + &["user".to_string()], + false, + ) + }); + + barrier.wait(); + let results = [first.join().expect("first"), second.join().expect("second")]; + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + let remaining_admins = [admin.user.user_id, user.user.user_id] + .into_iter() + .filter(|user_id| store.get_user(user_id).expect("user").is_admin()) + .count(); + assert_eq!(remaining_admins, 1); + } + + #[test] + fn voice_guard_matches_only_the_logged_in_user() { + let (store, admin) = store_with_admin(); + let alice = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("alice"); + let bob = store + .register("bob", "Bob", "", USER_PASSWORD) + .expect("bob"); + store + .bind_voiceprint(&alice.token, "voice-alice") + .expect("bind alice"); + store + .bind_voiceprint(&bob.token, "voice-bob") + .expect("bind bob"); + store + .admin_update_user( + &admin.token, + &alice.user.user_id, + true, + &["user".to_string()], + true, + ) + .expect("enable guard"); + + let (_, verified) = store + .verify_voice(&alice.token, "voice-alice", 0.91, 0.75) + .expect("matching voice"); + assert!(verified); + assert!(matches!( + store.verify_voice(&alice.token, "voice-bob", 0.91, 0.75), + Err(KeystoneError::VoiceprintMismatch) + )); + assert!(matches!( + store.verify_voice(&alice.token, "voice-alice", 0.5, 0.75), + Err(KeystoneError::VoiceprintConfidenceLow) + )); + } + + #[test] + fn voiceprint_preview_is_private_and_removed_with_binding() { + let (store, _) = store_with_admin(); + let alice = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("alice"); + let bob = store + .register("bob", "Bob", "", USER_PASSWORD) + .expect("bob"); + let audio = vec![0_u8, 1, 2, 3, 4, 5]; + + store + .replace_voiceprint(&alice.token, "voice-alice", &audio, 16_000) + .expect("replace"); + assert_eq!( + store + .voiceprint_preview(&alice.token) + .expect("alice preview") + .expect("preview") + .audio_data, + audio + ); + assert_eq!( + store.voiceprint_preview(&bob.token).expect("bob preview"), + None + ); + + store.unbind_voiceprint(&alice.token).expect("unbind"); + assert_eq!( + store + .voiceprint_preview(&alice.token) + .expect("preview removed"), + None + ); + } + + #[test] + fn password_change_revokes_other_sessions() { + let (store, _) = store_with_admin(); + let first = store + .register("alice", "Alice", "", USER_PASSWORD) + .expect("register"); + let second = store.login("alice", USER_PASSWORD).expect("login again"); + store + .change_password(&first.token, USER_PASSWORD, "new-password") + .expect("change"); + assert!(store.authenticate(&first.token).is_ok()); + assert!(matches!( + store.authenticate(&second.token), + Err(KeystoneError::AuthenticationFailed) + )); + assert!(store.login("alice", "new-password").is_ok()); + } +} diff --git a/system/keystone/src/main.rs b/system/keystone/src/main.rs new file mode 100644 index 000000000..d17658735 --- /dev/null +++ b/system/keystone/src/main.rs @@ -0,0 +1,463 @@ +// SPDX-License-Identifier: MulanPSL-2.0 + +mod config; +mod service; + +use std::net::{IpAddr, SocketAddr}; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use clap::Parser; +use rand::RngCore; +use robonix_atlas::client::{self as atlas_client, AtlasClient}; +use robonix_atlas::pb as atlas_pb; +use robonix_keystone::KeystoneStore; +use robonix_keystone::pb::contracts::{ + robonix_lifecycle_driver_client::RobonixLifecycleDriverClient, + robonix_lifecycle_driver_server::{RobonixLifecycleDriver, RobonixLifecycleDriverServer}, + robonix_system_keystone_admin_delete_user_server::RobonixSystemKeystoneAdminDeleteUserServer, + robonix_system_keystone_admin_reset_voiceprint_server::RobonixSystemKeystoneAdminResetVoiceprintServer, + robonix_system_keystone_admin_update_user_server::RobonixSystemKeystoneAdminUpdateUserServer, + robonix_system_keystone_change_password_server::RobonixSystemKeystoneChangePasswordServer, + robonix_system_keystone_get_profile_server::RobonixSystemKeystoneGetProfileServer, + robonix_system_keystone_get_system_config_server::RobonixSystemKeystoneGetSystemConfigServer, + robonix_system_keystone_get_voiceprint_preview_server::RobonixSystemKeystoneGetVoiceprintPreviewServer, + robonix_system_keystone_list_users_server::RobonixSystemKeystoneListUsersServer, + robonix_system_keystone_login_server::RobonixSystemKeystoneLoginServer, + robonix_system_keystone_logout_server::RobonixSystemKeystoneLogoutServer, + robonix_system_keystone_register_server::RobonixSystemKeystoneRegisterServer, + robonix_system_keystone_replace_voiceprint_server::RobonixSystemKeystoneReplaceVoiceprintServer, + robonix_system_keystone_unbind_voiceprint_server::RobonixSystemKeystoneUnbindVoiceprintServer, + robonix_system_keystone_update_profile_server::RobonixSystemKeystoneUpdateProfileServer, + robonix_system_keystone_update_system_config_server::RobonixSystemKeystoneUpdateSystemConfigServer, + robonix_system_keystone_verify_voice_server::RobonixSystemKeystoneVerifyVoiceServer, +}; +use robonix_keystone::pb::lifecycle::{DriverRequest, DriverResponse}; +use robonix_scribe::{info, warn}; +use service::KeystoneService; +use tonic::{Request, Response, Status}; + +use crate::config::{Args, KEYSTONE_NAMESPACE, KeystoneConfig}; + +const SHARED_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver"; +const CMD_INIT: u32 = 0; +const CMD_ACTIVATE: u32 = 1; +const CMD_DEACTIVATE: u32 = 2; +const CMD_SHUTDOWN: u32 = 3; + +#[derive(Clone)] +struct SystemLifecycleDriver { + atlas: AtlasClient, + provider_id: String, + shutdown_tx: tokio::sync::watch::Sender, +} + +impl SystemLifecycleDriver { + fn new(atlas: AtlasClient, provider_id: String) -> Self { + let (shutdown_tx, _) = tokio::sync::watch::channel(false); + Self { + atlas, + provider_id, + shutdown_tx, + } + } + + async fn transition(&self, command: u32) -> Result<&'static str> { + let (state, label) = lifecycle_target(command) + .ok_or_else(|| anyhow::anyhow!("unknown lifecycle command code {command}"))?; + let mut atlas = self.atlas.clone(); + atlas + .set_lifecycle_state(&self.provider_id, state, "") + .await + .with_context(|| format!("publish lifecycle state for '{}'", self.provider_id))?; + if command == CMD_SHUTDOWN { + self.shutdown_tx.send_replace(true); + } + Ok(label) + } + + fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver { + self.shutdown_tx.subscribe() + } +} + +#[tonic::async_trait] +impl RobonixLifecycleDriver for SystemLifecycleDriver { + async fn driver( + &self, + request: Request, + ) -> std::result::Result, Status> { + let response = match self.transition(request.into_inner().command).await { + Ok(state) => DriverResponse { + ok: true, + state: state.to_string(), + error: String::new(), + }, + Err(error) => DriverResponse { + ok: false, + state: "error".to_string(), + error: format!("{error:#}"), + }, + }; + Ok(Response::new(response)) + } +} + +fn lifecycle_target(command: u32) -> Option<(atlas_pb::LifecycleState, &'static str)> { + match command { + CMD_INIT => Some((atlas_pb::LifecycleState::StateInactive, "inactive")), + CMD_ACTIVATE => Some((atlas_pb::LifecycleState::StateActive, "active")), + CMD_DEACTIVATE => Some((atlas_pb::LifecycleState::StateInactive, "inactive")), + CMD_SHUTDOWN => Some((atlas_pb::LifecycleState::StateTerminated, "terminated")), + _ => None, + } +} + +async fn wait_for_driver_shutdown(mut shutdown: tokio::sync::watch::Receiver) { + if *shutdown.borrow() { + return; + } + while shutdown.changed().await.is_ok() { + if *shutdown.borrow() { + return; + } + } +} + +fn startup_driver_endpoint(listen_addr: SocketAddr) -> String { + let ip = match listen_addr.ip() { + IpAddr::V4(ip) if ip.is_unspecified() => IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + IpAddr::V6(ip) if ip.is_unspecified() => IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), + ip => ip, + }; + SocketAddr::new(ip, listen_addr.port()).to_string() +} + +async fn connect_startup_driver( + endpoint: &str, +) -> Result> { + let endpoint = if endpoint.starts_with("http") { + endpoint.to_string() + } else { + format!("http://{endpoint}") + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + match RobonixLifecycleDriverClient::connect(endpoint.clone()).await { + Ok(client) => return Ok(client), + Err(_) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(25)).await; + } + Err(error) => return Err(error).context("connect startup lifecycle Driver"), + } + } +} + +async fn call_startup_driver( + client: &mut RobonixLifecycleDriverClient, + command: u32, +) -> Result { + let response = client + .driver(DriverRequest { + command, + config_json: "{}".to_string(), + }) + .await + .context("call startup lifecycle Driver")? + .into_inner(); + if !response.ok { + anyhow::bail!("startup lifecycle Driver failed: {}", response.error); + } + Ok(response.state) +} + +struct CapabilityBinding { + contract_id: &'static str, + contract_toml: &'static str, + service: &'static str, + method: &'static str, +} + +macro_rules! capability { + ($name:literal, $suffix:literal) => { + CapabilityBinding { + contract_id: concat!("robonix/system/keystone/", $name), + contract_toml: concat!("capabilities/system/keystone/", $name, ".v1.toml"), + service: concat!("robonix.contracts.RobonixSystemKeystone", $suffix), + method: concat!( + "/robonix.contracts.RobonixSystemKeystone", + $suffix, + "/", + $suffix + ), + } + }; +} + +const CAPABILITIES: &[CapabilityBinding] = &[ + capability!("register", "Register"), + capability!("login", "Login"), + capability!("logout", "Logout"), + capability!("get_profile", "GetProfile"), + capability!("get_voiceprint_preview", "GetVoiceprintPreview"), + capability!("update_profile", "UpdateProfile"), + capability!("change_password", "ChangePassword"), + capability!("list_users", "ListUsers"), + capability!("admin_update_user", "AdminUpdateUser"), + capability!("admin_delete_user", "AdminDeleteUser"), + capability!("replace_voiceprint", "ReplaceVoiceprint"), + capability!("unbind_voiceprint", "UnbindVoiceprint"), + capability!("admin_reset_voiceprint", "AdminResetVoiceprint"), + capability!("verify_voice", "VerifyVoice"), + capability!("get_system_config", "GetSystemConfig"), + capability!("update_system_config", "UpdateSystemConfig"), +]; + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + robonix_scribe::init_from_config("keystone", args.config_json.as_deref()); + let config = KeystoneConfig::resolve(args)?; + + ensure_parent(&config.database)?; + let store = KeystoneStore::open(&config.database) + .with_context(|| format!("open Keystone database '{}'", config.database.display()))?; + bootstrap_admin(&store, &config)?; + + let listen: SocketAddr = config + .listen + .parse() + .with_context(|| format!("invalid Keystone listen address '{}'", config.listen))?; + let advertised = startup_driver_endpoint(listen); + + let mut atlas = + AtlasClient::connect_with_retry(&config.atlas_endpoint, 10, Duration::from_secs(2)) + .await + .context("connect Keystone to Atlas")?; + atlas + .register_service(&config.provider_id, KEYSTONE_NAMESPACE, "") + .await + .context("register Keystone")?; + atlas + .declare_capability( + &config.provider_id, + SHARED_DRIVER_CONTRACT, + atlas_pb::Transport::Grpc, + &advertised, + atlas_client::grpc_params( + "capabilities/lifecycle/driver.v1.toml", + "robonix.contracts.RobonixLifecycleDriver", + "/robonix.contracts.RobonixLifecycleDriver/Driver", + ), + ) + .await + .context("declare Keystone shared lifecycle Driver")?; + let lifecycle = SystemLifecycleDriver::new(atlas.clone(), config.provider_id.clone()); + for capability in CAPABILITIES { + atlas + .declare_capability( + &config.provider_id, + capability.contract_id, + atlas_pb::Transport::Grpc, + &advertised, + atlas_client::grpc_params( + capability.contract_toml, + capability.service, + capability.method, + ), + ) + .await + .with_context(|| format!("declare '{}'", capability.contract_id))?; + } + let service_atlas = std::sync::Arc::new(tokio::sync::Mutex::new(atlas.clone())); + let service = std::sync::Arc::new(KeystoneService::new(store, service_atlas)); + let server_shutdown = lifecycle.subscribe_shutdown(); + let server_lifecycle = lifecycle.clone(); + let mut server_task = tokio::spawn(async move { + tonic::transport::Server::builder() + .add_service(RobonixLifecycleDriverServer::new(server_lifecycle)) + .add_service(RobonixSystemKeystoneRegisterServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneLoginServer::from_arc(service.clone())) + .add_service(RobonixSystemKeystoneLogoutServer::from_arc(service.clone())) + .add_service(RobonixSystemKeystoneGetProfileServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneGetVoiceprintPreviewServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneUpdateProfileServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneChangePasswordServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneListUsersServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneAdminUpdateUserServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneAdminDeleteUserServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneReplaceVoiceprintServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneUnbindVoiceprintServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneAdminResetVoiceprintServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneVerifyVoiceServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneGetSystemConfigServer::from_arc( + service.clone(), + )) + .add_service(RobonixSystemKeystoneUpdateSystemConfigServer::from_arc( + service, + )) + .serve_with_shutdown(listen, wait_for_driver_shutdown(server_shutdown)) + .await + }); + + let mut startup_driver = tokio::select! { + client = connect_startup_driver(&advertised) => client?, + result = &mut server_task => { + result.context("join Keystone gRPC server")? + .context("Keystone gRPC server failed before readiness")?; + anyhow::bail!("Keystone gRPC server stopped before readiness"); + } + }; + call_startup_driver(&mut startup_driver, CMD_INIT) + .await + .context("initialize Keystone lifecycle")?; + call_startup_driver(&mut startup_driver, CMD_ACTIVATE) + .await + .context("activate Keystone lifecycle")?; + drop(startup_driver); + + spawn_heartbeat(atlas, config.provider_id.clone()); + info!( + "Keystone ready on {listen}; database={}", + config.database.display() + ); + server_task + .await + .context("join Keystone gRPC server")? + .context(format!("Keystone gRPC server failed at {advertised}")) +} + +/// Create the first administrator without embedding a reusable password in source. +fn bootstrap_admin(store: &KeystoneStore, config: &KeystoneConfig) -> Result<()> { + let (password, generated) = match config.bootstrap_admin_password.clone() { + Some(password) if !password.is_empty() => (password, false), + _ => (random_bootstrap_password(), true), + }; + let created = store.bootstrap_admin( + &config.bootstrap_admin_username, + &config.bootstrap_admin_display_name, + &config.bootstrap_admin_email, + &password, + true, + )?; + if created.is_some() && generated { + write_bootstrap_credentials( + &config.bootstrap_credentials_file, + &config.bootstrap_admin_username, + &password, + )?; + warn!( + "first administrator created; one-time credentials written to '{}'", + config.bootstrap_credentials_file.display() + ); + } else if created.is_some() { + info!("first administrator created from explicit bootstrap credentials"); + } + Ok(()) +} + +fn random_bootstrap_password() -> String { + let mut bytes = [0_u8; 24]; + rand::rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn ensure_parent(path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create data directory '{}'", parent.display()))?; + } + Ok(()) +} + +fn write_bootstrap_credentials(path: &Path, username: &str, password: &str) -> Result<()> { + ensure_parent(path)?; + let contents = + format!("username={username}\npassword={password}\nchange_password_required=true\n"); + std::fs::write(path, contents) + .with_context(|| format!("write bootstrap credentials '{}'", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .with_context(|| format!("protect bootstrap credentials '{}'", path.display()))?; + } + Ok(()) +} + +fn spawn_heartbeat(mut atlas: AtlasClient, provider_id: String) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(20)); + interval.tick().await; + loop { + interval.tick().await; + if let Err(error) = atlas.heartbeat(&provider_id).await { + warn!("Keystone heartbeat failed: {error:#}"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_bootstrap_credentials_are_private() { + let directory = tempfile::tempdir().expect("tempdir"); + let path = directory.path().join("bootstrap.txt"); + write_bootstrap_credentials(&path, "admin", "one-time-secret").expect("write"); + let contents = std::fs::read_to_string(&path).expect("read"); + assert!(contents.contains("username=admin")); + assert!(contents.contains("password=one-time-secret")); + assert!(contents.contains("change_password_required=true")); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(&path) + .expect("metadata") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + } + + #[test] + fn generated_bootstrap_password_is_not_reused() { + let first = random_bootstrap_password(); + let second = random_bootstrap_password(); + assert_ne!(first, second); + assert!(first.len() >= 32); + assert!(second.len() >= 32); + } +} diff --git a/system/keystone/src/pb.rs b/system/keystone/src/pb.rs new file mode 100644 index 000000000..06691f365 --- /dev/null +++ b/system/keystone/src/pb.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MulanPSL-2.0 +#![allow( + dead_code, + unused_imports, + unused_variables, + clippy::all, + rustdoc::broken_intra_doc_links, + rustdoc::invalid_html_tags +)] + +include!(concat!(env!("OUT_DIR"), "/contract_proto_modules.rs")); diff --git a/system/keystone/src/service.rs b/system/keystone/src/service.rs new file mode 100644 index 000000000..e7e4109c5 --- /dev/null +++ b/system/keystone/src/service.rs @@ -0,0 +1,559 @@ +// SPDX-License-Identifier: MulanPSL-2.0 + +use std::sync::Arc; + +use robonix_atlas::client::AtlasClient; +use robonix_atlas::pb as atlas_pb; +use tokio::sync::Mutex; +use tonic::{Request, Response, Status}; + +use robonix_keystone::pb::contracts::{ + robonix_service_voiceprint_delete_client::RobonixServiceVoiceprintDeleteClient, + robonix_service_voiceprint_enroll_client::RobonixServiceVoiceprintEnrollClient, + robonix_system_keystone_admin_delete_user_server::RobonixSystemKeystoneAdminDeleteUser, + robonix_system_keystone_admin_reset_voiceprint_server::RobonixSystemKeystoneAdminResetVoiceprint, + robonix_system_keystone_admin_update_user_server::RobonixSystemKeystoneAdminUpdateUser, + robonix_system_keystone_change_password_server::RobonixSystemKeystoneChangePassword, + robonix_system_keystone_get_profile_server::RobonixSystemKeystoneGetProfile, + robonix_system_keystone_get_system_config_server::RobonixSystemKeystoneGetSystemConfig, + robonix_system_keystone_get_voiceprint_preview_server::RobonixSystemKeystoneGetVoiceprintPreview, + robonix_system_keystone_list_users_server::RobonixSystemKeystoneListUsers, + robonix_system_keystone_login_server::RobonixSystemKeystoneLogin, + robonix_system_keystone_logout_server::RobonixSystemKeystoneLogout, + robonix_system_keystone_register_server::RobonixSystemKeystoneRegister, + robonix_system_keystone_replace_voiceprint_server::RobonixSystemKeystoneReplaceVoiceprint, + robonix_system_keystone_unbind_voiceprint_server::RobonixSystemKeystoneUnbindVoiceprint, + robonix_system_keystone_update_profile_server::RobonixSystemKeystoneUpdateProfile, + robonix_system_keystone_update_system_config_server::RobonixSystemKeystoneUpdateSystemConfig, + robonix_system_keystone_verify_voice_server::RobonixSystemKeystoneVerifyVoice, +}; +use robonix_keystone::pb::keystone::{ + AdminDeleteUserRequest, AdminDeleteUserResponse, AdminResetVoiceprintRequest, + AdminResetVoiceprintResponse, AdminUpdateUserRequest, AdminUpdateUserResponse, + ChangePasswordRequest, ChangePasswordResponse, GetProfileRequest, GetProfileResponse, + GetSystemConfigRequest, GetSystemConfigResponse, GetVoiceprintPreviewRequest, + GetVoiceprintPreviewResponse, ListUsersRequest, ListUsersResponse, LoginRequest, LoginResponse, + LogoutRequest, LogoutResponse, RegisterRequest, RegisterResponse, ReplaceVoiceprintRequest, + ReplaceVoiceprintResponse, UnbindVoiceprintRequest, UnbindVoiceprintResponse, + UpdateProfileRequest, UpdateProfileResponse, UpdateSystemConfigRequest, + UpdateSystemConfigResponse, VerifyVoiceRequest, VerifyVoiceResponse, +}; +use robonix_keystone::pb::voiceprint::{DeleteEnrolledRequest, EnrollRequest}; +use robonix_keystone::{AuthSession, KeystoneError, KeystoneStore, User}; + +#[derive(Clone)] +pub struct KeystoneService { + store: KeystoneStore, + atlas: Arc>, +} + +impl KeystoneService { + pub fn new(store: KeystoneStore, atlas: Arc>) -> Self { + Self { store, atlas } + } + + async fn resolve_endpoint( + &self, + contract_id: &str, + provider_id: &str, + ) -> Result { + let mut atlas = self.atlas.lock().await; + let transport = atlas_pb::Transport::Grpc; + let providers = atlas + .query_capabilities("", contract_id, transport) + .await + .map_err(|error| Status::unavailable(format!("query Atlas: {error:#}")))?; + let provider = if provider_id.is_empty() { + providers.iter().find(|provider| { + provider.capabilities.iter().any(|capability| { + capability.contract_id == contract_id + && capability.transport == transport as i32 + }) + }) + } else { + providers + .iter() + .find(|provider| provider.id == provider_id || provider.namespace == provider_id) + } + .ok_or_else(|| Status::unavailable(format!("no provider for '{contract_id}'")))?; + let (_, endpoint, _) = atlas + .connect_capability("keystone", &provider.id, contract_id, transport) + .await + .map_err(|error| Status::unavailable(format!("connect capability: {error:#}")))?; + Ok(normalize_endpoint(&endpoint)) + } + + async fn delete_external_voiceprint( + &self, + user_id: &str, + provider_id: &str, + ) -> Result<(), Status> { + let endpoint = self + .resolve_endpoint("robonix/service/voiceprint/delete", provider_id) + .await?; + let response = RobonixServiceVoiceprintDeleteClient::connect(endpoint.clone()) + .await + .map_err(|error| { + Status::unavailable(format!("connect Voiceprint at {endpoint}: {error}")) + })? + .delete_enrolled(DeleteEnrolledRequest { + user_id: user_id.to_string(), + }) + .await? + .into_inner(); + if response.success { + Ok(()) + } else { + Err(Status::failed_precondition(format!( + "Voiceprint deletion failed: {}", + response.error + ))) + } + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneRegister for KeystoneService { + async fn register( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .register( + &input.username, + &input.display_name, + &input.email, + &input.password, + ) + .map(auth_register_response) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneLogin for KeystoneService { + async fn login( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .login(&input.username, &input.password) + .map(auth_login_response) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneLogout for KeystoneService { + async fn logout( + &self, + request: Request, + ) -> Result, Status> { + self.store + .logout(&request.into_inner().session_token) + .map(|()| Response::new(LogoutResponse {})) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneGetProfile for KeystoneService { + async fn get_profile( + &self, + request: Request, + ) -> Result, Status> { + self.store + .authenticate(&request.into_inner().session_token) + .map(|user| GetProfileResponse { + user: Some(user_message(user)), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneUpdateProfile for KeystoneService { + async fn update_profile( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .update_profile(&input.session_token, &input.display_name, &input.email) + .map(|user| UpdateProfileResponse { + user: Some(user_message(user)), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneChangePassword for KeystoneService { + async fn change_password( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .change_password( + &input.session_token, + &input.current_password, + &input.new_password, + ) + .map(|()| Response::new(ChangePasswordResponse {})) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneListUsers for KeystoneService { + async fn list_users( + &self, + request: Request, + ) -> Result, Status> { + self.store + .list_users(&request.into_inner().session_token) + .map(|users| ListUsersResponse { + users: users.into_iter().map(user_message).collect(), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneAdminUpdateUser for KeystoneService { + async fn admin_update_user( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .admin_update_user( + &input.session_token, + &input.target_user_id, + input.enabled, + &input.roles, + input.voice_guard_enabled, + ) + .map(|user| AdminUpdateUserResponse { + user: Some(user_message(user)), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneAdminDeleteUser for KeystoneService { + async fn admin_delete_user( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + let target = self + .store + .list_users(&input.session_token) + .map_err(status)? + .into_iter() + .find(|user| user.user_id == input.target_user_id) + .ok_or_else(|| Status::not_found("target user does not exist"))?; + if target.voiceprint_enrolled { + self.delete_external_voiceprint(&target.user_id, "").await?; + } + self.store + .admin_delete_user(&input.session_token, &input.target_user_id) + .map(|()| Response::new(AdminDeleteUserResponse {})) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneReplaceVoiceprint for KeystoneService { + async fn replace_voiceprint( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + if input.audio_data.len() < 3_200 { + return Err(Status::invalid_argument( + "voiceprint sample must contain at least 0.1 seconds of 16 kHz PCM", + )); + } + if input.audio_data.len() > 1_920_000 { + return Err(Status::invalid_argument( + "voiceprint sample must not exceed 60 seconds of 16 kHz PCM", + )); + } + if !input.audio_data.len().is_multiple_of(2) { + return Err(Status::invalid_argument( + "voiceprint sample must contain complete signed 16-bit PCM frames", + )); + } + let sample_rate_hz = if input.sample_rate_hz == 0 { + 16_000 + } else { + input.sample_rate_hz + }; + if !(8_000..=96_000).contains(&sample_rate_hz) { + return Err(Status::invalid_argument( + "voiceprint sample rate must be between 8000 and 96000 Hz", + )); + } + let user = self + .store + .authenticate(&input.session_token) + .map_err(status)?; + if user.voiceprint_enrolled { + self.delete_external_voiceprint(&user.user_id, &input.voiceprint_provider_id) + .await?; + } + let endpoint = self + .resolve_endpoint( + "robonix/service/voiceprint/enroll", + &input.voiceprint_provider_id, + ) + .await?; + let enrollment = RobonixServiceVoiceprintEnrollClient::connect(endpoint.clone()) + .await + .map_err(|error| { + Status::unavailable(format!("connect Voiceprint at {endpoint}: {error}")) + })? + .enroll(EnrollRequest { + user_id: user.user_id.clone(), + user_name: user.display_name, + audio_data: input.audio_data.clone(), + encoding: "pcm_s16le".to_string(), + sample_rate_hz, + }) + .await? + .into_inner(); + if !enrollment.success { + return Err(Status::failed_precondition(format!( + "Voiceprint enrollment failed: {}", + enrollment.error + ))); + } + match self.store.replace_voiceprint( + &input.session_token, + &user.user_id, + &input.audio_data, + sample_rate_hz, + ) { + Ok(user) => Ok(Response::new(ReplaceVoiceprintResponse { + user: Some(user_message(user)), + })), + Err(error) => { + let _ = self + .delete_external_voiceprint(&user.user_id, &input.voiceprint_provider_id) + .await; + Err(status(error)) + } + } + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneGetVoiceprintPreview for KeystoneService { + async fn get_voiceprint_preview( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + let preview = self + .store + .voiceprint_preview(&input.session_token) + .map_err(status)?; + Ok(Response::new(match preview { + Some(preview) => GetVoiceprintPreviewResponse { + available: true, + audio_data: preview.audio_data, + sample_rate_hz: preview.sample_rate_hz, + updated_at_ms: preview.updated_at_ms, + }, + None => GetVoiceprintPreviewResponse { + available: false, + audio_data: Vec::new(), + sample_rate_hz: 0, + updated_at_ms: 0, + }, + })) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneUnbindVoiceprint for KeystoneService { + async fn unbind_voiceprint( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + let user = self + .store + .authenticate(&input.session_token) + .map_err(status)?; + if user.voiceprint_enrolled { + self.delete_external_voiceprint(&user.user_id, "").await?; + } + self.store + .unbind_voiceprint(&input.session_token) + .map(|user| UnbindVoiceprintResponse { + user: Some(user_message(user)), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneAdminResetVoiceprint for KeystoneService { + async fn admin_reset_voiceprint( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + let target = self + .store + .list_users(&input.session_token) + .map_err(status)? + .into_iter() + .find(|user| user.user_id == input.target_user_id) + .ok_or_else(|| Status::not_found("target user does not exist"))?; + if target.voiceprint_enrolled { + self.delete_external_voiceprint(&target.user_id, "").await?; + } + self.store + .admin_reset_voiceprint(&input.session_token, &input.target_user_id) + .map(|user| AdminResetVoiceprintResponse { + user: Some(user_message(user)), + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneVerifyVoice for KeystoneService { + async fn verify_voice( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .verify_voice( + &input.session_token, + &input.external_subject_id, + input.confidence, + input.minimum_confidence, + ) + .map(|(user, verified)| VerifyVoiceResponse { + user: Some(user_message(user)), + verified, + }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneGetSystemConfig for KeystoneService { + async fn get_system_config( + &self, + request: Request, + ) -> Result, Status> { + self.store + .authenticate(&request.into_inner().session_token) + .and_then(|_| self.store.signup_enabled()) + .map(|signup_enabled| GetSystemConfigResponse { signup_enabled }) + .map(Response::new) + .map_err(status) + } +} + +#[tonic::async_trait] +impl RobonixSystemKeystoneUpdateSystemConfig for KeystoneService { + async fn update_system_config( + &self, + request: Request, + ) -> Result, Status> { + let input = request.into_inner(); + self.store + .set_signup_enabled(&input.session_token, input.signup_enabled) + .map(|signup_enabled| UpdateSystemConfigResponse { signup_enabled }) + .map(Response::new) + .map_err(status) + } +} + +fn auth_register_response(session: AuthSession) -> RegisterResponse { + RegisterResponse { + session_token: session.token, + expires_at_ms: session.expires_at_ms, + user: Some(user_message(session.user)), + } +} + +fn auth_login_response(session: AuthSession) -> LoginResponse { + LoginResponse { + session_token: session.token, + expires_at_ms: session.expires_at_ms, + user: Some(user_message(session.user)), + } +} + +fn user_message(user: User) -> robonix_keystone::pb::keystone::User { + robonix_keystone::pb::keystone::User { + user_id: user.user_id, + username: user.username, + display_name: user.display_name, + email: user.email, + enabled: user.enabled, + roles: user.roles, + voice_guard_enabled: user.voice_guard_enabled, + voiceprint_enrolled: user.voiceprint_enrolled, + password_change_required: user.password_change_required, + created_at_ms: user.created_at_ms, + updated_at_ms: user.updated_at_ms, + } +} + +fn status(error: KeystoneError) -> Status { + match error { + KeystoneError::AuthenticationFailed + | KeystoneError::UserDisabled + | KeystoneError::VoiceprintMismatch + | KeystoneError::VoiceprintConfidenceLow => Status::unauthenticated(error.to_string()), + KeystoneError::AdminRequired | KeystoneError::LastAdmin => { + Status::permission_denied(error.to_string()) + } + KeystoneError::UserNotFound(_) => Status::not_found(error.to_string()), + KeystoneError::UsernameExists | KeystoneError::VoiceprintAlreadyBound => { + Status::already_exists(error.to_string()) + } + KeystoneError::SignupDisabled => Status::failed_precondition(error.to_string()), + KeystoneError::InvalidUsername + | KeystoneError::InvalidDisplayName + | KeystoneError::InvalidEmail + | KeystoneError::WeakPassword + | KeystoneError::InvalidRole(_) + | KeystoneError::InvalidVoiceprintSubject => Status::invalid_argument(error.to_string()), + KeystoneError::PasswordHash(_) + | KeystoneError::LockPoisoned + | KeystoneError::Database(_) => Status::internal(error.to_string()), + } +} + +fn normalize_endpoint(endpoint: &str) -> String { + let endpoint = endpoint.replace("localhost", "127.0.0.1"); + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint + } else { + format!("http://{endpoint}") + } +} diff --git a/system/liaison/README.md b/system/liaison/README.md index e8dedd746..1fc15cd8e 100644 --- a/system/liaison/README.md +++ b/system/liaison/README.md @@ -1,8 +1,9 @@ # robonix-liaison -Liaison is the user-input gateway in front of Pilot. It exposes one text/API -submission path and one push-to-talk voice path, normalises identity metadata, -applies optional access policy, then forwards accepted tasks to Pilot. +Liaison is the authenticated user-input gateway in front of Pilot. It exposes +text, voice, hands-free, and Keystone account APIs, resolves the login session +to a canonical account, applies per-user access policy, then forwards accepted +tasks to Pilot. Liaison does not implement ASR, TTS, microphone capture, speaker output, or voiceprint itself. Those are separate providers discovered through Atlas. @@ -16,8 +17,8 @@ User │ │ │ ▼ │ robonix/system/liaison/submit - │ │ normalize context_json.user_id - │ │ optional access check + │ │ authenticate session with Keystone + │ │ inject canonical account identity │ ▼ │ Pilot SubmitTask │ │ @@ -31,9 +32,9 @@ User │ ├─ robonix/primitive/audio/mic_stream ├─ robonix/service/speech/asr_stream - ├─ robonix/service/voiceprint/identify - ├─ voice access gate before Pilot/TTS/action - ├─ build pilot::Task with text + context_json.user_id/access + ├─ optional robonix/service/voiceprint/identify + ├─ Keystone voice guard before Pilot/TTS/action + ├─ build pilot::Task with canonical account context ├─ Pilot SubmitTask ├─ optional robonix/service/speech/tts ├─ optional speaker playback @@ -48,30 +49,34 @@ User `PilotEvent` responses back to the caller. - `robonix/system/liaison/voice`: starts a voice session and streams `VoiceEvent` state, ASR, voiceprint, Pilot, and TTS progress. +- `robonix/system/liaison/handsfree/*`: enables, observes, and disables the + robot-local wake-word loop. Enabling requires a live Keystone session. +- `robonix.keystone.v1.Keystone/*`: account API proxied by Liaison so clients + do not need network access to Keystone's private listener. -Both contracts are registered by the `liaison` provider id. +The Liaison capability contracts are registered by the `liaison` provider id. ## Identity and access -Pilot `Task` does not have a top-level `user_id` field. Liaison stores the -normalised identity in `Task.context_json.user_id`: +In a Keystone-enabled deployment, the client carries an opaque +`context_json.session_token` for text tasks and a typed `session_token` for +voice and hands-free RPCs. Liaison resolves it before opening audio devices or +forwarding work. Client-supplied `user_id`, display name, and roles are never +authorization inputs. -- text/API path: defaults to `local:` when absent, -- voice path: uses `voice:` from voiceprint when available. +Accepted Pilot tasks contain canonical `user_id`, `username`, `display_name`, +and `roles`. The session token itself is removed before forwarding to Pilot. -When `ROBONIX_LIAISON_ACCESS_ENABLED=1`: +Text turns require a valid account session and never require Voiceprint. For +voice turns: -- text/API tasks must carry, or be normalised to, a user id listed in - `ROBONIX_LIAISON_ALLOWED_USERS`; -- voice turns may capture audio and run voiceprint first, but cannot enter - Pilot, TTS, or any robot action unless voiceprint identifies an enrolled - speaker above `ROBONIX_LIAISON_VOICE_THRESHOLD` and `voice:` is - allowed; -- a client-provided user hint is audit metadata only for the voice path and - cannot bypass voiceprint. +- voice guard off: use the canonical login identity and skip Voiceprint; +- voice guard on: Voiceprint must identify the same account above the + configured threshold, otherwise the turn ends before Pilot, TTS, or action. -Accepted tasks also get `context_json.access` metadata so downstream logs can -distinguish allow-list and voiceprint grants. +When Liaison is intentionally deployed without `keystone_endpoint`, the +existing `ROBONIX_LIAISON_ACCESS_*` allow-list policy remains available for +backward compatibility. ## Mock mode @@ -90,6 +95,7 @@ robonix-liaison |------|--------|------| | `ROBONIX_ATLAS` | `127.0.0.1:50051` | Atlas address | | `ROBONIX_PILOT_ENDPOINT` | `127.0.0.1:50071` | Pilot address | +| `ROBONIX_KEYSTONE_ENDPOINT` | (unset) | Keystone address; when set, account authentication is mandatory | | `ROBONIX_LIAISON_PORT` | `50081` | Liaison listen port | | `ROBONIX_LIAISON_VOICE_MOCK` | (unset) | Set to `1` to skip real mic+ASR and use preset text | | `ROBONIX_LIAISON_VOICE_MOCK_TEXT` | `Hello, please introduce yourself.` | Text used in mock mode | diff --git a/system/liaison/src/handsfree.rs b/system/liaison/src/handsfree.rs index 203b16138..491abe8c9 100644 --- a/system/liaison/src/handsfree.rs +++ b/system/liaison/src/handsfree.rs @@ -22,6 +22,7 @@ use crate::pb::contracts::{ }; use crate::pb::liaison::{GetHandsfreeStatusResponse, StartVoiceSessionRequest, VoiceEvent}; use crate::{ + keystone_gateway::KeystoneGateway, voice, voice::{KIND_ASR_FINAL, KIND_ERROR}, }; @@ -40,6 +41,8 @@ pub struct HandsfreeConfig { pub handsfree_speaker_provider_id: String, pub handsfree_session_id: String, pub handsfree_record_seconds: u32, + #[serde(skip)] + pub session_token: String, } impl Default for HandsfreeConfig { @@ -53,6 +56,7 @@ impl Default for HandsfreeConfig { handsfree_speaker_provider_id: String::new(), handsfree_session_id: "handsfree".to_string(), handsfree_record_seconds: 20, + session_token: String::new(), } } } @@ -75,6 +79,7 @@ pub struct HandsfreeController { atlas: Arc>, pilot_endpoint_default: String, access: Arc, + keystone: Option>, events: broadcast::Sender, active_turns: Mutex>>, } @@ -85,10 +90,12 @@ impl HandsfreeController { atlas: Arc>, pilot_endpoint_default: String, access: Arc, + keystone: Option>, ) -> Arc { let enabled = config.handsfree_enabled && !config.handsfree_mic_provider_id.is_empty() - && !config.handsfree_speaker_provider_id.is_empty(); + && !config.handsfree_speaker_provider_id.is_empty() + && keystone.is_none(); let (events, _) = broadcast::channel(256); Arc::new(Self { enabled: AtomicBool::new(enabled), @@ -102,6 +109,7 @@ impl HandsfreeController { atlas, pilot_endpoint_default, access, + keystone, events, active_turns: Mutex::new(Vec::new()), }) @@ -117,6 +125,7 @@ impl HandsfreeController { enabled: bool, mic_provider_id: String, speaker_provider_id: String, + session_token: String, ) -> Result { { let mut config = self.config.lock().await; @@ -126,6 +135,11 @@ impl HandsfreeController { if !speaker_provider_id.trim().is_empty() { config.handsfree_speaker_provider_id = speaker_provider_id; } + if enabled { + config.session_token = session_token; + } else { + config.session_token.clear(); + } if enabled && (config.handsfree_mic_provider_id.trim().is_empty() || config.handsfree_speaker_provider_id.trim().is_empty()) @@ -384,12 +398,14 @@ impl HandsfreeController { } else { r#"{"client":"robot-handsfree","interaction_mode":"auto","barge_in":false,"handsfree":true}"#.to_string() }, + session_token: config.session_token.clone(), }; let mut stream = voice::start_voice_session( request, Arc::clone(&self.atlas), self.pilot_endpoint_default.clone(), Arc::clone(&self.access), + self.keystone.clone(), ) .await .map_err(|status| anyhow!(status.to_string()))?; diff --git a/system/liaison/src/keystone_gateway.rs b/system/liaison/src/keystone_gateway.rs new file mode 100644 index 000000000..7bb64b2bf --- /dev/null +++ b/system/liaison/src/keystone_gateway.rs @@ -0,0 +1,93 @@ +// SPDX-License-Identifier: MulanPSL-2.0 + +//! Internal Keystone client used by Liaison to authenticate task and voice sessions. +//! +//! Keystone is the only provider of account capabilities. Liaison deliberately +//! does not implement or re-export any Keystone server trait. + +use std::sync::Arc; + +use robonix_atlas::client::AtlasClient; +use tokio::sync::Mutex; +use tonic::Status; + +use crate::pb::contracts::{ + robonix_system_keystone_get_profile_client::RobonixSystemKeystoneGetProfileClient, + robonix_system_keystone_verify_voice_client::RobonixSystemKeystoneVerifyVoiceClient, +}; +use crate::pb::keystone::{GetProfileRequest, User, VerifyVoiceRequest, VerifyVoiceResponse}; +use crate::voice::resolve_endpoint; + +const KEYSTONE_PROVIDER_ID: &str = "keystone"; +const GET_PROFILE_CONTRACT: &str = "robonix/system/keystone/get_profile"; +const VERIFY_VOICE_CONTRACT: &str = "robonix/system/keystone/verify_voice"; + +#[derive(Clone)] +pub struct KeystoneGateway { + fallback_endpoint: String, + atlas: Arc>, +} + +impl KeystoneGateway { + pub fn new(endpoint: impl Into, atlas: Arc>) -> Self { + Self { + fallback_endpoint: normalize_endpoint(endpoint.into()), + atlas, + } + } + + async fn endpoint(&self, contract_id: &str) -> String { + resolve_endpoint(&self.atlas, contract_id, KEYSTONE_PROVIDER_ID) + .await + .unwrap_or_else(|| self.fallback_endpoint.clone()) + } + + /// Resolve an opaque login session to the canonical account. + pub async fn resolve_session(&self, token: &str) -> Result { + let endpoint = self.endpoint(GET_PROFILE_CONTRACT).await; + let response = RobonixSystemKeystoneGetProfileClient::connect(endpoint.clone()) + .await + .map_err(|error| { + Status::unavailable(format!("connect Keystone at {endpoint}: {error}")) + })? + .get_profile(GetProfileRequest { + session_token: token.to_string(), + }) + .await? + .into_inner(); + response + .user + .ok_or_else(|| Status::internal("Keystone returned an empty user profile")) + } + + pub async fn client_verify_voice( + &self, + token: &str, + external_subject_id: &str, + confidence: f32, + minimum_confidence: f32, + ) -> Result { + let endpoint = self.endpoint(VERIFY_VOICE_CONTRACT).await; + RobonixSystemKeystoneVerifyVoiceClient::connect(endpoint.clone()) + .await + .map_err(|error| { + Status::unavailable(format!("connect Keystone at {endpoint}: {error}")) + })? + .verify_voice(VerifyVoiceRequest { + session_token: token.to_string(), + external_subject_id: external_subject_id.to_string(), + confidence, + minimum_confidence, + }) + .await + .map(|response| response.into_inner()) + } +} + +fn normalize_endpoint(endpoint: String) -> String { + if endpoint.starts_with("http://") || endpoint.starts_with("https://") { + endpoint + } else { + format!("http://{endpoint}") + } +} diff --git a/system/liaison/src/main.rs b/system/liaison/src/main.rs index efea124a0..013ef5ab3 100644 --- a/system/liaison/src/main.rs +++ b/system/liaison/src/main.rs @@ -23,11 +23,12 @@ mod access; mod handsfree; +mod keystone_gateway; mod pb; mod voice; use access::{AccessControlConfig, AccessDecision}; -use anyhow::{Context, Result, anyhow}; +use anyhow::{Context, Result}; use clap::Parser; use pb::contracts::{ robonix_system_liaison_handsfree_events_server::{ @@ -101,6 +102,8 @@ pub struct LiaisonPipeline { pilot_endpoint_default: String, atlas: Arc>, access: Arc, + keystone: Arc, + enforce_keystone: bool, } impl LiaisonPipeline { @@ -111,11 +114,15 @@ impl LiaisonPipeline { pilot_endpoint_default: impl Into, atlas: Arc>, access: Arc, + keystone: Arc, + enforce_keystone: bool, ) -> Self { Self { pilot_endpoint_default: pilot_endpoint_default.into(), atlas, access, + keystone, + enforce_keystone, } } @@ -123,23 +130,30 @@ impl LiaisonPipeline { pub async fn handle_intent( &self, mut task: Task, - ) -> Result>> { + ) -> Result>, Status> { + if self.enforce_keystone { + authenticate_task(&self.keystone, &mut task).await?; + } ensure_user_id(&mut task); - let user_id = task_user_id(&task); - match self.access.authorize_user(&user_id) { - AccessDecision::Allow { - user_id, - method, - reason, - .. - } => { - info!("[liaison/access] text allow user={user_id} via {method:?}: {reason}"); - } - AccessDecision::Deny { - user_id, reason, .. - } => { - warn!("[liaison/access] text deny user={user_id}: {reason}"); - return Err(anyhow!("access denied for user '{user_id}': {reason}")); + if !self.enforce_keystone { + let user_id = task_user_id(&task); + match self.access.authorize_user(&user_id) { + AccessDecision::Allow { + user_id, + method, + reason, + .. + } => { + info!("[liaison/access] text allow user={user_id} via {method:?}: {reason}"); + } + AccessDecision::Deny { + user_id, reason, .. + } => { + warn!("[liaison/access] text deny user={user_id}: {reason}"); + return Err(Status::permission_denied(format!( + "access denied for user '{user_id}': {reason}" + ))); + } } } let (tx, rx) = mpsc::channel(64); @@ -151,11 +165,15 @@ impl LiaisonPipeline { let mut client = RobonixSystemPilotClient::connect(pilot_ep.clone()) .await - .with_context(|| format!("connect Pilot at {pilot_ep}"))?; + .map_err(|error| { + Status::unavailable(format!("connect Pilot at {pilot_ep}: {error}")) + })?; let response = client .submit_task(Request::new(task)) .await - .with_context(|| format!("Pilot SubmitTask at {pilot_ep}"))?; + .map_err(|error| { + Status::unavailable(format!("Pilot SubmitTask at {pilot_ep}: {error}")) + })?; let mut grpc = response.into_inner(); tokio::spawn(async move { while let Some(item) = grpc.next().await { @@ -172,6 +190,35 @@ impl LiaisonPipeline { } } +/// Resolve the login token, replace caller-supplied identity with Keystone's +/// canonical account, and remove the secret before forwarding to Pilot. +async fn authenticate_task( + keystone: &keystone_gateway::KeystoneGateway, + task: &mut Task, +) -> Result<(), Status> { + let mut context = serde_json::from_str::(&task.context_json) + .unwrap_or_else(|_| serde_json::json!({})); + let object = context + .as_object_mut() + .ok_or_else(|| Status::invalid_argument("Task.context_json must be a JSON object"))?; + let token = object + .remove("session_token") + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .filter(|value| !value.is_empty()) + .ok_or_else(|| Status::unauthenticated("login session is required"))?; + let user = keystone.resolve_session(&token).await?; + object.insert("user_id".to_string(), serde_json::json!(user.user_id)); + object.insert("username".to_string(), serde_json::json!(user.username)); + object.insert( + "display_name".to_string(), + serde_json::json!(user.display_name), + ); + object.insert("roles".to_string(), serde_json::json!(user.roles)); + task.context_json = + serde_json::to_string(&context).map_err(|error| Status::internal(error.to_string()))?; + Ok(()) +} + async fn resolve_pilot_endpoint(atlas: &Arc>) -> Option { let mut atlas = atlas.lock().await; let transport = atlas_pb::Transport::Grpc; @@ -269,11 +316,7 @@ impl RobonixSystemLiaisonSubmit for LiaisonServiceImpl { request: Request, ) -> Result, Status> { let task = request.into_inner(); - let rx = self - .pipeline - .handle_intent(task) - .await - .map_err(|e| Status::unavailable(format!("Pilot unreachable: {e:#}")))?; + let rx = self.pipeline.handle_intent(task).await?; Ok(Response::new(ReceiverStream::new(rx))) } } @@ -289,11 +332,16 @@ impl RobonixSystemLiaisonVoice for LiaisonServiceImpl { ) -> Result, Status> { let req = request.into_inner(); self.handsfree.suspend_capture().await; + let keystone = self + .pipeline + .enforce_keystone + .then(|| Arc::clone(&self.pipeline.keystone)); let stream = match voice::start_voice_session( req, Arc::clone(&self.atlas), self.pilot_endpoint_default.clone(), Arc::clone(&self.access), + keystone, ) .await { @@ -337,12 +385,19 @@ impl RobonixSystemLiaisonHandsfreeSetEnabled for LiaisonServiceImpl { request: Request, ) -> Result, Status> { let request = request.into_inner(); + if request.enabled && self.pipeline.enforce_keystone { + self.pipeline + .keystone + .resolve_session(&request.session_token) + .await?; + } let status = self .handsfree .set_enabled( request.enabled, request.mic_provider_id, request.speaker_provider_id, + request.session_token, ) .await .map_err(|error| Status::invalid_argument(error.to_string()))?; @@ -394,7 +449,7 @@ async fn drain_session_end(pipeline: &LiaisonPipeline, session_id: &str) { let mut stream = ReceiverStream::new(rx); while stream.next().await.is_some() {} } - Err(e) => debug!("[liaison/text] session_end: {e:#}"), + Err(e) => debug!("[liaison/text] session_end: {e}"), } } @@ -526,6 +581,11 @@ struct Args { #[arg(long = "pilot-endpoint")] pilot_endpoint: Option, + /// Keystone endpoint. Providing this enables mandatory login sessions for + /// task and voice submission while exposing the account API on Liaison. + #[arg(long = "keystone-endpoint", env = "ROBONIX_KEYSTONE_ENDPOINT")] + keystone_endpoint: Option, + /// Log level for this component (`debug`/`info`/`warn`/`error`). Sets the /// scribe log-file floor; falls back to `SCRIBE_FILE_LEVEL` / `info`. /// Normally arrives inside `--config-json`, not as a standalone flag. @@ -572,6 +632,11 @@ async fn main() -> Result<()> { }; raw.replace("localhost", "127.0.0.1") }; + let enforce_keystone = args.keystone_endpoint.is_some(); + let keystone_endpoint = args + .keystone_endpoint + .clone() + .unwrap_or_else(|| "127.0.0.1:50095".to_string()); // --listen accepts host:port. If the manifest passes just a port (or // the user sets ROBONIX_LIAISON_PORT), bind 0.0.0.0:. @@ -704,6 +769,10 @@ async fn main() -> Result<()> { let atlas = Arc::new(Mutex::new(atlas)); let access = Arc::new(AccessControlConfig::from_env()); + let keystone = Arc::new(keystone_gateway::KeystoneGateway::new( + keystone_endpoint, + Arc::clone(&atlas), + )); info!( "access gate enabled={} allowed_users={} voice_threshold={:.2}", access.enabled, @@ -714,6 +783,8 @@ async fn main() -> Result<()> { pilot_http.clone(), Arc::clone(&atlas), Arc::clone(&access), + Arc::clone(&keystone), + enforce_keystone, )); let handsfree_config = args .config_json @@ -727,6 +798,7 @@ async fn main() -> Result<()> { Arc::clone(&atlas), pilot_http.clone(), Arc::clone(&access), + enforce_keystone.then(|| Arc::clone(&keystone)), ); handsfree.spawn(); diff --git a/system/liaison/src/voice.rs b/system/liaison/src/voice.rs index 9689ed3c5..2c5c0b362 100644 --- a/system/liaison/src/voice.rs +++ b/system/liaison/src/voice.rs @@ -43,6 +43,7 @@ use tokio_stream::{StreamExt, wrappers::ReceiverStream}; use tonic::{Request, Status}; use uuid::Uuid; +use crate::keystone_gateway::KeystoneGateway; use crate::pb::audio::AudioChunk; use crate::pb::contracts::{ robonix_primitive_audio_mic_client::RobonixPrimitiveAudioMicClient, @@ -205,6 +206,7 @@ pub async fn start_voice_session( atlas: Arc>, pilot_endpoint_default: String, access: Arc, + keystone: Option>, ) -> Result>, Status> { let session_id = if req.session_id.is_empty() { Uuid::new_v4().to_string() @@ -250,6 +252,7 @@ pub async fn start_voice_session( atlas, pilot_endpoint_default, access, + keystone, tx.clone(), ) .await; @@ -287,9 +290,18 @@ async fn run_session( atlas: Arc>, pilot_endpoint_default: String, access: Arc, + keystone: Option>, tx: mpsc::Sender>, ) -> Result<()> { let mock = is_mock_mode(); + // Reject an absent, expired, or disabled account before taking microphone + // ownership or invoking ASR. The resolved identity is reused after + // transcription so caller-supplied user fields never reach Pilot. + let keystone_account = if let Some(keystone) = keystone.as_ref() { + Some(keystone.resolve_session(&req.session_token).await?) + } else { + None + }; // `record_seconds` is now a hard-stop ceiling on streaming capture // (FunASR's VAD ends the turn under most conditions; this just // protects against a sensor that never goes silent). 0 / unset → @@ -360,57 +372,120 @@ async fn run_session( anyhow::bail!("empty transcript — nothing to send to Pilot"); } - // 3. Voiceprint + access gate. ASR may already have produced a transcript, - // but no Pilot task, TTS, or action is allowed until the voice identity passes - // the Liaison access policy. Client hints cannot bypass voiceprint when the - // gate is enabled. - let identity = identify_user( - &atlas, - &req.voiceprint_node_id, - &audio_pcm, - &req.client_user_id, - &session_id, - &tx, - ) - .await; - let decision = access.authorize_voice(&req.client_user_id, identity.response.as_ref()); - let (user_id, access_context) = match decision { - access::AccessDecision::Allow { - user_id, - method, - confidence, - reason, - } => { - info!( - "[liaison/access] voice allow user={user_id} via {method:?} confidence={confidence:.2}: {reason}" - ); + // 3. Resolve the login session and apply its per-user voice guard. When the + // guard is off, Voiceprint is deliberately not called. When it is on, a + // Voiceprint outage, unknown speaker, low-confidence result, or another + // enrolled user all reject before Pilot receives a task. + let principal = if let (Some(keystone), Some(account)) = (keystone.as_ref(), keystone_account) { + if account.voice_guard_enabled { + let identity = identify_user( + &atlas, + &req.voiceprint_node_id, + &audio_pcm, + "", + &session_id, + &tx, + ) + .await; + let identified = identity.response.ok_or_else(|| { + anyhow::anyhow!("voice guard requires an available Voiceprint result") + })?; + let verified = keystone + .client_verify_voice( + &req.session_token, + &identified.user_id, + identified.confidence, + access.voice_threshold, + ) + .await?; + let account = verified + .user + .ok_or_else(|| anyhow::anyhow!("Keystone returned no verified user"))?; let _ = tx .send(Ok(event_user( KIND_USER_IDENTIFIED, &session_id, - &user_id, - confidence, - &format!("access allowed: {reason}"), + &account.user_id, + identified.confidence, + "voiceprint matched logged-in account", ))) .await; - ( - user_id, - AccessContext { - method: method.as_str().to_string(), - confidence, - reason, + VoicePrincipal { + user_id: account.user_id, + username: account.username, + display_name: account.display_name, + roles: account.roles, + access: AccessContext { + method: "voiceprint".to_string(), + confidence: identified.confidence, + reason: "voiceprint matched logged-in account".to_string(), }, - ) + } + } else { + VoicePrincipal { + user_id: account.user_id, + username: account.username, + display_name: account.display_name, + roles: account.roles, + access: AccessContext { + method: "session".to_string(), + confidence: 1.0, + reason: "voice guard disabled for logged-in account".to_string(), + }, + } } - access::AccessDecision::Deny { - user_id, - confidence, - reason, - } => { - warn!( - "[liaison/access] voice deny user={user_id} confidence={confidence:.2}: {reason}" - ); - anyhow::bail!("access denied for voice user '{user_id}': {reason}"); + } else { + let identity = identify_user( + &atlas, + &req.voiceprint_node_id, + &audio_pcm, + &req.client_user_id, + &session_id, + &tx, + ) + .await; + let decision = access.authorize_voice(&req.client_user_id, identity.response.as_ref()); + match decision { + access::AccessDecision::Allow { + user_id, + method, + confidence, + reason, + } => { + info!( + "[liaison/access] voice allow user={user_id} via {method:?} confidence={confidence:.2}: {reason}" + ); + let _ = tx + .send(Ok(event_user( + KIND_USER_IDENTIFIED, + &session_id, + &user_id, + confidence, + &format!("access allowed: {reason}"), + ))) + .await; + VoicePrincipal { + user_id, + username: String::new(), + display_name: String::new(), + roles: Vec::new(), + access: AccessContext { + method: method.as_str().to_string(), + confidence, + reason, + }, + } + } + access::AccessDecision::Deny { + user_id, + confidence, + reason, + } => { + warn!( + "[liaison/access] voice deny user={user_id} confidence={confidence:.2}: {reason}" + ); + anyhow::bail!("access denied for voice user '{user_id}': {reason}"); + } } }; @@ -422,8 +497,7 @@ async fn run_session( let task = build_task( &session_id, &transcript, - &user_id, - &access_context, + &principal, &audio_pcm, &req.context_json, ); @@ -491,7 +565,7 @@ async fn run_session( event_kind: KIND_PILOT, session_id: session_id.clone(), text: String::new(), - user_id: user_id.clone(), + user_id: principal.user_id.clone(), confidence: 0.0, pilot: Some(ev), error: String::new(), @@ -867,6 +941,14 @@ struct AccessContext { reason: String, } +struct VoicePrincipal { + user_id: String, + username: String, + display_name: String, + roles: Vec, + access: AccessContext, +} + async fn identify_user( atlas: &Arc>, pin_provider_id: &str, @@ -1073,8 +1155,7 @@ pub(crate) async fn play_prompt( fn build_task( session_id: &str, transcript: &str, - user_id: &str, - access: &AccessContext, + principal: &VoicePrincipal, audio_pcm: &[u8], extra_context_json: &str, ) -> Task { @@ -1089,14 +1170,29 @@ fn build_task( // updating pilot in lockstep. obj.insert("modality".to_string(), serde_json::json!("voice")); obj.insert("voice_session".to_string(), serde_json::json!(true)); - obj.insert("user_id".to_string(), serde_json::json!(user_id)); + obj.insert("user_id".to_string(), serde_json::json!(principal.user_id)); + if !principal.username.is_empty() { + obj.insert( + "username".to_string(), + serde_json::json!(principal.username), + ); + } + if !principal.display_name.is_empty() { + obj.insert( + "display_name".to_string(), + serde_json::json!(principal.display_name), + ); + } + if !principal.roles.is_empty() { + obj.insert("roles".to_string(), serde_json::json!(principal.roles)); + } obj.insert( "access".to_string(), serde_json::json!({ "allowed": true, - "method": access.method, - "confidence": access.confidence, - "reason": access.reason, + "method": principal.access.method, + "confidence": principal.access.confidence, + "reason": principal.access.reason, }), ); } @@ -1318,17 +1414,25 @@ mod tests { let task = build_task( "sess-1", "hello", - "voice:alice", - &AccessContext { - method: "voiceprint".to_string(), - confidence: 0.9, - reason: "matched".to_string(), + &VoicePrincipal { + user_id: "user-alice".to_string(), + username: "alice".to_string(), + display_name: "Alice".to_string(), + roles: vec!["user".to_string()], + access: AccessContext { + method: "voiceprint".to_string(), + confidence: 0.9, + reason: "matched".to_string(), + }, }, &[], r#"{"foo":"bar"}"#, ); let v: serde_json::Value = serde_json::from_str(&task.context_json).unwrap(); - assert_eq!(v["user_id"], "voice:alice"); + assert_eq!(v["user_id"], "user-alice"); + assert_eq!(v["username"], "alice"); + assert_eq!(v["display_name"], "Alice"); + assert_eq!(v["roles"], serde_json::json!(["user"])); assert_eq!(v["voice_session"], true); assert_eq!(v["access"]["allowed"], true); assert_eq!(v["access"]["method"], "voiceprint"); diff --git a/tools/rbnx/src/cmd/chat.rs b/tools/rbnx/src/cmd/chat.rs index 73f7a25f2..7a3d7245e 100644 --- a/tools/rbnx/src/cmd/chat.rs +++ b/tools/rbnx/src/cmd/chat.rs @@ -1673,6 +1673,10 @@ async fn run_tui( // ── Liaison gRPC helpers ───────────────────────────────────────────────────── +fn keystone_session_token() -> String { + std::env::var("ROBONIX_SESSION_TOKEN").unwrap_or_default() +} + fn build_text_task(session_id: &str, user_id: &str, text: &str) -> crate::pb::pilot::Task { use crate::pb::pilot::Task; const INTENT_SOURCE_TEXT: u32 = 0; @@ -1682,7 +1686,12 @@ fn build_text_task(session_id: &str, user_id: &str, text: &str) -> crate::pb::pi source: INTENT_SOURCE_TEXT, text: text.to_string(), audio_data: vec![], - context_json: serde_json::json!({"user_id": user_id, "modality": "text"}).to_string(), + context_json: serde_json::json!({ + "user_id": user_id, + "modality": "text", + "session_token": keystone_session_token(), + }) + .to_string(), timestamp_ms: now_ms(), } } @@ -1698,6 +1707,8 @@ fn build_control_task( serde_json::from_str(extra_context_json.trim()).unwrap_or_else(|_| serde_json::json!({})); if let Some(obj) = ctx.as_object_mut() { obj.entry("user_id").or_insert(serde_json::json!(user_id)); + obj.entry("session_token") + .or_insert_with(|| serde_json::json!(keystone_session_token())); } Task { task_id: Uuid::new_v4().to_string(), @@ -1771,6 +1782,7 @@ fn spawn_voice_steer( chat_cfg.speaker_cap_id.as_deref(), ), context_json: String::new(), + session_token: keystone_session_token(), }; tokio::spawn(async move { if let Ok(mut client) = RobonixSystemLiaisonVoiceClient::connect(ep).await @@ -1969,6 +1981,7 @@ async fn run_voice_session_with_esc_abort( chat_cfg.speaker_cap_id.as_deref(), ), context_json: String::new(), + session_token: keystone_session_token(), }; let (tx, mut rx) = tokio::sync::mpsc::channel::>(64); diff --git a/tools/rbnx/src/cmd/clean.rs b/tools/rbnx/src/cmd/clean.rs index 3e514c475..ab664cdad 100644 --- a/tools/rbnx/src/cmd/clean.rs +++ b/tools/rbnx/src/cmd/clean.rs @@ -151,7 +151,7 @@ fn clean_deploy(config: &Config, manifest_path: &Path, also_cache: bool) -> Resu } // system: section — non-builtin entries are real packages under // `/system//` (memory/scene/speech/…). - const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma"]; + const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "keystone", "pilot", "liaison", "soma"]; if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) && let Some(source_root) = config.robonix_source_path.as_ref() { diff --git a/tools/rbnx/src/cmd/deploy.rs b/tools/rbnx/src/cmd/deploy.rs index 2193f820c..6dc7218b4 100644 --- a/tools/rbnx/src/cmd/deploy.rs +++ b/tools/rbnx/src/cmd/deploy.rs @@ -233,6 +233,25 @@ stop: "true" assert_eq!(manifest_arg, Some(selected.to_string_lossy().as_ref())); } + + #[test] + fn liaison_receives_the_keystone_endpoint_from_the_manifest() { + let cfg: serde_yaml::Value = serde_yaml::from_str( + r#" +listen: 0.0.0.0:50081 +keystone_endpoint: 127.0.0.1:50095 +"#, + ) + .expect("liaison config"); + + let args = system_cli_args("liaison", Some(&cfg), Some("0.0.0.0:50051")); + let endpoint = args + .windows(2) + .find(|pair| pair[0] == "--keystone-endpoint") + .map(|pair| pair[1].as_str()); + + assert_eq!(endpoint, Some("127.0.0.1:50095")); + } } /// Boot-time prerequisites check: @@ -300,7 +319,9 @@ fn check_prerequisites( // otherwise `rbnx start` performs the build after spawn and the provider // registration timeout can kill a legitimate first build (Scene model // downloads are a common example). - const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma"]; + const SYSTEM_BUILTINS: &[&str] = &[ + "atlas", "executor", "keystone", "pilot", "liaison", "soma", "vitals", + ]; if let Some(source_root) = robonix_source_path { for name in deploy.system.keys() { if SYSTEM_BUILTINS.contains(&name.as_str()) { @@ -1108,6 +1129,7 @@ pub async fn execute( let bin_map: &[(&str, &str)] = &[ ("atlas", "robonix-atlas"), ("executor", "robonix-executor"), + ("keystone", "robonix-keystone"), ("soma", "robonix-soma"), ("pilot", "robonix-pilot"), ("liaison", "robonix-liaison"), @@ -1277,7 +1299,8 @@ pub async fn execute( let mut failures: Vec<(String, String, String)> = Vec::new(); // (component, name, err) if !skip_system { - let builtin_names: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma"]; + let builtin_names: &[&str] = + &["atlas", "keystone", "executor", "pilot", "liaison", "soma"]; for (key, value) in &deploy.system { if builtin_names.contains(&key.as_str()) { continue; @@ -1639,7 +1662,12 @@ fn system_listen(name: &str, cfg: Option<&serde_yaml::Value>) -> Option .get(serde_yaml::Value::String("listen".into()))? .as_str()?; let trimmed = s.trim(); - if trimmed.is_empty() || !matches!(name, "atlas" | "executor" | "pilot" | "liaison" | "soma") { + if trimmed.is_empty() + || !matches!( + name, + "atlas" | "keystone" | "executor" | "pilot" | "liaison" | "soma" + ) + { return None; } Some(trimmed.to_string()) @@ -1752,6 +1780,7 @@ fn system_cli_args( s("atlas").or_else(|| atlas_listen.map(str::to_string)), ); push_pair(&mut out, "--pilot-endpoint", s("pilot_endpoint")); + push_pair(&mut out, "--keystone-endpoint", s("keystone_endpoint")); push_pair(&mut out, "--log", s("log")); } "soma" => { diff --git a/tools/rbnx/src/cmd/run_package.rs b/tools/rbnx/src/cmd/run_package.rs index 82c035c60..c5cd42a7e 100644 --- a/tools/rbnx/src/cmd/run_package.rs +++ b/tools/rbnx/src/cmd/run_package.rs @@ -275,7 +275,7 @@ fn build_deploy_manifest( // instead of being declared with an explicit `path:` / `url:`. The // builtin Rust binaries (atlas / executor / pilot / liaison / soma) are // shipped via `cargo install` and skipped here. - const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "pilot", "liaison", "soma"]; + const SYSTEM_BUILTINS: &[&str] = &["atlas", "executor", "keystone", "pilot", "liaison", "soma"]; if let Some(map) = root.get("system").and_then(|v| v.as_mapping()) { let source_root = config.robonix_source_path.as_ref(); for (key, _value) in map {