feat(tauth): rework TAuth

This commit is contained in:
Alois 2026-09-14 19:31:37 +02:00
commit 3685babebf
Signed by: alois
SSH key fingerprint: SHA256:GBzT2DXvAuGV9XIV5W3WrzVpjU54FThmxHXdbz95J24
31 changed files with 3418 additions and 502 deletions

View file

@ -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]

View file

@ -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 },

View file

@ -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};

View file

@ -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<Vec<TAuthAppSummary>, 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<Option<TAuthAppSummary>, 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<Option<SecretString>, 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<bool, String> {
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<String>,
connection: TAuthConnectionInput,
) -> Result<CreatedApp, String> {
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::<Result<BTreeSet<_>, _>>()?
.into_iter()
.collect::<Vec<_>>();
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<String, String> {
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<Url, String> {
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)
}