From 3685babebfcf8dae43a6ca33bdc36ec10db75761 Mon Sep 17 00:00:00 2001 From: Alois Date: Mon, 14 Sep 2026 19:31:37 +0200 Subject: [PATCH] feat(tauth): rework TAuth --- Cargo.lock | 140 ++- client/src/client_connection.rs | 53 - iota-cli/src/ipc_client.rs | 23 +- iota-cli/src/ui.rs | 4 +- iota-connection/src/message_handlers.rs | 60 -- iota-daemon-lib/Cargo.toml | 4 + iota-daemon-lib/src/command_router.rs | 96 +- iota-daemon-lib/src/lib.rs | 1 + iota-daemon-lib/src/tauth_apps.rs | 317 ++++++ iota-ipc/src/lib.rs | 5 +- iota-ipc/src/protocol.rs | 69 +- iota-ipc/src/text_commands.rs | 58 +- iota-storage/Cargo.toml | 1 + iota-storage/src/lib.rs | 1 + iota-storage/src/tauth.rs | 1005 +++++++++++++++++++ iota-storage/src/users/user_manager.rs | 155 ++- iota-storage/src/users/user_profile.rs | 12 - iota-storage/src/util/db.rs | 71 +- iota-util/Cargo.toml | 2 + iota-util/src/file_util.rs | 29 + iota-util/src/lib.rs | 1 + iota-util/src/ta.rs | 275 +++++ iota/Cargo.toml | 1 + iota/src/cli_args.rs | 113 +++ iota/src/main.rs | 189 +++- mtp-type-maps | 2 +- omikron-connector/Cargo.toml | 7 + omikron-connector/src/lib.rs | 1 + omikron-connector/src/omikron_connection.rs | 766 +++++++++----- omikron-connector/src/tauth.rs | 490 +++++++++ todo.md | 5 + 31 files changed, 3436 insertions(+), 520 deletions(-) create mode 100644 iota-daemon-lib/src/tauth_apps.rs create mode 100644 iota-storage/src/tauth.rs create mode 100644 iota-util/src/ta.rs create mode 100644 omikron-connector/src/tauth.rs create mode 100644 todo.md diff --git a/Cargo.lock b/Cargo.lock index 19f0d62..1627bab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1304,6 +1304,18 @@ dependencies = [ "simdutf8", ] +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2018,6 +2030,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "1.1.0" @@ -2090,6 +2112,7 @@ dependencies = [ name = "iota" version = "0.1.0" dependencies = [ + "base64 0.22.1", "clap", "iota-cli", "iota-core", @@ -2199,6 +2222,8 @@ name = "iota-daemon-lib" version = "0.1.0" dependencies = [ "async-trait", + "base64 0.22.1", + "hex", "iota-auth", "iota-connection", "iota-identity", @@ -2212,12 +2237,14 @@ dependencies = [ "mtp", "omikron-connector", "other-iota", + "serde", "serde_json", "serde_yaml", "sysinfo", "tempfile", "tokio", "tokio-util", + "url", "uuid", ] @@ -2311,6 +2338,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2 0.10.9", "tempfile", "thiserror 2.0.20", "tokio", @@ -2355,14 +2383,29 @@ dependencies = [ "iota-paths", "mtp", "reqwest", + "sha2 0.10.9", "sysinfo", "tempfile", "tokio", + "url", "uuid", "walkdir", "zip", ] +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + [[package]] name = "ipnet" version = "2.12.2" @@ -2579,6 +2622,12 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2638,6 +2687,15 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -3125,6 +3183,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "dashmap", + "hex", "iota-auth", "iota-connection", "iota-identity", @@ -3136,8 +3195,14 @@ dependencies = [ "mtp", "rand_core 0.6.4", "reqwest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.20", "tokio", "tokio-util", + "trust-dns-resolver", + "url", "uuid", ] @@ -3948,6 +4013,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + [[package]] name = "ring" version = "0.17.14" @@ -4921,6 +4992,52 @@ dependencies = [ "once_cell", ] +[[package]] +name = "trust-dns-proto" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3119112651c157f4488931a01e586aa459736e9d6046d3bd9105ffb69352d374" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "enum-as-inner", + "futures-channel", + "futures-io", + "futures-util", + "idna 0.4.0", + "ipnet", + "once_cell", + "rand 0.8.8", + "smallvec", + "thiserror 1.0.69", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a3e6c3aff1718b3c73e395d1f35202ba2ffa847c6a62eea0db8fb4cfe30be6" +dependencies = [ + "cfg-if", + "futures-util", + "ipconfig", + "lru-cache", + "once_cell", + "parking_lot", + "rand 0.8.8", + "resolv-conf", + "smallvec", + "thiserror 1.0.69", + "tokio", + "tracing", + "trust-dns-proto", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -4939,12 +5056,27 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -5009,7 +5141,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna", + "idna 1.1.0", "percent-encoding", "serde", ] @@ -5283,6 +5415,12 @@ dependencies = [ "wezterm-dynamic", ] +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + [[package]] name = "winapi" version = "0.3.9" diff --git a/client/src/client_connection.rs b/client/src/client_connection.rs index 6a793e8..fd128b6 100644 --- a/client/src/client_connection.rs +++ b/client/src/client_connection.rs @@ -157,59 +157,6 @@ impl ClientConnection { return; } - if cv.is_type(CommunicationType::SaveAppData) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let _app_data = cv - .get_data(DataType::AppData) - .as_str() - .unwrap_or("") - .to_string(); - - let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_request_id(&cv) - .with_receiver(sender_id); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::LoadAppData) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - self.send_message(&error_response(&cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let app_data = String::new(); - - let res = CommunicationValue::new(CommunicationType::LoadAppData) - .with_request_id(&cv) - .with_receiver(sender_id) - .add_typed_default(DataType::AppData, DataValue::Str(app_data)); - self.send_message(&res).await; - return; - } - - if cv.is_type(CommunicationType::CreateApp) { - self.send_message(&message_handlers::handle_create_app(&cv)) - .await; - return; - } - - if cv.is_type(CommunicationType::DeleteApp) { - self.send_message(&message_handlers::handle_delete_app(&cv)) - .await; - return; - } - if cv.is_type(CommunicationType::AccountStateRequest) { // Account initialization is routed through Omikron. self.send_message( diff --git a/iota-cli/src/ipc_client.rs b/iota-cli/src/ipc_client.rs index 2a32219..1e4477a 100644 --- a/iota-cli/src/ipc_client.rs +++ b/iota-cli/src/ipc_client.rs @@ -491,6 +491,21 @@ impl IpcClient { .join("\n") } } + ResponsePayload::TAuthApps(apps) => apps + .iter() + .map(|app| format!("{} {} {}", app.app_id, app.domain, app.connection_mode)) + .collect::>() + .join("\n"), + ResponsePayload::TAuthApp(app) => format!( + "TAuth app {}: domain={}, owner={}, mode={}, endpoint={}", + app.app_id, app.domain, app.owner_user_id, app.connection_mode, app.endpoint_url + ), + ResponsePayload::TAuthAppCreated { app, .. } => { + format!("Created TAuth app {} for {}", app.app_id, app.domain) + } + ResponsePayload::TAuthAppCredentialExport { .. } => { + "TAuth credential export payload withheld.".into() + } ResponsePayload::InvitationCreated(invitation) => format!( "Created {:?} invitation {}\nToken: {}\nExpires: {}{}{}", invitation.authority, @@ -538,12 +553,12 @@ impl IpcClient { format!("Reconciled user {}: {:?}", result.user_id, result.action) } ResponsePayload::UserDiagnostics(diagnostics) => format!( - "User {}: state={:?}, data={}, credential={:?}, trusted_apps={}", + "User {}: state={:?}, data={}, credential={:?}, tauth_grants={}", diagnostics.user_id, diagnostics.local_state, diagnostics.data_present, diagnostics.credential_status, - diagnostics.trusted_app_count, + diagnostics.tauth_grant_count, ), ResponsePayload::UserCredentialExport { .. } => { "Credential export payload withheld.".into() @@ -588,8 +603,8 @@ impl IpcClient { msg.push_str(&format!("\nDisplay Name: {name}")); } msg.push_str(&format!("\nCreated At: {}", user.created_at)); - if !user.trusted_apps.is_empty() { - msg.push_str(&format!("\nTrusted Apps: {}", user.trusted_apps.join(", "))); + if !user.tauth_grants.is_empty() { + msg.push_str(&format!("\nTAuth grants: {}", user.tauth_grants.join(", "))); } msg } diff --git a/iota-cli/src/ui.rs b/iota-cli/src/ui.rs index 0ef7cf2..d0f08de 100644 --- a/iota-cli/src/ui.rs +++ b/iota-cli/src/ui.rs @@ -1030,7 +1030,7 @@ fn spawn_ipc_effect( Ok(iota_ipc::ResponseResult::Ok( iota_ipc::ResponsePayload::UserDiagnostics(diagnostics), )) => Ok(format!( - "Diagnostics: state={:?}, data={}, credential={}, trusted apps={}, pending={}", + "Diagnostics: state={:?}, data={}, credential={}, TAuth grants={}, pending={}", diagnostics.local_state, if diagnostics.data_present { "present" @@ -1038,7 +1038,7 @@ fn spawn_ipc_effect( "empty" }, credential_status_label(diagnostics.credential_status), - diagnostics.trusted_app_count, + diagnostics.tauth_grant_count, diagnostics .pending_operation .unwrap_or_else(|| "none".into()), diff --git a/iota-connection/src/message_handlers.rs b/iota-connection/src/message_handlers.rs index f3ed19e..fe1880e 100644 --- a/iota-connection/src/message_handlers.rs +++ b/iota-connection/src/message_handlers.rs @@ -941,66 +941,6 @@ pub fn handle_get_chat_secret(cv: &CommunicationValue) -> CommunicationValue { } } -pub fn handle_create_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() && !app_public_key.is_empty() { - let user = match iota_storage::users::user_manager::get_user(sender_id) { - Ok(user) => user, - Err(_) => return error_response(cv, CommunicationType::ErrorInternal), - }; - if let Some(mut user) = user { - if !user.trusted_apps.contains_key(&app_identifier) { - user.trusted_apps.insert(app_identifier, app_public_key); - if iota_storage::users::user_manager::update_user(user).is_err() { - return error_response(cv, CommunicationType::ErrorInternal); - } - } - } - } - - CommunicationValue::new(CommunicationType::CreateApp) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - -pub fn handle_delete_app(cv: &CommunicationValue) -> CommunicationValue { - let sender_id = match required_sender_id(cv) { - Ok(sender_id) => sender_id, - Err(response) => return response, - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - - if !app_identifier.is_empty() { - if iota_storage::users::user_manager::revoke_trusted_app(sender_id, &app_identifier) - .is_err() - { - return error_response(cv, CommunicationType::ErrorInternal); - } - } - - CommunicationValue::new(CommunicationType::DeleteApp) - .with_request_id(cv) - .with_receiver(sender_wire_id(sender_id)) -} - fn contact_value( contact: &iota_storage::users::contact::Contact, messages: &[chat_files::StoredMessage], diff --git a/iota-daemon-lib/Cargo.toml b/iota-daemon-lib/Cargo.toml index 1cdad75..dc665ab 100644 --- a/iota-daemon-lib/Cargo.toml +++ b/iota-daemon-lib/Cargo.toml @@ -5,6 +5,8 @@ edition = "2024" [dependencies] async-trait = "0.1.89" +base64 = "0.22" +hex = "0.4" iota-ipc = { path = "../iota-ipc" } iota-auth = { path = "../iota-auth" } iota-connection = { path = "../iota-connection" } @@ -20,9 +22,11 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c2 libc = "0.2" sysinfo = "0.38.0" serde_yaml = "0.9" +serde = { version = "1", features = ["derive"] } serde_json = "1" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } +url = "2" uuid = { version = "*", features = ["v4"] } [dev-dependencies] diff --git a/iota-daemon-lib/src/command_router.rs b/iota-daemon-lib/src/command_router.rs index bcb9276..9fb5b86 100644 --- a/iota-daemon-lib/src/command_router.rs +++ b/iota-daemon-lib/src/command_router.rs @@ -280,6 +280,67 @@ impl CommandRouter { }; ResponseResult::Ok(ResponsePayload::Users(users)) } + LocalRequest::ListTAuthApps => match crate::tauth_apps::list() { + Ok(apps) => ResponseResult::Ok(ResponsePayload::TAuthApps(apps)), + Err(error) => { + log!("TAuth app listing failed: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + LocalRequest::GetTAuthApp { app_id } => match crate::tauth_apps::get(&app_id) { + Ok(Some(app)) => ResponseResult::Ok(ResponsePayload::TAuthApp(app)), + Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(error) => { + log!("TAuth app lookup failed: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + LocalRequest::CreateTAuthApp { + owner_user_id, + domain, + name, + redirects, + connection, + } => { + match crate::tauth_apps::create(owner_user_id, domain, name, redirects, connection) + { + Ok(created) => ResponseResult::Ok(ResponsePayload::TAuthAppCreated { + app: created.app, + credential: created.credential, + txt_record: created.txt_record, + manifest_template: created.manifest_template, + }), + Err(error) => { + log!("TAuth app creation failed: {error}"); + ResponseResult::Error(IpcErrorCode::InvalidRequest) + } + } + } + LocalRequest::ExportTAuthApp { app_id } => match crate::tauth_apps::export(&app_id) { + Ok(Some(credential)) => { + ResponseResult::Ok(ResponsePayload::TAuthAppCredentialExport { + app_id, + credential, + }) + } + Ok(None) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(error) => { + log!("TAuth app export failed: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, + LocalRequest::DeleteTAuthApp { app_id } => match crate::tauth_apps::delete(&app_id) { + Ok(true) => ResponseResult::Ok(ResponsePayload::Acknowledged { + message: format!( + "Deleted TAuth app {app_id}. Remove its DNS TXT record and HTTPS manifest." + ), + }), + Ok(false) => ResponseResult::Error(IpcErrorCode::NotFound), + Err(error) => { + log!("TAuth app deletion failed: {error}"); + ResponseResult::Error(IpcErrorCode::StorageFailure) + } + }, LocalRequest::CreateInvitation { authority, lifetime_seconds, @@ -730,33 +791,30 @@ impl CommandRouter { }, data_present: residency.data_present, credential_status, - trusted_app_count: profile - .as_ref() - .map_or(0, |profile| profile.trusted_apps.len()), + tauth_grant_count: iota_storage::tauth::list_grants(user_id) + .map_or(0, |grants| grants.len()), pending_operation, })) } - LocalRequest::RevokeTrustedApp { user_id, app_id } => { - match user_manager::revoke_trusted_app(user_id, &app_id) { + LocalRequest::RevokeTAuthGrant { user_id, app_id } => { + match user_manager::revoke_tauth_grant(user_id, &app_id) { Ok(true) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!("Revoked trusted application {app_id} for user {user_id}"), + message: format!("Revoked TAuth grant {app_id} for user {user_id}"), }), Ok(false) => ResponseResult::Error(IpcErrorCode::NotFound), Err(error) => { - log!("Trusted application revocation failed for {user_id}: {error}"); + log!("TAuth grant revocation failed for {user_id}: {error}"); ResponseResult::Error(IpcErrorCode::StorageFailure) } } } - LocalRequest::RevokeAllTrustedApps { user_id } => { - match user_manager::revoke_all_trusted_apps(user_id) { + LocalRequest::RevokeAllTAuthGrants { user_id } => { + match user_manager::revoke_all_tauth_grants(user_id) { Ok(removed) => ResponseResult::Ok(ResponsePayload::Acknowledged { - message: format!( - "Revoked {removed} trusted applications for user {user_id}" - ), + message: format!("Revoked {removed} TAuth grants for user {user_id}"), }), Err(error) => { - log!("Trusted application revocation failed for {user_id}: {error}"); + log!("TAuth grant revocation failed for {user_id}: {error}"); ResponseResult::Error(IpcErrorCode::StorageFailure) } } @@ -935,7 +993,11 @@ impl CommandRouter { username: user.username, display_name: user.display_name, created_at: user.created_at, - trusted_apps: user.trusted_apps.keys().cloned().collect(), + tauth_grants: iota_storage::tauth::list_grants(user_id) + .map(|grants| { + grants.into_iter().map(|grant| grant.app_id).collect() + }) + .unwrap_or_default(), state: iota_ipc::LocalUserState::Managed, data_present: residency.data_present, credential_status, @@ -947,7 +1009,7 @@ impl CommandRouter { username: residency.username, display_name: None, created_at: 0, - trusted_apps: Vec::new(), + tauth_grants: Vec::new(), state: iota_ipc::LocalUserState::Released, data_present: residency.data_present, credential_status: iota_ipc::CredentialStatus::None, @@ -1055,11 +1117,11 @@ mod tests { LocalRequest::ForceDetachUser { user_id: 1 }, LocalRequest::ForgetReleasedUser { user_id: 1 }, LocalRequest::GetUserDiagnostics { user_id: 1 }, - LocalRequest::RevokeTrustedApp { + LocalRequest::RevokeTAuthGrant { user_id: 1, app_id: "desktop".into(), }, - LocalRequest::RevokeAllTrustedApps { user_id: 1 }, + LocalRequest::RevokeAllTAuthGrants { user_id: 1 }, LocalRequest::ExportUserCredential { user_id: 1 }, LocalRequest::PurgeUserData { user_id: 1 }, LocalRequest::ReleaseUser { user_id: 1 }, diff --git a/iota-daemon-lib/src/lib.rs b/iota-daemon-lib/src/lib.rs index 23b021a..fa17162 100644 --- a/iota-daemon-lib/src/lib.rs +++ b/iota-daemon-lib/src/lib.rs @@ -7,6 +7,7 @@ pub mod log_broadcaster; pub mod log_buffer; pub mod services; pub mod task_registry; +mod tauth_apps; pub use accounts::{AccountAuthority, LocalIotaAccountAuthority, OmegaAccountAuthority}; pub use command_router::{CommandRouter, IpcRole, PeerContext}; diff --git a/iota-daemon-lib/src/tauth_apps.rs b/iota-daemon-lib/src/tauth_apps.rs new file mode 100644 index 0000000..6a6ab99 --- /dev/null +++ b/iota-daemon-lib/src/tauth_apps.rs @@ -0,0 +1,317 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use iota_ipc::{SecretString, TAuthAppSummary, TAuthConnectionInput}; +use iota_storage::tauth::{self, OwnerApp}; +use iota_util::crypto_helper::{public_key_bundle_from_base64, public_key_bundle_to_base64}; +use iota_util::ta::{ConnectionMode, TaCredential}; +use mtp::crypto::{Ed25519Signer, Keyring, SignatureScheme}; +use serde::Serialize; +use std::collections::BTreeSet; +use url::Url; + +#[derive(Serialize)] +struct UnsignedOwnerCertificate<'a> { + version: u16, + app_id: &'a str, + app_public_key: &'a str, + owner_principal: &'a str, + owner_public_key: &'a str, + owner_iota_id: u64, + issued_at: i64, +} + +#[derive(Serialize)] +struct OwnerCertificate<'a> { + version: u16, + app_id: &'a str, + app_public_key: &'a str, + owner_principal: &'a str, + owner_public_key: &'a str, + owner_iota_id: u64, + issued_at: i64, + signature: String, +} + +#[derive(Serialize)] +struct UnsignedManifest<'a> { + version: u16, + app_id: &'a str, + public_key: &'a str, + name: &'a str, + domain: &'a str, + redirects: &'a [String], + owner_certificate: &'a str, +} + +#[derive(Serialize)] +struct Manifest<'a> { + version: u16, + app_id: &'a str, + public_key: &'a str, + name: &'a str, + domain: &'a str, + redirects: &'a [String], + owner_certificate: &'a str, + signature: String, +} + +pub struct CreatedApp { + pub app: TAuthAppSummary, + pub credential: SecretString, + pub txt_record: String, + pub manifest_template: String, +} + +pub fn list() -> Result, String> { + tauth::list_owner_apps() + .map(|apps| apps.into_iter().map(summary).collect()) + .map_err(|error| error.to_string()) +} + +pub fn get(app_id: &str) -> Result, String> { + tauth::get_owner_app(app_id) + .map(|app| app.map(summary)) + .map_err(|error| error.to_string()) +} + +pub fn export(app_id: &str) -> Result, String> { + iota_util::file_util::read_tauth_credential(app_id) + .map(|credential| credential.map(|bytes| SecretString(STANDARD.encode(bytes)))) + .map_err(|error| error.to_string()) +} + +pub fn delete(app_id: &str) -> Result { + if tauth::get_owner_app(app_id) + .map_err(|error| error.to_string())? + .is_none() + { + return Ok(false); + } + iota_util::file_util::remove_tauth_credential(app_id).map_err(|error| error.to_string())?; + tauth::delete_owner_app(app_id).map_err(|error| error.to_string()) +} + +pub fn create( + owner_user_id: i64, + domain: String, + name: String, + redirects: Vec, + connection: TAuthConnectionInput, +) -> Result { + let domain = canonical_domain(domain)?; + let name = name.trim().to_owned(); + if name.is_empty() { + return Err("app name is empty".into()); + } + let redirects = redirects + .into_iter() + .map(|redirect| validate_redirect(&redirect).map(|()| redirect)) + .collect::, _>>()? + .into_iter() + .collect::>(); + if redirects.is_empty() { + return Err("at least one redirect is required".into()); + } + let owner_iota_id = iota_storage::util::config_util::CONFIG + .load() + .iota_id + .ok_or_else(|| "Iota ID is not configured".to_string())?; + let owner = iota_storage::users::user_manager::get_user(owner_user_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "owner user does not exist".to_string())?; + let owner_credential = + iota_util::file_util::read_user_credential_with_legacy(owner_user_id, &owner.username) + .map_err(|error| error.to_string())? + .ok_or_else(|| "owner user credential is unavailable".to_string())?; + let owner_credential = + iota_util::tu::TuCredential::parse(&owner_credential).map_err(|error| error.to_string())?; + let owner_principal = owner_credential + .principal() + .map_err(|error| error.to_string())?; + let owner_principal = format!( + "{}#{}", + owner_principal.authority.as_str(), + owner_principal.user_id + ); + let owner_public_key = public_key_bundle_to_base64(&owner_credential.public_key_bundle()); + + let keyring = Keyring::generate(); + let app_id = iota_util::ta::app_id(&keyring.public_key_bundle()); + let app_public_key = public_key_bundle_to_base64(&keyring.public_key_bundle()); + let issued_at = tauth::now_seconds(); + let unsigned_certificate = UnsignedOwnerCertificate { + version: 1, + app_id: &app_id, + app_public_key: &app_public_key, + owner_principal: &owner_principal, + owner_public_key: &owner_public_key, + owner_iota_id, + issued_at, + }; + let owner_signer = Ed25519Signer::new(&owner_credential.keyring.sig_cl_secret_key) + .map_err(|error| error.to_string())?; + let owner_signature = owner_signer + .sign(&serde_json::to_vec(&unsigned_certificate).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + let certificate = OwnerCertificate { + version: unsigned_certificate.version, + app_id: unsigned_certificate.app_id, + app_public_key: unsigned_certificate.app_public_key, + owner_principal: unsigned_certificate.owner_principal, + owner_public_key: unsigned_certificate.owner_public_key, + owner_iota_id: unsigned_certificate.owner_iota_id, + issued_at: unsigned_certificate.issued_at, + signature: STANDARD.encode(owner_signature), + }; + let certificate_bytes = serde_json::to_vec(&certificate).map_err(|error| error.to_string())?; + let owner_certificate = STANDARD.encode(&certificate_bytes); + + let unsigned_manifest = UnsignedManifest { + version: 1, + app_id: &app_id, + public_key: &app_public_key, + name: &name, + domain: &domain, + redirects: &redirects, + owner_certificate: &owner_certificate, + }; + let app_signer = + Ed25519Signer::new(&keyring.sig_cl_secret_key).map_err(|error| error.to_string())?; + let manifest_signature = app_signer + .sign(&serde_json::to_vec(&unsigned_manifest).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string())?; + let manifest = Manifest { + version: unsigned_manifest.version, + app_id: unsigned_manifest.app_id, + public_key: unsigned_manifest.public_key, + name: unsigned_manifest.name, + domain: unsigned_manifest.domain, + redirects: unsigned_manifest.redirects, + owner_certificate: unsigned_manifest.owner_certificate, + signature: STANDARD.encode(manifest_signature), + }; + let manifest_template = + serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + + let (mode, connection_mode, endpoint_url, omikron_public_key) = match connection { + TAuthConnectionInput::Hosted { omega_url } => { + let omega_url = validate_https(&omega_url)?; + ( + ConnectionMode::Hosted { + omega_url: omega_url.clone(), + owner_iota_id, + }, + "hosted".to_owned(), + omega_url.to_string(), + None, + ) + } + TAuthConnectionInput::ForcedOmikron { + omikron_url, + omikron_public_key, + } => { + let omikron_url = validate_https(&omikron_url)?; + let key = public_key_bundle_from_base64(&omikron_public_key) + .ok_or_else(|| "forced Omikron public key is invalid".to_string())?; + ( + ConnectionMode::ForcedOmikron { + omikron_url: omikron_url.clone(), + omikron_public_key: key, + owner_iota_id, + }, + "forced_omikron".to_owned(), + omikron_url.to_string(), + Some(omikron_public_key), + ) + } + }; + let credential = TaCredential { + keyring, + owner_certificate: certificate_bytes, + mode, + }; + let credential_bytes = credential.to_bytes().map_err(|error| error.to_string())?; + let credential_path = + iota_util::file_util::tauth_credential_path(&app_id).map_err(|error| error.to_string())?; + credential + .export(&credential_path) + .map_err(|error| error.to_string())?; + + let app = OwnerApp { + app_id: app_id.clone(), + owner_user_id, + domain: domain.clone(), + public_key: app_public_key, + owner_certificate, + connection_mode, + endpoint_url, + omikron_public_key, + created_at: issued_at, + }; + if let Err(error) = tauth::register_owner_app(&app) { + let _ = iota_util::file_util::remove_tauth_credential(&app_id); + return Err(error.to_string()); + } + let manifest_hash = hex::encode(mtp::crypto::sha256(manifest_template.as_bytes())); + Ok(CreatedApp { + app: summary(app), + credential: SecretString(STANDARD.encode(credential_bytes)), + txt_record: format!("v=TAUTH1;app={app_id};manifest={manifest_hash}"), + manifest_template, + }) +} + +fn summary(app: OwnerApp) -> TAuthAppSummary { + TAuthAppSummary { + app_id: app.app_id, + owner_user_id: app.owner_user_id, + domain: app.domain, + public_key: app.public_key, + connection_mode: app.connection_mode, + endpoint_url: app.endpoint_url, + created_at: app.created_at, + } +} + +fn canonical_domain(domain: String) -> Result { + let domain = domain.trim().to_ascii_lowercase(); + if domain.is_empty() + || domain.contains(['/', ':']) + || domain.split('.').any(|label| { + label.is_empty() + || label.starts_with('-') + || label.ends_with('-') + || !label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') + }) + { + return Err("app domain is invalid".into()); + } + Ok(domain) +} + +fn validate_redirect(value: &str) -> Result<(), String> { + let url = Url::parse(value).map_err(|error| error.to_string())?; + let loopback = url.scheme() == "http" + && matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + || url + .query_pairs() + .any(|(key, _)| matches!(key.as_ref(), "code" | "state" | "locator" | "error")) + || (url.scheme() != "https" && !loopback) + { + return Err("redirect must be exact HTTPS, or HTTP loopback, without a fragment".into()); + } + Ok(()) +} + +fn validate_https(value: &str) -> Result { + let url = Url::parse(value).map_err(|error| error.to_string())?; + if url.scheme() != "https" || url.host_str().is_none() { + return Err("connection URL must use HTTPS".into()); + } + Ok(url) +} diff --git a/iota-ipc/src/lib.rs b/iota-ipc/src/lib.rs index f8c251f..3e2f2c8 100644 --- a/iota-ipc/src/lib.rs +++ b/iota-ipc/src/lib.rs @@ -10,8 +10,9 @@ pub use protocol::{ LocalRequest, LocalUserState, LogEntriesResponse, LogEntry, MetricSample, OmikronStatusResponse, ReconcileAction, RequestEnvelope, ResponseEnvelope, ResponsePayload, ResponseResult, SecretString, StartupPhase, StateSnapshot, StatusResponse, SupervisorKind, - TaskSummary, TuCredentialPreview, UpdateStatusResponse, UserDetailResponse, UserDiagnostics, - UserOperationKind, UserOperationSummary, UserReconcileResult, UserSummary, + TAuthAppSummary, TAuthConnectionInput, TaskSummary, TuCredentialPreview, UpdateStatusResponse, + UserDetailResponse, UserDiagnostics, UserOperationKind, UserOperationSummary, + UserReconcileResult, UserSummary, }; pub use transport::{MAX_MESSAGE_SIZE, read_msg, write_msg}; diff --git a/iota-ipc/src/protocol.rs b/iota-ipc/src/protocol.rs index 26e9e9a..09e881b 100644 --- a/iota-ipc/src/protocol.rs +++ b/iota-ipc/src/protocol.rs @@ -45,6 +45,23 @@ pub enum LocalRequest { GetStatus, ListTasks, ListUsers, + ListTAuthApps, + GetTAuthApp { + app_id: String, + }, + CreateTAuthApp { + owner_user_id: i64, + domain: String, + name: String, + redirects: Vec, + connection: TAuthConnectionInput, + }, + ExportTAuthApp { + app_id: String, + }, + DeleteTAuthApp { + app_id: String, + }, CreateInvitation { authority: InvitationAuthority, lifetime_seconds: u64, @@ -79,11 +96,11 @@ pub enum LocalRequest { GetUserDiagnostics { user_id: i64, }, - RevokeTrustedApp { + RevokeTAuthGrant { user_id: i64, app_id: String, }, - RevokeAllTrustedApps { + RevokeAllTAuthGrants { user_id: i64, }, ExportUserCredential { @@ -161,6 +178,8 @@ impl LocalRequest { Self::GetStatus | Self::ListTasks | Self::ListUsers + | Self::ListTAuthApps + | Self::GetTAuthApp { .. } | Self::ListInvitations { .. } | Self::GetDaemonStatus | Self::GetOmikronStatus @@ -174,6 +193,9 @@ impl LocalRequest { Self::ReconnectOmikron | Self::ReloadConfig => IpcRole::Operate, Self::CreateUser { .. } + | Self::CreateTAuthApp { .. } + | Self::ExportTAuthApp { .. } + | Self::DeleteTAuthApp { .. } | Self::CreateInvitation { .. } | Self::RevokeInvitation { .. } | Self::InspectTuCredential { .. } @@ -181,8 +203,8 @@ impl LocalRequest { | Self::ReconcileUser { .. } | Self::ForceDetachUser { .. } | Self::ForgetReleasedUser { .. } - | Self::RevokeTrustedApp { .. } - | Self::RevokeAllTrustedApps { .. } + | Self::RevokeTAuthGrant { .. } + | Self::RevokeAllTAuthGrants { .. } | Self::ExportUserCredential { .. } | Self::PurgeUserData { .. } | Self::ReleaseUser { .. } @@ -265,6 +287,18 @@ pub enum ResponsePayload { Status(StatusResponse), Tasks(Vec), Users(Vec), + TAuthApps(Vec), + TAuthApp(TAuthAppSummary), + TAuthAppCreated { + app: TAuthAppSummary, + credential: SecretString, + txt_record: String, + manifest_template: String, + }, + TAuthAppCredentialExport { + app_id: String, + credential: SecretString, + }, InvitationCreated(InvitationCreated), Invitations(Vec), InvitationUpdated(InvitationSummary), @@ -311,6 +345,29 @@ pub struct OmikronStatusResponse { pub iota_id: Option, } +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(tag = "mode", rename_all = "snake_case")] +pub enum TAuthConnectionInput { + Hosted { + omega_url: String, + }, + ForcedOmikron { + omikron_url: String, + omikron_public_key: String, + }, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct TAuthAppSummary { + pub app_id: String, + pub owner_user_id: i64, + pub domain: String, + pub public_key: String, + pub connection_mode: String, + pub endpoint_url: String, + pub created_at: i64, +} + #[derive(Clone, Debug, Deserialize, Serialize)] pub struct ComponentStatusResponse { pub id: ComponentId, @@ -324,7 +381,7 @@ pub struct UserDetailResponse { pub username: String, pub display_name: Option, pub created_at: i64, - pub trusted_apps: Vec, + pub tauth_grants: Vec, pub state: LocalUserState, pub data_present: bool, pub credential_status: CredentialStatus, @@ -467,7 +524,7 @@ pub struct UserDiagnostics { pub local_state: LocalUserState, pub data_present: bool, pub credential_status: CredentialStatus, - pub trusted_app_count: usize, + pub tauth_grant_count: usize, pub pending_operation: Option, } diff --git a/iota-ipc/src/text_commands.rs b/iota-ipc/src/text_commands.rs index 1eccdf7..c2c7e31 100644 --- a/iota-ipc/src/text_commands.rs +++ b/iota-ipc/src/text_commands.rs @@ -15,6 +15,11 @@ pub const COMMANDS: &[&str] = &[ "users forget ", "users apps revoke ", "users apps revoke-all ", + "apps list", + "apps show ", + "apps create hosted ", + "apps create forced-omikron ", + "apps delete ", "omikron status", "reconnect", "identity rotate", @@ -97,16 +102,61 @@ pub fn parse(line: &str) -> Option { user_id: id_str.parse::().ok()?, }), ["user" | "users", "apps", "revoke", id_str, app_id] => { - Some(LocalRequest::RevokeTrustedApp { + Some(LocalRequest::RevokeTAuthGrant { user_id: id_str.parse::().ok()?, app_id: app_id.to_string(), }) } ["user" | "users", "apps", "revoke-all", id_str] => { - Some(LocalRequest::RevokeAllTrustedApps { + Some(LocalRequest::RevokeAllTAuthGrants { user_id: id_str.parse::().ok()?, }) } + ["apps", "list"] => Some(LocalRequest::ListTAuthApps), + ["apps", "show", app_id] => Some(LocalRequest::GetTAuthApp { + app_id: app_id.to_string(), + }), + [ + "apps", + "create", + "hosted", + owner, + domain, + name, + redirect, + omega_url, + ] => Some(LocalRequest::CreateTAuthApp { + owner_user_id: owner.parse().ok()?, + domain: domain.to_string(), + name: name.to_string(), + redirects: vec![redirect.to_string()], + connection: crate::TAuthConnectionInput::Hosted { + omega_url: omega_url.to_string(), + }, + }), + [ + "apps", + "create", + "forced-omikron", + owner, + domain, + name, + redirect, + omikron_url, + omikron_public_key, + ] => Some(LocalRequest::CreateTAuthApp { + owner_user_id: owner.parse().ok()?, + domain: domain.to_string(), + name: name.to_string(), + redirects: vec![redirect.to_string()], + connection: crate::TAuthConnectionInput::ForcedOmikron { + omikron_url: omikron_url.to_string(), + omikron_public_key: omikron_public_key.to_string(), + }, + }), + ["apps", "delete", app_id, "confirm"] => Some(LocalRequest::DeleteTAuthApp { + app_id: app_id.to_string(), + }), ["reconnect"] => Some(LocalRequest::ReconnectOmikron), ["regenerate", "keys"] | ["identity", "rotate"] => Some(LocalRequest::RotateIotaIdentity), ["reload"] | ["restart"] => Some(LocalRequest::RequestProcessExit { @@ -174,11 +224,11 @@ mod tests { )); assert!(matches!( parse("users apps revoke 42 desktop"), - Some(LocalRequest::RevokeTrustedApp { user_id: 42, .. }) + Some(LocalRequest::RevokeTAuthGrant { user_id: 42, .. }) )); assert!(matches!( parse("users apps revoke-all 42"), - Some(LocalRequest::RevokeAllTrustedApps { user_id: 42 }) + Some(LocalRequest::RevokeAllTAuthGrants { user_id: 42 }) )); assert!(matches!( parse("identity rotate"), diff --git a/iota-storage/Cargo.toml b/iota-storage/Cargo.toml index 6eac45b..4a77d10 100644 --- a/iota-storage/Cargo.toml +++ b/iota-storage/Cargo.toml @@ -17,6 +17,7 @@ r2d2 = "0.8" serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +sha2 = "0.10" thiserror = "2" rand = "0.8" rusqlite = "0.40.0" diff --git a/iota-storage/src/lib.rs b/iota-storage/src/lib.rs index 1c8813d..d6f90a4 100644 --- a/iota-storage/src/lib.rs +++ b/iota-storage/src/lib.rs @@ -1,5 +1,6 @@ pub mod identity; pub mod node_directory; pub mod storage_error; +pub mod tauth; pub mod users; pub mod util; diff --git a/iota-storage/src/tauth.rs b/iota-storage/src/tauth.rs new file mode 100644 index 0000000..c21bfa1 --- /dev/null +++ b/iota-storage/src/tauth.rs @@ -0,0 +1,1005 @@ +use crate::storage_error::StorageError; +use crate::util::db; +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use rand::{RngCore, rngs::OsRng}; +use rusqlite::{OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub const MAX_METADATA_BYTES: usize = 16 * 1024 * 1024; +pub const AUTHORIZATION_CODE_LIFETIME_SECONDS: i64 = 60; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum Scope { + #[serde(rename = "identity.read")] + IdentityRead, + #[serde(rename = "contacts.read")] + ContactsRead, + #[serde(rename = "metadata.read")] + MetadataRead, + #[serde(rename = "metadata.write")] + MetadataWrite, +} + +impl Scope { + pub fn as_str(self) -> &'static str { + match self { + Self::IdentityRead => "identity.read", + Self::ContactsRead => "contacts.read", + Self::MetadataRead => "metadata.read", + Self::MetadataWrite => "metadata.write", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "identity.read" => Some(Self::IdentityRead), + "contacts.read" => Some(Self::ContactsRead), + "metadata.read" => Some(Self::MetadataRead), + "metadata.write" => Some(Self::MetadataWrite), + _ => None, + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OwnerApp { + pub app_id: String, + pub owner_user_id: i64, + pub domain: String, + pub public_key: String, + pub owner_certificate: String, + pub connection_mode: String, + pub endpoint_url: String, + pub omikron_public_key: Option, + pub created_at: i64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Grant { + pub local_user_id: i64, + pub app_id: String, + pub app_name: String, + pub domain: String, + pub redirect_uri: String, + pub scopes: Vec, + pub app_public_key: String, + pub manifest_hash: String, + pub insecure: bool, + pub updated_at: i64, + pub metadata: Option, +} + +#[derive(Clone, Debug)] +pub struct AuthorizationGrant<'a> { + pub local_user_id: i64, + pub app_id: &'a str, + pub app_name: &'a str, + pub domain: &'a str, + pub redirect_uri: &'a str, + pub scopes: &'a [String], + pub app_public_key: &'a str, + pub manifest_hash: &'a str, + pub pkce_challenge: &'a str, + pub authorization_request: &'a str, + pub locator: &'a str, +} + +#[derive(Clone, Debug)] +pub struct Session { + pub token: String, + pub local_user_id: i64, + pub app_id: String, + pub scopes: Vec, + pub locator: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum TAuthStorageError { + #[error(transparent)] + Storage(#[from] StorageError), + #[error("unknown TAuth scope: {0}")] + UnknownScope(String), + #[error("authorization code is invalid, expired, or already used")] + InvalidCode, + #[error("PKCE verifier does not match authorization request")] + InvalidPkce, + #[error("TAuth grant is insecure")] + InsecureGrant, + #[error("TAuth session is invalid")] + InvalidSession, + #[error("TAuth scope is required: {0}")] + ScopeDenied(&'static str), + #[error("metadata is not valid JSON")] + InvalidJson, + #[error("metadata exceeds 16 MiB")] + MetadataTooLarge, +} + +pub fn now_seconds() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() as i64 +} + +pub fn normalize_scopes(scopes: &[String]) -> Result, TAuthStorageError> { + let mut normalized = BTreeSet::new(); + for scope in scopes { + let parsed = + Scope::parse(scope).ok_or_else(|| TAuthStorageError::UnknownScope(scope.clone()))?; + normalized.insert(parsed.as_str().to_owned()); + } + Ok(normalized.into_iter().collect()) +} + +pub fn grant_covers(grant: &Grant, scopes: &[String]) -> Result { + let requested = normalize_scopes(scopes)?; + Ok(!grant.insecure && requested.iter().all(|scope| grant.scopes.contains(scope))) +} + +pub fn register_owner_app(app: &OwnerApp) -> Result<(), TAuthStorageError> { + db::with_db(|conn| { + conn.execute( + r#"INSERT INTO tauth_owner_apps ( + app_id, owner_user_id, domain, public_key, owner_certificate, + connection_mode, endpoint_url, omikron_public_key, created_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#, + params![ + app.app_id, + app.owner_user_id, + app.domain, + app.public_key, + app.owner_certificate, + app.connection_mode, + app.endpoint_url, + app.omikron_public_key, + app.created_at, + ], + )?; + Ok(()) + })?; + Ok(()) +} + +pub fn list_owner_apps() -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT app_id, owner_user_id, domain, public_key, owner_certificate, connection_mode, endpoint_url, omikron_public_key, created_at FROM tauth_owner_apps ORDER BY domain, app_id", + )?; + let rows = statement.query_map([], owner_app_from_row)?; + rows.collect::, _>>().map_err(Into::into) + })?) +} + +pub fn get_owner_app(app_id: &str) -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT app_id, owner_user_id, domain, public_key, owner_certificate, connection_mode, endpoint_url, omikron_public_key, created_at FROM tauth_owner_apps WHERE app_id = ?1", + params![app_id], + owner_app_from_row, + ) + .optional() + .map_err(Into::into) + })?) +} + +pub fn delete_owner_app(app_id: &str) -> Result { + Ok(db::with_immediate_transaction(|tx| { + let removed = tx.execute( + "DELETE FROM tauth_owner_apps WHERE app_id = ?1", + params![app_id], + )?; + Ok(removed > 0) + })?) +} + +pub fn get_grant(local_user_id: i64, app_id: &str) -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT g.local_user_id, g.app_id, g.app_name, g.domain, g.redirect_uri, g.scopes, g.app_public_key, g.manifest_hash, g.security_state, g.updated_at, m.json FROM tauth_grants g LEFT JOIN tauth_metadata m ON m.local_user_id = g.local_user_id AND m.app_id = g.app_id WHERE g.local_user_id = ?1 AND g.app_id = ?2", + params![local_user_id, app_id], + grant_from_row, + ) + .optional() + .map_err(Into::into) + })?) +} + +pub fn list_grants(local_user_id: i64) -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT g.local_user_id, g.app_id, g.app_name, g.domain, g.redirect_uri, g.scopes, g.app_public_key, g.manifest_hash, g.security_state, g.updated_at, m.json FROM tauth_grants g LEFT JOIN tauth_metadata m ON m.local_user_id = g.local_user_id AND m.app_id = g.app_id WHERE g.local_user_id = ?1 ORDER BY g.app_name, g.domain", + )?; + let rows = statement.query_map(params![local_user_id], grant_from_row)?; + rows.collect::, _>>().map_err(Into::into) + })?) +} + +pub fn issue_code(grant: AuthorizationGrant<'_>) -> Result { + normalize_scopes(grant.scopes)?; + let code = random_token(); + let code_hash = hash(&code); + let now = now_seconds(); + db::with_immediate_transaction(|tx| issue_code_in(tx, grant, &code_hash, now))?; + Ok(code) +} + +fn issue_code_in( + tx: &rusqlite::Transaction<'_>, + grant: AuthorizationGrant<'_>, + code_hash: &[u8; 32], + now: i64, +) -> Result<(), StorageError> { + let requested_scopes = + normalize_scopes(grant.scopes).map_err(|error| StorageError::Other(error.to_string()))?; + let requested_scopes_json = + serde_json::to_string(&requested_scopes).expect("string list serializes"); + let mut granted_scopes = tx + .query_row( + "SELECT scopes FROM tauth_grants WHERE local_user_id = ?1 AND app_id = ?2", + params![grant.local_user_id, grant.app_id], + |row| row.get::<_, String>(0), + ) + .optional()? + .map(|scopes| serde_json::from_str::>(&scopes)) + .transpose() + .map_err(|error| StorageError::Other(error.to_string()))? + .unwrap_or_default(); + granted_scopes.extend(requested_scopes); + let granted_scopes_json = + serde_json::to_string(&granted_scopes).expect("string set serializes"); + tx.execute( + "DELETE FROM tauth_codes WHERE expires_at <= ?1", + params![now], + )?; + tx.execute( + r#"INSERT INTO tauth_grants ( + local_user_id, app_id, app_name, domain, redirect_uri, scopes, + app_public_key, manifest_hash, security_state, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'secure', ?9) + ON CONFLICT(local_user_id, app_id) DO UPDATE SET + app_name = excluded.app_name, + domain = excluded.domain, + redirect_uri = excluded.redirect_uri, + scopes = excluded.scopes, + app_public_key = excluded.app_public_key, + manifest_hash = excluded.manifest_hash, + security_state = 'secure', + updated_at = excluded.updated_at"#, + params![ + grant.local_user_id, + grant.app_id, + grant.app_name, + grant.domain, + grant.redirect_uri, + granted_scopes_json, + grant.app_public_key, + grant.manifest_hash, + now, + ], + )?; + tx.execute( + "INSERT INTO tauth_codes (code_hash, local_user_id, app_id, redirect_uri, scopes, pkce_challenge, authorization_request, locator, expires_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![code_hash.as_slice(), grant.local_user_id, grant.app_id, grant.redirect_uri, requested_scopes_json, grant.pkce_challenge, grant.authorization_request, grant.locator, now + AUTHORIZATION_CODE_LIFETIME_SECONDS], + )?; + Ok(()) +} + +pub fn authorization_request_for_code(code: &str) -> Result, TAuthStorageError> { + let code_hash = hash(code); + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT authorization_request FROM tauth_codes WHERE code_hash = ?1 AND expires_at > ?2", + params![code_hash.as_slice(), now_seconds()], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + })?) +} + +pub fn exchange_code( + code: &str, + app_id: &str, + redirect_uri: &str, + verifier: &str, +) -> Result { + let code_hash = hash(code); + let now = now_seconds(); + let token = random_token(); + let token_hash = hash(&token); + let session = db::with_immediate_transaction(|tx| { + exchange_code_in( + tx, + &code_hash, + app_id, + redirect_uri, + verifier, + &token_hash, + now, + ) + }); + let (local_user_id, app_id, scopes_json, locator) = session.map_err(map_exchange_error)?; + Ok(Session { + token, + local_user_id, + app_id, + scopes: serde_json::from_str(&scopes_json) + .map_err(|_| TAuthStorageError::InvalidSession)?, + locator, + }) +} + +fn exchange_code_in( + tx: &rusqlite::Transaction<'_>, + code_hash: &[u8; 32], + app_id: &str, + redirect_uri: &str, + verifier: &str, + token_hash: &[u8; 32], + now: i64, +) -> Result<(i64, String, String, String), StorageError> { + let row = tx + .query_row( + "SELECT local_user_id, app_id, redirect_uri, scopes, pkce_challenge, locator, expires_at FROM tauth_codes WHERE code_hash = ?1", + params![code_hash.as_slice()], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i64>(6)?, + )) + }, + ) + .optional()?; + tx.execute( + "DELETE FROM tauth_codes WHERE code_hash = ?1", + params![code_hash.as_slice()], + )?; + let Some(( + local_user_id, + stored_app_id, + stored_redirect, + scopes_json, + challenge, + locator, + expires_at, + )) = row + else { + return Err(StorageError::Other("invalid authorization code".into())); + }; + if expires_at <= now || stored_app_id != app_id || stored_redirect != redirect_uri { + return Err(StorageError::Other("invalid authorization code".into())); + } + if pkce_challenge(verifier) != challenge { + return Err(StorageError::Other("invalid PKCE verifier".into())); + } + let secure: bool = tx.query_row( + "SELECT security_state = 'secure' FROM tauth_grants WHERE local_user_id = ?1 AND app_id = ?2", + params![local_user_id, app_id], + |row| row.get(0), + )?; + if !secure { + return Err(StorageError::Other("insecure TAuth grant".into())); + } + tx.execute( + "INSERT INTO tauth_sessions (token_hash, local_user_id, app_id, scopes, locator, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![token_hash.as_slice(), local_user_id, app_id, scopes_json, locator, now], + )?; + Ok((local_user_id, stored_app_id, scopes_json, locator)) +} + +fn map_exchange_error(error: StorageError) -> TAuthStorageError { + match error { + StorageError::Other(message) if message == "invalid PKCE verifier" => { + TAuthStorageError::InvalidPkce + } + StorageError::Other(message) if message == "insecure TAuth grant" => { + TAuthStorageError::InsecureGrant + } + StorageError::Other(message) if message == "invalid authorization code" => { + TAuthStorageError::InvalidCode + } + other => TAuthStorageError::Storage(other), + } +} + +pub fn authorize_session(token: &str, required: Scope) -> Result { + let token_hash = hash(token); + let session = db::with_db(|conn| authorize_session_in(conn, &token_hash))? + .ok_or(TAuthStorageError::InvalidSession)?; + let scopes: Vec = + serde_json::from_str(&session.2).map_err(|_| TAuthStorageError::InvalidSession)?; + if !session_has_scope(&scopes, required) { + return Err(TAuthStorageError::ScopeDenied(required.as_str())); + } + Ok(Session { + token: token.to_owned(), + local_user_id: session.0, + app_id: session.1, + scopes, + locator: session.3, + }) +} + +fn session_has_scope(scopes: &[String], required: Scope) -> bool { + scopes.iter().any(|scope| scope == required.as_str()) +} + +fn authorize_session_in( + conn: &rusqlite::Connection, + token_hash: &[u8; 32], +) -> Result, StorageError> { + conn.query_row( + "SELECT local_user_id, app_id, scopes, locator FROM tauth_sessions WHERE token_hash = ?1", + params![token_hash.as_slice()], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + )) + }, + ) + .optional() + .map_err(Into::into) +} + +pub fn mark_insecure(app_id: &str, domain: &str) -> Result { + Ok(db::with_db(|conn| { + mark_insecure_in(conn, app_id, domain, now_seconds()) + })?) +} + +fn mark_insecure_in( + conn: &rusqlite::Connection, + app_id: &str, + domain: &str, + now: i64, +) -> Result { + Ok(conn.execute( + "UPDATE tauth_grants SET security_state = 'insecure', updated_at = ?3 WHERE app_id = ?1 AND domain = ?2", + params![app_id, domain, now], + )?) +} + +pub fn get_metadata(token: &str) -> Result, TAuthStorageError> { + let session = authorize_session(token, Scope::MetadataRead)?; + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT json FROM tauth_metadata WHERE local_user_id = ?1 AND app_id = ?2", + params![session.local_user_id, session.app_id], + |row| row.get(0), + ) + .optional() + .map_err(Into::into) + })?) +} + +pub fn set_metadata(token: &str, json: &str) -> Result<(), TAuthStorageError> { + let session = authorize_session(token, Scope::MetadataWrite)?; + validate_json(json)?; + db::with_db(|conn| { + conn.execute( + "INSERT INTO tauth_metadata (local_user_id, app_id, json, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(local_user_id, app_id) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at", + params![session.local_user_id, session.app_id, json, now_seconds()], + )?; + Ok(()) + })?; + Ok(()) +} + +pub fn set_metadata_for_user( + local_user_id: i64, + app_id: &str, + json: &str, +) -> Result<(), TAuthStorageError> { + validate_json(json)?; + db::with_immediate_transaction(|tx| { + let exists: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM tauth_grants WHERE local_user_id = ?1 AND app_id = ?2)", + params![local_user_id, app_id], + |row| row.get(0), + )?; + if !exists { + return Err(StorageError::Other("TAuth grant does not exist".into())); + } + tx.execute( + "INSERT INTO tauth_metadata (local_user_id, app_id, json, updated_at) VALUES (?1, ?2, ?3, ?4) ON CONFLICT(local_user_id, app_id) DO UPDATE SET json = excluded.json, updated_at = excluded.updated_at", + params![local_user_id, app_id, json, now_seconds()], + )?; + Ok(()) + })?; + Ok(()) +} + +pub fn delete_metadata(token: &str) -> Result<(), TAuthStorageError> { + let session = authorize_session(token, Scope::MetadataWrite)?; + db::with_db(|conn| { + conn.execute( + "DELETE FROM tauth_metadata WHERE local_user_id = ?1 AND app_id = ?2", + params![session.local_user_id, session.app_id], + )?; + Ok(()) + })?; + Ok(()) +} + +pub fn disconnect_and_delete(token: &str) -> Result<(), TAuthStorageError> { + let token_hash = hash(token); + db::with_immediate_transaction(|tx| { + let grant = tx + .query_row( + "SELECT local_user_id, app_id FROM tauth_sessions WHERE token_hash = ?1", + params![token_hash.as_slice()], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?; + let Some((local_user_id, app_id)) = grant else { + return Err(StorageError::Other("invalid TAuth session".into())); + }; + revoke_grant_in(tx, local_user_id, &app_id)?; + Ok(()) + }) + .map_err(|error| match error { + StorageError::Other(message) if message == "invalid TAuth session" => { + TAuthStorageError::InvalidSession + } + other => TAuthStorageError::Storage(other), + }) +} + +pub fn revoke_grant(local_user_id: i64, app_id: &str) -> Result { + Ok(db::with_immediate_transaction(|tx| { + revoke_grant_in(tx, local_user_id, app_id) + })?) +} + +fn revoke_grant_in( + tx: &rusqlite::Transaction<'_>, + local_user_id: i64, + app_id: &str, +) -> Result { + tx.execute( + "DELETE FROM tauth_codes WHERE local_user_id = ?1 AND app_id = ?2", + params![local_user_id, app_id], + )?; + tx.execute( + "DELETE FROM tauth_sessions WHERE local_user_id = ?1 AND app_id = ?2", + params![local_user_id, app_id], + )?; + tx.execute( + "DELETE FROM tauth_metadata WHERE local_user_id = ?1 AND app_id = ?2", + params![local_user_id, app_id], + )?; + Ok(tx.execute( + "DELETE FROM tauth_grants WHERE local_user_id = ?1 AND app_id = ?2", + params![local_user_id, app_id], + )? > 0) +} + +pub fn canonical_contact_ids(local_user_id: i64) -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + let mut statement = conn.prepare( + "SELECT p.authority_id, p.remote_user_id FROM contacts c JOIN principals p ON p.principal_pk = c.principal_handle WHERE c.storage_owner = ?1 ORDER BY p.authority_id, p.remote_user_id", + )?; + let rows = statement.query_map(params![local_user_id], |row| { + Ok(format!( + "{}#{}", + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? + )) + })?; + rows.collect::, _>>().map_err(Into::into) + })?) +} + +pub fn canonical_principal(local_user_id: i64) -> Result, TAuthStorageError> { + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT p.authority_id, p.remote_user_id FROM hosted_principals h JOIN principals p ON p.principal_pk = h.principal_handle WHERE h.local_user_id = ?1", + params![local_user_id], + |row| Ok(format!("{}#{}", row.get::<_, String>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(Into::into) + })?) +} + +pub fn public_profile(principal: &str) -> Result, TAuthStorageError> { + let Some((authority, user_id)) = principal.rsplit_once('#') else { + return Ok(None); + }; + let Ok(user_id) = user_id.parse::() else { + return Ok(None); + }; + if user_id < 0 { + return Ok(None); + } + Ok(db::with_db(|conn| { + let profile = conn + .query_row( + "SELECT principal_pk, username, display_name FROM principals WHERE authority_id = ?1 AND remote_user_id = ?2", + params![authority, user_id], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, Option>(1)?, row.get::<_, Option>(2)?)), + ) + .optional()?; + let Some((handle, username, display_name)) = profile else { + return Ok(None); + }; + let mut statement = conn.prepare("SELECT public_key FROM principal_keys WHERE principal_pk = ?1 ORDER BY valid_from DESC")?; + let keys = statement + .query_map(params![handle], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(Some(serde_json::json!({ + "principal": principal, + "username": username, + "display_name": display_name, + "avatar": null, + "public_keys": keys, + "home_node": null, + }))) + })?) +} + +pub fn is_local_principal(principal: &str) -> Result { + let Some((authority, user_id)) = principal.rsplit_once('#') else { + return Ok(false); + }; + let Ok(user_id) = user_id.parse::() else { + return Ok(false); + }; + Ok(db::with_db(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM hosted_principals h JOIN principals p ON p.principal_pk = h.principal_handle WHERE p.authority_id = ?1 AND p.remote_user_id = ?2)", + params![authority, user_id], + |row| row.get(0), + ) + .map_err(Into::into) + })?) +} + +fn validate_json(json: &str) -> Result<(), TAuthStorageError> { + if json.len() > MAX_METADATA_BYTES { + return Err(TAuthStorageError::MetadataTooLarge); + } + serde_json::from_str::(json).map_err(|_| TAuthStorageError::InvalidJson)?; + Ok(()) +} + +pub fn pkce_challenge(verifier: &str) -> String { + URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())) +} + +fn random_token() -> String { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} + +fn hash(value: &str) -> [u8; 32] { + Sha256::digest(value.as_bytes()).into() +} + +fn owner_app_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(OwnerApp { + app_id: row.get(0)?, + owner_user_id: row.get(1)?, + domain: row.get(2)?, + public_key: row.get(3)?, + owner_certificate: row.get(4)?, + connection_mode: row.get(5)?, + endpoint_url: row.get(6)?, + omikron_public_key: row.get(7)?, + created_at: row.get(8)?, + }) +} + +fn grant_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + let scopes_json: String = row.get(5)?; + let scopes = serde_json::from_str(&scopes_json).map_err(|error| { + rusqlite::Error::FromSqlConversionFailure( + scopes_json.len(), + rusqlite::types::Type::Text, + Box::new(error), + ) + })?; + Ok(Grant { + local_user_id: row.get(0)?, + app_id: row.get(1)?, + app_name: row.get(2)?, + domain: row.get(3)?, + redirect_uri: row.get(4)?, + scopes, + app_public_key: row.get(6)?, + manifest_hash: row.get(7)?, + insecure: row.get::<_, String>(8)? == "insecure", + updated_at: row.get(9)?, + metadata: row.get(10)?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn test_database() -> Connection { + let connection = Connection::open_in_memory().unwrap(); + connection + .execute_batch( + r#" + CREATE TABLE tauth_grants ( + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + app_name TEXT NOT NULL, + domain TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + app_public_key TEXT NOT NULL, + manifest_hash TEXT NOT NULL, + security_state TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (local_user_id, app_id) + ); + CREATE TABLE tauth_codes ( + code_hash BLOB PRIMARY KEY, + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + pkce_challenge TEXT NOT NULL, + authorization_request TEXT NOT NULL, + locator TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE TABLE tauth_sessions ( + token_hash BLOB PRIMARY KEY, + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + scopes TEXT NOT NULL, + locator TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE TABLE tauth_metadata ( + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (local_user_id, app_id) + ); + "#, + ) + .unwrap(); + connection + } + + fn issue_test_code(connection: &mut Connection, code: &str, scopes: &[String], now: i64) { + let verifier = "verifier"; + let tx = connection.transaction().unwrap(); + issue_code_in( + &tx, + AuthorizationGrant { + local_user_id: 7, + app_id: "app", + app_name: "Example", + domain: "app.example", + redirect_uri: "https://app.example/callback", + scopes, + app_public_key: "key", + manifest_hash: "hash", + pkce_challenge: &pkce_challenge(verifier), + authorization_request: "request", + locator: "locator", + }, + &hash(code), + now, + ) + .unwrap(); + tx.commit().unwrap(); + } + + #[test] + fn metadata_validation_rejects_invalid_and_oversized_json() { + assert!(matches!( + validate_json("{"), + Err(TAuthStorageError::InvalidJson) + )); + let oversized = format!("\"{}\"", "x".repeat(MAX_METADATA_BYTES)); + assert!(matches!( + validate_json(&oversized), + Err(TAuthStorageError::MetadataTooLarge) + )); + } + + #[test] + fn pkce_uses_s256_base64url() { + assert_eq!( + pkce_challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn codes_bind_redirect_expire_once_and_enforce_scopes() { + let mut connection = test_database(); + let scopes = vec!["identity.read".to_owned()]; + issue_test_code(&mut connection, "valid-code", &scopes, 1_000); + + let tx = connection.transaction().unwrap(); + assert!( + exchange_code_in( + &tx, + &hash("valid-code"), + "app", + "https://evil.example/callback", + "verifier", + &hash("token"), + 1_001, + ) + .is_err() + ); + drop(tx); + + let tx = connection.transaction().unwrap(); + let session = exchange_code_in( + &tx, + &hash("valid-code"), + "app", + "https://app.example/callback", + "verifier", + &hash("token"), + 1_001, + ) + .unwrap(); + tx.commit().unwrap(); + let session_scopes: Vec = serde_json::from_str(&session.2).unwrap(); + assert!(session_has_scope(&session_scopes, Scope::IdentityRead)); + assert!(!session_has_scope(&session_scopes, Scope::MetadataRead)); + + let tx = connection.transaction().unwrap(); + assert!( + exchange_code_in( + &tx, + &hash("valid-code"), + "app", + "https://app.example/callback", + "verifier", + &hash("second-token"), + 1_002, + ) + .is_err() + ); + drop(tx); + + issue_test_code(&mut connection, "expired-code", &scopes, 2_000); + let tx = connection.transaction().unwrap(); + assert!( + exchange_code_in( + &tx, + &hash("expired-code"), + "app", + "https://app.example/callback", + "verifier", + &hash("expired-token"), + 2_061, + ) + .is_err() + ); + } + + #[test] + fn insecure_discovery_blocks_new_sessions_but_revoke_cleans_everything() { + let mut connection = test_database(); + let scopes = vec!["identity.read".to_owned()]; + issue_test_code(&mut connection, "first-code", &scopes, 1_000); + let tx = connection.transaction().unwrap(); + exchange_code_in( + &tx, + &hash("first-code"), + "app", + "https://app.example/callback", + "verifier", + &hash("live-token"), + 1_001, + ) + .unwrap(); + tx.commit().unwrap(); + connection + .execute( + "INSERT INTO tauth_metadata VALUES (7, 'app', '{}', 1001)", + [], + ) + .unwrap(); + issue_test_code(&mut connection, "pending-code", &scopes, 1_002); + assert_eq!( + mark_insecure_in(&connection, "app", "app.example", 1_003).unwrap(), + 1 + ); + assert!( + authorize_session_in(&connection, &hash("live-token")) + .unwrap() + .is_some() + ); + + let tx = connection.transaction().unwrap(); + assert!( + exchange_code_in( + &tx, + &hash("pending-code"), + "app", + "https://app.example/callback", + "verifier", + &hash("blocked-token"), + 1_004, + ) + .is_err() + ); + drop(tx); + + let tx = connection.transaction().unwrap(); + assert!(revoke_grant_in(&tx, 7, "app").unwrap()); + tx.commit().unwrap(); + for table in [ + "tauth_grants", + "tauth_codes", + "tauth_sessions", + "tauth_metadata", + ] { + let count: i64 = connection + .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(count, 0, "{table} was not cleaned up"); + } + } + + #[test] + fn narrower_session_keeps_broader_persistent_grant() { + let mut connection = test_database(); + issue_test_code( + &mut connection, + "broad-code", + &["identity.read".to_owned(), "metadata.read".to_owned()], + 1_000, + ); + issue_test_code( + &mut connection, + "narrow-code", + &["identity.read".to_owned()], + 1_001, + ); + let grant_scopes: String = connection + .query_row( + "SELECT scopes FROM tauth_grants WHERE local_user_id = 7 AND app_id = 'app'", + [], + |row| row.get(0), + ) + .unwrap(); + let grant_scopes: BTreeSet = serde_json::from_str(&grant_scopes).unwrap(); + assert_eq!( + grant_scopes, + BTreeSet::from(["identity.read".to_owned(), "metadata.read".to_owned()]) + ); + let code_scopes: String = connection + .query_row( + "SELECT scopes FROM tauth_codes WHERE code_hash = ?1", + params![hash("narrow-code").as_slice()], + |row| row.get(0), + ) + .unwrap(); + assert_eq!( + serde_json::from_str::>(&code_scopes).unwrap(), + vec!["identity.read"] + ); + } +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 3654793..81690e3 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -1,7 +1,7 @@ use crate::users::pending_operations; use crate::users::user_profile::UserProfile; use crate::util::db; -use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential, save_file}; +use iota_util::file_util::{delete_user_directory, load_file, remove_user_credential}; use rusqlite::params; use std::time::{SystemTime, UNIX_EPOCH}; @@ -113,15 +113,6 @@ fn persist_user_profile( ], )?; - for (app_id, app_secret) in &user.trusted_apps { - tx.execute( - r#" - INSERT OR REPLACE INTO trusted_apps (user_id, app_id, app_secret) - VALUES (?1, ?2, ?3) - "#, - params![user.user_id, app_id, app_secret], - )?; - } Ok(()) } @@ -142,7 +133,6 @@ pub fn get_user_by_username( private_key_hash: r.get(3)?, created_at: r.get(5)?, reset_token: r.get(4)?, - trusted_apps: std::collections::HashMap::new(), }) }, ) { @@ -151,11 +141,7 @@ pub fn get_user_by_username( Err(e) => Err(e.into()), } })?; - user.map(|mut user| { - user.trusted_apps = load_trusted_apps(user.user_id)?; - Ok(user) - }) - .transpose() + Ok(user) } pub fn get_user(user_id: i64) -> Result, crate::storage_error::StorageError> { @@ -173,7 +159,6 @@ pub fn get_user(user_id: i64) -> Result, crate::storage_erro private_key_hash: r.get(3)?, created_at: r.get(5)?, reset_token: r.get(4)?, - trusted_apps: std::collections::HashMap::new(), }) }, ) { @@ -182,11 +167,7 @@ pub fn get_user(user_id: i64) -> Result, crate::storage_erro Err(e) => Err(e.into()), } })?; - user.map(|mut user| { - user.trusted_apps = load_trusted_apps(user_id)?; - Ok(user) - }) - .transpose() + Ok(user) } pub fn get_users() -> Result, crate::storage_error::StorageError> { @@ -216,71 +197,51 @@ pub fn get_users() -> Result, crate::storage_error::StorageErro private_key_hash, created_at, reset_token, - trusted_apps: std::collections::HashMap::new(), }) })?; let mut out = Vec::new(); for row in rows { - let mut user = row?; - user.trusted_apps = load_trusted_apps_from(conn, user.user_id)?; - out.push(user); + out.push(row?); } Ok(out) }) } -fn load_trusted_apps( - user_id: i64, -) -> Result, crate::storage_error::StorageError> { - db::with_db(|conn| load_trusted_apps_from(conn, user_id)) -} - -fn load_trusted_apps_from( - conn: &rusqlite::Connection, - user_id: i64, -) -> Result, crate::storage_error::StorageError> { - let mut stmt = - conn.prepare("SELECT app_id, app_secret FROM trusted_apps WHERE user_id = ?1")?; - let rows = stmt.query_map(params![user_id], |r| { - Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)) - })?; - - let mut map = std::collections::HashMap::new(); - for row in rows { - let (key, value) = row?; - map.insert(key, value); - } - Ok(map) -} - -pub fn revoke_trusted_app( +pub fn revoke_tauth_grant( user_id: i64, app_id: &str, ) -> Result { - db::with_immediate_transaction(|tx| { - let removed = tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1 AND app_id = ?2", - params![user_id, app_id], - )?; - Ok(removed > 0) - }) + crate::tauth::revoke_grant(user_id, app_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string())) } -pub fn revoke_all_trusted_apps(user_id: i64) -> Result { - db::with_immediate_transaction(|tx| { - let removed = tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", - params![user_id], - )?; - Ok(removed) - }) +pub fn revoke_all_tauth_grants(user_id: i64) -> Result { + let grants = crate::tauth::list_grants(user_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?; + for grant in &grants { + crate::tauth::revoke_grant(user_id, &grant.app_id) + .map_err(|error| crate::storage_error::StorageError::Other(error.to_string()))?; + } + Ok(grants.len()) } pub fn remove_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { db::with_immediate_transaction(|conn| { conn.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", + "DELETE FROM tauth_codes WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_sessions WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_metadata WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_grants WHERE local_user_id = ?1", params![user_id], )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; @@ -321,7 +282,19 @@ pub fn finalize_local_release( db::with_db(|conn| { let tx = conn.unchecked_transaction()?; tx.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", + "DELETE FROM tauth_codes WHERE local_user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM tauth_sessions WHERE local_user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM tauth_metadata WHERE local_user_id = ?1", + params![user_id], + )?; + tx.execute( + "DELETE FROM tauth_grants WHERE local_user_id = ?1", params![user_id], )?; tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; @@ -586,7 +559,19 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor purge_user_data(user_id)?; db::with_db(|conn| { conn.execute( - "DELETE FROM trusted_apps WHERE user_id = ?1", + "DELETE FROM tauth_codes WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_sessions WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_metadata WHERE local_user_id = ?1", + params![user_id], + )?; + conn.execute( + "DELETE FROM tauth_grants WHERE local_user_id = ?1", params![user_id], )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; @@ -658,7 +643,7 @@ pub fn get_residency_by_id( pub fn clear() -> Result<(), crate::storage_error::StorageError> { db::with_immediate_transaction(|conn| { conn.execute_batch( - "DELETE FROM trusted_apps; DELETE FROM users; DELETE FROM user_residency;", + "DELETE FROM tauth_codes; DELETE FROM tauth_sessions; DELETE FROM tauth_metadata; DELETE FROM tauth_grants; DELETE FROM users; DELETE FROM user_residency;", )?; Ok(()) }) @@ -778,12 +763,6 @@ mod tests { created_at INTEGER NOT NULL, display_name TEXT ); - CREATE TABLE trusted_apps ( - user_id INTEGER NOT NULL, - app_id TEXT NOT NULL, - app_secret TEXT NOT NULL, - PRIMARY KEY (user_id, app_id) - ); CREATE TABLE user_residency ( user_id INTEGER PRIMARY KEY, username TEXT NOT NULL, @@ -797,7 +776,7 @@ mod tests { "#, ) .unwrap(); - let mut user = UserProfile::new_with_created_at( + let user = UserProfile::new_with_created_at( 1, "alice".into(), Some("Alice".into()), @@ -806,8 +785,6 @@ mod tests { None, 1, ); - user.trusted_apps.insert("app".into(), "secret".into()); - let transaction = connection.transaction().unwrap(); persist_user_profile(&transaction, &user).unwrap(); transaction.commit().unwrap(); @@ -829,25 +806,5 @@ mod tests { 42, ) ); - let trusted_app_count: i64 = connection - .query_row( - "SELECT COUNT(*) FROM trusted_apps WHERE user_id = ?1", - params![1], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(trusted_app_count, 1); } } - -pub fn save_app_data(user_id: i64, app_identifier: &str, data: &str) { - let path = format!("users/{}/apps", user_id); - let name = format!("{}.json", app_identifier); - save_file(&path, &name, data); -} - -pub fn load_app_data(user_id: i64, app_identifier: &str) -> String { - let path = format!("users/{}/apps", user_id); - let name = format!("{}.json", app_identifier); - load_file(&path, &name) -} diff --git a/iota-storage/src/users/user_profile.rs b/iota-storage/src/users/user_profile.rs index f097ae5..0e15dd2 100644 --- a/iota-storage/src/users/user_profile.rs +++ b/iota-storage/src/users/user_profile.rs @@ -16,7 +16,6 @@ pub struct UserProfile { pub reset_token: Option, pub created_at: i64, pub display_name: Option, - pub trusted_apps: std::collections::HashMap, } impl UserProfile { @@ -59,7 +58,6 @@ impl UserProfile { private_key_hash, created_at, reset_token, - trusted_apps: std::collections::HashMap::new(), } } @@ -92,15 +90,6 @@ impl UserProfile { let created_at = j["created_at"].as_i64()?; let display_name = j["display_name"].as_str().map(|s| s.to_string()); - let mut trusted_apps = std::collections::HashMap::new(); - if j["trusted_apps"].is_object() { - for (key, value) in j["trusted_apps"].entries() { - if let Some(s) = value.as_str() { - trusted_apps.insert(key.to_string(), s.to_string()); - } - } - } - Some(UserProfile { user_id, username, @@ -109,7 +98,6 @@ impl UserProfile { private_key_hash, created_at, reset_token, - trusted_apps, }) } diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index 734477e..e0cab2b 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -1898,6 +1898,67 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { conn.pragma_update(None, "user_version", 43)?; } + if current_version < 44 { + conn.execute_batch( + r#" + DROP TABLE IF EXISTS trusted_apps; + CREATE TABLE tauth_owner_apps ( + app_id TEXT PRIMARY KEY, + owner_user_id INTEGER NOT NULL, + domain TEXT NOT NULL, + public_key TEXT NOT NULL, + owner_certificate TEXT NOT NULL, + connection_mode TEXT NOT NULL CHECK (connection_mode IN ('hosted', 'forced_omikron')), + endpoint_url TEXT NOT NULL, + omikron_public_key TEXT, + created_at INTEGER NOT NULL + ); + CREATE TABLE tauth_grants ( + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + app_name TEXT NOT NULL, + domain TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + app_public_key TEXT NOT NULL, + manifest_hash TEXT NOT NULL, + security_state TEXT NOT NULL CHECK (security_state IN ('secure', 'insecure')), + updated_at INTEGER NOT NULL, + PRIMARY KEY (local_user_id, app_id) + ); + CREATE TABLE tauth_codes ( + code_hash BLOB PRIMARY KEY, + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + scopes TEXT NOT NULL, + pkce_challenge TEXT NOT NULL, + authorization_request TEXT NOT NULL, + locator TEXT NOT NULL, + expires_at INTEGER NOT NULL + ); + CREATE INDEX tauth_codes_expiry ON tauth_codes (expires_at); + CREATE TABLE tauth_sessions ( + token_hash BLOB PRIMARY KEY, + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + scopes TEXT NOT NULL, + locator TEXT NOT NULL, + created_at INTEGER NOT NULL + ); + CREATE INDEX tauth_sessions_grant ON tauth_sessions (local_user_id, app_id); + CREATE TABLE tauth_metadata ( + local_user_id INTEGER NOT NULL, + app_id TEXT NOT NULL, + json TEXT NOT NULL, + updated_at INTEGER NOT NULL, + PRIMARY KEY (local_user_id, app_id) + ); + PRAGMA user_version = 44; + "#, + )?; + } + Ok(()) } @@ -1972,7 +2033,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 43); + assert_eq!(version, 44); for column in ["height", "reply_to", "edited_count", "deleted_by_external"] { let mut statement = conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; @@ -1991,7 +2052,7 @@ mod tests { run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 43); + assert_eq!(version, 44); for table in [ "sync_heads", "sync_events", @@ -2072,7 +2133,7 @@ mod tests { run_migrations_on_connection(&conn)?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 43); + assert_eq!(version, 44); for column in [ "id", "user_id", @@ -2167,7 +2228,7 @@ mod tests { )?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; assert_eq!(preserved, "remote_committed"); - assert_eq!(version, 43); + assert_eq!(version, 44); Ok(()) } @@ -2205,7 +2266,7 @@ mod tests { })?; assert_eq!(count, 0); let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 43); + assert_eq!(version, 44); Ok(()) } diff --git a/iota-util/Cargo.toml b/iota-util/Cargo.toml index d8caa2c..d35e9de 100644 --- a/iota-util/Cargo.toml +++ b/iota-util/Cargo.toml @@ -18,6 +18,8 @@ walkdir = "2.5.0" zip = "6.0.0" base64 = "0.22.1" hex = "*" +sha2 = "0.10" +url = "2" [dev-dependencies] tempfile = "3" diff --git a/iota-util/src/file_util.rs b/iota-util/src/file_util.rs index 0232148..8d84fbf 100755 --- a/iota-util/src/file_util.rs +++ b/iota-util/src/file_util.rs @@ -56,6 +56,35 @@ fn credential_filename(username: &str) -> io::Result { Ok(format!("{username}.tu")) } +pub fn tauth_credential_path(app_id: &str) -> io::Result { + if app_id.len() != 64 || !app_id.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid TAuth app ID", + )); + } + Ok(storage_directory() + .join("credentials") + .join("apps") + .join(format!("{}.ta", app_id.to_ascii_lowercase()))) +} + +pub fn read_tauth_credential(app_id: &str) -> io::Result>> { + match fs::read(tauth_credential_path(app_id)?) { + Ok(value) => Ok(Some(value)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +pub fn remove_tauth_credential(app_id: &str) -> io::Result<()> { + match fs::remove_file(tauth_credential_path(app_id)?) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + pub fn credential_path(username: &str) -> io::Result { credential_path_in(&storage_directory(), username) } diff --git a/iota-util/src/lib.rs b/iota-util/src/lib.rs index 6ec88ec..9d5171c 100644 --- a/iota-util/src/lib.rs +++ b/iota-util/src/lib.rs @@ -5,4 +5,5 @@ pub mod crypto_util; pub mod file_util; pub mod mtp_compat; pub mod route_target; +pub mod ta; pub mod tu; diff --git a/iota-util/src/ta.rs b/iota-util/src/ta.rs new file mode 100644 index 0000000..3233d17 --- /dev/null +++ b/iota-util/src/ta.rs @@ -0,0 +1,275 @@ +use crate::atomic_file; +use mtp::crypto::{Keyring, PublicKeyBundle}; +use sha2::{Digest, Sha256}; +use std::fmt; +use std::path::Path; +use url::Url; + +const MAGIC: &[u8; 4] = b"TAUT"; +const VERSION: u16 = 1; +const MAX_FIELD_SIZE: usize = 32 * 1024 * 1024; + +#[derive(Clone, Debug)] +pub enum ConnectionMode { + Hosted { + omega_url: Url, + owner_iota_id: u64, + }, + ForcedOmikron { + omikron_url: Url, + omikron_public_key: PublicKeyBundle, + owner_iota_id: u64, + }, +} + +pub struct TaCredential { + pub keyring: Keyring, + pub owner_certificate: Vec, + pub mode: ConnectionMode, +} + +impl fmt::Debug for TaCredential { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("TaCredential") + .field("app_id", &self.app_id()) + .field("owner_certificate", &"") + .field("mode", &self.mode) + .field("keyring", &"") + .finish() + } +} + +#[derive(Debug)] +pub enum TaError { + InvalidFormat(&'static str), + InvalidKeyring, + InvalidPublicKey, + InvalidUrl, + Io(std::io::Error), +} + +impl fmt::Display for TaError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidFormat(message) => write!(f, "invalid .ta credential: {message}"), + Self::InvalidKeyring => f.write_str("invalid .ta MTP keyring"), + Self::InvalidPublicKey => f.write_str("invalid forced Omikron public key"), + Self::InvalidUrl => f.write_str("invalid .ta connection URL"), + Self::Io(error) => error.fmt(f), + } + } +} + +impl std::error::Error for TaError {} + +impl From for TaError { + fn from(value: std::io::Error) -> Self { + Self::Io(value) + } +} + +impl TaCredential { + pub fn app_id(&self) -> String { + app_id(&self.keyring.public_key_bundle()) + } + + pub fn owner_iota_id(&self) -> u64 { + match &self.mode { + ConnectionMode::Hosted { owner_iota_id, .. } + | ConnectionMode::ForcedOmikron { owner_iota_id, .. } => *owner_iota_id, + } + } + + pub fn to_bytes(&self) -> Result, TaError> { + let keyring = self + .keyring + .try_to_bytes() + .map_err(|_| TaError::InvalidKeyring)?; + let mut output = Vec::new(); + output.extend_from_slice(MAGIC); + output.extend_from_slice(&VERSION.to_be_bytes()); + push_bytes(&mut output, &keyring)?; + push_bytes(&mut output, &self.owner_certificate)?; + match &self.mode { + ConnectionMode::Hosted { + omega_url, + owner_iota_id, + } => { + output.push(0); + output.extend_from_slice(&owner_iota_id.to_be_bytes()); + push_bytes(&mut output, omega_url.as_str().as_bytes())?; + } + ConnectionMode::ForcedOmikron { + omikron_url, + omikron_public_key, + owner_iota_id, + } => { + output.push(1); + output.extend_from_slice(&owner_iota_id.to_be_bytes()); + push_bytes(&mut output, omikron_url.as_str().as_bytes())?; + let public_key = omikron_public_key + .try_as_bytes() + .map_err(|_| TaError::InvalidPublicKey)?; + push_bytes(&mut output, &public_key)?; + } + } + Ok(output) + } + + pub fn from_bytes(input: &[u8]) -> Result { + let mut reader = Reader::new(input); + if reader.take(4)? != MAGIC { + return Err(TaError::InvalidFormat("bad magic")); + } + let version = u16::from_be_bytes(reader.take(2)?.try_into().unwrap()); + if version != VERSION { + return Err(TaError::InvalidFormat("unsupported version")); + } + let keyring = Keyring::from_bytes(reader.bytes()?).map_err(|_| TaError::InvalidKeyring)?; + let owner_certificate = reader.bytes()?.to_vec(); + if owner_certificate.is_empty() { + return Err(TaError::InvalidFormat("missing owner certificate")); + } + let mode = *reader + .take(1)? + .first() + .ok_or(TaError::InvalidFormat("missing connection mode"))?; + let owner_iota_id = u64::from_be_bytes(reader.take(8)?.try_into().unwrap()); + if owner_iota_id == 0 { + return Err(TaError::InvalidFormat("invalid owner Iota ID")); + } + let endpoint = parse_https_url(reader.bytes()?)?; + let mode = match mode { + 0 => ConnectionMode::Hosted { + omega_url: endpoint, + owner_iota_id, + }, + 1 => ConnectionMode::ForcedOmikron { + omikron_url: endpoint, + omikron_public_key: PublicKeyBundle::from_bytes(reader.bytes()?) + .map_err(|_| TaError::InvalidPublicKey)?, + owner_iota_id, + }, + _ => return Err(TaError::InvalidFormat("unknown connection mode")), + }; + if !reader.remaining().is_empty() { + return Err(TaError::InvalidFormat("trailing bytes")); + } + Ok(Self { + keyring, + owner_certificate, + mode, + }) + } + + pub fn write_internal(&self, path: &Path) -> Result<(), TaError> { + atomic_file::replace_private(path, &self.to_bytes()?, 0)?; + Ok(()) + } + + pub fn export(&self, path: &Path) -> Result<(), TaError> { + atomic_file::create_private(path, &self.to_bytes()?)?; + Ok(()) + } +} + +pub fn app_id(public_key: &PublicKeyBundle) -> String { + let bytes = public_key + .try_as_bytes() + .expect("validated public key bundle must serialize"); + hex::encode(Sha256::digest(bytes)) +} + +fn parse_https_url(bytes: &[u8]) -> Result { + let value = std::str::from_utf8(bytes).map_err(|_| TaError::InvalidUrl)?; + let url = Url::parse(value).map_err(|_| TaError::InvalidUrl)?; + if url.scheme() != "https" || url.host_str().is_none() { + return Err(TaError::InvalidUrl); + } + Ok(url) +} + +fn push_bytes(output: &mut Vec, bytes: &[u8]) -> Result<(), TaError> { + if bytes.len() > MAX_FIELD_SIZE { + return Err(TaError::InvalidFormat("field too large")); + } + output.extend_from_slice(&(bytes.len() as u32).to_be_bytes()); + output.extend_from_slice(bytes); + Ok(()) +} + +struct Reader<'a> { + remaining: &'a [u8], +} + +impl<'a> Reader<'a> { + fn new(input: &'a [u8]) -> Self { + Self { remaining: input } + } + + fn take(&mut self, length: usize) -> Result<&'a [u8], TaError> { + if self.remaining.len() < length { + return Err(TaError::InvalidFormat("truncated field")); + } + let (value, remaining) = self.remaining.split_at(length); + self.remaining = remaining; + Ok(value) + } + + fn bytes(&mut self) -> Result<&'a [u8], TaError> { + let length = u32::from_be_bytes(self.take(4)?.try_into().unwrap()) as usize; + if length > MAX_FIELD_SIZE { + return Err(TaError::InvalidFormat("field too large")); + } + self.take(length) + } + + fn remaining(&self) -> &'a [u8] { + self.remaining + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn binary_credential_round_trip_preserves_identity() { + let credential = TaCredential { + keyring: Keyring::generate(), + owner_certificate: br#"{"owner":"iota:7"}"#.to_vec(), + mode: ConnectionMode::Hosted { + omega_url: Url::parse("https://omega.example").unwrap(), + owner_iota_id: 7, + }, + }; + let app_id = credential.app_id(); + let parsed = TaCredential::from_bytes(&credential.to_bytes().unwrap()).unwrap(); + assert_eq!(parsed.app_id(), app_id); + assert_eq!(parsed.owner_iota_id(), 7); + } + + #[test] + fn export_refuses_overwrite_and_is_private() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("app.ta"); + let credential = TaCredential { + keyring: Keyring::generate(), + owner_certificate: b"certificate".to_vec(), + mode: ConnectionMode::Hosted { + omega_url: Url::parse("https://omega.example").unwrap(), + owner_iota_id: 7, + }, + }; + credential.export(&path).unwrap(); + assert!(credential.export(&path).is_err()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/iota/Cargo.toml b/iota/Cargo.toml index b05cfe8..020897f 100644 --- a/iota/Cargo.toml +++ b/iota/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [dependencies] +base64 = "0.22" iota-cli = { path = "../iota-cli" } iota-ipc = { path = "../iota-ipc" } iota-installer = { path = "../iota-installer" } diff --git a/iota/src/cli_args.rs b/iota/src/cli_args.rs index 2d540c1..5406cb3 100644 --- a/iota/src/cli_args.rs +++ b/iota/src/cli_args.rs @@ -83,6 +83,7 @@ enum CliCommand { Status, Tasks, Users(UsersArgs), + Apps(AppsArgs), Omikron(OmikronArgs), Identity(IdentityArgs), Daemon(DaemonArgs), @@ -110,6 +111,60 @@ struct UsersArgs { #[command(subcommand)] action: UsersAction, } + +#[derive(Args, Debug)] +struct AppsArgs { + #[command(subcommand)] + action: AppsAction, +} + +#[derive(Subcommand, Debug)] +enum AppsAction { + List, + Show { + app_id: String, + }, + Create { + #[arg(long)] + owner_user_id: i64, + #[arg(long)] + domain: String, + #[arg(long)] + name: String, + #[arg(long = "redirect", required = true)] + redirects: Vec, + #[arg(long)] + output: PathBuf, + #[arg(long)] + manifest_output: PathBuf, + #[command(subcommand)] + connection: AppConnectionAction, + }, + Export { + app_id: String, + #[arg(long)] + output: PathBuf, + }, + Delete { + app_id: String, + #[arg(long)] + yes: bool, + }, +} + +#[derive(Subcommand, Debug)] +enum AppConnectionAction { + Hosted { + #[arg(long)] + omega_url: String, + }, + ForcedOmikron { + #[arg(long)] + omikron_url: String, + #[arg(long)] + omikron_public_key: String, + }, +} #[derive(Subcommand, Debug)] enum UsersAction { List, @@ -412,6 +467,27 @@ pub enum Command { user_id: i64, output: PathBuf, }, + AppsList, + AppsShow { + app_id: String, + }, + AppsCreate { + owner_user_id: i64, + domain: String, + name: String, + redirects: Vec, + connection: iota_ipc::TAuthConnectionInput, + output: PathBuf, + manifest_output: PathBuf, + }, + AppsExport { + app_id: String, + output: PathBuf, + }, + AppsDelete { + app_id: String, + confirmed: bool, + }, OmikronReconnect, IdentityRotate { confirmed: bool, @@ -577,6 +653,43 @@ impl CliInvocation { action: UserCredentialAction::Export { user_id, output }, } => Command::UsersExportCredential { user_id, output }, }, + Some(CliCommand::Apps(apps)) => match apps.action { + AppsAction::List => Command::AppsList, + AppsAction::Show { app_id } => Command::AppsShow { app_id }, + AppsAction::Create { + owner_user_id, + domain, + name, + redirects, + output, + manifest_output, + connection, + } => Command::AppsCreate { + owner_user_id, + domain, + name, + redirects, + connection: match connection { + AppConnectionAction::Hosted { omega_url } => { + iota_ipc::TAuthConnectionInput::Hosted { omega_url } + } + AppConnectionAction::ForcedOmikron { + omikron_url, + omikron_public_key, + } => iota_ipc::TAuthConnectionInput::ForcedOmikron { + omikron_url, + omikron_public_key, + }, + }, + output, + manifest_output, + }, + AppsAction::Export { app_id, output } => Command::AppsExport { app_id, output }, + AppsAction::Delete { app_id, yes } => Command::AppsDelete { + app_id, + confirmed: resolve_confirmed(yes), + }, + }, Some(CliCommand::Omikron(omikron)) => match omikron.action { OmikronAction::Reconnect => Command::OmikronReconnect, OmikronAction::Status => Command::OmikronStatus, diff --git a/iota/src/main.rs b/iota/src/main.rs index 23c1418..807b88b 100644 --- a/iota/src/main.rs +++ b/iota/src/main.rs @@ -1,3 +1,4 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; use iota_cli::{ ipc_client::IpcClient, screens::main_screen::MainScreen, theme, ui::start_bootstrap_tui_with_config, @@ -388,8 +389,13 @@ fn print_help() { println!(" users repair force-detach Remove local management (requires --yes)"); println!(" users forget Forget an empty released residency (requires --yes)"); println!(" users apps revoke Revoke a trusted app (requires --yes)"); - println!(" users apps revoke-all Revoke all trusted apps (requires --yes)"); + println!(" users apps revoke-all Revoke all TAuth grants (requires --yes)"); println!(" users credential export --output Export the local TU"); + println!(" apps list List owned TAuth apps"); + println!(" apps show Show an owned TAuth app"); + println!(" apps create ... Create and export a TAuth app"); + println!(" apps export --output Export an existing .ta"); + println!(" apps delete Delete app key and registration (requires --yes)"); println!(" omikron status Show Omikron connection status"); println!(" omikron reconnect Reconnect to Omikron"); println!(" identity rotate Rotate identity keys (requires --yes)"); @@ -536,6 +542,8 @@ async fn run_command( ) -> Result<(), StartupError> { let color = ColorConfig::new(); let mut credential_output = None; + let mut tauth_credential_output = None; + let mut tauth_manifest_output = None; let request = match command { Command::Status => LocalRequest::GetStatus, Command::Tasks => LocalRequest::ListTasks, @@ -623,11 +631,11 @@ async fn run_command( user_id, app_id, confirmed: true, - } => LocalRequest::RevokeTrustedApp { user_id, app_id }, + } => LocalRequest::RevokeTAuthGrant { user_id, app_id }, Command::UsersRevokeAllApps { user_id, confirmed: true, - } => LocalRequest::RevokeAllTrustedApps { user_id }, + } => LocalRequest::RevokeAllTAuthGrants { user_id }, Command::UsersExportCredential { user_id, output } => { let daemon_status = ipc.daemon_status(); if !daemon_status @@ -643,6 +651,54 @@ async fn run_command( credential_output = Some(output); LocalRequest::ExportUserCredential { user_id } } + Command::AppsList => LocalRequest::ListTAuthApps, + Command::AppsShow { app_id } => LocalRequest::GetTAuthApp { app_id }, + Command::AppsCreate { + owner_user_id, + domain, + name, + redirects, + connection, + output, + manifest_output, + } => { + if output == manifest_output { + return Err(StartupError::InvalidCommand( + ".ta and manifest outputs must use different paths.".into(), + )); + } + for path in [&output, &manifest_output] { + if path.exists() { + return Err(StartupError::InvalidCommand(format!( + "Refusing to overwrite {}.", + path.display() + ))); + } + } + tauth_credential_output = Some(output); + tauth_manifest_output = Some(manifest_output); + LocalRequest::CreateTAuthApp { + owner_user_id, + domain, + name, + redirects, + connection, + } + } + Command::AppsExport { app_id, output } => { + if output.exists() { + return Err(StartupError::InvalidCommand(format!( + "Refusing to overwrite {}.", + output.display() + ))); + } + tauth_credential_output = Some(output); + LocalRequest::ExportTAuthApp { app_id } + } + Command::AppsDelete { + app_id, + confirmed: true, + } => LocalRequest::DeleteTAuthApp { app_id }, Command::OmikronReconnect => LocalRequest::ReconnectOmikron, Command::IdentityRotate { confirmed: true } => LocalRequest::RotateIotaIdentity, Command::RegenerateKeys { confirmed: true } => LocalRequest::RotateIotaIdentity, @@ -683,6 +739,9 @@ async fn run_command( | Command::UsersRevokeAllApps { confirmed: false, .. } + | Command::AppsDelete { + confirmed: false, .. + } | Command::IdentityRotate { confirmed: false } | Command::RegenerateKeys { confirmed: false } | Command::DaemonRestart { confirmed: false } @@ -745,7 +804,63 @@ async fn run_command( ); return Ok(()); } - if credential_output.is_some() { + if let ResponsePayload::TAuthAppCreated { + app, + credential, + txt_record, + manifest_template, + } = &payload + { + let credential_path = tauth_credential_output.as_deref().ok_or_else(|| { + StartupError::Other("Missing TAuth credential export destination.".into()) + })?; + let manifest_path = tauth_manifest_output.as_deref().ok_or_else(|| { + StartupError::Other("Missing TAuth manifest export destination.".into()) + })?; + let bytes = STANDARD.decode(&credential.0).map_err(|error| { + StartupError::Other(format!("Daemon returned invalid .ta data: {error}")) + })?; + iota_util::atomic_file::create_private(credential_path, &bytes).map_err( + |error| { + StartupError::Other(format!( + "Cannot export credential to {}: {error}", + credential_path.display() + )) + }, + )?; + if let Err(error) = iota_util::atomic_file::create_private( + manifest_path, + manifest_template.as_bytes(), + ) { + let _ = std::fs::remove_file(credential_path); + return Err(StartupError::Other(format!( + "Cannot export manifest template to {}: {error}", + manifest_path.display() + ))); + } + println!("Created TAuth app {} for {}.", app.app_id, app.domain); + println!(".ta: {}", credential_path.display()); + println!("TXT _tauth.{}: {}", app.domain, txt_record); + println!("Manifest: {}", manifest_path.display()); + return Ok(()); + } + if let ResponsePayload::TAuthAppCredentialExport { app_id, credential } = &payload { + let path = tauth_credential_output.as_deref().ok_or_else(|| { + StartupError::Other("Missing TAuth credential export destination.".into()) + })?; + let bytes = STANDARD.decode(&credential.0).map_err(|error| { + StartupError::Other(format!("Daemon returned invalid .ta data: {error}")) + })?; + iota_util::atomic_file::create_private(path, &bytes).map_err(|error| { + StartupError::Other(format!( + "Cannot export credential to {}: {error}", + path.display() + )) + })?; + println!("Exported TAuth app {app_id} to {}.", path.display()); + return Ok(()); + } + if credential_output.is_some() || tauth_credential_output.is_some() { return Err(StartupError::Other( "The daemon returned an unexpected credential export response.".into(), )); @@ -822,6 +937,28 @@ async fn run_command( } } } + ResponsePayload::TAuthApps(apps) => { + if apps.is_empty() { + println!("{}", cli_color::muted(&color, "No TAuth apps.")); + } else { + for app in apps { + println!("{} {} {}", app.app_id, app.domain, app.connection_mode); + } + } + } + ResponsePayload::TAuthApp(app) => { + println!("App ID: {}", app.app_id); + println!("Domain: {}", app.domain); + println!("Owner user: {}", app.owner_user_id); + println!("Mode: {}", app.connection_mode); + println!("Endpoint: {}", app.endpoint_url); + } + ResponsePayload::TAuthAppCreated { .. } + | ResponsePayload::TAuthAppCredentialExport { .. } => { + return Err(StartupError::Other( + "Refusing to display TAuth credential material.".into(), + )); + } ResponsePayload::InvitationCreated(invitation) => { println!("Invitation: {}", invitation.invitation_id); println!("Token: {}", invitation.raw_token.0); @@ -928,8 +1065,8 @@ async fn run_command( println!("Display Name: {name}"); } println!("Created At: {}", user.created_at); - if !user.trusted_apps.is_empty() { - println!("Trusted Apps: {}", user.trusted_apps.join(", ")); + if !user.tauth_grants.is_empty() { + println!("TAuth grants: {}", user.tauth_grants.join(", ")); } } ResponsePayload::TuCredentialPreview(preview) => { @@ -950,7 +1087,7 @@ async fn run_command( println!("Local state: {:?}", diagnostics.local_state); println!("Hosted data: {}", diagnostics.data_present); println!("Credential: {:?}", diagnostics.credential_status); - println!("Trusted applications: {}", diagnostics.trusted_app_count); + println!("TAuth grants: {}", diagnostics.tauth_grant_count); if let Some(operation) = &diagnostics.pending_operation { println!("Pending operation: {operation}"); } @@ -1005,7 +1142,12 @@ async fn run_command( /// Text remains an operator-oriented presentation; JSON and YAML must never /// require consumers to parse it. fn render_structured(payload: &ResponsePayload, output: OutputFormat) -> Result<(), StartupError> { - if matches!(payload, ResponsePayload::UserCredentialExport { .. }) { + if matches!( + payload, + ResponsePayload::UserCredentialExport { .. } + | ResponsePayload::TAuthAppCreated { .. } + | ResponsePayload::TAuthAppCredentialExport { .. } + ) { return Err(StartupError::Other( "Refusing to encode credential material as ordinary output.".into(), )); @@ -1096,6 +1238,31 @@ fn render_table(payload: &ResponsePayload) { ); } } + ResponsePayload::TAuthApps(apps) => { + if apps.is_empty() { + println!("No TAuth apps."); + return; + } + println!("{:<66} {:<28} MODE", "APP ID", "DOMAIN"); + for app in apps { + println!( + "{:<66} {:<28} {}", + app.app_id, app.domain, app.connection_mode + ); + } + } + ResponsePayload::TAuthApp(app) => { + println!("{:<18} {}", "App ID", app.app_id); + println!("{:<18} {}", "Domain", app.domain); + println!("{:<18} {}", "Owner user", app.owner_user_id); + println!("{:<18} {}", "Public key", app.public_key); + println!("{:<18} {}", "Mode", app.connection_mode); + println!("{:<18} {}", "Endpoint", app.endpoint_url); + } + ResponsePayload::TAuthAppCreated { .. } + | ResponsePayload::TAuthAppCredentialExport { .. } => { + println!("TAuth credential material withheld."); + } ResponsePayload::Tasks(tasks) => { if tasks.is_empty() { println!("No active tasks."); @@ -1216,7 +1383,7 @@ fn render_table(payload: &ResponsePayload) { println!("{:<15} {:?}", "Local state", diagnostics.local_state); println!("{:<15} {}", "Hosted data", diagnostics.data_present); println!("{:<15} {:?}", "Credential", diagnostics.credential_status); - println!("{:<15} {}", "Trusted apps", diagnostics.trusted_app_count); + println!("{:<15} {}", "TAuth grants", diagnostics.tauth_grant_count); } ResponsePayload::UserCredentialExport { .. } => { println!("Credential export payload withheld."); @@ -1239,8 +1406,8 @@ fn render_table(payload: &ResponsePayload) { println!("{:<15} {}", "Display Name", name); } println!("{:<15} {}", "Created At", user.created_at); - if !user.trusted_apps.is_empty() { - println!("{:<15} {}", "Trusted Apps", user.trusted_apps.join(", ")); + if !user.tauth_grants.is_empty() { + println!("{:<15} {}", "TAuth grants", user.tauth_grants.join(", ")); } } } diff --git a/mtp-type-maps b/mtp-type-maps index 91d0397..10e418c 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 91d03974632a2897de5457d52170dda9d9e36c3d +Subproject commit 10e418ca69c27db0e89ac7dc6a2a348ad9b70017 diff --git a/omikron-connector/Cargo.toml b/omikron-connector/Cargo.toml index c10f784..fd69dff 100644 --- a/omikron-connector/Cargo.toml +++ b/omikron-connector/Cargo.toml @@ -20,10 +20,17 @@ mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "1f19a0d897c2 ] } dashmap = "6.2.1" +hex = "0.4" json = "*" reqwest = "0.13.2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +thiserror = "2" tokio = { version = "1.50.0", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } uuid = { version = "*", features = ["v4"] } base64 = "0.22.1" rand_core = { version = "0.6", features = ["getrandom", "std"] } +trust-dns-resolver = { version = "0.23", features = ["tokio-runtime"] } +url = "2" diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index c2d1f6a..13de4f5 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -3,6 +3,7 @@ pub mod identity; pub mod omega_discovery; pub mod omikron_connection; pub mod router; +pub mod tauth; pub mod user_ops; pub use client::{OmikronClient, OmikronError, OmikronStartupError}; diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index 2d0665b..f5aacd4 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -1,3 +1,4 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; use dashmap::{DashMap, DashSet}; use iota_logger::{log, log_cv_in, log_cv_out, log_t}; use iota_state::AppState; @@ -5,11 +6,11 @@ use iota_storage::util::config_util::{CONFIG, modify_config}; use iota_storage::util::relay_replay; use iota_storage::util::{chat_files, client_relay_delivery, relay_queue}; use iota_util::crypto_helper::{self, keyring_from_base64}; -use iota_util::crypto_util::{self}; use mtp::client::{Client, ClientConfig, MTPConnection, Policy, SendMode, Sender}; use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataTypeId, DataValue, TypeMap}; -use mtp::crypto::{Keyring, PublicKeyBundle}; +use mtp::crypto::{Ed25519Signer, Keyring, PublicKeyBundle, SignatureScheme}; use rand_core::RngCore; +use serde::Serialize; use std::env; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; @@ -30,6 +31,7 @@ use iota_connection::relay::{ RelayValidationError, forward_verified_relay, open_verified_relay_content, verify_relay_metadata, }; +use iota_identity::AuthorityLocator; // ============================================================================ // Configuration @@ -345,17 +347,31 @@ pub enum ConnectionState { Connected { identified: bool }, } -#[derive(Clone)] -struct PendingAppChallenge { - challenge: String, - user_id: i64, - app_identifier: String, +#[derive(Serialize)] +struct TAuthLocatorUnsigned<'a> { + version: u16, + principal: &'a str, + omikron_url: &'a str, + omikron_public_key: &'a str, + target_iota_id: u64, + iota_public_key: &'a str, + issued_at: i64, } -#[derive(Clone)] -struct AppSession { - connection_id: Uuid, - app_identifier: String, +#[derive(Serialize)] +struct TAuthLocator<'a> { + version: u16, + principal: &'a str, + omikron_url: &'a str, + omikron_public_key: &'a str, + target_iota_id: u64, + iota_public_key: &'a str, + issued_at: i64, + signature: String, +} + +fn string_array(values: &[String]) -> DataValue { + DataValue::Array(values.iter().cloned().map(DataValue::Str).collect()) } impl ConnectionState { @@ -385,8 +401,7 @@ pub struct OmikronConnection { reconnect_on_close: Arc>, auth_failure: Arc>>, keyring: Arc>>>, - app_challenges: Arc>, - app_sessions: Arc>, + http_client: reqwest::Client, session_manager: Arc, handler_semaphore: Arc, cancellation: CancellationToken, @@ -421,8 +436,7 @@ impl OmikronConnection { reconnect_on_close: Arc::new(RwLock::new(true)), auth_failure: Arc::new(RwLock::new(None)), keyring: Arc::new(RwLock::new(None)), - app_challenges: Arc::new(DashMap::new()), - app_sessions: Arc::new(DashMap::new()), + http_client: reqwest::Client::new(), session_manager: Arc::new(iota_auth::SessionManager::default()), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), cancellation, @@ -1676,12 +1690,18 @@ impl OmikronConnection { } dispatch!(GetChatSecret, handle_get_chat_secret); - dispatch!(AppIdentification, handle_app_identification); - dispatch!(AppChallengeResponse, handle_app_challenge_response); - dispatch!(SaveAppData, handle_save_app_data); - dispatch!(LoadAppData, handle_load_app_data); - dispatch!(CreateApp, handle_create_app); - dispatch!(DeleteApp, handle_delete_app); + dispatch!(TAuthAuthorize, handle_tauth_authorize); + dispatch!(TAuthExchangeCode, handle_tauth_exchange_code); + dispatch!(TAuthUser, handle_tauth_user); + dispatch!(TAuthContacts, handle_tauth_contacts); + dispatch!(TAuthPublicLookup, handle_tauth_public_lookup); + dispatch!(TAuthMetadataGet, handle_tauth_metadata_get); + dispatch!(TAuthMetadataSet, handle_tauth_metadata_set); + dispatch!(TAuthMetadataDelete, handle_tauth_metadata_delete); + dispatch!(TAuthDisconnectDelete, handle_tauth_disconnect_delete); + dispatch!(TAuthGrantList, handle_tauth_grant_list); + dispatch!(TAuthGrantRevoke, handle_tauth_grant_revoke); + dispatch!(TAuthGrantMetadataSet, handle_tauth_grant_metadata_set); dispatch!(AccountStateRequest, handle_account_state_request); dispatch!(AccountStateApplied, handle_account_state_applied); dispatch!(ReadNotification, handle_read_notification); @@ -1871,251 +1891,513 @@ impl OmikronConnection { .await; } - async fn handle_app_identification(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - let app_identifier = cv - .get_data(DataType::AppIdentifier) - .as_str() - .unwrap_or("") - .to_string(); - let app_public_key = cv - .get_data(DataType::AppPublicKey) - .as_str() - .unwrap_or("") - .to_string(); - let Some(user_id) = data_i64(cv, DataType::UserId).filter(|id| *id > 0) else { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + async fn handle_tauth_authorize(self: Arc, cv: &CommunicationValue) { + let Some(user_id) = cv + .require_sender() + .ok() + .and_then(|value| i64::try_from(value).ok()) + else { + self.send_tauth_error(cv, "missing authenticated user") .await; return; }; - - let mut trusted = false; - let user = match iota_storage::users::user_manager::get_user(user_id) { - Ok(user) => user, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; + let Some(request_json) = cv.get_data(DataType::AuthorizationRequest).as_str() else { + self.send_tauth_error(cv, "missing authorization request") + .await; + return; + }; + let verified = + match crate::tauth::verify_authorization(&self.http_client, request_json).await { + Ok(verified) => verified, + Err(error) => { + self.send_tauth_error(cv, &error.to_string()).await; + return; + } + }; + let existing = iota_storage::tauth::get_grant(user_id, &verified.request.app_id) + .ok() + .flatten(); + let reusable = existing.as_ref().is_some_and(|grant| { + grant.domain == verified.request.domain + && grant.app_public_key == verified.manifest.public_key + && iota_storage::tauth::grant_covers(grant, &verified.request.scopes) + .unwrap_or(false) + }); + let approved = cv.get_data(DataType::Enabled) == Some(&DataValue::BoolTrue); + let locator = match self.signed_tauth_locator(user_id).await { + Ok(locator) => locator, + Err(error) => { + self.send_tauth_error(cv, &error).await; return; } }; - if let Some(user) = user { - if let Some(pub_k) = user.trusted_apps.get(&app_identifier) { - if pub_k == &app_public_key { - trusted = true; - } - } - } - - if trusted { - let challenge = Uuid::new_v4().to_string(); - - self.app_challenges.insert( - sender_id, - PendingAppChallenge { - challenge: challenge.clone(), - user_id, - app_identifier: app_identifier.clone(), - }, - ); - - if let Some(app_pub_bundle) = - iota_util::crypto_helper::public_key_bundle_from_base64(&app_public_key) - { - let keyring = self.keyring.read().await.as_ref().cloned(); - if let Some(keyring) = keyring { - if let Ok(encrypted_challenge) = - crypto_util::encrypt_challenge(&challenge, &app_pub_bundle) - { - let bundle = keyring.public_key_bundle(); - let pub_k_b64 = crypto_helper::public_key_bundle_to_base64(&bundle); - - let res = CommunicationValue::new(CommunicationType::AppChallenge) - .with_request_id(cv) - .with_receiver(sender_id) - .add_typed_default(DataType::PublicKey, DataValue::Str(pub_k_b64)) - .add_typed_default( - DataType::Challenge, - DataValue::Str(encrypted_challenge), - ); - - let _ = self.send_message(&res).await; - return; - } - } - } - } - - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) + let mut response = CommunicationValue::new(CommunicationType::TAuthAuthorizationResult) .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - } - - async fn handle_app_challenge_response(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; - return; - } - }; - if let Some((_, pending)) = self.app_challenges.remove(&sender_id) { - if let Some(DataValue::Str(response)) = cv.get_data(DataType::Challenge) { - if pending.challenge == *response { - let authority = match iota_identity::AuthorityId::omega_legacy( - &omega_discovery::omega_host(), - ) { - Ok(authority) => authority, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - let user_id = match u64::try_from(pending.user_id) { - Ok(user_id) => user_id, - Err(_) => { - let _ = self - .send_message(&error_response( - cv, - CommunicationType::ErrorInvalidData, - )) - .await; - return; - } - }; - let principal = iota_identity::PrincipalId { authority, user_id }; - let principal = match iota_identity::PrincipalStore::get_by_canonical_id( - &iota_storage::identity::SqlitePrincipalStore, - &principal, - ) { - Ok(Some(principal)) => principal, - _ => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInternal)) - .await; - return; - } - }; - let connection_id = Uuid::new_v4(); - iota_auth::HostedSessionRegistrar::new(self.session_manager.clone()) - .authenticate( - connection_id, - iota_identity::LocalUserId(pending.user_id), - principal.handle, - ); - self.app_sessions.insert( - sender_id, - AppSession { - connection_id, - app_identifier: pending.app_identifier, - }, - ); - let res = CommunicationValue::new(CommunicationType::AppIdentificationResponse) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; + .with_receiver(user_id as u64) + .add_typed_default( + DataType::AppId, + DataValue::Str(verified.request.app_id.clone()), + ) + .add_typed_default( + DataType::AppName, + DataValue::Str(verified.manifest.name.clone()), + ) + .add_typed_default( + DataType::Domain, + DataValue::Str(verified.request.domain.clone()), + ) + .add_typed_default( + DataType::RedirectUri, + DataValue::Str(verified.request.redirect_uri.clone()), + ) + .add_typed_default( + DataType::State, + DataValue::Str(verified.request.state.clone()), + ) + .add_typed_default(DataType::Scopes, string_array(&verified.request.scopes)) + .add_typed_default(DataType::Locator, DataValue::Str(locator.clone())); + if approved || reusable { + match iota_storage::tauth::issue_code(iota_storage::tauth::AuthorizationGrant { + local_user_id: user_id, + app_id: &verified.request.app_id, + app_name: &verified.manifest.name, + domain: &verified.request.domain, + redirect_uri: &verified.request.redirect_uri, + scopes: &verified.request.scopes, + app_public_key: &verified.manifest.public_key, + manifest_hash: &verified.manifest_hash, + pkce_challenge: &verified.request.pkce_challenge, + authorization_request: request_json, + locator: &locator, + }) { + Ok(code) => { + response = response + .add_typed_default(DataType::AuthorizationCode, DataValue::Str(code)) + } + Err(error) => { + self.send_tauth_error(cv, &error.to_string()).await; return; } } } - let res = CommunicationValue::new(CommunicationType::ErrorInvalidChallenge) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; + let _ = self.send_message(&response).await; } - async fn handle_save_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) + async fn handle_tauth_exchange_code(self: Arc, cv: &CommunicationValue) { + let Some(code) = cv.get_data(DataType::AuthorizationCode).as_str() else { + self.send_tauth_error(cv, "missing authorization code") + .await; + return; + }; + let Some(app_id) = cv.get_data(DataType::AppId).as_str() else { + self.send_tauth_error(cv, "missing app ID").await; + return; + }; + let Some(redirect) = cv.get_data(DataType::RedirectUri).as_str() else { + self.send_tauth_error(cv, "missing redirect URI").await; + return; + }; + let Some(verifier) = cv.get_data(DataType::CodeVerifier).as_str() else { + self.send_tauth_error(cv, "missing PKCE verifier").await; + return; + }; + let request_json = match iota_storage::tauth::authorization_request_for_code(code) { + Ok(Some(request)) => request, + _ => { + self.send_tauth_error(cv, "authorization code is invalid, expired, or used") .await; return; } }; - let app_data = cv - .get_data(DataType::AppData) - .as_str() - .unwrap_or("") - .to_string(); - - if let Some(session) = self.app_sessions.get(&sender_id) - && let Ok(authenticated) = self.session_manager.authorize( - session.connection_id, - &iota_auth::SessionCapability::LocalStorage, - ) - && let iota_auth::SessionIdentity::Hosted { local_user, .. } = authenticated.identity - { - iota_storage::users::user_manager::save_app_data( - local_user.0, - &session.app_identifier, - &app_data, - ); - } - - let res = CommunicationValue::new(CommunicationType::SaveAppData) - .with_request_id(cv) - .with_receiver(sender_id); - let _ = self.send_message(&res).await; - } - - async fn handle_load_app_data(self: Arc, cv: &CommunicationValue) { - let sender_id = match cv.require_sender() { - Ok(sender_id) => sender_id, - Err(_) => { - let _ = self - .send_message(&error_response(cv, CommunicationType::ErrorInvalidData)) - .await; + let verified = + match crate::tauth::verify_authorization(&self.http_client, &request_json).await { + Ok(verified) + if verified.request.app_id == app_id + && verified.request.redirect_uri == redirect => + { + verified + } + Ok(_) => { + self.send_tauth_error(cv, "authorization code binding mismatch") + .await; + return; + } + Err(error) => { + self.send_tauth_error(cv, &error.to_string()).await; + return; + } + }; + let session = match iota_storage::tauth::exchange_code(code, app_id, redirect, verifier) { + Ok(session) => session, + Err(error) => { + self.send_tauth_error(cv, &error.to_string()).await; return; } }; - let mut app_data = String::new(); - - if let Some(session) = self.app_sessions.get(&sender_id) - && let Ok(authenticated) = self.session_manager.authorize( - session.connection_id, - &iota_auth::SessionCapability::LocalStorage, - ) - && let iota_auth::SessionIdentity::Hosted { local_user, .. } = authenticated.identity - { - app_data = iota_storage::users::user_manager::load_app_data( - local_user.0, - &session.app_identifier, - ); - } - - let res = CommunicationValue::new(CommunicationType::LoadAppData) + let principal = iota_storage::tauth::canonical_principal(session.local_user_id) + .ok() + .flatten() + .unwrap_or_default(); + let response = CommunicationValue::new(CommunicationType::TAuthExchangeCodeResponse) .with_request_id(cv) - .with_receiver(sender_id) - .add_typed_default(DataType::AppData, DataValue::Str(app_data)); - let _ = self.send_message(&res).await; + .add_typed_default(DataType::SessionToken, DataValue::Str(session.token)) + .add_typed_default(DataType::AppId, DataValue::Str(verified.request.app_id)) + .add_typed_default(DataType::Principal, DataValue::Str(principal)) + .add_typed_default(DataType::Scopes, string_array(&session.scopes)) + .add_typed_default(DataType::Locator, DataValue::Str(session.locator)); + let _ = self.send_message(&response).await; } - async fn handle_create_app(self: Arc, cv: &CommunicationValue) { + async fn handle_tauth_user(self: Arc, cv: &CommunicationValue) { + let Some(session) = self + .tauth_session(cv, iota_storage::tauth::Scope::IdentityRead) + .await + else { + return; + }; + let principal = iota_storage::tauth::canonical_principal(session.local_user_id) + .ok() + .flatten() + .unwrap_or_default(); + match iota_storage::tauth::public_profile(&principal) { + Ok(Some(mut profile)) => { + match self.signed_tauth_home_node().await { + Ok(descriptor) => profile["home_node"] = descriptor, + Err(error) => { + self.send_tauth_error(cv, &error).await; + return; + } + } + self.send_tauth_json(cv, CommunicationType::TAuthUser, profile.to_string()) + .await + } + _ => { + self.send_tauth_error(cv, "user profile is unavailable") + .await + } + } + } + + async fn handle_tauth_contacts(self: Arc, cv: &CommunicationValue) { + let Some(session) = self + .tauth_session(cv, iota_storage::tauth::Scope::ContactsRead) + .await + else { + return; + }; + match iota_storage::tauth::canonical_contact_ids(session.local_user_id) { + Ok(contacts) => { + self.send_tauth_json( + cv, + CommunicationType::TAuthContacts, + serde_json::to_string(&contacts).unwrap(), + ) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_public_lookup(self: Arc, cv: &CommunicationValue) { + if self + .tauth_session(cv, iota_storage::tauth::Scope::IdentityRead) + .await + .is_none() + { + return; + } + let Some(principal) = cv.get_data(DataType::Principal).as_str() else { + self.send_tauth_error(cv, "missing principal").await; + return; + }; + match iota_storage::tauth::public_profile(principal) { + Ok(Some(mut profile)) => { + if iota_storage::tauth::is_local_principal(principal).unwrap_or(false) { + match self.signed_tauth_home_node().await { + Ok(descriptor) => profile["home_node"] = descriptor, + Err(error) => { + self.send_tauth_error(cv, &error).await; + return; + } + } + } + self.send_tauth_json( + cv, + CommunicationType::TAuthPublicLookup, + profile.to_string(), + ) + .await + } + Ok(None) => self.send_tauth_error(cv, "principal was not found").await, + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_metadata_get(self: Arc, cv: &CommunicationValue) { + let Some(token) = cv.get_data(DataType::SessionToken).as_str() else { + self.send_tauth_error(cv, "missing session token").await; + return; + }; + match iota_storage::tauth::get_metadata(token) { + Ok(value) => { + let mut response = CommunicationValue::new(CommunicationType::TAuthMetadataGet) + .with_request_id(cv); + if let Some(value) = value { + response = response.add_typed_default(DataType::Json, DataValue::Str(value)); + } + let _ = self.send_message(&response).await; + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_metadata_set(self: Arc, cv: &CommunicationValue) { + let token = cv.get_data(DataType::SessionToken).as_str(); + let json = cv.get_data(DataType::Json).as_str(); + match token.zip(json) { + Some((token, json)) => match iota_storage::tauth::set_metadata(token, json) { + Ok(()) => { + self.send_tauth_empty(cv, CommunicationType::TAuthMetadataSet) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + }, + None => { + self.send_tauth_error(cv, "missing session token or JSON") + .await + } + } + } + + async fn handle_tauth_metadata_delete(self: Arc, cv: &CommunicationValue) { + let Some(token) = cv.get_data(DataType::SessionToken).as_str() else { + self.send_tauth_error(cv, "missing session token").await; + return; + }; + match iota_storage::tauth::delete_metadata(token) { + Ok(()) => { + self.send_tauth_empty(cv, CommunicationType::TAuthMetadataDelete) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_disconnect_delete(self: Arc, cv: &CommunicationValue) { + let Some(token) = cv.get_data(DataType::SessionToken).as_str() else { + self.send_tauth_error(cv, "missing session token").await; + return; + }; + match iota_storage::tauth::disconnect_and_delete(token) { + Ok(()) => { + self.send_tauth_empty(cv, CommunicationType::TAuthDisconnectDelete) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_grant_list(self: Arc, cv: &CommunicationValue) { + let Some(user_id) = cv + .require_sender() + .ok() + .and_then(|value| i64::try_from(value).ok()) + else { + self.send_tauth_error(cv, "missing authenticated user") + .await; + return; + }; + match iota_storage::tauth::list_grants(user_id) { + Ok(grants) => { + self.send_tauth_json( + cv, + CommunicationType::TAuthGrantResponse, + serde_json::to_string(&grants).unwrap(), + ) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + + async fn handle_tauth_grant_revoke(self: Arc, cv: &CommunicationValue) { + let user_id = cv + .require_sender() + .ok() + .and_then(|value| i64::try_from(value).ok()); + let app_id = cv.get_data(DataType::AppId).as_str(); + match user_id.zip(app_id) { + Some((user_id, app_id)) => match iota_storage::tauth::revoke_grant(user_id, app_id) { + Ok(_) => { + self.send_tauth_empty(cv, CommunicationType::TAuthGrantResponse) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + }, + None => self.send_tauth_error(cv, "missing user or app ID").await, + } + } + + async fn handle_tauth_grant_metadata_set(self: Arc, cv: &CommunicationValue) { + let user_id = cv + .require_sender() + .ok() + .and_then(|value| i64::try_from(value).ok()); + let app_id = cv.get_data(DataType::AppId).as_str(); + let json = cv.get_data(DataType::Json).as_str(); + match user_id.zip(app_id).zip(json) { + Some(((user_id, app_id), json)) => { + match iota_storage::tauth::set_metadata_for_user(user_id, app_id, json) { + Ok(()) => { + self.send_tauth_empty(cv, CommunicationType::TAuthGrantResponse) + .await + } + Err(error) => self.send_tauth_error(cv, &error.to_string()).await, + } + } + None => { + self.send_tauth_error(cv, "missing user, app ID, or JSON") + .await + } + } + } + + async fn tauth_session( + self: &Arc, + cv: &CommunicationValue, + scope: iota_storage::tauth::Scope, + ) -> Option { + let token = cv.get_data(DataType::SessionToken).as_str(); + match token.map(|token| iota_storage::tauth::authorize_session(token, scope)) { + Some(Ok(session)) => Some(session), + Some(Err(error)) => { + self.clone().send_tauth_error(cv, &error.to_string()).await; + None + } + None => { + self.clone() + .send_tauth_error(cv, "missing session token") + .await; + None + } + } + } + + async fn signed_tauth_locator(&self, user_id: i64) -> Result { + let config = CONFIG.load(); + let target_iota_id = config + .iota_id + .ok_or_else(|| "Iota ID is unavailable".to_string())?; + let omikron_id = config + .omikron_id + .ok_or_else(|| "Omikron ID is unavailable".to_string())?; + let host = config + .omikron_host + .as_deref() + .ok_or_else(|| "Omikron host is unavailable".to_string())?; + let port = config + .omikron_port + .ok_or_else(|| "Omikron port is unavailable".to_string())?; + let omikron_key = mtp::files::load_public_key_bundle(&omikron_public_key_path(omikron_id)) + .map_err(|error| error.to_string())?; + let omikron_public_key = omikron_key + .try_to_base64() + .map_err(|error| error.to_string())?; + let keyring = self + .keyring + .read() + .await + .as_ref() + .cloned() + .ok_or_else(|| "Iota keyring is unavailable".to_string())?; + let iota_public_key = keyring + .public_key_bundle() + .try_to_base64() + .map_err(|error| error.to_string())?; + let principal = iota_storage::tauth::canonical_principal(user_id) + .map_err(|error| error.to_string())? + .ok_or_else(|| "canonical principal is unavailable".to_string())?; + let omikron_url = format!("https://{host}:{port}"); + let issued_at = iota_storage::tauth::now_seconds(); + let unsigned = TAuthLocatorUnsigned { + version: 1, + principal: &principal, + omikron_url: &omikron_url, + omikron_public_key: &omikron_public_key, + target_iota_id, + iota_public_key: &iota_public_key, + issued_at, + }; + let signer = + Ed25519Signer::new(&keyring.sig_cl_secret_key).map_err(|error| error.to_string())?; + let signature = STANDARD.encode( + signer + .sign(&serde_json::to_vec(&unsigned).unwrap()) + .map_err(|error| error.to_string())?, + ); + serde_json::to_string(&TAuthLocator { + version: 1, + principal: &principal, + omikron_url: &omikron_url, + omikron_public_key: &omikron_public_key, + target_iota_id, + iota_public_key: &iota_public_key, + issued_at, + signature, + }) + .map_err(|error| error.to_string()) + } + + async fn signed_tauth_home_node(&self) -> Result { + let config = CONFIG.load(); + let host = config + .omikron_host + .as_deref() + .ok_or_else(|| "Omikron host is unavailable".to_string())?; + let port = config + .omikron_port + .ok_or_else(|| "Omikron port is unavailable".to_string())?; + let relay = + AuthorityLocator::new(format!("{host}:{port}")).map_err(|error| error.to_string())?; + let identity = self + .load_or_migrate_keyring() + .await + .map_err(|error| error.to_string())?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(i64::MAX as u128) as i64; + let descriptor = iota_storage::node_directory::SqliteNodeDirectory + .ensure_local_descriptor(&identity, Vec::new(), vec![relay], now) + .map_err(|error| error.to_string())? + .to_wire_v1() + .map_err(|error| error.to_string())?; + serde_json::to_value(descriptor).map_err(|error| error.to_string()) + } + + async fn send_tauth_empty(self: Arc, cv: &CommunicationValue, kind: CommunicationType) { let _ = self - .send_message(&message_handlers::handle_create_app(cv)) + .send_message(&CommunicationValue::new(kind).with_request_id(cv)) .await; } - async fn handle_delete_app(self: Arc, cv: &CommunicationValue) { - let _ = self - .send_message(&message_handlers::handle_delete_app(cv)) - .await; + async fn send_tauth_json( + self: Arc, + cv: &CommunicationValue, + kind: CommunicationType, + json: String, + ) { + let response = CommunicationValue::new(kind) + .with_request_id(cv) + .add_typed_default(DataType::Json, DataValue::Str(json)); + let _ = self.send_message(&response).await; + } + + async fn send_tauth_error(self: Arc, cv: &CommunicationValue, message: &str) { + let response = error_response(cv, CommunicationType::ErrorInvalidData) + .add_typed_default(DataType::Message, DataValue::Str(message.to_owned())); + let _ = self.send_message(&response).await; } async fn handle_account_state_request(self: Arc, cv: &CommunicationValue) { @@ -3101,8 +3383,7 @@ impl OmikronClient for OmikronConnection { reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), keyring: self.keyring.clone(), - app_challenges: self.app_challenges.clone(), - app_sessions: self.app_sessions.clone(), + http_client: self.http_client.clone(), session_manager: self.session_manager.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), @@ -3128,8 +3409,7 @@ impl OmikronClient for OmikronConnection { reconnect_on_close: self.reconnect_on_close.clone(), auth_failure: self.auth_failure.clone(), keyring: self.keyring.clone(), - app_challenges: self.app_challenges.clone(), - app_sessions: self.app_sessions.clone(), + http_client: self.http_client.clone(), session_manager: self.session_manager.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), diff --git a/omikron-connector/src/tauth.rs b/omikron-connector/src/tauth.rs new file mode 100644 index 0000000..56ede2c --- /dev/null +++ b/omikron-connector/src/tauth.rs @@ -0,0 +1,490 @@ +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use iota_storage::tauth::{self, Scope}; +use mtp::crypto::{PublicKeyBundle, verify_ed25519}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use trust_dns_resolver::{ + TokioAsyncResolver, + config::{ResolverConfig, ResolverOpts}, +}; +use url::Url; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct SignedAuthorizationRequest { + pub version: u16, + pub app_id: String, + pub domain: String, + pub redirect_uri: String, + pub scopes: Vec, + pub state: String, + pub pkce_challenge: String, + pub expires_at: i64, + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AppManifest { + pub version: u16, + pub app_id: String, + pub public_key: String, + pub name: String, + pub domain: String, + pub redirects: Vec, + pub owner_certificate: String, + pub signature: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct OwnerCertificate { + pub version: u16, + pub app_id: String, + pub app_public_key: String, + pub owner_principal: String, + pub owner_public_key: String, + pub owner_iota_id: u64, + pub issued_at: i64, + pub signature: String, +} + +#[derive(Clone, Debug)] +pub struct VerifiedAuthorization { + pub request: SignedAuthorizationRequest, + pub manifest: AppManifest, + pub manifest_hash: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum VerificationError { + #[error("invalid signed authorization request: {0}")] + Request(String), + #[error("TAuth DNS discovery failed: {0}")] + Dns(String), + #[error("TAuth manifest fetch failed: {0}")] + Http(String), + #[error("TAuth manifest verification failed: {0}")] + Manifest(String), +} + +#[derive(Serialize)] +struct UnsignedAuthorizationRequest<'a> { + version: u16, + app_id: &'a str, + domain: &'a str, + redirect_uri: &'a str, + scopes: &'a [String], + state: &'a str, + pkce_challenge: &'a str, + expires_at: i64, +} + +#[derive(Serialize)] +struct UnsignedManifest<'a> { + version: u16, + app_id: &'a str, + public_key: &'a str, + name: &'a str, + domain: &'a str, + redirects: &'a [String], + owner_certificate: &'a str, +} + +#[derive(Serialize)] +struct UnsignedOwnerCertificate<'a> { + version: u16, + app_id: &'a str, + app_public_key: &'a str, + owner_principal: &'a str, + owner_public_key: &'a str, + owner_iota_id: u64, + issued_at: i64, +} + +pub async fn verify_authorization( + client: &reqwest::Client, + request_json: &str, +) -> Result { + let request: SignedAuthorizationRequest = serde_json::from_str(request_json) + .map_err(|error| VerificationError::Request(error.to_string()))?; + validate_request_shape(&request)?; + let result = verify_discovery(client, &request).await; + if matches!( + &result, + Err(VerificationError::Dns(_) + | VerificationError::Http(_) + | VerificationError::Manifest(_)) + ) { + let _ = tauth::mark_insecure(&request.app_id, &request.domain); + } + result +} + +async fn verify_discovery( + client: &reqwest::Client, + request: &SignedAuthorizationRequest, +) -> Result { + let resolver = TokioAsyncResolver::tokio(ResolverConfig::default(), ResolverOpts::default()); + let lookup = resolver + .txt_lookup(format!("_tauth.{}", request.domain)) + .await + .map_err(|error| VerificationError::Dns(error.to_string()))?; + let txt = lookup + .iter() + .map(|record| { + record + .txt_data() + .iter() + .flat_map(|part| part.iter().copied()) + .collect::>() + }) + .find_map(|bytes| { + String::from_utf8(bytes) + .ok() + .filter(|value| value.starts_with("v=TAUTH1;")) + }) + .ok_or_else(|| VerificationError::Dns("missing v=TAUTH1 TXT value".into()))?; + let txt_app_id = txt_value(&txt, "app") + .ok_or_else(|| VerificationError::Dns("TXT app fingerprint is missing".into()))?; + let txt_manifest_hash = txt_value(&txt, "manifest") + .ok_or_else(|| VerificationError::Dns("TXT manifest hash is missing".into()))?; + if txt_app_id != request.app_id || !is_sha256_hex(txt_manifest_hash) { + return Err(VerificationError::Dns( + "TXT fingerprint or manifest hash is invalid".into(), + )); + } + + let manifest_url = Url::parse(&format!( + "https://{}/.well-known/tauth.json", + request.domain + )) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + let response = client + .get(manifest_url) + .send() + .await + .map_err(|error| VerificationError::Http(error.to_string()))? + .error_for_status() + .map_err(|error| VerificationError::Http(error.to_string()))?; + let bytes = response + .bytes() + .await + .map_err(|error| VerificationError::Http(error.to_string()))?; + let manifest_hash = hex::encode(Sha256::digest(&bytes)); + if manifest_hash != txt_manifest_hash.to_ascii_lowercase() { + return Err(VerificationError::Manifest( + "manifest hash does not match DNS".into(), + )); + } + let manifest: AppManifest = serde_json::from_slice(&bytes) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + validate_manifest(request, &manifest)?; + Ok(VerifiedAuthorization { + request: request.clone(), + manifest, + manifest_hash, + }) +} + +fn validate_request_shape(request: &SignedAuthorizationRequest) -> Result<(), VerificationError> { + if request.version != 1 || !is_sha256_hex(&request.app_id) || request.state.is_empty() { + return Err(VerificationError::Request( + "version, app ID, or state is invalid".into(), + )); + } + let domain = request.domain.trim().to_ascii_lowercase(); + if domain != request.domain || domain.is_empty() || domain.contains('/') { + return Err(VerificationError::Request("domain is not canonical".into())); + } + validate_redirect(&request.redirect_uri)?; + tauth::normalize_scopes(&request.scopes) + .map_err(|error| VerificationError::Request(error.to_string()))?; + if request.pkce_challenge.len() < 43 || request.pkce_challenge.len() > 128 { + return Err(VerificationError::Request( + "PKCE challenge length is invalid".into(), + )); + } + let now = tauth::now_seconds(); + if request.expires_at <= now || request.expires_at > now + 300 { + return Err(VerificationError::Request( + "request is expired or too long-lived".into(), + )); + } + Ok(()) +} + +fn validate_manifest( + request: &SignedAuthorizationRequest, + manifest: &AppManifest, +) -> Result<(), VerificationError> { + if manifest.version != 1 + || manifest.app_id != request.app_id + || manifest.domain != request.domain + || manifest.name.trim().is_empty() + || !manifest + .redirects + .iter() + .any(|redirect| redirect == &request.redirect_uri) + { + return Err(VerificationError::Manifest( + "manifest fields do not match request".into(), + )); + } + for redirect in &manifest.redirects { + validate_redirect(redirect)?; + } + let public_key = PublicKeyBundle::from_base64(&manifest.public_key) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + if iota_util::ta::app_id(&public_key) != request.app_id { + return Err(VerificationError::Manifest( + "public key fingerprint does not match app ID".into(), + )); + } + let owner_bytes = STANDARD + .decode(&manifest.owner_certificate) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + let owner: OwnerCertificate = serde_json::from_slice(&owner_bytes) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + validate_owner_certificate(&owner, manifest)?; + verify( + &public_key, + &serde_json::to_vec(&UnsignedManifest { + version: manifest.version, + app_id: &manifest.app_id, + public_key: &manifest.public_key, + name: &manifest.name, + domain: &manifest.domain, + redirects: &manifest.redirects, + owner_certificate: &manifest.owner_certificate, + }) + .expect("manifest fields serialize"), + &manifest.signature, + "manifest", + )?; + let request_signature = STANDARD + .decode(&request.signature) + .map_err(|error| VerificationError::Request(error.to_string()))?; + verify_ed25519( + &public_key.sig_cl_public_key, + &serde_json::to_vec(&UnsignedAuthorizationRequest { + version: request.version, + app_id: &request.app_id, + domain: &request.domain, + redirect_uri: &request.redirect_uri, + scopes: &request.scopes, + state: &request.state, + pkce_challenge: &request.pkce_challenge, + expires_at: request.expires_at, + }) + .expect("authorization fields serialize"), + &request_signature, + ) + .map_err(|_| VerificationError::Request("authorization request signature is invalid".into())) +} + +fn validate_owner_certificate( + owner: &OwnerCertificate, + manifest: &AppManifest, +) -> Result<(), VerificationError> { + if owner.version != 1 + || owner.owner_iota_id == 0 + || owner.owner_principal.is_empty() + || owner.app_id != manifest.app_id + || owner.app_public_key != manifest.public_key + { + return Err(VerificationError::Manifest( + "owner certificate does not bind this app".into(), + )); + } + let owner_key = PublicKeyBundle::from_base64(&owner.owner_public_key) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + verify( + &owner_key, + &serde_json::to_vec(&UnsignedOwnerCertificate { + version: owner.version, + app_id: &owner.app_id, + app_public_key: &owner.app_public_key, + owner_principal: &owner.owner_principal, + owner_public_key: &owner.owner_public_key, + owner_iota_id: owner.owner_iota_id, + issued_at: owner.issued_at, + }) + .expect("certificate fields serialize"), + &owner.signature, + "owner certificate", + ) +} + +fn verify( + key: &PublicKeyBundle, + message: &[u8], + encoded_signature: &str, + label: &str, +) -> Result<(), VerificationError> { + let signature = STANDARD + .decode(encoded_signature) + .map_err(|error| VerificationError::Manifest(error.to_string()))?; + verify_ed25519(&key.sig_cl_public_key, message, &signature) + .map_err(|_| VerificationError::Manifest(format!("{label} signature is invalid"))) +} + +fn validate_redirect(value: &str) -> Result<(), VerificationError> { + let url = Url::parse(value).map_err(|error| VerificationError::Request(error.to_string()))?; + let localhost = matches!(url.host_str(), Some("localhost" | "127.0.0.1" | "[::1]")); + if url.host_str().is_none() + || !url.username().is_empty() + || url.password().is_some() + || (url.scheme() != "https" && !(url.scheme() == "http" && localhost)) + || url.fragment().is_some() + || url + .query_pairs() + .any(|(key, _)| matches!(key.as_ref(), "code" | "state" | "locator" | "error")) + { + return Err(VerificationError::Request( + "redirect URL must be exact HTTPS without a fragment".into(), + )); + } + Ok(()) +} + +fn txt_value<'a>(record: &'a str, key: &str) -> Option<&'a str> { + record.split(';').find_map(|field| { + field + .split_once('=') + .filter(|(name, value)| *name == key && !value.is_empty()) + .map(|(_, value)| value) + }) +} + +fn is_sha256_hex(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +pub fn scope_for_command(command: mtp::codec::CommunicationType) -> Option { + use mtp::codec::CommunicationType; + match command { + CommunicationType::TAuthUser | CommunicationType::TAuthPublicLookup => { + Some(Scope::IdentityRead) + } + CommunicationType::TAuthContacts => Some(Scope::ContactsRead), + CommunicationType::TAuthMetadataGet => Some(Scope::MetadataRead), + CommunicationType::TAuthMetadataSet | CommunicationType::TAuthMetadataDelete => { + Some(Scope::MetadataWrite) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp::crypto::{Ed25519Signer, Keyring, SignatureScheme}; + + fn signed_authorization() -> (SignedAuthorizationRequest, AppManifest) { + let app_keyring = Keyring::generate(); + let owner_keyring = Keyring::generate(); + let app_public_key = app_keyring.public_key_bundle().try_to_base64().unwrap(); + let owner_public_key = owner_keyring.public_key_bundle().try_to_base64().unwrap(); + let app_id = iota_util::ta::app_id(&app_keyring.public_key_bundle()); + let owner_unsigned = UnsignedOwnerCertificate { + version: 1, + app_id: &app_id, + app_public_key: &app_public_key, + owner_principal: "iota:owner#7", + owner_public_key: &owner_public_key, + owner_iota_id: 7, + issued_at: tauth::now_seconds(), + }; + let owner_signer = Ed25519Signer::new(&owner_keyring.sig_cl_secret_key).unwrap(); + let owner = OwnerCertificate { + version: owner_unsigned.version, + app_id: owner_unsigned.app_id.to_owned(), + app_public_key: owner_unsigned.app_public_key.to_owned(), + owner_principal: owner_unsigned.owner_principal.to_owned(), + owner_public_key: owner_unsigned.owner_public_key.to_owned(), + owner_iota_id: owner_unsigned.owner_iota_id, + issued_at: owner_unsigned.issued_at, + signature: STANDARD.encode( + owner_signer + .sign(&serde_json::to_vec(&owner_unsigned).unwrap()) + .unwrap(), + ), + }; + let owner_certificate = STANDARD.encode(serde_json::to_vec(&owner).unwrap()); + let redirects = vec!["https://app.example/callback".to_owned()]; + let manifest_unsigned = UnsignedManifest { + version: 1, + app_id: &app_id, + public_key: &app_public_key, + name: "Example", + domain: "app.example", + redirects: &redirects, + owner_certificate: &owner_certificate, + }; + let app_signer = Ed25519Signer::new(&app_keyring.sig_cl_secret_key).unwrap(); + let manifest = AppManifest { + version: manifest_unsigned.version, + app_id: manifest_unsigned.app_id.to_owned(), + public_key: manifest_unsigned.public_key.to_owned(), + name: manifest_unsigned.name.to_owned(), + domain: manifest_unsigned.domain.to_owned(), + redirects: manifest_unsigned.redirects.to_vec(), + owner_certificate: manifest_unsigned.owner_certificate.to_owned(), + signature: STANDARD.encode( + app_signer + .sign(&serde_json::to_vec(&manifest_unsigned).unwrap()) + .unwrap(), + ), + }; + let scopes = vec!["identity.read".to_owned()]; + let request_unsigned = UnsignedAuthorizationRequest { + version: 1, + app_id: &app_id, + domain: "app.example", + redirect_uri: &redirects[0], + scopes: &scopes, + state: "state", + pkce_challenge: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + expires_at: tauth::now_seconds() + 60, + }; + let request = SignedAuthorizationRequest { + version: request_unsigned.version, + app_id: request_unsigned.app_id.to_owned(), + domain: request_unsigned.domain.to_owned(), + redirect_uri: request_unsigned.redirect_uri.to_owned(), + scopes: request_unsigned.scopes.to_vec(), + state: request_unsigned.state.to_owned(), + pkce_challenge: request_unsigned.pkce_challenge.to_owned(), + expires_at: request_unsigned.expires_at, + signature: STANDARD.encode( + app_signer + .sign(&serde_json::to_vec(&request_unsigned).unwrap()) + .unwrap(), + ), + }; + (request, manifest) + } + + #[test] + fn signatures_and_exact_redirect_are_bound() { + let (request, manifest) = signed_authorization(); + validate_request_shape(&request).unwrap(); + validate_manifest(&request, &manifest).unwrap(); + + let mut tampered = request.clone(); + tampered.state = "different".into(); + assert!(validate_manifest(&tampered, &manifest).is_err()); + + let mut wrong_redirect = request; + wrong_redirect.redirect_uri = "https://app.example/other".into(); + assert!(validate_manifest(&wrong_redirect, &manifest).is_err()); + } + + #[test] + fn expired_requests_and_unsafe_redirects_fail_before_discovery() { + let (mut request, _) = signed_authorization(); + request.expires_at = tauth::now_seconds(); + assert!(validate_request_shape(&request).is_err()); + request.expires_at = tauth::now_seconds() + 60; + request.redirect_uri = "https://app.example/callback#fragment".into(); + assert!(validate_request_shape(&request).is_err()); + } +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..9c91f46 --- /dev/null +++ b/todo.md @@ -0,0 +1,5 @@ +# Direct Iota TAuth + +Add `DirectIota` connection mode to binary `.ta` format and Rust SDK. + +Direct sessions must implement same TAuth MTP messages, signed authorization checks, exact redirect and PKCE binding, discovery revalidation, scope enforcement, one-time codes, metadata limits, and atomic revocation used by Omikron-routed sessions. Direct raw MTP access must not bypass grant checks.