Implement OPAQUE password provisioning on Iota

This commit is contained in:
Alex-Emmet 2026-09-28 00:13:35 +02:00
commit d23ad21f88
16 changed files with 1636 additions and 16 deletions

172
Cargo.lock generated
View file

@ -461,6 +461,12 @@ dependencies = [
"pkg-config", "pkg-config",
] ]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -961,6 +967,18 @@ dependencies = [
"winapi", "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]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -1013,7 +1031,9 @@ dependencies = [
"curve25519-dalek-derive", "curve25519-dalek-derive",
"digest 0.10.7", "digest 0.10.7",
"fiat-crypto 0.2.9", "fiat-crypto 0.2.9",
"rand_core 0.6.4",
"rustc_version", "rustc_version",
"serde",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -1152,6 +1172,17 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" 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]] [[package]]
name = "derive_arbitrary" name = "derive_arbitrary"
version = "1.4.2" version = "1.4.2"
@ -1235,6 +1266,17 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" 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]] [[package]]
name = "ed25519" name = "ed25519"
version = "2.2.3" version = "2.2.3"
@ -1242,6 +1284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [ dependencies = [
"pkcs8 0.10.2", "pkcs8 0.10.2",
"serde",
"signature 2.2.0", "signature 2.2.0",
] ]
@ -1265,6 +1308,7 @@ dependencies = [
"ed25519 2.2.3", "ed25519 2.2.3",
"serde", "serde",
"sha2 0.10.9", "sha2 0.10.9",
"signature 2.2.0",
"subtle", "subtle",
"zeroize", "zeroize",
] ]
@ -1290,6 +1334,26 @@ version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" 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]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.41" version = "0.8.41"
@ -1382,6 +1446,16 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" 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]] [[package]]
name = "fiat-crypto" name = "fiat-crypto"
version = "0.2.9" version = "0.2.9"
@ -1565,8 +1639,10 @@ version = "0.14.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"serde",
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@ -1614,6 +1690,17 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" 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]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@ -1757,6 +1844,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "hkdf" name = "hkdf"
version = "0.13.0" version = "0.13.0"
@ -2133,11 +2229,15 @@ dependencies = [
name = "iota-auth" name = "iota-auth"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"async-trait", "async-trait",
"dashmap", "dashmap",
"iota-identity", "iota-identity",
"mtp", "mtp",
"opaque-ke",
"rand_core 0.6.4", "rand_core 0.6.4",
"sha2 0.10.9",
"thiserror 2.0.20",
"tokio", "tokio",
"uuid", "uuid",
] ]
@ -2909,7 +3009,7 @@ dependencies = [
"chacha20poly1305", "chacha20poly1305",
"ed25519-dalek 3.0.0", "ed25519-dalek 3.0.0",
"getrandom 0.4.3", "getrandom 0.4.3",
"hkdf", "hkdf 0.13.0",
"ml-dsa", "ml-dsa",
"mlkem-tls", "mlkem-tls",
"rand 0.10.3", "rand 0.10.3",
@ -3228,6 +3328,30 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" 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]] [[package]]
name = "open" name = "open"
version = "5.4.4" version = "5.4.4"
@ -4230,6 +4354,21 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"
@ -4327,6 +4466,16 @@ dependencies = [
"unsafe-libyaml", "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]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.10.7"
@ -4435,6 +4584,7 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [ dependencies = [
"digest 0.10.7",
"rand_core 0.6.4", "rand_core 0.6.4",
] ]
@ -5188,6 +5338,25 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" 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]] [[package]]
name = "vtparse" name = "vtparse"
version = "0.6.2" version = "0.6.2"
@ -5820,6 +5989,7 @@ version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
dependencies = [ dependencies = [
"serde",
"zeroize_derive", "zeroize_derive",
] ]

6
flake.lock generated
View file

@ -21,10 +21,10 @@
"mtp-type-maps": { "mtp-type-maps": {
"flake": false, "flake": false,
"locked": { "locked": {
"lastModified": 1790379257, "lastModified": 1790546862,
"narHash": "sha256-LeomTZv7nBmJGkDEH3OgnRTDAVUGZaj2NFesuQ2yrp8=", "narHash": "sha256-yiy2GXDR6QwNiUTT+L4mZaPmqQygt1BfmLf/WRSW7MY=",
"ref": "refs/heads/main", "ref": "refs/heads/main",
"rev": "4eccf976c224a2a0d95c8f3ead596265a74a4fab", "rev": "b996bb8afbc54391e38c03a92e6226d43aa8ecf8",
"revCount": 26, "revCount": 26,
"type": "git", "type": "git",
"url": "https://git.methanium.net/tensamin/mtp-type-maps" "url": "https://git.methanium.net/tensamin/mtp-type-maps"

View file

@ -9,6 +9,10 @@ dashmap = "6.2.1"
iota-identity = { path = "../iota-identity" } iota-identity = { path = "../iota-identity" }
mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "bb0f682b735de5ebb36bb41dc699260578341828", features = ["crypto"] } mtp = { git = "https://git.methanium.net/Methanium/mtp.git", rev = "bb0f682b735de5ebb36bb41dc699260578341828", features = ["crypto"] }
rand_core = { version = "0.6", features = ["getrandom", "std"] } 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"] } uuid = { version = "*", features = ["v4"] }
[dev-dependencies] [dev-dependencies]

View file

@ -1,3 +1,4 @@
pub mod password;
mod principal_auth; mod principal_auth;
mod session; mod session;

336
iota-auth/src/password.rs Normal file
View file

@ -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<Ristretto255, sha2::Sha512>;
type Ksf = argon2::Argon2<'static>;
}
pub type PasswordServerSetup = ServerSetup<TensaminOpaque>;
#[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<u8> {
setup.serialize().to_vec()
}
pub fn deserialize_server_setup(bytes: &[u8]) -> Result<PasswordServerSetup, PasswordAuthError> {
ServerSetup::deserialize(bytes).map_err(|error| PasswordAuthError::Opaque(error.to_string()))
}
pub fn registration_start(
setup: &PasswordServerSetup,
request: &[u8],
identifier: &[u8],
) -> Result<Vec<u8>, PasswordAuthError> {
let message = RegistrationRequest::<TensaminOpaque>::deserialize(request)
.map_err(|error| PasswordAuthError::Opaque(error.to_string()))?;
let result = ServerRegistration::<TensaminOpaque>::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<Vec<u8>, PasswordAuthError> {
let message = RegistrationUpload::<TensaminOpaque>::deserialize(upload)
.map_err(|error| PasswordAuthError::Opaque(error.to_string()))?;
Ok(ServerRegistration::<TensaminOpaque>::finish(message)
.serialize()
.to_vec())
}
pub fn server_identifier(iota_id: i64) -> Vec<u8> {
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<Vec<u8>, 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<u8>,
pub state: Vec<u8>,
}
pub fn login_start(
setup: &PasswordServerSetup,
record: Option<&[u8]>,
request: &[u8],
principal: &[u8],
server_id: &[u8],
context: &[u8],
) -> Result<LoginStartResult, PasswordAuthError> {
let message = CredentialRequest::<TensaminOpaque>::deserialize(request)
.map_err(|error| PasswordAuthError::Opaque(error.to_string()))?;
let registration = record
.map(ServerRegistration::<TensaminOpaque>::deserialize)
.transpose()
.map_err(|error| PasswordAuthError::Opaque(error.to_string()))?;
let result = ServerLogin::<TensaminOpaque>::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::<TensaminOpaque>::deserialize(serialized_state)
.map_err(|error| PasswordAuthError::Opaque(error.to_string()))?;
let message = CredentialFinalization::<TensaminOpaque>::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::<TensaminOpaque>::start(&mut OsRng, b"correct horse").unwrap();
let response =
registration_start(&restored, &registration.message.serialize(), principal).unwrap();
let upload = registration
.state
.finish(
&mut OsRng,
b"correct horse",
RegistrationResponse::<TensaminOpaque>::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::<TensaminOpaque>::start(&mut OsRng, b"correct horse").unwrap();
let result = login_start(
&restored,
Some(&record),
&client.message.serialize(),
principal,
&server,
&context,
)
.unwrap();
let ke2 = CredentialResponse::<TensaminOpaque>::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::<TensaminOpaque>::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::<TensaminOpaque>::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::<TensaminOpaque>::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::<TensaminOpaque>::deserialize(&result.response)
.unwrap(),
ClientLoginFinishParameters::new(Some(&context), identifiers, None)
)
.is_err()
);
}
let client = ClientLogin::<TensaminOpaque>::start(&mut OsRng, b"correct horse").unwrap();
let dummy = login_start(
&restored,
None,
&client.message.serialize(),
principal,
&server,
&context,
)
.unwrap();
assert!(CredentialResponse::<TensaminOpaque>::deserialize(&dummy.response).is_ok());
let unrelated = generate_server_setup();
let client = ClientLogin::<TensaminOpaque>::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::<TensaminOpaque>::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);
}
}
}

View file

@ -1,5 +1,6 @@
pub mod contact; pub mod contact;
pub mod invitations; pub mod invitations;
pub mod password_credentials;
pub mod pending_operations; pub mod pending_operations;
pub mod user_manager; pub mod user_manager;
pub mod user_profile; pub mod user_profile;

View file

@ -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<u8>,
pub encrypted_tu_credential: Vec<u8>,
pub account_public_key_sha256: Vec<u8>,
pub created_at: i64,
pub updated_at: i64,
}
pub fn get(user_id: i64) -> Result<Option<PasswordCredential>, 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<bool, StorageError> {
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<bool, StorageError> {
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<bool, StorageError> {
db::with_db(|conn| {
Ok(conn.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1",
params![user_id],
)? != 0)
})
}

View file

@ -91,6 +91,13 @@ fn persist_user_profile(
tx: &rusqlite::Transaction<'_>, tx: &rusqlite::Transaction<'_>,
user: &UserProfile, user: &UserProfile,
) -> Result<(), crate::storage_error::StorageError> { ) -> 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( tx.execute(
r#" r#"
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name) 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<usize, crate::storage_err
pub fn remove_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> { pub fn remove_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|conn| { db::with_immediate_transaction(|conn| {
conn.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1",
params![user_id],
)?;
conn.execute( conn.execute(
"DELETE FROM tauth_codes WHERE local_user_id = ?1", "DELETE FROM tauth_codes WHERE local_user_id = ?1",
params![user_id], params![user_id],
@ -298,6 +309,12 @@ pub fn finalize_local_release(
params![user_id], params![user_id],
)?; )?;
tx.execute("DELETE FROM users WHERE user_id = ?1", 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( tx.execute(
"UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'user_released' WHERE target_kind = 0 AND destination_id = ?1", "UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'user_released' WHERE target_kind = 0 AND destination_id = ?1",
params![user_id], params![user_id],
@ -576,6 +593,10 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor
params![user_id], params![user_id],
)?; )?;
conn.execute("DELETE FROM users WHERE user_id = ?1", 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( conn.execute(
"DELETE FROM user_residency WHERE user_id = ?1", "DELETE FROM user_residency WHERE user_id = ?1",
params![user_id], params![user_id],
@ -644,7 +665,7 @@ pub fn get_residency_by_id(
pub fn clear() -> Result<(), crate::storage_error::StorageError> { pub fn clear() -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|conn| { db::with_immediate_transaction(|conn| {
conn.execute_batch( 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(()) Ok(())
}) })
@ -772,8 +793,13 @@ mod tests {
credential_origin TEXT NOT NULL, credential_origin TEXT NOT NULL,
updated_at INTEGER 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 users VALUES (1, 'alice', 'old-key', NULL, NULL, 1, NULL);
INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42); INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42);
INSERT INTO user_password_credentials VALUES (1, x'00');
"#, "#,
) )
.unwrap(); .unwrap();
@ -789,6 +815,14 @@ mod tests {
let transaction = connection.transaction().unwrap(); let transaction = connection.transaction().unwrap();
persist_user_profile(&transaction, &user).unwrap(); persist_user_profile(&transaction, &user).unwrap();
transaction.commit().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 let residency: (String, String, String, String, i64) = connection
.query_row( .query_row(

View file

@ -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(()) Ok(())
} }
@ -2096,7 +2122,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))?; 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"] { for column in ["height", "reply_to", "edited_count", "deleted_by_external"] {
let mut statement = let mut statement =
conn.prepare("SELECT 1 FROM pragma_table_info('messages') WHERE name = ?1")?; 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)?;
run_migrations_on_connection(&conn)?; run_migrations_on_connection(&conn)?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; 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 [ for table in [
"sync_heads", "sync_heads",
"sync_events", "sync_events",
@ -2135,6 +2161,8 @@ mod tests {
"principals", "principals",
"principal_keys", "principal_keys",
"identity_configuration", "identity_configuration",
"user_password_credentials",
"protected_command_replay",
] { ] {
let exists: i64 = conn.query_row( let exists: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1", "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
@ -2196,7 +2224,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))?; 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 [ for column in [
"id", "id",
"user_id", "user_id",
@ -2291,7 +2319,7 @@ mod tests {
)?; )?;
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(preserved, "remote_committed"); assert_eq!(preserved, "remote_committed");
assert_eq!(version, 46); assert_eq!(version, 47);
Ok(()) Ok(())
} }
@ -2329,7 +2357,7 @@ mod tests {
})?; })?;
assert_eq!(count, 0); assert_eq!(count, 0);
let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?; let version: i64 = conn.pragma_query_value(None, "user_version", |row| row.get(0))?;
assert_eq!(version, 46); assert_eq!(version, 47);
Ok(()) Ok(())
} }

View file

@ -12,6 +12,7 @@ pub mod e2ee_storage;
pub mod message_retention; pub mod message_retention;
pub mod message_storage_policy; pub mod message_storage_policy;
pub mod outgoing_relay; pub mod outgoing_relay;
pub mod protected_replay;
pub mod receipt_policy; pub mod receipt_policy;
pub mod relay_queue; pub mod relay_queue;
pub mod relay_replay; pub mod relay_replay;

View file

@ -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<bool, StorageError> {
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<usize, StorageError> {
db::with_db(|conn| {
Ok(conn.execute(
"DELETE FROM protected_command_replay WHERE created_at < ?1",
params![before],
)?)
})
}

@ -1 +1 @@
Subproject commit 4eccf976c224a2a0d95c8f3ead596265a74a4fab Subproject commit b996bb8afbc54391e38c03a92e6226d43aa8ecf8

View file

@ -2,6 +2,7 @@ pub mod client;
pub mod identity; pub mod identity;
pub mod omega_discovery; pub mod omega_discovery;
pub mod omikron_connection; pub mod omikron_connection;
mod password_provisioning;
pub mod router; pub mod router;
pub mod tauth; pub mod tauth;
pub mod user_ops; pub mod user_ops;

View file

@ -13,7 +13,7 @@ pub struct OmikronEndpoint {
pub public_key: PublicKeyBundle, 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()) env::var("OMEGA_API_URL").unwrap_or_else(|_| OMEGA_API_BASE_DEFAULT.to_string())
} }

View file

@ -24,6 +24,7 @@ use uuid::Uuid;
use crate::client::{OmikronClient, OmikronError}; use crate::client::{OmikronClient, OmikronError};
use crate::omega_discovery; use crate::omega_discovery;
use crate::password_provisioning::PasswordAuthRuntime;
use iota_connection::message_common::*; use iota_connection::message_common::*;
use iota_connection::message_handlers; use iota_connection::message_handlers;
@ -424,9 +425,10 @@ pub struct OmikronConnection {
shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>, shutdown_tx: Arc<Mutex<Option<watch::Sender<bool>>>>,
reconnect_on_close: Arc<RwLock<bool>>, reconnect_on_close: Arc<RwLock<bool>>,
auth_failure: Arc<RwLock<Option<String>>>, auth_failure: Arc<RwLock<Option<String>>>,
keyring: Arc<RwLock<Option<Arc<Keyring>>>>, pub(super) keyring: Arc<RwLock<Option<Arc<Keyring>>>>,
http_client: reqwest::Client, pub(super) http_client: reqwest::Client,
session_manager: Arc<iota_auth::SessionManager>, session_manager: Arc<iota_auth::SessionManager>,
pub(super) password_auth: Arc<PasswordAuthRuntime>,
handler_semaphore: Arc<Semaphore>, handler_semaphore: Arc<Semaphore>,
cancellation: CancellationToken, cancellation: CancellationToken,
pub(crate) active_tasks: Arc<DashSet<String>>, pub(crate) active_tasks: Arc<DashSet<String>>,
@ -462,6 +464,7 @@ impl OmikronConnection {
keyring: Arc::new(RwLock::new(None)), keyring: Arc::new(RwLock::new(None)),
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),
session_manager: Arc::new(iota_auth::SessionManager::default()), session_manager: Arc::new(iota_auth::SessionManager::default()),
password_auth: Arc::new(PasswordAuthRuntime::default()),
handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)), handler_semaphore: Arc::new(Semaphore::new(MAX_CONCURRENT_HANDLERS)),
cancellation, cancellation,
active_tasks, active_tasks,
@ -637,6 +640,7 @@ impl OmikronConnection {
.await .await
.map_err(|error| format!("Iota identity initialization failed: {error}"))?; .map_err(|error| format!("Iota identity initialization failed: {error}"))?;
let keyring = identity.keyring(); let keyring = identity.keyring();
self.password_auth.initialize(identity_path())?;
*self.keyring.write().await = Some(keyring.clone()); *self.keyring.write().await = Some(keyring.clone());
let existing_iota_id = CONFIG.load().iota_id; let existing_iota_id = CONFIG.load().iota_id;
@ -1154,6 +1158,12 @@ impl OmikronConnection {
) { ) {
log!("Relay replay cleanup failed: {}", error); 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<Self>, cv: CommunicationValue) { pub async fn handle_message(self: Arc<Self>, cv: CommunicationValue) {
if !matches!(
cv.get_comm_type_enum(),
Some(
CommunicationType::PasswordEnrollmentStart
| CommunicationType::PasswordEnrollmentFinish
| CommunicationType::PasswordEnrollmentStatus
| CommunicationType::PasswordEnrollmentDisable
| CommunicationType::PasswordProvisioningStart
| CommunicationType::PasswordProvisioningFinish
)
) {
log_cv_in!(&cv); log_cv_in!(&cv);
}
if cv.is_type(CommunicationType::Success) if cv.is_type(CommunicationType::Success)
&& let Some(frame_id) = cv.id() && let Some(frame_id) = cv.id()
@ -1715,6 +1737,21 @@ impl OmikronConnection {
dispatch!(GetChatSecret, handle_get_chat_secret); dispatch!(GetChatSecret, handle_get_chat_secret);
dispatch!(TAuthAuthorize, handle_tauth_authorize); 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!(TAuthExchangeCode, handle_tauth_exchange_code);
dispatch!(TAuthUser, handle_tauth_user); dispatch!(TAuthUser, handle_tauth_user);
dispatch!(TAuthContacts, handle_tauth_contacts); dispatch!(TAuthContacts, handle_tauth_contacts);
@ -3587,6 +3624,7 @@ impl OmikronClient for OmikronConnection {
keyring: self.keyring.clone(), keyring: self.keyring.clone(),
http_client: self.http_client.clone(), http_client: self.http_client.clone(),
session_manager: self.session_manager.clone(), session_manager: self.session_manager.clone(),
password_auth: self.password_auth.clone(),
handler_semaphore: self.handler_semaphore.clone(), handler_semaphore: self.handler_semaphore.clone(),
cancellation: self.cancellation.clone(), cancellation: self.cancellation.clone(),
active_tasks: self.active_tasks.clone(), active_tasks: self.active_tasks.clone(),
@ -3613,6 +3651,7 @@ impl OmikronClient for OmikronConnection {
keyring: self.keyring.clone(), keyring: self.keyring.clone(),
http_client: self.http_client.clone(), http_client: self.http_client.clone(),
session_manager: self.session_manager.clone(), session_manager: self.session_manager.clone(),
password_auth: self.password_auth.clone(),
handler_semaphore: self.handler_semaphore.clone(), handler_semaphore: self.handler_semaphore.clone(),
cancellation: self.cancellation.clone(), cancellation: self.cancellation.clone(),
active_tasks: self.active_tasks.clone(), active_tasks: self.active_tasks.clone(),

View file

@ -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<bool, ReplayError> {
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<u8>,
state: Vec<u8>,
real_record: bool,
record_hash: Option<Vec<u8>>,
created: Instant,
}
pub(super) struct PasswordAuthRuntime {
setup: OnceLock<Arc<PasswordServerSetup>>,
omega_authority: OnceLock<String>,
logins: DashMap<Uuid, PendingLogin>,
enrollments: DashMap<Uuid, (i64, Instant)>,
attempts: DashMap<i64, (Instant, u32)>,
max_pending: usize,
pending_ttl: Duration,
attempt_window: Duration,
max_attempts: u32,
}
fn configured_positive<T: std::str::FromStr + PartialOrd + Default>(name: &str, default: T) -> T {
std::env::var(name)
.ok()
.and_then(|value| value.parse::<T>().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<Vec<u8>, 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<i64, String> {
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, String> {
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<PublicKeyBundle, String> {
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<Vec<u8>, 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<String, String> {
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<VerifiedProtectedMessage, String> {
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<CommunicationValue, String> {
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<CommunicationValue, String> {
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<Self>,
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<CommunicationValue, String> {
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<Self>,
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<CommunicationValue, String> {
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<Self>,
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<Self>,
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<Self>,
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<CommunicationValue, String> {
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<Self>,
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<CommunicationValue, String> {
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()
);
}
}