Move OPAQUE into Iota and harden password authentication
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s
Some checks failed
Validate authentication / Validate authentication (push) Failing after 1s
This commit is contained in:
parent
e3a16be2f9
commit
fdba718306
27 changed files with 1177 additions and 134 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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![
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue