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

View file

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

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<'_>,
user: &UserProfile,
) -> Result<(), crate::storage_error::StorageError> {
// A password backup for the old account key must never restore a rotated identity.
tx.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1 AND EXISTS (
SELECT 1 FROM users WHERE user_id = ?1 AND public_key != ?2
)",
params![user.user_id, user.public_key],
)?;
tx.execute(
r#"
INSERT INTO users (user_id, username, public_key, private_key_hash, reset_token, created_at, display_name)
@ -228,6 +235,10 @@ pub fn revoke_all_tauth_grants(user_id: i64) -> Result<usize, crate::storage_err
pub fn remove_user(user_id: i64) -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|conn| {
conn.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1",
params![user_id],
)?;
conn.execute(
"DELETE FROM tauth_codes WHERE local_user_id = ?1",
params![user_id],
@ -298,6 +309,12 @@ pub fn finalize_local_release(
params![user_id],
)?;
tx.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
// OPAQUE records cannot authenticate against another Iota's ServerSetup.
// Reassignment requires enrollment again from an authenticated device.
tx.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1",
params![user_id],
)?;
tx.execute(
"UPDATE pending_relays SET delivery_state = 'waiting_client', last_error = 'user_released' WHERE target_kind = 0 AND destination_id = ?1",
params![user_id],
@ -576,6 +593,10 @@ pub fn erase_user_locally(user_id: i64) -> Result<(), crate::storage_error::Stor
params![user_id],
)?;
conn.execute("DELETE FROM users WHERE user_id = ?1", params![user_id])?;
conn.execute(
"DELETE FROM user_password_credentials WHERE user_id = ?1",
params![user_id],
)?;
conn.execute(
"DELETE FROM user_residency WHERE user_id = ?1",
params![user_id],
@ -644,7 +665,7 @@ pub fn get_residency_by_id(
pub fn clear() -> Result<(), crate::storage_error::StorageError> {
db::with_immediate_transaction(|conn| {
conn.execute_batch(
"DELETE FROM tauth_codes; DELETE FROM tauth_sessions; DELETE FROM tauth_metadata; DELETE FROM tauth_grants; DELETE FROM users; DELETE FROM user_residency;",
"DELETE FROM tauth_codes; DELETE FROM tauth_sessions; DELETE FROM tauth_metadata; DELETE FROM tauth_grants; DELETE FROM user_password_credentials; DELETE FROM protected_command_replay; DELETE FROM users; DELETE FROM user_residency;",
)?;
Ok(())
})
@ -772,8 +793,13 @@ mod tests {
credential_origin TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE user_password_credentials (
user_id INTEGER PRIMARY KEY,
account_public_key_sha256 BLOB NOT NULL
);
INSERT INTO users VALUES (1, 'alice', 'old-key', NULL, NULL, 1, NULL);
INSERT INTO user_residency VALUES (1, 'alice', 'released', 'empty', 'external', 42);
INSERT INTO user_password_credentials VALUES (1, x'00');
"#,
)
.unwrap();
@ -789,6 +815,14 @@ mod tests {
let transaction = connection.transaction().unwrap();
persist_user_profile(&transaction, &user).unwrap();
transaction.commit().unwrap();
let password_rows: i64 = connection
.query_row(
"SELECT COUNT(*) FROM user_password_credentials WHERE user_id = 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(password_rows, 0);
let residency: (String, String, String, String, i64) = connection
.query_row(

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

View file

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

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],
)?)
})
}