From d23ad21f887c8453a4051c8313cfad45f3bf1a7c Mon Sep 17 00:00:00 2001 From: Alex-Emmet Date: Mon, 28 Sep 2026 00:13:35 +0200 Subject: [PATCH] Implement OPAQUE password provisioning on Iota --- Cargo.lock | 172 +++- flake.lock | 6 +- iota-auth/Cargo.toml | 4 + iota-auth/src/lib.rs | 1 + iota-auth/src/password.rs | 336 +++++++ iota-storage/src/users/mod.rs | 3 +- .../src/users/password_credentials.rs | 80 ++ iota-storage/src/users/user_manager.rs | 36 +- iota-storage/src/util/db.rs | 38 +- iota-storage/src/util/mod.rs | 1 + iota-storage/src/util/protected_replay.rs | 21 + mtp-type-maps | 2 +- omikron-connector/src/lib.rs | 1 + omikron-connector/src/omega_discovery.rs | 2 +- omikron-connector/src/omikron_connection.rs | 45 +- .../src/password_provisioning.rs | 904 ++++++++++++++++++ 16 files changed, 1636 insertions(+), 16 deletions(-) create mode 100644 iota-auth/src/password.rs create mode 100644 iota-storage/src/users/password_credentials.rs create mode 100644 iota-storage/src/util/protected_replay.rs create mode 100644 omikron-connector/src/password_provisioning.rs diff --git a/Cargo.lock b/Cargo.lock index db97f40..e78b115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -461,6 +461,12 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -961,6 +967,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1013,7 +1031,9 @@ dependencies = [ "curve25519-dalek-derive", "digest 0.10.7", "fiat-crypto 0.2.9", + "rand_core 0.6.4", "rustc_version", + "serde", "subtle", "zeroize", ] @@ -1152,6 +1172,17 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +[[package]] +name = "derive-where" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1235,6 +1266,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "elliptic-curve", + "serdect", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1242,6 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ "pkcs8 0.10.2", + "serde", "signature 2.2.0", ] @@ -1265,6 +1308,7 @@ dependencies = [ "ed25519 2.2.3", "serde", "sha2 0.10.9", + "signature 2.2.0", "subtle", "zeroize", ] @@ -1290,6 +1334,26 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "encoding_rs" version = "0.8.41" @@ -1382,6 +1446,16 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1565,8 +1639,10 @@ version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ + "serde", "typenum", "version_check", + "zeroize", ] [[package]] @@ -1614,6 +1690,17 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.3.27" @@ -1757,6 +1844,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" @@ -2133,11 +2229,15 @@ dependencies = [ name = "iota-auth" version = "0.1.0" dependencies = [ + "argon2", "async-trait", "dashmap", "iota-identity", "mtp", + "opaque-ke", "rand_core 0.6.4", + "sha2 0.10.9", + "thiserror 2.0.20", "tokio", "uuid", ] @@ -2909,7 +3009,7 @@ dependencies = [ "chacha20poly1305", "ed25519-dalek 3.0.0", "getrandom 0.4.3", - "hkdf", + "hkdf 0.13.0", "ml-dsa", "mlkem-tls", "rand 0.10.3", @@ -3228,6 +3328,30 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaque-ke" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ded22991b43cd15561b62b2e1cf9ace1344a8534eebec96202d5c96a77a6616a" +dependencies = [ + "argon2", + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "ecdsa", + "ed25519-dalek 2.2.0", + "elliptic-curve", + "generic-array", + "hkdf 0.12.4", + "hmac 0.12.1", + "rand 0.8.8", + "serde", + "subtle", + "voprf", + "zeroize", +] + [[package]] name = "open" version = "5.4.4" @@ -4230,6 +4354,21 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -4327,6 +4466,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -4435,6 +4584,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -5188,6 +5338,25 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "voprf" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f59c30c76e2fea54cdece6a054e2662feffa7ab19658a7887524265ee39470" +dependencies = [ + "curve25519-dalek 4.1.3", + "derive-where", + "digest 0.10.7", + "displaydoc", + "elliptic-curve", + "generic-array", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -5820,6 +5989,7 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ + "serde", "zeroize_derive", ] diff --git a/flake.lock b/flake.lock index d6e9e64..47b5e01 100644 --- a/flake.lock +++ b/flake.lock @@ -21,10 +21,10 @@ "mtp-type-maps": { "flake": false, "locked": { - "lastModified": 1790379257, - "narHash": "sha256-LeomTZv7nBmJGkDEH3OgnRTDAVUGZaj2NFesuQ2yrp8=", + "lastModified": 1790546862, + "narHash": "sha256-yiy2GXDR6QwNiUTT+L4mZaPmqQygt1BfmLf/WRSW7MY=", "ref": "refs/heads/main", - "rev": "4eccf976c224a2a0d95c8f3ead596265a74a4fab", + "rev": "b996bb8afbc54391e38c03a92e6226d43aa8ecf8", "revCount": 26, "type": "git", "url": "https://git.methanium.net/tensamin/mtp-type-maps" diff --git a/iota-auth/Cargo.toml b/iota-auth/Cargo.toml index 9a135da..5195580 100644 --- a/iota-auth/Cargo.toml +++ b/iota-auth/Cargo.toml @@ -9,6 +9,10 @@ dashmap = "6.2.1" iota-identity = { path = "../iota-identity" } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "bb0f682b735de5ebb36bb41dc699260578341828", features = ["crypto"] } rand_core = { version = "0.6", features = ["getrandom", "std"] } +opaque-ke = { version = "4.0.1", features = ["argon2"] } +argon2 = "0.5" +sha2 = "0.10" +thiserror = "2" uuid = { version = "*", features = ["v4"] } [dev-dependencies] diff --git a/iota-auth/src/lib.rs b/iota-auth/src/lib.rs index bf6e334..2416be9 100644 --- a/iota-auth/src/lib.rs +++ b/iota-auth/src/lib.rs @@ -1,3 +1,4 @@ +pub mod password; mod principal_auth; mod session; diff --git a/iota-auth/src/password.rs b/iota-auth/src/password.rs new file mode 100644 index 0000000..2929ff9 --- /dev/null +++ b/iota-auth/src/password.rs @@ -0,0 +1,336 @@ +use mtp::crypto::PublicKeyBundle; +use opaque_ke::{ + CipherSuite, CredentialFinalization, CredentialRequest, Identifiers, RegistrationRequest, + RegistrationUpload, Ristretto255, ServerLogin, ServerLoginParameters, ServerRegistration, + ServerSetup, TripleDh, +}; +use rand_core::OsRng; +use sha2::{Digest, Sha256}; + +pub struct TensaminOpaque; + +impl CipherSuite for TensaminOpaque { + type OprfCs = Ristretto255; + type KeyExchange = TripleDh; + type Ksf = argon2::Argon2<'static>; +} + +pub type PasswordServerSetup = ServerSetup; + +#[derive(Debug, thiserror::Error)] +pub enum PasswordAuthError { + #[error("invalid OPAQUE exchange: {0}")] + Opaque(String), + #[error("invalid Contact public key: {0}")] + ContactKey(String), + #[error("OPAQUE context field is too large")] + FieldTooLarge, +} + +pub fn generate_server_setup() -> PasswordServerSetup { + ServerSetup::new(&mut OsRng) +} + +pub fn serialize_server_setup(setup: &PasswordServerSetup) -> Vec { + setup.serialize().to_vec() +} + +pub fn deserialize_server_setup(bytes: &[u8]) -> Result { + ServerSetup::deserialize(bytes).map_err(|error| PasswordAuthError::Opaque(error.to_string())) +} + +pub fn registration_start( + setup: &PasswordServerSetup, + request: &[u8], + identifier: &[u8], +) -> Result, PasswordAuthError> { + let message = RegistrationRequest::::deserialize(request) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + let result = ServerRegistration::::start(setup, message, identifier) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + Ok(result.message.serialize().to_vec()) +} + +pub fn registration_finish(upload: &[u8]) -> Result, PasswordAuthError> { + let message = RegistrationUpload::::deserialize(upload) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + Ok(ServerRegistration::::finish(message) + .serialize() + .to_vec()) +} + +pub fn server_identifier(iota_id: i64) -> Vec { + let mut result = b"tensamin:iota-password:v1\0".to_vec(); + result.extend_from_slice(&iota_id.to_be_bytes()); + result +} + +pub fn login_context( + principal: &str, + iota_id: i64, + session_id: uuid::Uuid, + contact_key: &PublicKeyBundle, +) -> Result, PasswordAuthError> { + let mut result = b"tensamin:password-provisioning:v1\0".to_vec(); + let principal_len = + u32::try_from(principal.len()).map_err(|_| PasswordAuthError::FieldTooLarge)?; + result.extend_from_slice(&principal_len.to_be_bytes()); + result.extend_from_slice(principal.as_bytes()); + result.extend_from_slice(&iota_id.to_be_bytes()); + result.extend_from_slice(session_id.as_bytes()); + let key = contact_key + .try_as_bytes() + .map_err(|error| PasswordAuthError::ContactKey(error.to_string()))?; + result.extend_from_slice(&Sha256::digest(key)); + Ok(result) +} + +pub struct LoginStartResult { + pub response: Vec, + pub state: Vec, +} + +pub fn login_start( + setup: &PasswordServerSetup, + record: Option<&[u8]>, + request: &[u8], + principal: &[u8], + server_id: &[u8], + context: &[u8], +) -> Result { + let message = CredentialRequest::::deserialize(request) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + let registration = record + .map(ServerRegistration::::deserialize) + .transpose() + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + let result = ServerLogin::::start( + &mut OsRng, + setup, + registration, + message, + principal, + ServerLoginParameters { + context: Some(context), + identifiers: Identifiers { + client: Some(principal), + server: Some(server_id), + }, + }, + ) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + Ok(LoginStartResult { + response: result.message.serialize().to_vec(), + state: result.state.serialize().to_vec(), + }) +} + +pub fn login_finish( + serialized_state: &[u8], + finalization: &[u8], + principal: &[u8], + server_id: &[u8], + context: &[u8], +) -> Result<(), PasswordAuthError> { + let state = ServerLogin::::deserialize(serialized_state) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + let message = CredentialFinalization::::deserialize(finalization) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + state + .finish( + message, + ServerLoginParameters { + context: Some(context), + identifiers: Identifiers { + client: Some(principal), + server: Some(server_id), + }, + }, + ) + .map_err(|error| PasswordAuthError::Opaque(error.to_string()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp::crypto::Keyring; + use opaque_ke::{ + ClientLogin, ClientLoginFinishParameters, ClientRegistration, + ClientRegistrationFinishParameters, CredentialResponse, RegistrationResponse, + }; + use uuid::Uuid; + + #[test] + fn registration_and_login_bind_context_identifiers_and_persisted_setup() { + let setup = generate_server_setup(); + let restored = deserialize_server_setup(&serialize_server_setup(&setup)).unwrap(); + let principal = b"omega-key:example#7"; + let server = server_identifier(11); + let identifiers = Identifiers { + client: Some(principal), + server: Some(&server), + }; + let registration = + ClientRegistration::::start(&mut OsRng, b"correct horse").unwrap(); + let response = + registration_start(&restored, ®istration.message.serialize(), principal).unwrap(); + let upload = registration + .state + .finish( + &mut OsRng, + b"correct horse", + RegistrationResponse::::deserialize(&response).unwrap(), + ClientRegistrationFinishParameters::new(identifiers, None), + ) + .unwrap(); + let record = registration_finish(&upload.message.serialize()).unwrap(); + let key = Keyring::generate().public_key_bundle(); + let context = login_context("omega-key:example#7", 11, Uuid::new_v4(), &key).unwrap(); + + let client = ClientLogin::::start(&mut OsRng, b"correct horse").unwrap(); + let result = login_start( + &restored, + Some(&record), + &client.message.serialize(), + principal, + &server, + &context, + ) + .unwrap(); + let ke2 = CredentialResponse::::deserialize(&result.response).unwrap(); + let finish = client + .state + .finish( + &mut OsRng, + b"correct horse", + ke2, + ClientLoginFinishParameters::new(Some(&context), identifiers, None), + ) + .unwrap(); + assert!( + login_finish( + &result.state, + &finish.message.serialize(), + principal, + &server, + &context + ) + .is_ok() + ); + let client = ClientLogin::::start(&mut OsRng, b"correct horse").unwrap(); + let mismatched = login_start( + &restored, + Some(&record), + &client.message.serialize(), + principal, + &server, + b"wrong context", + ) + .unwrap(); + assert!( + client + .state + .finish( + &mut OsRng, + b"correct horse", + CredentialResponse::::deserialize(&mismatched.response) + .unwrap(), + ClientLoginFinishParameters::new(Some(&context), identifiers, None) + ) + .is_err() + ); + + let wrong_server = server_identifier(12); + for (login_principal, login_server, password) in [ + ( + b"omega-key:example#8".as_slice(), + server.as_slice(), + b"correct horse".as_slice(), + ), + ( + principal.as_slice(), + wrong_server.as_slice(), + b"correct horse".as_slice(), + ), + ( + principal.as_slice(), + server.as_slice(), + b"wrong horse".as_slice(), + ), + ] { + let client = ClientLogin::::start(&mut OsRng, password).unwrap(); + let result = login_start( + &restored, + Some(&record), + &client.message.serialize(), + login_principal, + login_server, + &context, + ) + .unwrap(); + assert!( + client + .state + .finish( + &mut OsRng, + password, + CredentialResponse::::deserialize(&result.response) + .unwrap(), + ClientLoginFinishParameters::new(Some(&context), identifiers, None) + ) + .is_err() + ); + } + + let client = ClientLogin::::start(&mut OsRng, b"correct horse").unwrap(); + let dummy = login_start( + &restored, + None, + &client.message.serialize(), + principal, + &server, + &context, + ) + .unwrap(); + assert!(CredentialResponse::::deserialize(&dummy.response).is_ok()); + let unrelated = generate_server_setup(); + let client = ClientLogin::::start(&mut OsRng, b"correct horse").unwrap(); + let response = login_start( + &unrelated, + Some(&record), + &client.message.serialize(), + principal, + &server, + &context, + ) + .unwrap(); + assert!( + client + .state + .finish( + &mut OsRng, + b"correct horse", + CredentialResponse::::deserialize(&response.response).unwrap(), + ClientLoginFinishParameters::new(Some(&context), identifiers, None) + ) + .is_err() + ); + } + + #[test] + fn login_context_binds_account_iota_session_and_contact_key() { + let first = Keyring::generate().public_key_bundle(); + let second = Keyring::generate().public_key_bundle(); + let session = Uuid::new_v4(); + let original = login_context("omega-key:example#7", 11, session, &first).unwrap(); + for changed in [ + login_context("omega-key:example#8", 11, session, &first).unwrap(), + login_context("omega-key:example#7", 12, session, &first).unwrap(), + login_context("omega-key:example#7", 11, Uuid::new_v4(), &first).unwrap(), + login_context("omega-key:example#7", 11, session, &second).unwrap(), + ] { + assert_ne!(original, changed); + } + } +} diff --git a/iota-storage/src/users/mod.rs b/iota-storage/src/users/mod.rs index ab1268f..9b95de8 100644 --- a/iota-storage/src/users/mod.rs +++ b/iota-storage/src/users/mod.rs @@ -1,5 +1,6 @@ pub mod contact; pub mod invitations; +pub mod password_credentials; pub mod pending_operations; pub mod user_manager; -pub mod user_profile; +pub mod user_profile; diff --git a/iota-storage/src/users/password_credentials.rs b/iota-storage/src/users/password_credentials.rs new file mode 100644 index 0000000..baa1443 --- /dev/null +++ b/iota-storage/src/users/password_credentials.rs @@ -0,0 +1,80 @@ +use rusqlite::{OptionalExtension, params}; + +use crate::{storage_error::StorageError, util::db}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PasswordCredential { + pub user_id: i64, + pub protocol_version: i64, + pub credential_format_version: i64, + pub opaque_record: Vec, + pub encrypted_tu_credential: Vec, + pub account_public_key_sha256: Vec, + pub created_at: i64, + pub updated_at: i64, +} + +pub fn get(user_id: i64) -> Result, StorageError> { + db::with_db(|conn| { + conn.query_row( + "SELECT user_id, protocol_version, credential_format_version, opaque_record, encrypted_tu_credential, account_public_key_sha256, created_at, updated_at FROM user_password_credentials WHERE user_id = ?1", + params![user_id], + |row| Ok(PasswordCredential { + user_id: row.get(0)?, protocol_version: row.get(1)?, credential_format_version: row.get(2)?, + opaque_record: row.get(3)?, encrypted_tu_credential: row.get(4)?, + account_public_key_sha256: row.get(5)?, created_at: row.get(6)?, updated_at: row.get(7)?, + }), + ).optional().map_err(Into::into) + }) +} + +pub fn exists(user_id: i64) -> Result { + db::with_db(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM user_password_credentials WHERE user_id = ?1)", + params![user_id], + |row| row.get(0), + ) + .map_err(Into::into) + }) +} + +pub fn any() -> Result { + db::with_db(|conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM user_password_credentials)", + [], + |row| row.get(0), + ) + .map_err(Into::into) + }) +} + +pub fn upsert(credential: &PasswordCredential) -> Result<(), StorageError> { + db::with_immediate_transaction(|tx| { + tx.execute( + "INSERT INTO user_password_credentials (user_id, protocol_version, credential_format_version, opaque_record, encrypted_tu_credential, account_public_key_sha256, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(user_id) DO UPDATE SET + protocol_version = excluded.protocol_version, + credential_format_version = excluded.credential_format_version, + opaque_record = excluded.opaque_record, + encrypted_tu_credential = excluded.encrypted_tu_credential, + account_public_key_sha256 = excluded.account_public_key_sha256, + updated_at = excluded.updated_at", + params![credential.user_id, credential.protocol_version, credential.credential_format_version, + credential.opaque_record, credential.encrypted_tu_credential, + credential.account_public_key_sha256, credential.created_at, credential.updated_at], + )?; + Ok(()) + }) +} + +pub fn delete(user_id: i64) -> Result { + db::with_db(|conn| { + Ok(conn.execute( + "DELETE FROM user_password_credentials WHERE user_id = ?1", + params![user_id], + )? != 0) + }) +} diff --git a/iota-storage/src/users/user_manager.rs b/iota-storage/src/users/user_manager.rs index 19e4ecd..1f57421 100644 --- a/iota-storage/src/users/user_manager.rs +++ b/iota-storage/src/users/user_manager.rs @@ -91,6 +91,13 @@ fn persist_user_profile( tx: &rusqlite::Transaction<'_>, user: &UserProfile, ) -> Result<(), crate::storage_error::StorageError> { + // A password backup for the old account key must never restore a rotated identity. + tx.execute( + "DELETE FROM user_password_credentials WHERE user_id = ?1 AND EXISTS ( + SELECT 1 FROM users WHERE user_id = ?1 AND public_key != ?2 + )", + params![user.user_id, user.public_key], + )?; tx.execute( r#" INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) @@ -228,6 +235,10 @@ pub fn revoke_all_tauth_grants(user_id: i64) -> Result Result<(), crate::storage_error::StorageError> { db::with_immediate_transaction(|conn| { + conn.execute( + "DELETE FROM user_password_credentials WHERE user_id = ?1", + params![user_id], + )?; conn.execute( "DELETE FROM tauth_codes WHERE local_user_id = ?1", params![user_id], @@ -298,6 +309,12 @@ pub fn finalize_local_release( params![user_id], )?; tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + // OPAQUE records cannot authenticate against another Iota's ServerSetup. + // Reassignment requires enrollment again from an authenticated device. + tx.execute( + "DELETE FROM user_password_credentials WHERE user_id = ?1", + params![user_id], + )?; tx.execute( "UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'user_released' WHERE target_kind = 0 AND destination_id = ?1", params![user_id], @@ -576,6 +593,10 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor params![user_id], )?; conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?; + conn.execute( + "DELETE FROM user_password_credentials WHERE user_id = ?1", + params![user_id], + )?; conn.execute( "DELETE FROM user_residency WHERE user_id = ?1", params![user_id], @@ -644,7 +665,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 tauth_codes; DELETE FROM tauth_sessions; DELETE FROM tauth_metadata; DELETE FROM tauth_grants; 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 user_password_credentials; DELETE FROM protected_command_replay; DELETE FROM users; DELETE FROM user_residency;", )?; Ok(()) }) @@ -772,8 +793,13 @@ mod tests { credential_origin TEXT NOT NULL, updated_at INTEGER NOT NULL ); + CREATE TABLE user_password_credentials ( + user_id INTEGER PRIMARY KEY, + account_public_key_sha256 BLOB NOT NULL + ); INSERT INTO users VALUES (1, 'alice', 'old-key', NULL, NULL, 1, NULL); INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42); + INSERT INTO user_password_credentials VALUES (1, x'00'); "#, ) .unwrap(); @@ -789,6 +815,14 @@ mod tests { let transaction = connection.transaction().unwrap(); persist_user_profile(&transaction, &user).unwrap(); transaction.commit().unwrap(); + let password_rows: i64 = connection + .query_row( + "SELECT COUNT(*) FROM user_password_credentials WHERE user_id = 1", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(password_rows, 0); let residency: (String, String, String, String, i64) = connection .query_row( diff --git a/iota-storage/src/util/db.rs b/iota-storage/src/util/db.rs index d9e9b86..a46d0ff 100644 --- a/iota-storage/src/util/db.rs +++ b/iota-storage/src/util/db.rs @@ -2022,6 +2022,32 @@ fn run_migrations_on_connection(conn: &Connection) -> Result<(), StorageError> { )?; } + if current_version < 47 { + conn.execute_batch( + r#" + CREATE TABLE user_password_credentials ( + user_id INTEGER PRIMARY KEY, + protocol_version INTEGER NOT NULL, + credential_format_version INTEGER NOT NULL, + opaque_record BLOB NOT NULL, + encrypted_tu_credential BLOB NOT NULL, + account_public_key_sha256 BLOB NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + CREATE TABLE protected_command_replay ( + signer_id INTEGER NOT NULL, + message_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (signer_id, message_id) + ); + CREATE INDEX protected_command_replay_created_at + ON protected_command_replay (created_at); + PRAGMA user_version = 47; + "#, + )?; + } + Ok(()) } @@ -2096,7 +2122,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, 46); + assert_eq!(version, 47); 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")?; @@ -2115,7 +2141,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, 46); + assert_eq!(version, 47); for table in [ "sync_heads", "sync_events", @@ -2135,6 +2161,8 @@ mod tests { "principals", "principal_keys", "identity_configuration", + "user_password_credentials", + "protected_command_replay", ] { let exists: i64 = conn.query_row( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", @@ -2196,7 +2224,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, 46); + assert_eq!(version, 47); for column in [ "id", "user_id", @@ -2291,7 +2319,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, 46); + assert_eq!(version, 47); Ok(()) } @@ -2329,7 +2357,7 @@ mod tests { })?; assert_eq!(count, 0); let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; - assert_eq!(version, 46); + assert_eq!(version, 47); Ok(()) } diff --git a/iota-storage/src/util/mod.rs b/iota-storage/src/util/mod.rs index 3d11d2c..ead1ade 100644 --- a/iota-storage/src/util/mod.rs +++ b/iota-storage/src/util/mod.rs @@ -12,6 +12,7 @@ pub mod e2ee_storage; pub mod message_retention; pub mod message_storage_policy; pub mod outgoing_relay; +pub mod protected_replay; pub mod receipt_policy; pub mod relay_queue; pub mod relay_replay; diff --git a/iota-storage/src/util/protected_replay.rs b/iota-storage/src/util/protected_replay.rs new file mode 100644 index 0000000..78a7d1f --- /dev/null +++ b/iota-storage/src/util/protected_replay.rs @@ -0,0 +1,21 @@ +use rusqlite::params; + +use crate::{storage_error::StorageError, util::db}; + +pub fn accept(signer_id: i64, message_id: &str, created_at: i64) -> Result { + db::with_db(|conn| { + Ok(conn.execute( + "INSERT OR IGNORE INTO protected_command_replay (signer_id, message_id, created_at) VALUES (?1, ?2, ?3)", + params![signer_id, message_id, created_at], + )? == 1) + }) +} + +pub fn prune(before: i64) -> Result { + db::with_db(|conn| { + Ok(conn.execute( + "DELETE FROM protected_command_replay WHERE created_at < ?1", + params![before], + )?) + }) +} diff --git a/mtp-type-maps b/mtp-type-maps index 4eccf97..b996bb8 160000 --- a/mtp-type-maps +++ b/mtp-type-maps @@ -1 +1 @@ -Subproject commit 4eccf976c224a2a0d95c8f3ead596265a74a4fab +Subproject commit b996bb8afbc54391e38c03a92e6226d43aa8ecf8 diff --git a/omikron-connector/src/lib.rs b/omikron-connector/src/lib.rs index 13de4f5..902b523 100644 --- a/omikron-connector/src/lib.rs +++ b/omikron-connector/src/lib.rs @@ -2,6 +2,7 @@ pub mod client; pub mod identity; pub mod omega_discovery; pub mod omikron_connection; +mod password_provisioning; pub mod router; pub mod tauth; pub mod user_ops; diff --git a/omikron-connector/src/omega_discovery.rs b/omikron-connector/src/omega_discovery.rs index e623daf..debff3e 100644 --- a/omikron-connector/src/omega_discovery.rs +++ b/omikron-connector/src/omega_discovery.rs @@ -13,7 +13,7 @@ pub struct OmikronEndpoint { pub public_key: PublicKeyBundle, } -fn api_base() -> String { +pub(crate) fn api_base() -> String { env::var("OMEGA_API_URL").unwrap_or_else(|_| OMEGA_API_BASE_DEFAULT.to_string()) } diff --git a/omikron-connector/src/omikron_connection.rs b/omikron-connector/src/omikron_connection.rs index e5ea992..a5d156c 100644 --- a/omikron-connector/src/omikron_connection.rs +++ b/omikron-connector/src/omikron_connection.rs @@ -24,6 +24,7 @@ use uuid::Uuid; use crate::client::{OmikronClient, OmikronError}; use crate::omega_discovery; +use crate::password_provisioning::PasswordAuthRuntime; use iota_connection::message_common::*; use iota_connection::message_handlers; @@ -424,9 +425,10 @@ pub struct OmikronConnection { shutdown_tx: Arc>>>, reconnect_on_close: Arc>, auth_failure: Arc>>, - keyring: Arc>>>, - http_client: reqwest::Client, + pub(super) keyring: Arc>>>, + pub(super) http_client: reqwest::Client, session_manager: Arc, + pub(super) password_auth: Arc, handler_semaphore: Arc, cancellation: CancellationToken, pub(crate) active_tasks: Arc>, @@ -462,6 +464,7 @@ impl OmikronConnection { keyring: Arc::new(RwLock::new(None)), http_client: reqwest::Client::new(), session_manager: Arc::new(iota_auth::SessionManager::default()), + password_auth: Arc::new(PasswordAuthRuntime::default()), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), cancellation, active_tasks, @@ -637,6 +640,7 @@ impl OmikronConnection { .await .map_err(|error| format!("Iota identity initialization failed: {error}"))?; let keyring = identity.keyring(); + self.password_auth.initialize(identity_path())?; *self.keyring.write().await = Some(keyring.clone()); let existing_iota_id = CONFIG.load().iota_id; @@ -1154,6 +1158,12 @@ impl OmikronConnection { ) { log!("Relay replay cleanup failed: {}", error); } + self.password_auth.prune(); + if let Err(error) = iota_storage::util::protected_replay::prune( + now_millis_i64().saturating_sub(7 * 24 * 60 * 60 * 1000), + ) { + log!("Password command replay cleanup failed: {error}"); + } } } @@ -1630,7 +1640,19 @@ impl OmikronConnection { // ------------------------------------------------------------------------- pub async fn handle_message(self: Arc, cv: CommunicationValue) { - log_cv_in!(&cv); + if !matches!( + cv.get_comm_type_enum(), + Some( + CommunicationType::PasswordEnrollmentStart + | CommunicationType::PasswordEnrollmentFinish + | CommunicationType::PasswordEnrollmentStatus + | CommunicationType::PasswordEnrollmentDisable + | CommunicationType::PasswordProvisioningStart + | CommunicationType::PasswordProvisioningFinish + ) + ) { + log_cv_in!(&cv); + } if cv.is_type(CommunicationType::Success) && let Some(frame_id) = cv.id() @@ -1715,6 +1737,21 @@ impl OmikronConnection { dispatch!(GetChatSecret, handle_get_chat_secret); dispatch!(TAuthAuthorize, handle_tauth_authorize); + dispatch!(PasswordEnrollmentStart, handle_password_enrollment_start); + dispatch!(PasswordEnrollmentFinish, handle_password_enrollment_finish); + dispatch!(PasswordEnrollmentStatus, handle_password_enrollment_status); + dispatch!( + PasswordEnrollmentDisable, + handle_password_enrollment_disable + ); + dispatch!( + PasswordProvisioningStart, + handle_password_provisioning_start + ); + dispatch!( + PasswordProvisioningFinish, + handle_password_provisioning_finish + ); dispatch!(TAuthExchangeCode, handle_tauth_exchange_code); dispatch!(TAuthUser, handle_tauth_user); dispatch!(TAuthContacts, handle_tauth_contacts); @@ -3587,6 +3624,7 @@ impl OmikronClient for OmikronConnection { keyring: self.keyring.clone(), http_client: self.http_client.clone(), session_manager: self.session_manager.clone(), + password_auth: self.password_auth.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), @@ -3613,6 +3651,7 @@ impl OmikronClient for OmikronConnection { keyring: self.keyring.clone(), http_client: self.http_client.clone(), session_manager: self.session_manager.clone(), + password_auth: self.password_auth.clone(), handler_semaphore: self.handler_semaphore.clone(), cancellation: self.cancellation.clone(), active_tasks: self.active_tasks.clone(), diff --git a/omikron-connector/src/password_provisioning.rs b/omikron-connector/src/password_provisioning.rs new file mode 100644 index 0000000..c50b63d --- /dev/null +++ b/omikron-connector/src/password_provisioning.rs @@ -0,0 +1,904 @@ +use std::{ + path::Path, + sync::{Arc, OnceLock}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use dashmap::{DashMap, mapref::entry::Entry}; +use iota_auth::password::{self, PasswordServerSetup}; +use iota_storage::{ + users::{ + password_credentials::{self, PasswordCredential}, + user_manager, + }, + util::protected_replay, +}; +use mtp::{ + codec::{ + CommunicationType, CommunicationValue, DataType, DataTypeId, + DataValue, ProtectedMessageBuilder, ProtectedOpenOptions, ProtectionPolicy, + ReplayError, ReplayGuard, TypeMap, VerifiedProtectedMessage, + open_protected_with_checked, + }, + crypto::{DualSigner, PublicKeyBundle}, +}; +use rand_core::{OsRng, RngCore}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::omikron_connection::OmikronConnection; + +#[allow(dead_code)] +mod app_protection { + include!(concat!(env!("CARGO_MANIFEST_DIR"), "/../mtp-type-maps/app_protection.rs")); +} +use app_protection::{ + purpose, PASSWORD_MANAGEMENT_SIGNATURE, PASSWORD_MANAGEMENT_ENCRYPTION, + PASSWORD_RESPONSE_SIGNATURE, PASSWORD_RESPONSE_ENCRYPTION, + PROVISIONING_CREDENTIAL_SIGNATURE, PROVISIONING_CREDENTIAL_ENCRYPTION, +}; + +const MAX_OPAQUE_BYTES: usize = 16 * 1024; +const MAX_CREDENTIAL_BYTES: usize = 256 * 1024; + +struct CommandReplayGuard; + +impl ReplayGuard for CommandReplayGuard { + fn accept( + &mut self, + signer_id: u64, + message_id: mtp::common::MessageId, + created_at: u64, + ) -> Result { + let now = now_millis(); + if created_at < now.saturating_sub(7 * 24 * 60 * 60 * 1000) + || created_at > now.saturating_add(5 * 60 * 1000) + { + return Ok(false); + } + let signer = + i64::try_from(signer_id).map_err(|error| ReplayError::Store(error.to_string()))?; + let time = + i64::try_from(created_at).map_err(|error| ReplayError::Store(error.to_string()))?; + protected_replay::accept(signer, &message_id.to_string(), time) + .map_err(|error| ReplayError::Store(error.to_string())) + } +} + +pub(super) struct PendingLogin { + user_id: i64, + participant_id: u64, + contact_key: PublicKeyBundle, + context: Vec, + state: Vec, + real_record: bool, + record_hash: Option>, + created: Instant, +} + +pub(super) struct PasswordAuthRuntime { + setup: OnceLock>, + omega_authority: OnceLock, + logins: DashMap, + enrollments: DashMap, + attempts: DashMap, + max_pending: usize, + pending_ttl: Duration, + attempt_window: Duration, + max_attempts: u32, +} + +fn configured_positive(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > T::default()) + .unwrap_or(default) +} + +impl Default for PasswordAuthRuntime { + fn default() -> Self { + Self { + setup: OnceLock::new(), + omega_authority: OnceLock::new(), + logins: DashMap::new(), + enrollments: DashMap::new(), + attempts: DashMap::new(), + max_pending: configured_positive("PASSWORD_MAX_PENDING_EXCHANGES", 1024), + pending_ttl: Duration::from_secs(configured_positive( + "PASSWORD_PENDING_TTL_SECONDS", + 180, + )), + attempt_window: Duration::from_secs(configured_positive( + "PASSWORD_ATTEMPT_WINDOW_SECONDS", + 300, + )), + max_attempts: configured_positive("PASSWORD_MAX_ATTEMPTS_PER_ACCOUNT", 10), + } + } +} + +impl PasswordAuthRuntime { + pub(super) fn initialize(&self, identity: &Path) -> Result<(), String> { + if self.setup.get().is_some() { + return Ok(()); + } + let path = identity.with_file_name("password-auth.setup"); + let setup = match std::fs::read(&path) { + Ok(bytes) => { + password::deserialize_server_setup(&bytes).map_err(|error| error.to_string())? + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if password_credentials::any().map_err(|error| error.to_string())? { + return Err("OPAQUE setup is missing while password credentials exist".into()); + } + let setup = password::generate_server_setup(); + iota_util::atomic_file::replace_private( + &path, + &password::serialize_server_setup(&setup), + 0, + ) + .map_err(|error| error.to_string())?; + setup + } + Err(error) => return Err(error.to_string()), + }; + let _ = self.setup.set(Arc::new(setup)); + Ok(()) + } + + pub(super) fn prune(&self) { + self.logins + .retain(|_, value| value.created.elapsed() < self.pending_ttl); + self.enrollments + .retain(|_, (_, created)| created.elapsed() < self.pending_ttl); + self.attempts + .retain(|_, (started, _)| started.elapsed() < self.attempt_window); + } + + fn allow_attempt(&self, user_id: i64) -> bool { + let mut attempt = self.attempts.entry(user_id).or_insert((Instant::now(), 0)); + if attempt.0.elapsed() >= self.attempt_window { + *attempt = (Instant::now(), 0); + } + if attempt.1 >= self.max_attempts { + return false; + } + attempt.1 += 1; + true + } +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + +fn field<'a>(content: &'a DataValue, kind: DataType) -> Result<&'a DataValue, String> { + let id = kind + .try_to_id(&TypeMap::latest()) + .ok_or("unknown data field")?; + let DataValue::Container(fields) = content else { + return Err("content is not a container".into()); + }; + fields + .iter() + .find_map(|(field_id, value)| (*field_id == id).then_some(value)) + .ok_or_else(|| format!("missing {kind:?}")) +} + +fn typed(kind: DataType, value: DataValue) -> Result<(DataTypeId, DataValue), String> { + Ok(( + kind.try_to_id(&TypeMap::latest()).ok_or("unknown field")?, + value, + )) +} + +fn bytes(content: &DataValue, kind: DataType, max: usize) -> Result, String> { + let value = field(content, kind)? + .as_bytes() + .ok_or("field is not bytes")?; + if value.is_empty() || value.len() > max { + return Err("field exceeds size limits".into()); + } + Ok(value) +} + +fn positive(content: &DataValue, kind: DataType) -> Result { + field(content, kind)? + .as_number() + .and_then(|value| i64::try_from(value).ok()) + .filter(|value| *value > 0) + .ok_or_else(|| format!("invalid {kind:?}")) +} + +fn session(content: &DataValue) -> Result { + Uuid::parse_str( + field(content, DataType::Uuid)? + .as_str() + .ok_or("invalid session UUID")?, + ) + .map_err(|error| error.to_string()) +} + +fn public_key(content: &DataValue) -> Result { + let encoded = field(content, DataType::PublicKey)? + .as_str() + .ok_or("invalid public key")?; + if encoded.len() > 16 * 1024 { + return Err("public key too large".into()); + } + PublicKeyBundle::from_base64(encoded).map_err(|error| error.to_string()) +} + +fn key_fingerprint(encoded: &str) -> Result, String> { + let bundle = PublicKeyBundle::from_base64(encoded).map_err(|error| error.to_string())?; + Ok(Sha256::digest(bundle.try_as_bytes().map_err(|error| error.to_string())?).to_vec()) +} + +impl OmikronConnection { + // Password login uses Omega's key-scoped principal, including for accounts + // whose older hosted-principal record still uses the legacy host locator. + async fn password_principal(&self, user_id: i64) -> Result { + if user_id <= 0 + || user_manager::get_user(user_id) + .map_err(|error| error.to_string())? + .is_none() + { + return Err("account not hosted".into()); + } + let authority = if let Some(cached) = self.password_auth.omega_authority.get() { + cached.clone() + } else { + let url = format!( + "{}/.well-known/tensamin", + crate::omega_discovery::api_base() + ); + let response = + tokio::time::timeout(Duration::from_secs(10), self.http_client.get(url).send()) + .await + .map_err(|_| "Omega identity lookup timed out")? + .map_err(|error| error.to_string())? + .error_for_status() + .map_err(|error| error.to_string())?; + let discovery: serde_json::Value = + response.json().await.map_err(|error| error.to_string())?; + let key = discovery + .get("public_key") + .and_then(serde_json::Value::as_str) + .ok_or("Omega discovery is missing its key")?; + let key = PublicKeyBundle::from_base64(key).map_err(|error| error.to_string())?; + let authority = iota_identity::AuthorityId::for_omega(&key) + .map_err(|error| error.to_string())? + .as_str() + .to_owned(); + if discovery + .get("authority_id") + .and_then(serde_json::Value::as_str) + != Some(authority.as_str()) + { + return Err("Omega discovery identity mismatch".into()); + } + let _ = self.password_auth.omega_authority.set(authority.clone()); + authority + }; + Ok(format!("{authority}#{user_id}")) + } + + async fn open_password_command( + &self, + frame: &CommunicationValue, + kind: CommunicationType, + ) -> Result { + let iota_id = iota_storage::util::config_util::CONFIG + .load() + .iota_id + .ok_or("Iota has no identity")?; + let keyring = self + .keyring + .read() + .await + .clone() + .ok_or("Iota has no keyring")?; + let mut replay = CommandReplayGuard; + let opened = open_protected_with_checked( + frame, + &[keyring.as_ref()], + None, + |signer_id| { + let id = i64::try_from(signer_id).ok()?; + let profile = user_manager::get_user(id).ok()??; + Some(vec![ + PublicKeyBundle::from_base64(&profile.public_key).ok()?, + ]) + }, + ProtectedOpenOptions::new( + Some(iota_id), + purpose(PASSWORD_MANAGEMENT_SIGNATURE), + purpose(PASSWORD_MANAGEMENT_ENCRYPTION), + ProtectionPolicy::dual(), + ), + &mut replay, + ) + .map_err(|error| error.to_string())?; + if opened.message_type() != kind { + return Err("wrong protected command type".into()); + } + Ok(opened) + } + + async fn protected_response( + &self, + frame: &CommunicationValue, + kind: CommunicationType, + user_id: i64, + content: DataValue, + signature: u8, + encryption: u8, + ) -> Result { + let iota_id = iota_storage::util::config_util::CONFIG + .load() + .iota_id + .ok_or("Iota has no identity")?; + let keyring = self + .keyring + .read() + .await + .clone() + .ok_or("Iota has no keyring")?; + let recipient = PublicKeyBundle::from_base64( + &user_manager::get_user(user_id) + .map_err(|error| error.to_string())? + .ok_or("account not hosted")? + .public_key, + ) + .map_err(|error| error.to_string())?; + self.build_protected( + frame, + kind, + iota_id, + user_id as u64, + content, + recipient, + signature, + encryption, + &keyring, + ) + } + + fn build_protected( + &self, + frame: &CommunicationValue, + kind: CommunicationType, + signer_id: u64, + recipient_id: u64, + content: DataValue, + recipient: PublicKeyBundle, + signature: u8, + encryption: u8, + keyring: &mtp::crypto::Keyring, + ) -> Result { + let signer = DualSigner::new( + &keyring.sig_cl_secret_key, + &keyring.sig_pq_secret_key, + &keyring.sig_pq_public_key, + ) + .map_err(|error| error.to_string())?; + let mut rng = OsRng; + let id = ((rng.next_u64() as u128) << 64) | rng.next_u64() as u128; + ProtectedMessageBuilder::new( + kind, + content, + signer_id, + recipient_id, + &signer, + purpose(signature), + purpose(encryption), + ) + .message_id(id) + .created_at(now_millis()) + .recipients(vec![recipient]) + .frame_id(frame.id().ok_or("missing request ID")?) + .build() + .map_err(|error| error.to_string()) + } + + async fn password_error(&self, frame: &CommunicationValue, kind: CommunicationType) { + let mut response = CommunicationValue::new(kind); + if let Some(id) = frame.id() { + response = response.with_id(id); + } + let _ = self.send_message(&response).await; + } + + pub(super) async fn handle_password_enrollment_start( + self: Arc, + frame: &CommunicationValue, + ) { + let result = self.enrollment_start(frame).await; + match result { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password enrollment start failed: {error}"); + self.password_error(frame, CommunicationType::ErrorInvalidData) + .await; + } + } + } + + async fn enrollment_start( + &self, + frame: &CommunicationValue, + ) -> Result { + let opened = self + .open_password_command(frame, CommunicationType::PasswordEnrollmentStart) + .await?; + let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?; + if self.password_auth.enrollments.len() >= self.password_auth.max_pending { + return Err("too many enrollments".into()); + } + let principal = self.password_principal(user_id).await?; + let request = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?; + let setup = self + .password_auth + .setup + .get() + .ok_or("OPAQUE setup unavailable")?; + let response = password::registration_start(setup, &request, principal.as_bytes()) + .map_err(|error| error.to_string())?; + let enrollment_id = Uuid::new_v4(); + self.password_auth + .enrollments + .insert(enrollment_id, (user_id, Instant::now())); + self.protected_response( + frame, + CommunicationType::PasswordEnrollmentResponse, + user_id, + DataValue::Container(vec![ + typed(DataType::Uuid, DataValue::Str(enrollment_id.to_string()))?, + typed(DataType::OpaqueMessage, DataValue::Bytes(response))?, + ]), + PASSWORD_RESPONSE_SIGNATURE, + PASSWORD_RESPONSE_ENCRYPTION, + ) + .await + } + + pub(super) async fn handle_password_enrollment_finish( + self: Arc, + frame: &CommunicationValue, + ) { + match self.enrollment_finish(frame).await { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password enrollment finish failed: {error}"); + self.password_error(frame, CommunicationType::ErrorInvalidData) + .await; + } + } + } + + async fn enrollment_finish( + &self, + frame: &CommunicationValue, + ) -> Result { + let opened = self + .open_password_command(frame, CommunicationType::PasswordEnrollmentFinish) + .await?; + let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?; + let enrollment_id = session(opened.content())?; + let (_, (pending_user, started)) = self + .password_auth + .enrollments + .remove(&enrollment_id) + .ok_or("enrollment expired")?; + if pending_user != user_id || started.elapsed() >= self.password_auth.pending_ttl { + return Err("enrollment mismatch".into()); + } + let upload = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?; + let encrypted = bytes( + opened.content(), + DataType::EncryptedCredential, + MAX_CREDENTIAL_BYTES, + )?; + let version = positive(opened.content(), DataType::CredentialFormatVersion)?; + if version != 1 { + return Err("unsupported credential format".into()); + } + let opaque_record = + password::registration_finish(&upload).map_err(|error| error.to_string())?; + let profile = user_manager::get_user(user_id) + .map_err(|error| error.to_string())? + .ok_or("account not hosted")?; + let fingerprint = key_fingerprint(&profile.public_key)?; + let now = now_millis() as i64; + password_credentials::upsert(&PasswordCredential { + user_id, + protocol_version: 1, + credential_format_version: version, + opaque_record, + encrypted_tu_credential: encrypted, + account_public_key_sha256: fingerprint, + created_at: now, + updated_at: now, + }) + .map_err(|error| error.to_string())?; + self.password_auth + .logins + .retain(|_, pending| pending.user_id != user_id); + self.protected_response( + frame, + CommunicationType::PasswordEnrollmentStatus, + user_id, + DataValue::Container(vec![typed(DataType::Enabled, DataValue::Bool(true))?]), + PASSWORD_RESPONSE_SIGNATURE, + PASSWORD_RESPONSE_ENCRYPTION, + ) + .await + } + + pub(super) async fn handle_password_enrollment_status( + self: Arc, + frame: &CommunicationValue, + ) { + let result = async { + let opened = self + .open_password_command(frame, CommunicationType::PasswordEnrollmentStatus) + .await?; + let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?; + let enabled = + password_credentials::exists(user_id).map_err(|error| error.to_string())?; + self.protected_response( + frame, + CommunicationType::PasswordEnrollmentStatus, + user_id, + DataValue::Container(vec![typed(DataType::Enabled, DataValue::Bool(enabled))?]), + PASSWORD_RESPONSE_SIGNATURE, + PASSWORD_RESPONSE_ENCRYPTION, + ) + .await + } + .await; + match result { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password status failed: {error}"); + self.password_error(frame, CommunicationType::ErrorInvalidData) + .await; + } + } + } + + pub(super) async fn handle_password_enrollment_disable( + self: Arc, + frame: &CommunicationValue, + ) { + let result = async { + let opened = self + .open_password_command(frame, CommunicationType::PasswordEnrollmentDisable) + .await?; + let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?; + password_credentials::delete(user_id).map_err(|error| error.to_string())?; + self.password_auth + .logins + .retain(|_, pending| pending.user_id != user_id); + self.protected_response( + frame, + CommunicationType::PasswordEnrollmentStatus, + user_id, + DataValue::Container(vec![typed(DataType::Enabled, DataValue::Bool(false))?]), + PASSWORD_RESPONSE_SIGNATURE, + PASSWORD_RESPONSE_ENCRYPTION, + ) + .await + } + .await; + match result { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password disable failed: {error}"); + self.password_error(frame, CommunicationType::ErrorInvalidData) + .await; + } + } + } + + pub(super) async fn handle_password_provisioning_start( + self: Arc, + frame: &CommunicationValue, + ) { + match self.provisioning_start(frame).await { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password KE1 failed: {error}"); + self.password_error(frame, CommunicationType::ErrorNotAuthenticated) + .await; + } + } + } + + async fn provisioning_start( + &self, + frame: &CommunicationValue, + ) -> Result { + let content = frame.payload(); + let id = session(content)?; + let participant_id = u64::try_from(positive(content, DataType::ProvisioningParticipantId)?) + .map_err(|error| error.to_string())?; + let user_id = positive(content, DataType::UserId)?; + let iota_id = positive(content, DataType::IotaId)?; + if Some(iota_id as u64) != iota_storage::util::config_util::CONFIG.load().iota_id { + return Err("wrong Iota".into()); + } + if self.password_auth.logins.len() >= self.password_auth.max_pending + || !self.password_auth.allow_attempt(user_id) + { + return Err("password attempt limit reached".into()); + } + let contact_key = public_key(content)?; + let ke1 = bytes(content, DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?; + let principal = self.password_principal(user_id).await?; + let context = password::login_context(&principal, iota_id, id, &contact_key) + .map_err(|error| error.to_string())?; + let credential = password_credentials::get(user_id).map_err(|error| error.to_string())?; + let setup = self + .password_auth + .setup + .get() + .ok_or("OPAQUE setup unavailable")?; + let response = password::login_start( + setup, + credential + .as_ref() + .map(|value| value.opaque_record.as_slice()), + &ke1, + principal.as_bytes(), + &password::server_identifier(iota_id), + &context, + ) + .map_err(|error| error.to_string())?; + let pending = PendingLogin { + user_id, + participant_id, + contact_key, + context, + state: response.state, + real_record: credential.is_some(), + created: Instant::now(), + record_hash: credential + .as_ref() + .map(|value| Sha256::digest(&value.opaque_record).to_vec()), + }; + match self.password_auth.logins.entry(id) { + Entry::Vacant(slot) => { + slot.insert(pending); + } + Entry::Occupied(_) => return Err("session already started".into()), + } + Ok( + CommunicationValue::new(CommunicationType::PasswordProvisioningResponse) + .with_id(frame.id().ok_or("missing request ID")?) + .add_typed_default(DataType::Uuid, DataValue::Str(id.to_string())) + .add_typed_default(DataType::OpaqueMessage, DataValue::Bytes(response.response)), + ) + } + + pub(super) async fn handle_password_provisioning_finish( + self: Arc, + frame: &CommunicationValue, + ) { + match self.provisioning_finish(frame).await { + Ok(response) => { + let _ = self.send_message(&response).await; + } + Err(error) => { + iota_logger::log!("Password KE3 failed: {error}"); + self.password_error(frame, CommunicationType::ErrorNotAuthenticated) + .await; + } + } + } + + async fn provisioning_finish( + &self, + frame: &CommunicationValue, + ) -> Result { + let content = frame.payload(); + let id = session(content)?; + let (_, pending) = self + .password_auth + .logins + .remove(&id) + .ok_or("OPAQUE session expired")?; + if pending.created.elapsed() >= self.password_auth.pending_ttl + || pending.user_id != positive(content, DataType::UserId)? + || pending.participant_id + != u64::try_from(positive(content, DataType::ProvisioningParticipantId)?) + .map_err(|error| error.to_string())? + || pending + .contact_key + .try_as_bytes() + .map_err(|error| error.to_string())? + != public_key(content)? + .try_as_bytes() + .map_err(|error| error.to_string())? + { + return Err("OPAQUE session binding mismatch".into()); + } + let iota_id = positive(content, DataType::IotaId)?; + if Some(iota_id as u64) != iota_storage::util::config_util::CONFIG.load().iota_id { + return Err("wrong Iota".into()); + } + let ke3 = bytes(content, DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?; + let principal = self.password_principal(pending.user_id).await?; + password::login_finish( + &pending.state, + &ke3, + principal.as_bytes(), + &password::server_identifier(iota_id), + &pending.context, + ) + .map_err(|error| error.to_string())?; + if !pending.real_record { + return Err("authentication failed".into()); + } + let credential = password_credentials::get(pending.user_id) + .map_err(|error| error.to_string())? + .ok_or("credential unavailable")?; + if pending.record_hash.as_deref() + != Some(Sha256::digest(&credential.opaque_record).as_slice()) + { + return Err("password credential changed during login".into()); + } + let profile = user_manager::get_user(pending.user_id) + .map_err(|error| error.to_string())? + .ok_or("account unavailable")?; + if credential.account_public_key_sha256 != key_fingerprint(&profile.public_key)? + || credential.encrypted_tu_credential.is_empty() + || credential.encrypted_tu_credential.len() > MAX_CREDENTIAL_BYTES + || credential.credential_format_version != 1 + { + return Err("credential stale".into()); + } + let content = DataValue::Container(vec![ + typed(DataType::Uuid, DataValue::Str(id.to_string()))?, + typed( + DataType::ProvisioningMethod, + DataValue::Str("password".into()), + )?, + typed( + DataType::UserId, + DataValue::SignedNumber(pending.user_id.into()), + )?, + typed(DataType::IotaId, DataValue::SignedNumber(iota_id.into()))?, + typed( + DataType::EncryptedCredential, + DataValue::Bytes(credential.encrypted_tu_credential), + )?, + typed( + DataType::CredentialFormatVersion, + DataValue::SignedNumber(credential.credential_format_version.into()), + )?, + typed( + DataType::Sha256, + DataValue::Bytes(credential.account_public_key_sha256), + )?, + ]); + let keyring = self + .keyring + .read() + .await + .clone() + .ok_or("Iota keyring unavailable")?; + self.build_protected( + frame, + CommunicationType::ProvisioningCredential, + iota_id as u64, + pending.participant_id, + content, + pending.contact_key, + PROVISIONING_CREDENTIAL_SIGNATURE, + PROVISIONING_CREDENTIAL_ENCRYPTION, + &keyring, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use mtp::{codec::InMemoryReplayGuard, crypto::Keyring}; + + #[test] + fn password_management_requires_signed_encrypted_single_use_frames() { + let account = Keyring::generate(); + let iota = Keyring::generate(); + let signer = DualSigner::new( + &account.sig_cl_secret_key, + &account.sig_pq_secret_key, + &account.sig_pq_public_key, + ) + .unwrap(); + let frame = ProtectedMessageBuilder::new( + CommunicationType::PasswordEnrollmentFinish, + DataValue::Container(vec![ + typed( + DataType::EncryptedCredential, + DataValue::Bytes(vec![1, 2, 3]), + ) + .unwrap(), + ]), + 7, + 11, + &signer, + purpose(PASSWORD_MANAGEMENT_SIGNATURE), + purpose(PASSWORD_MANAGEMENT_ENCRYPTION), + ) + .message_id(123_u128) + .created_at(now_millis()) + .recipients(vec![iota.public_key_bundle()]) + .frame_id(42) + .build() + .unwrap(); + let options = ProtectedOpenOptions::new( + Some(11), + purpose(PASSWORD_MANAGEMENT_SIGNATURE), + purpose(PASSWORD_MANAGEMENT_ENCRYPTION), + ProtectionPolicy::dual(), + ); + let mut guard = InMemoryReplayGuard::default(); + let resolve = |_| Some(vec![account.public_key_bundle()]); + let opened = + open_protected_with_checked(&frame, &[&iota], Some(7), resolve, options, &mut guard) + .unwrap(); + assert_eq!(opened.signer_id(), 7); + assert!( + open_protected_with_checked(&frame, &[&iota], Some(7), resolve, options, &mut guard) + .is_err() + ); + assert!( + open_protected_with_checked( + &frame, + &[&iota], + Some(7), + resolve, + ProtectedOpenOptions::new( + Some(12), + purpose(PASSWORD_MANAGEMENT_SIGNATURE), + purpose(PASSWORD_MANAGEMENT_ENCRYPTION), + ProtectionPolicy::dual() + ), + &mut InMemoryReplayGuard::default() + ) + .is_err() + ); + let clear = CommunicationValue::new(CommunicationType::PasswordEnrollmentFinish) + .with_receiver(11) + .with_id(42); + assert!( + open_protected_with_checked( + &clear, + &[&iota], + Some(7), + resolve, + options, + &mut InMemoryReplayGuard::default() + ) + .is_err() + ); + } +}