From a7ac6028eed8a4ba78eaf1b398af4384151229e9 Mon Sep 17 00:00:00 2001 From: wheatfox Date: Mon, 27 Jul 2026 02:15:17 +0800 Subject: [PATCH 1/2] feat(keystone): add persistent user identity and access Add the canonical Keystone IDL and capability contracts, persistent account and voiceprint storage, bootstrap administrator lifecycle, and direct Atlas registration through the shared lifecycle driver. Integrate authenticated text, voice, and hands-free access through Liaison, launch Keystone as an rbnx builtin, and configure the Webots deployment for external Client discovery. Assisted-by: Codex:gpt-5.6 --- Cargo.lock | 114 ++ Cargo.toml | 12 +- Makefile | 7 +- capabilities/lib/keystone/msg/User.msg | 11 + .../lib/keystone/srv/AdminDeleteUser.srv | 3 + .../lib/keystone/srv/AdminResetVoiceprint.srv | 4 + .../lib/keystone/srv/AdminUpdateUser.srv | 7 + .../lib/keystone/srv/ChangePassword.srv | 4 + capabilities/lib/keystone/srv/GetProfile.srv | 3 + .../lib/keystone/srv/GetSystemConfig.srv | 3 + .../lib/keystone/srv/GetVoiceprintPreview.srv | 6 + capabilities/lib/keystone/srv/ListUsers.srv | 3 + capabilities/lib/keystone/srv/Login.srv | 6 + capabilities/lib/keystone/srv/Logout.srv | 2 + capabilities/lib/keystone/srv/Register.srv | 8 + .../lib/keystone/srv/ReplaceVoiceprint.srv | 6 + .../lib/keystone/srv/UnbindVoiceprint.srv | 3 + .../lib/keystone/srv/UpdateProfile.srv | 5 + .../lib/keystone/srv/UpdateSystemConfig.srv | 4 + capabilities/lib/keystone/srv/VerifyVoice.srv | 7 + capabilities/lib/liaison/srv/SetHandsfree.srv | 1 + .../lib/liaison/srv/StartVoiceSession.srv | 1 + capabilities/lifecycle/driver.v1.toml | 17 + .../system/keystone/admin_delete_user.v1.toml | 9 + .../keystone/admin_reset_voiceprint.v1.toml | 9 + .../system/keystone/admin_update_user.v1.toml | 9 + .../system/keystone/change_password.v1.toml | 9 + .../system/keystone/get_profile.v1.toml | 9 + .../system/keystone/get_system_config.v1.toml | 9 + .../keystone/get_voiceprint_preview.v1.toml | 9 + .../system/keystone/list_users.v1.toml | 9 + capabilities/system/keystone/login.v1.toml | 9 + capabilities/system/keystone/logout.v1.toml | 9 + capabilities/system/keystone/register.v1.toml | 9 + .../keystone/replace_voiceprint.v1.toml | 9 + .../system/keystone/unbind_voiceprint.v1.toml | 9 + .../system/keystone/update_profile.v1.toml | 9 + .../keystone/update_system_config.v1.toml | 9 + .../system/keystone/verify_voice.v1.toml | 9 + examples/webots/robonix_manifest.yaml | 5 + system/keystone/Cargo.toml | 43 + system/keystone/README.md | 92 +- system/keystone/build.rs | 81 ++ system/keystone/src/config.rs | 150 ++ system/keystone/src/lib.rs | 1205 +++++++++++++++++ system/keystone/src/main.rs | 463 +++++++ system/keystone/src/pb.rs | 11 + system/keystone/src/service.rs | 559 ++++++++ system/liaison/README.md | 54 +- system/liaison/src/handsfree.rs | 18 +- system/liaison/src/keystone_gateway.rs | 93 ++ system/liaison/src/main.rs | 122 +- system/liaison/src/voice.rs | 220 ++- tools/rbnx/src/cmd/chat.rs | 15 +- tools/rbnx/src/cmd/clean.rs | 2 +- tools/rbnx/src/cmd/deploy.rs | 15 +- tools/rbnx/src/cmd/run_package.rs | 2 +- 57 files changed, 3398 insertions(+), 133 deletions(-) create mode 100644 capabilities/lib/keystone/msg/User.msg create mode 100644 capabilities/lib/keystone/srv/AdminDeleteUser.srv create mode 100644 capabilities/lib/keystone/srv/AdminResetVoiceprint.srv create mode 100644 capabilities/lib/keystone/srv/AdminUpdateUser.srv create mode 100644 capabilities/lib/keystone/srv/ChangePassword.srv create mode 100644 capabilities/lib/keystone/srv/GetProfile.srv create mode 100644 capabilities/lib/keystone/srv/GetSystemConfig.srv create mode 100644 capabilities/lib/keystone/srv/GetVoiceprintPreview.srv create mode 100644 capabilities/lib/keystone/srv/ListUsers.srv create mode 100644 capabilities/lib/keystone/srv/Login.srv create mode 100644 capabilities/lib/keystone/srv/Logout.srv create mode 100644 capabilities/lib/keystone/srv/Register.srv create mode 100644 capabilities/lib/keystone/srv/ReplaceVoiceprint.srv create mode 100644 capabilities/lib/keystone/srv/UnbindVoiceprint.srv create mode 100644 capabilities/lib/keystone/srv/UpdateProfile.srv create mode 100644 capabilities/lib/keystone/srv/UpdateSystemConfig.srv create mode 100644 capabilities/lib/keystone/srv/VerifyVoice.srv create mode 100644 capabilities/lifecycle/driver.v1.toml create mode 100644 capabilities/system/keystone/admin_delete_user.v1.toml create mode 100644 capabilities/system/keystone/admin_reset_voiceprint.v1.toml create mode 100644 capabilities/system/keystone/admin_update_user.v1.toml create mode 100644 capabilities/system/keystone/change_password.v1.toml create mode 100644 capabilities/system/keystone/get_profile.v1.toml create mode 100644 capabilities/system/keystone/get_system_config.v1.toml create mode 100644 capabilities/system/keystone/get_voiceprint_preview.v1.toml create mode 100644 capabilities/system/keystone/list_users.v1.toml create mode 100644 capabilities/system/keystone/login.v1.toml create mode 100644 capabilities/system/keystone/logout.v1.toml create mode 100644 capabilities/system/keystone/register.v1.toml create mode 100644 capabilities/system/keystone/replace_voiceprint.v1.toml create mode 100644 capabilities/system/keystone/unbind_voiceprint.v1.toml create mode 100644 capabilities/system/keystone/update_profile.v1.toml create mode 100644 capabilities/system/keystone/update_system_config.v1.toml create mode 100644 capabilities/system/keystone/verify_voice.v1.toml create mode 100644 system/keystone/Cargo.toml create mode 100644 system/keystone/build.rs create mode 100644 system/keystone/src/config.rs create mode 100644 system/keystone/src/lib.rs create mode 100644 system/keystone/src/main.rs create mode 100644 system/keystone/src/pb.rs create mode 100644 system/keystone/src/service.rs create mode 100644 system/liaison/src/keystone_gateway.rs 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..936f51181 100644 --- a/examples/webots/robonix_manifest.yaml +++ b/examples/webots/robonix_manifest.yaml @@ -13,6 +13,10 @@ 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 # soma+scene layer scene: log: info @@ -33,6 +37,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..2ef0842fd 100644 --- a/system/keystone/README.md +++ b/system/keystone/README.md @@ -1,22 +1,80 @@ -# 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. -- 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 + 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..e3691d557 100644 --- a/tools/rbnx/src/cmd/deploy.rs +++ b/tools/rbnx/src/cmd/deploy.rs @@ -300,7 +300,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 +1110,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 +1280,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 +1643,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()) 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 { From 4b69c0f7d199b22827ba1c022cd0186bf30de3fa Mon Sep 17 00:00:00 2001 From: wheatfox Date: Mon, 27 Jul 2026 09:58:01 +0800 Subject: [PATCH 2/2] fix(keystone): enforce configured voice guard Pass the deployment Keystone endpoint to Liaison so authenticated voice sessions cannot fall back to the disabled legacy access gate. Keep Webots account data in a robot-owned path across checkout changes and cover the launch mapping with a regression test. Assisted-by: Codex:gpt-5.6 --- examples/webots/robonix_manifest.yaml | 6 ++++++ system/keystone/README.md | 8 +++++++- tools/rbnx/src/cmd/deploy.rs | 20 ++++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/examples/webots/robonix_manifest.yaml b/examples/webots/robonix_manifest.yaml index 936f51181..b6fd2640c 100644 --- a/examples/webots/robonix_manifest.yaml +++ b/examples/webots/robonix_manifest.yaml @@ -17,6 +17,12 @@ system: # 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 diff --git a/system/keystone/README.md b/system/keystone/README.md index 2ef0842fd..46196930d 100644 --- a/system/keystone/README.md +++ b/system/keystone/README.md @@ -14,7 +14,11 @@ Keystone stores: 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. +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`. ## First administrator @@ -39,6 +43,8 @@ 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 diff --git a/tools/rbnx/src/cmd/deploy.rs b/tools/rbnx/src/cmd/deploy.rs index e3691d557..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: @@ -1761,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" => {