Move OPAQUE into Iota and harden password authentication
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s

This commit is contained in:
Alex-Emmet 2026-10-02 21:29:04 +02:00
commit fdba718306
No known key found for this signature in database
27 changed files with 1177 additions and 134 deletions

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2024"
[dependencies]
opague-integration = { git = "https://git.methanium.net/tensamin/opaque-integration.git", rev = "d996c29366429160103e2a3f99c3e435522456f0" }
iota-opaque = { path = "../iota-opaque" }
async-trait = "0.1.89"
iota-connection = { path = "../iota-connection" }
iota-auth = { path = "../iota-auth" }

View file

@ -1,16 +1,17 @@
use std::{sync::Arc, time::Instant};
use iota_opaque::{credential, opaque};
use iota_storage::users::{
password_credentials::{self, PasswordCredential},
user_manager,
};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use opague_integration::opaque;
use uuid::Uuid;
use super::{
MAX_CREDENTIAL_BYTES, MAX_OPAQUE_BYTES, OmikronConnection,
app_protection::{PASSWORD_RESPONSE_ENCRYPTION, PASSWORD_RESPONSE_SIGNATURE},
runtime::PendingEnrollment,
wire::{bytes, key_fingerprint, now_millis, session, typed},
};
@ -39,9 +40,7 @@ impl OmikronConnection {
.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 permit = self.password_auth.begin_enrollment()?;
let principal = self.password_principal(user_id).await?;
let request = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
let setup = self
@ -51,21 +50,28 @@ impl OmikronConnection {
let response = opaque::server_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
let response = 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?;
self.password_auth.enrollments.insert(
enrollment_id,
PendingEnrollment {
user_id,
created: Instant::now(),
_permit: permit,
},
);
Ok(response)
}
pub(crate) async fn handle_password_enrollment_finish(
@ -93,12 +99,13 @@ impl OmikronConnection {
.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
let (_, pending) = self
.password_auth
.enrollments
.remove(&enrollment_id)
.ok_or("enrollment expired")?;
if pending_user != user_id || started.elapsed() >= self.password_auth.pending_ttl {
if pending.user_id != user_id || pending.created.elapsed() >= self.password_auth.pending_ttl
{
return Err("enrollment mismatch".into());
}
let upload = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
@ -107,6 +114,7 @@ impl OmikronConnection {
DataType::EncryptedCredential,
MAX_CREDENTIAL_BYTES,
)?;
credential::validate_envelope(&encrypted).map_err(|error| error.to_string())?;
let opaque_record =
opaque::server_registration_finish(&upload).map_err(|error| error.to_string())?;
let profile = user_manager::get_user(user_id)

View file

@ -1,8 +1,9 @@
use std::time::Duration;
use iota_storage::users::user_manager;
use mtp::crypto::PublicKeyBundle;
#[cfg(test)]
use iota_opaque::opaque::{self, PasswordLoginBindingV1};
#[cfg(test)]
use mtp::codec::{
CommunicationType, CommunicationValue, DataType, DataValue, ProtectedMessageBuilder,
@ -11,8 +12,6 @@ use mtp::codec::{
#[cfg(test)]
use mtp::crypto::DualSigner;
#[cfg(test)]
use opague_integration::opaque::{self, PasswordLoginBindingV1};
#[cfg(test)]
use sha2::{Digest, Sha256};
#[cfg(test)]
use std::time::Instant;
@ -53,12 +52,8 @@ 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());
if user_id <= 0 {
return Err("invalid user ID".into());
}
let authority = if let Some(cached) = self.password_auth.omega_authority.get() {
cached.clone()

View file

@ -4,10 +4,13 @@ use std::{
};
use dashmap::mapref::entry::Entry;
use iota_opaque::{
credential,
opaque::{self, PasswordLoginBindingV1, PasswordServerSetup},
};
use iota_storage::users::{password_credentials, user_manager};
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
use mtp::crypto::PublicKeyBundle;
use opague_integration::opaque::{self, PasswordLoginBindingV1, PasswordServerSetup};
use sha2::{Digest, Sha256};
use uuid::Uuid;
@ -127,35 +130,44 @@ impl OmikronConnection {
tokio::time::sleep(delay).await;
}
let principal = self.password_principal(user_id).await?;
let contact_key_sha256 = public_key_fingerprint(&contact_key)?;
let binding = PasswordLoginBindingV1 {
principal: &principal,
iota_id,
session_id,
contact_key_sha256: &contact_key_sha256,
};
let credential = password_credentials::get(user_id).map_err(|error| error.to_string())?;
let compatible_credential = credential
.as_ref()
.filter(|credential| credential.opaque_profile == opaque::CURRENT_OPAQUE_PROFILE);
let response = opaque::server_login_start(
&setup,
compatible_credential.map(|value| value.opaque_record.as_slice()),
&ke1,
&binding,
)
.map_err(|error| error.to_string())?;
let pending = PendingLogin {
user_id,
participant_id,
contact_key,
state: Some(response.state),
real_record: compatible_credential.is_some(),
created: Instant::now(),
attempt: Some(attempt),
record_hash: compatible_credential
.map(|value| Sha256::digest(&value.opaque_record).to_vec()),
};
let (response, pending) = self
.password_auth
.run_blocking(move || {
let contact_key_sha256 = public_key_fingerprint(&contact_key)?;
let binding = PasswordLoginBindingV1 {
principal: &principal,
iota_id,
session_id,
contact_key_sha256: &contact_key_sha256,
};
// Run the same lookups for every account and use a dummy record when absent.
let profile = user_manager::get_user(user_id).map_err(|error| error.to_string())?;
let credential =
password_credentials::get(user_id).map_err(|error| error.to_string())?;
let compatible_credential = credential.as_ref().filter(|credential| {
profile.is_some() && credential.opaque_profile == opaque::CURRENT_OPAQUE_PROFILE
});
let response = opaque::server_login_start(
&setup,
compatible_credential.map(|value| value.opaque_record.as_slice()),
&ke1,
&binding,
)
.map_err(|error| error.to_string())?;
let pending = PendingLogin {
user_id,
participant_id,
contact_key,
state: Some(response.state),
real_record: compatible_credential.is_some(),
created: Instant::now(),
attempt: Some(attempt),
record_hash: compatible_credential
.map(|value| Sha256::digest(&value.opaque_record).to_vec()),
};
Ok::<_, String>((response.response, pending))
})
.await??;
match self.password_auth.logins.entry(session_id) {
Entry::Vacant(slot) => {
slot.insert(pending);
@ -168,7 +180,7 @@ impl OmikronConnection {
CommunicationValue::new(CommunicationType::PasswordProvisioningResponse)
.with_id(request_id)
.add_typed_default(DataType::Uuid, DataValue::Str(session_id.to_string()))
.add_typed_default(DataType::OpaqueMessage, DataValue::Bytes(response.response)),
.add_typed_default(DataType::OpaqueMessage, DataValue::Bytes(response)),
)
}
@ -252,56 +264,72 @@ impl OmikronConnection {
.password_principal(pending.user_id)
.await
.map_err(|_| PasswordFinishError::CredentialUnavailable)?;
let contact_key_sha256 = public_key_fingerprint(&pending.contact_key)
.map_err(|_| PasswordFinishError::InvalidClientMessage)?;
let binding = PasswordLoginBindingV1 {
principal: &principal,
iota_id,
session_id: id,
contact_key_sha256: &contact_key_sha256,
};
let state = pending
.state
.take()
.ok_or(PasswordFinishError::InvalidClientMessage)?;
match opaque::server_login_finish(state, &ke3, &binding) {
Ok(()) => {}
Err(opaque::LoginFinishError::AuthenticationFailed) => {
return Err(PasswordFinishError::Authentication);
}
Err(opaque::LoginFinishError::InvalidMessage) => {
return Err(PasswordFinishError::InvalidClientMessage);
}
}
if !pending.real_record {
return Err(PasswordFinishError::Authentication);
}
let credential = password_credentials::get(pending.user_id)
.map_err(|_| PasswordFinishError::Storage)?
.ok_or(PasswordFinishError::CredentialUnavailable)?;
if credential.opaque_profile != opaque::CURRENT_OPAQUE_PROFILE {
return Err(PasswordFinishError::CredentialUnavailable);
}
if pending.record_hash.as_deref()
!= Some(Sha256::digest(&credential.opaque_record).as_slice())
{
return Err(PasswordFinishError::CredentialChanged);
}
let user_id = pending.user_id;
let contact_key = pending.contact_key.clone();
let real_record = pending.real_record;
let record_hash = pending.record_hash.clone();
let created = pending.created;
let pending_ttl = self.password_auth.pending_ttl;
let credential = self
.password_auth
.run_blocking(move || {
if created.elapsed() >= pending_ttl {
return Err(PasswordFinishError::BindingMismatch);
}
let contact_key_sha256 = public_key_fingerprint(&contact_key)
.map_err(|_| PasswordFinishError::InvalidClientMessage)?;
let binding = PasswordLoginBindingV1 {
principal: &principal,
iota_id,
session_id: id,
contact_key_sha256: &contact_key_sha256,
};
match opaque::server_login_finish(state, &ke3, &binding) {
Ok(()) => {}
Err(opaque::LoginFinishError::AuthenticationFailed) => {
return Err(PasswordFinishError::Authentication);
}
Err(opaque::LoginFinishError::InvalidMessage) => {
return Err(PasswordFinishError::InvalidClientMessage);
}
}
if !real_record {
return Err(PasswordFinishError::Authentication);
}
let credential = password_credentials::get(user_id)
.map_err(|_| PasswordFinishError::Storage)?
.ok_or(PasswordFinishError::CredentialUnavailable)?;
if credential.opaque_profile != opaque::CURRENT_OPAQUE_PROFILE {
return Err(PasswordFinishError::CredentialUnavailable);
}
if record_hash.as_deref()
!= Some(Sha256::digest(&credential.opaque_record).as_slice())
{
return Err(PasswordFinishError::CredentialChanged);
}
let profile = user_manager::get_user(user_id)
.map_err(|_| PasswordFinishError::Storage)?
.ok_or(PasswordFinishError::CredentialUnavailable)?;
if credential.account_public_key_sha256
!= key_fingerprint(&profile.public_key)
.map_err(|_| PasswordFinishError::CredentialUnavailable)?
|| credential.encrypted_tu_credential.len() > MAX_CREDENTIAL_BYTES
|| credential::validate_envelope(&credential.encrypted_tu_credential).is_err()
{
return Err(PasswordFinishError::CredentialUnavailable);
}
Ok(credential)
})
.await
.map_err(|_| PasswordFinishError::Storage)??;
// Only the same registered credential can clear password pressure.
if let Some(attempt) = pending.attempt.take() {
attempt.success();
}
let profile = user_manager::get_user(pending.user_id)
.map_err(|_| PasswordFinishError::Storage)?
.ok_or(PasswordFinishError::CredentialUnavailable)?;
if credential.account_public_key_sha256
!= key_fingerprint(&profile.public_key)
.map_err(|_| PasswordFinishError::CredentialUnavailable)?
|| credential.encrypted_tu_credential.is_empty()
|| credential.encrypted_tu_credential.len() > MAX_CREDENTIAL_BYTES
{
return Err(PasswordFinishError::CredentialUnavailable);
}
let wire =
|kind, value| typed(kind, value).map_err(|_| PasswordFinishError::InvalidClientMessage);
let content = DataValue::Container(vec![

View file

@ -5,9 +5,9 @@ use std::{
};
use dashmap::DashMap;
use iota_opaque::opaque::{self, PasswordServerSetup, ServerLoginState};
use iota_storage::users::password_credentials;
use mtp::crypto::PublicKeyBundle;
use opague_integration::opaque::{self, PasswordServerSetup, ServerLoginState};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use uuid::Uuid;
@ -22,6 +22,12 @@ pub(super) struct PendingLogin {
pub(super) attempt: Option<AttemptLease>,
}
pub(super) struct PendingEnrollment {
pub(super) user_id: i64,
pub(super) created: Instant,
pub(super) _permit: OwnedSemaphorePermit,
}
#[derive(Debug)]
pub(super) struct AccountAttemptState {
pub(super) window_started: Instant,
@ -85,10 +91,13 @@ pub(crate) struct PasswordAuthRuntime {
pub(super) setup: OnceLock<Arc<PasswordServerSetup>>,
pub(super) omega_authority: OnceLock<String>,
pub(super) logins: DashMap<Uuid, PendingLogin>,
pub(super) enrollments: DashMap<Uuid, (i64, Instant)>,
pub(super) enrollments: DashMap<Uuid, PendingEnrollment>,
pub(super) attempts: DashMap<i64, AccountAttemptState>,
#[cfg(test)]
pub(super) max_pending: usize,
exchange_slots: Arc<Semaphore>,
enrollment_slots: Arc<Semaphore>,
blocking_slots: Arc<Semaphore>,
pub(super) pending_ttl: Duration,
pub(super) throttle: PasswordThrottleConfig,
}
@ -104,14 +113,22 @@ fn configured_positive<T: std::str::FromStr + PartialOrd + Default>(name: &str,
impl Default for PasswordAuthRuntime {
fn default() -> Self {
let max_pending = configured_positive("PASSWORD_MAX_PENDING_EXCHANGES", 1024);
let blocking_workers = configured_positive(
"PASSWORD_MAX_BLOCKING_WORKERS",
std::thread::available_parallelism().map_or(1, usize::from),
)
.min(max_pending);
Self {
setup: OnceLock::new(),
omega_authority: OnceLock::new(),
logins: DashMap::new(),
enrollments: DashMap::new(),
attempts: DashMap::new(),
#[cfg(test)]
max_pending,
exchange_slots: Arc::new(Semaphore::new(max_pending)),
enrollment_slots: Arc::new(Semaphore::new(max_pending)),
blocking_slots: Arc::new(Semaphore::new(blocking_workers)),
pending_ttl: Duration::from_secs(configured_positive(
"PASSWORD_PENDING_TTL_SECONDS",
180,
@ -135,6 +152,32 @@ impl Default for PasswordAuthRuntime {
}
impl PasswordAuthRuntime {
pub(super) fn begin_enrollment(&self) -> Result<OwnedSemaphorePermit, String> {
self.enrollment_slots
.clone()
.try_acquire_owned()
.map_err(|_| "too many enrollments".into())
}
pub(super) async fn run_blocking<T: Send + 'static>(
&self,
work: impl FnOnce() -> T + Send + 'static,
) -> Result<T, String> {
let permit = self
.blocking_slots
.clone()
.acquire_owned()
.await
.map_err(|error| error.to_string())?;
tokio::task::spawn_blocking(move || {
// Keep capacity reserved even if the awaiting task is cancelled.
let _permit = permit;
work()
})
.await
.map_err(|error| error.to_string())
}
pub(crate) fn setup(&self) -> Result<Arc<PasswordServerSetup>, PasswordRuntimeError> {
self.setup
.get()
@ -183,7 +226,7 @@ impl PasswordAuthRuntime {
}
}
self.enrollments
.retain(|_, (_, created)| created.elapsed() < self.pending_ttl);
.retain(|_, pending| pending.created.elapsed() < self.pending_ttl);
self.attempts.retain(|_, state| {
state.in_flight > 0 || state.window_started.elapsed() < self.throttle.failure_window
});