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
34
.forgejo/workflows/validate.yml
Normal file
34
.forgejo/workflows/validate.yml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: Validate authentication
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
NIX_CONFIG: experimental-features = nix-command flakes
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
authentication:
|
||||||
|
name: Validate authentication
|
||||||
|
runs-on: host
|
||||||
|
steps:
|
||||||
|
- name: Set up repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
submodules: recursive
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: nix develop -c cargo fmt -p iota-opaque -p omikron-connector --check
|
||||||
|
|
||||||
|
- name: Check OPAQUE lints
|
||||||
|
run: nix develop -c cargo clippy -p iota-opaque --locked --all-targets --all-features -- -D warnings -W unreachable-pub
|
||||||
|
|
||||||
|
- name: Check connector lints
|
||||||
|
run: nix develop -c cargo clippy -p omikron-connector --locked --all-targets --all-features
|
||||||
|
|
||||||
|
- name: Run authentication tests
|
||||||
|
run: nix develop -c cargo test -p iota-opaque -p omikron-connector --locked --all-features
|
||||||
|
|
||||||
|
- name: Check release binaries
|
||||||
|
run: nix develop -c cargo check -p iota -p iota-daemon -p iota-updater -p iota-installer --locked
|
||||||
46
Cargo.lock
generated
46
Cargo.lock
generated
|
|
@ -2392,6 +2392,24 @@ dependencies = [
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iota-opaque"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
|
"chacha20poly1305",
|
||||||
|
"generic-array",
|
||||||
|
"hex",
|
||||||
|
"hkdf 0.12.4",
|
||||||
|
"opaque-ke",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"thiserror 2.0.20",
|
||||||
|
"uuid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iota-paths"
|
name = "iota-paths"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -3288,12 +3306,12 @@ dependencies = [
|
||||||
"iota-connection",
|
"iota-connection",
|
||||||
"iota-identity",
|
"iota-identity",
|
||||||
"iota-logger",
|
"iota-logger",
|
||||||
|
"iota-opaque",
|
||||||
"iota-state",
|
"iota-state",
|
||||||
"iota-storage",
|
"iota-storage",
|
||||||
"iota-util",
|
"iota-util",
|
||||||
"json",
|
"json",
|
||||||
"mtp",
|
"mtp",
|
||||||
"opague-integration",
|
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
@ -3319,32 +3337,6 @@ version = "1.70.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opague-integration"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "git+https://git.methanium.net/tensamin/opaque-integration.git?rev=d996c29366429160103e2a3f99c3e435522456f0#d996c29366429160103e2a3f99c3e435522456f0"
|
|
||||||
dependencies = [
|
|
||||||
"opague-integration-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "opague-integration-core"
|
|
||||||
version = "0.1.0"
|
|
||||||
source = "git+https://git.methanium.net/tensamin/opaque-integration.git?rev=d996c29366429160103e2a3f99c3e435522456f0#d996c29366429160103e2a3f99c3e435522456f0"
|
|
||||||
dependencies = [
|
|
||||||
"argon2",
|
|
||||||
"chacha20poly1305",
|
|
||||||
"generic-array",
|
|
||||||
"getrandom 0.2.17",
|
|
||||||
"hkdf 0.12.4",
|
|
||||||
"opaque-ke",
|
|
||||||
"rand_core 0.6.4",
|
|
||||||
"sha2 0.10.9",
|
|
||||||
"thiserror 2.0.20",
|
|
||||||
"uuid",
|
|
||||||
"zeroize",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opaque-debug"
|
name = "opaque-debug"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ members = [
|
||||||
"iota-paths",
|
"iota-paths",
|
||||||
"iota-installer",
|
"iota-installer",
|
||||||
"iota-core",
|
"iota-core",
|
||||||
|
"iota-opaque",
|
||||||
]
|
]
|
||||||
exclude = ["communities"]
|
exclude = ["communities"]
|
||||||
resolver = "3"
|
resolver = "3"
|
||||||
|
|
|
||||||
22
iota-opaque/Cargo.toml
Normal file
22
iota-opaque/Cargo.toml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
[package]
|
||||||
|
name = "iota-opaque"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license-file = "../LICENSE"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
opaque-ke = { version = "=4.0.1", features = ["argon2"] }
|
||||||
|
argon2 = "0.5"
|
||||||
|
generic-array = "0.14"
|
||||||
|
rand_core = { version = "0.6", features = ["getrandom"] }
|
||||||
|
sha2 = "0.10"
|
||||||
|
zeroize = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
uuid = "1"
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
hkdf = "0.12"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
hex = "0.4"
|
||||||
|
serde_json = "1"
|
||||||
40
iota-opaque/README.md
Normal file
40
iota-opaque/README.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Iota OPAQUE
|
||||||
|
|
||||||
|
Native Rust profile-1 OPAQUE client/server operations and client-only password credential encryption. This workspace crate owns the cryptographic implementation used by `omikron-connector`.
|
||||||
|
|
||||||
|
`opaque` provides registration, login, setup serialization, and transcript bindings. `credential` encrypts and decrypts credential bytes with the client's OPAQUE export key. The implementation uses `opaque-ke 4.0.1` and preserves existing profile-1 setup files, registration records, and credential envelopes.
|
||||||
|
|
||||||
|
See [profile 1](docs/OPAQUE_PROFILE_V1.md) and [credential envelope 1](docs/CREDENTIAL_ENVELOPE_V1.md) for the persistent byte formats.
|
||||||
|
|
||||||
|
## Rust API
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use iota_opaque::{credential, opaque};
|
||||||
|
|
||||||
|
let registration = opaque::client_registration_start(password)?;
|
||||||
|
let response = opaque::server_registration_start(
|
||||||
|
&setup, ®istration.request, principal.as_bytes(),
|
||||||
|
)?;
|
||||||
|
let finished = registration.state.finish(&response, principal, iota_id)?;
|
||||||
|
let record = opaque::server_registration_finish(&finished.upload)?;
|
||||||
|
let encrypted = credential::encrypt(&finished.export_key, &credential_binding, &tu_bytes)?;
|
||||||
|
```
|
||||||
|
|
||||||
|
Login uses `client_login_start`, `server_login_start`, the consumed client's `finish`, and `server_login_finish`. Only the client uses `credential::decrypt` with its finish export key.
|
||||||
|
|
||||||
|
`credential::validate_envelope` checks the magic, version, and minimum framing length without the export key. It does not authenticate ciphertext. Enrollment uses this check before storing credentials.
|
||||||
|
|
||||||
|
The connector returns dummy-record OPAQUE responses for unknown accounts and accounts without compatible password records. Login database work and OPAQUE computation run on blocking threads. `PASSWORD_MAX_BLOCKING_WORKERS` limits concurrency and defaults to the available CPU count, capped by `PASSWORD_MAX_PENDING_EXCHANGES`.
|
||||||
|
|
||||||
|
`PASSWORD_MAX_PENDING_EXCHANGES` bounds pending logins and enrollments separately. Enrollment permits stay reserved during preparation and until finish or expiry. `PASSWORD_PENDING_TTL_SECONDS` controls expiry.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Run from the Iota workspace in `nix develop`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo fmt -p iota-opaque -p omikron-connector --check
|
||||||
|
cargo clippy -p iota-opaque --locked --all-targets --all-features -- -D warnings -W unreachable-pub
|
||||||
|
cargo clippy -p omikron-connector --locked --all-targets --all-features
|
||||||
|
cargo test -p iota-opaque -p omikron-connector --locked --all-features
|
||||||
|
```
|
||||||
37
iota-opaque/docs/CREDENTIAL_ENVELOPE_V1.md
Normal file
37
iota-opaque/docs/CREDENTIAL_ENVELOPE_V1.md
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Credential envelope version 1
|
||||||
|
|
||||||
|
The plaintext is arbitrary canonical credential bytes. Serialization of `.tu` credentials belongs to the consumer.
|
||||||
|
|
||||||
|
## Key derivation
|
||||||
|
|
||||||
|
Input secret is the OPAQUE client export key. Use HKDF-SHA-256 with absent salt and 32-byte output. HKDF info concatenates:
|
||||||
|
|
||||||
|
1. ASCII `tensamin:opaque-export-key:credential:v1\0`.
|
||||||
|
2. OPAQUE profile ID `1` as signed i64 big-endian.
|
||||||
|
3. Credential version `1` as unsigned u16 big-endian.
|
||||||
|
|
||||||
|
## Associated data
|
||||||
|
|
||||||
|
Concatenate:
|
||||||
|
|
||||||
|
1. ASCII `tensamin:opaque-credential-aad:v1\0`.
|
||||||
|
2. OPAQUE profile ID `1` as signed i64 big-endian.
|
||||||
|
3. Principal UTF-8 byte length as unsigned u32 big-endian.
|
||||||
|
4. Principal UTF-8 bytes.
|
||||||
|
5. Signed i64 Iota ID big-endian.
|
||||||
|
6. The 32-byte SHA-256 digest of the canonical account public key bundle.
|
||||||
|
|
||||||
|
The provisioning session UUID is not credential AAD. Enrollment ciphertext must decrypt in later provisioning sessions.
|
||||||
|
|
||||||
|
## Envelope bytes
|
||||||
|
|
||||||
|
| Offset | Length | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | 8 | `TSCRED\0\0` |
|
||||||
|
| 8 | 2 | Version `1`, unsigned big-endian |
|
||||||
|
| 10 | 24 | Fresh random XChaCha20 nonce |
|
||||||
|
| 34 | Remaining | XChaCha20-Poly1305 ciphertext followed by its 16-byte tag |
|
||||||
|
|
||||||
|
Minimum version-1 envelope length is 50 bytes, including an empty plaintext. The parser rejects wrong magic and incomplete framing, reports unknown versions explicitly, and authenticates before returning plaintext. It never guesses a format or falls back to version 1. The fixed magic and version are enforced by the parser; all identity AAD and nonce bytes affect authentication.
|
||||||
|
|
||||||
|
Derived key buffers and decrypted plaintext zeroize on drop. Iota stores and returns only the envelope, never the export key or plaintext.
|
||||||
32
iota-opaque/docs/OPAQUE_PROFILE_V1.md
Normal file
32
iota-opaque/docs/OPAQUE_PROFILE_V1.md
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Tensamin OPAQUE profile 1
|
||||||
|
|
||||||
|
Profile ID is signed i64 `1`. The implementation is pinned to `opaque-ke 4.0.1`.
|
||||||
|
|
||||||
|
The OPRF group is Ristretto255. The authenticated key exchange is TripleDH over Ristretto255 with SHA-512.
|
||||||
|
|
||||||
|
The key stretching function is Argon2id, version `0x13`, with `m_cost = 19456` KiB, `t_cost = 2`, `p_cost = 1`. It uses the deployed deterministic 16-byte zero salt. The output length equals the input length. A known-answer vector is in `tests/vectors/profile_v1.json`.
|
||||||
|
|
||||||
|
## Identifiers and login context
|
||||||
|
|
||||||
|
The server identifier is ASCII `tensamin:iota-password\0` followed by the signed i64 Iota ID in big-endian order. The client identifier and credential identifier are the UTF-8 Omega principal, normally `{omega-authority}#{user_id}`. Callers supply the canonical principal without normalization by this library.
|
||||||
|
|
||||||
|
Login context concatenates these bytes:
|
||||||
|
|
||||||
|
1. ASCII `tensamin:password-provisioning\0`.
|
||||||
|
2. Principal UTF-8 byte length as unsigned u32 big-endian.
|
||||||
|
3. Principal UTF-8 bytes.
|
||||||
|
4. Signed i64 Iota ID big-endian.
|
||||||
|
5. The 16 raw provisioning UUID bytes.
|
||||||
|
6. A 32-byte SHA-256 digest of canonical MTP PublicKeyBundle bytes.
|
||||||
|
|
||||||
|
The caller computes the fingerprint. The library has no MTP dependency. Both client and server construct this context from `PasswordLoginBindingV1`. Pending server state also retains the initial context and rejects a changed finish binding.
|
||||||
|
|
||||||
|
Any change to these persistent values requires a new profile ID.
|
||||||
|
|
||||||
|
## Persistence and secrets
|
||||||
|
|
||||||
|
Setup files and registration records use opaque-ke's profile-1 serialization directly, with no new framing. Iota owns setup file location and lifecycle, record persistence, account discovery, throttling, and protected transport. Missing setup with enrolled accounts must remain an Iota error.
|
||||||
|
|
||||||
|
Client registration and login states are consumed by finish. Password buffers and native server exchange state are zeroized on drop. OPAQUE export keys remain in `OpaqueExportKey` and credential operations borrow them without exporting their bytes. Servers discard the OPAQUE session key and never receive the export key.
|
||||||
|
|
||||||
|
The permanent vectors preserve the deployed server identifier, login context, and KSF bytes. There is no legacy implementation or fallback.
|
||||||
23
iota-opaque/src/credential/aad.rs
Normal file
23
iota-opaque/src/credential/aad.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
use super::CredentialError;
|
||||||
|
use crate::opaque::CURRENT_OPAQUE_PROFILE;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct CredentialBindingV1<'a> {
|
||||||
|
pub principal: &'a str,
|
||||||
|
pub iota_id: i64,
|
||||||
|
pub account_public_key_sha256: &'a [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn associated_data(
|
||||||
|
binding: &CredentialBindingV1<'_>,
|
||||||
|
) -> Result<Vec<u8>, CredentialError> {
|
||||||
|
let principal = binding.principal.as_bytes();
|
||||||
|
let length = u32::try_from(principal.len()).map_err(|_| CredentialError::FieldTooLarge)?;
|
||||||
|
let mut output = b"tensamin:opaque-credential-aad:v1\0".to_vec();
|
||||||
|
output.extend_from_slice(&CURRENT_OPAQUE_PROFILE.to_be_bytes());
|
||||||
|
output.extend_from_slice(&length.to_be_bytes());
|
||||||
|
output.extend_from_slice(principal);
|
||||||
|
output.extend_from_slice(&binding.iota_id.to_be_bytes());
|
||||||
|
output.extend_from_slice(binding.account_public_key_sha256);
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
15
iota-opaque/src/credential/error.rs
Normal file
15
iota-opaque/src/credential/error.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum CredentialError {
|
||||||
|
#[error("credential field is too large")]
|
||||||
|
FieldTooLarge,
|
||||||
|
#[error("credential key derivation failed")]
|
||||||
|
KeyDerivation,
|
||||||
|
#[error("credential encryption failed")]
|
||||||
|
Encryption,
|
||||||
|
#[error("credential authentication failed")]
|
||||||
|
Authentication,
|
||||||
|
#[error("invalid encrypted credential")]
|
||||||
|
InvalidEnvelope,
|
||||||
|
#[error("unsupported encrypted credential version: {0}")]
|
||||||
|
UnsupportedVersion(u16),
|
||||||
|
}
|
||||||
19
iota-opaque/src/credential/kdf.rs
Normal file
19
iota-opaque/src/credential/kdf.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{CredentialError, v1::VERSION};
|
||||||
|
use crate::opaque::{CURRENT_OPAQUE_PROFILE, OpaqueExportKey};
|
||||||
|
|
||||||
|
pub(crate) fn derive_credential_key(
|
||||||
|
export_key: &OpaqueExportKey,
|
||||||
|
) -> Result<Zeroizing<[u8; 32]>, CredentialError> {
|
||||||
|
let hkdf = Hkdf::<Sha256>::new(None, export_key.as_bytes());
|
||||||
|
let mut info = b"tensamin:opaque-export-key:credential:v1\0".to_vec();
|
||||||
|
info.extend_from_slice(&CURRENT_OPAQUE_PROFILE.to_be_bytes());
|
||||||
|
info.extend_from_slice(&VERSION.to_be_bytes());
|
||||||
|
let mut key = Zeroizing::new([0_u8; 32]);
|
||||||
|
hkdf.expand(&info, &mut *key)
|
||||||
|
.map_err(|_| CredentialError::KeyDerivation)?;
|
||||||
|
Ok(key)
|
||||||
|
}
|
||||||
11
iota-opaque/src/credential/mod.rs
Normal file
11
iota-opaque/src/credential/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
mod aad;
|
||||||
|
mod error;
|
||||||
|
mod kdf;
|
||||||
|
mod v1;
|
||||||
|
|
||||||
|
pub use aad::CredentialBindingV1;
|
||||||
|
pub use error::CredentialError;
|
||||||
|
pub use v1::{decrypt, encrypt, validate_envelope};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
71
iota-opaque/src/credential/tests.rs
Normal file
71
iota-opaque/src/credential/tests.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
use super::*;
|
||||||
|
use crate::opaque::OpaqueExportKey;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn envelope_authenticates_key_identity_nonce_and_ciphertext() {
|
||||||
|
let key = OpaqueExportKey::new(vec![0x77; 64]);
|
||||||
|
let wrong_key = OpaqueExportKey::new(vec![0x88; 64]);
|
||||||
|
let binding = CredentialBindingV1 {
|
||||||
|
principal: "omega-key:example#7",
|
||||||
|
iota_id: 11,
|
||||||
|
account_public_key_sha256: &[0x11; 32],
|
||||||
|
};
|
||||||
|
let plaintext = b"canonical credential bytes\0";
|
||||||
|
let envelope = encrypt(&key, &binding, plaintext).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
decrypt(&key, &binding, &envelope).unwrap().as_slice(),
|
||||||
|
plaintext
|
||||||
|
);
|
||||||
|
assert_ne!(envelope, encrypt(&key, &binding, plaintext).unwrap());
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&wrong_key, &binding, &envelope),
|
||||||
|
Err(CredentialError::Authentication)
|
||||||
|
));
|
||||||
|
for changed in [
|
||||||
|
CredentialBindingV1 {
|
||||||
|
principal: "omega-key:example#8",
|
||||||
|
..binding
|
||||||
|
},
|
||||||
|
CredentialBindingV1 {
|
||||||
|
iota_id: 12,
|
||||||
|
..binding
|
||||||
|
},
|
||||||
|
CredentialBindingV1 {
|
||||||
|
account_public_key_sha256: &[0x22; 32],
|
||||||
|
..binding
|
||||||
|
},
|
||||||
|
] {
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&key, &changed, &envelope),
|
||||||
|
Err(CredentialError::Authentication)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for offset in [10, 34, envelope.len() - 1] {
|
||||||
|
let mut changed = envelope.clone();
|
||||||
|
changed[offset] ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&key, &binding, &changed),
|
||||||
|
Err(CredentialError::Authentication)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for length in 0..50 {
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&key, &binding, &envelope[..length]),
|
||||||
|
Err(CredentialError::InvalidEnvelope)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let mut changed = envelope.clone();
|
||||||
|
changed[0] ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&key, &binding, &changed),
|
||||||
|
Err(CredentialError::InvalidEnvelope)
|
||||||
|
));
|
||||||
|
changed = envelope.clone();
|
||||||
|
changed[9] = 2;
|
||||||
|
assert!(matches!(
|
||||||
|
decrypt(&key, &binding, &changed),
|
||||||
|
Err(CredentialError::UnsupportedVersion(2))
|
||||||
|
));
|
||||||
|
let empty = encrypt(&key, &binding, b"").unwrap();
|
||||||
|
assert!(decrypt(&key, &binding, &empty).unwrap().is_empty());
|
||||||
|
}
|
||||||
81
iota-opaque/src/credential/v1.rs
Normal file
81
iota-opaque/src/credential/v1.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
use chacha20poly1305::{
|
||||||
|
XChaCha20Poly1305, XNonce,
|
||||||
|
aead::{Aead, KeyInit, Payload},
|
||||||
|
};
|
||||||
|
use rand_core::{OsRng, RngCore};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
CredentialBindingV1, CredentialError, aad::associated_data, kdf::derive_credential_key,
|
||||||
|
};
|
||||||
|
use crate::opaque::OpaqueExportKey;
|
||||||
|
|
||||||
|
const MAGIC: &[u8; 8] = b"TSCRED\0\0";
|
||||||
|
pub(super) const VERSION: u16 = 1;
|
||||||
|
const HEADER_LEN: usize = 8 + 2 + 24;
|
||||||
|
const TAG_LEN: usize = 16;
|
||||||
|
|
||||||
|
pub fn encrypt(
|
||||||
|
export_key: &OpaqueExportKey,
|
||||||
|
binding: &CredentialBindingV1<'_>,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Result<Vec<u8>, CredentialError> {
|
||||||
|
let key = derive_credential_key(export_key)?;
|
||||||
|
let cipher =
|
||||||
|
XChaCha20Poly1305::new_from_slice(&*key).map_err(|_| CredentialError::Encryption)?;
|
||||||
|
let mut nonce = [0_u8; 24];
|
||||||
|
OsRng.fill_bytes(&mut nonce);
|
||||||
|
let aad = associated_data(binding)?;
|
||||||
|
let ciphertext = cipher
|
||||||
|
.encrypt(
|
||||||
|
XNonce::from_slice(&nonce),
|
||||||
|
Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad: &aad,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|_| CredentialError::Encryption)?;
|
||||||
|
let mut envelope = Vec::with_capacity(HEADER_LEN + ciphertext.len());
|
||||||
|
envelope.extend_from_slice(MAGIC);
|
||||||
|
envelope.extend_from_slice(&VERSION.to_be_bytes());
|
||||||
|
envelope.extend_from_slice(&nonce);
|
||||||
|
envelope.extend_from_slice(&ciphertext);
|
||||||
|
Ok(envelope)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks framing and version only. Ciphertext authentication requires the client's export key.
|
||||||
|
pub fn validate_envelope(envelope: &[u8]) -> Result<(), CredentialError> {
|
||||||
|
if envelope.len() < 10 || &envelope[..8] != MAGIC {
|
||||||
|
return Err(CredentialError::InvalidEnvelope);
|
||||||
|
}
|
||||||
|
let version = u16::from_be_bytes([envelope[8], envelope[9]]);
|
||||||
|
if version != VERSION {
|
||||||
|
return Err(CredentialError::UnsupportedVersion(version));
|
||||||
|
}
|
||||||
|
if envelope.len() < HEADER_LEN + TAG_LEN {
|
||||||
|
return Err(CredentialError::InvalidEnvelope);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt(
|
||||||
|
export_key: &OpaqueExportKey,
|
||||||
|
binding: &CredentialBindingV1<'_>,
|
||||||
|
envelope: &[u8],
|
||||||
|
) -> Result<Zeroizing<Vec<u8>>, CredentialError> {
|
||||||
|
validate_envelope(envelope)?;
|
||||||
|
let key = derive_credential_key(export_key)?;
|
||||||
|
let cipher =
|
||||||
|
XChaCha20Poly1305::new_from_slice(&*key).map_err(|_| CredentialError::KeyDerivation)?;
|
||||||
|
let aad = associated_data(binding)?;
|
||||||
|
cipher
|
||||||
|
.decrypt(
|
||||||
|
XNonce::from_slice(&envelope[10..HEADER_LEN]),
|
||||||
|
Payload {
|
||||||
|
msg: &envelope[HEADER_LEN..],
|
||||||
|
aad: &aad,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(Zeroizing::new)
|
||||||
|
.map_err(|_| CredentialError::Authentication)
|
||||||
|
}
|
||||||
2
iota-opaque/src/lib.rs
Normal file
2
iota-opaque/src/lib.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod credential;
|
||||||
|
pub mod opaque;
|
||||||
143
iota-opaque/src/opaque/client.rs
Normal file
143
iota-opaque/src/opaque/client.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
use opaque_ke::{
|
||||||
|
ClientLogin, ClientLoginFinishParameters, ClientRegistration,
|
||||||
|
ClientRegistrationFinishParameters, CredentialResponse, Identifiers, RegistrationResponse,
|
||||||
|
};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
OpaqueIntegrationError, OpaqueProfileV1, PasswordLoginBindingV1, login_context,
|
||||||
|
server_identifier,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Client-only key material. Credential operations borrow it without exporting its bytes.
|
||||||
|
pub struct OpaqueExportKey(Zeroizing<Vec<u8>>);
|
||||||
|
|
||||||
|
impl OpaqueExportKey {
|
||||||
|
pub(crate) fn new(bytes: Vec<u8>) -> Self {
|
||||||
|
Self(Zeroizing::new(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_bytes(&self) -> &[u8] {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegistrationClient {
|
||||||
|
state: ClientRegistration<OpaqueProfileV1>,
|
||||||
|
password: Zeroizing<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegistrationStart {
|
||||||
|
pub request: Vec<u8>,
|
||||||
|
pub state: RegistrationClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct RegistrationFinish {
|
||||||
|
pub upload: Vec<u8>,
|
||||||
|
pub export_key: OpaqueExportKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registration_start(password: &[u8]) -> Result<RegistrationStart, OpaqueIntegrationError> {
|
||||||
|
let result = ClientRegistration::<OpaqueProfileV1>::start(&mut OsRng, password)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(RegistrationStart {
|
||||||
|
request: result.message.serialize().to_vec(),
|
||||||
|
state: RegistrationClient {
|
||||||
|
state: result.state,
|
||||||
|
password: Zeroizing::new(password.to_vec()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegistrationClient {
|
||||||
|
pub fn finish(
|
||||||
|
self,
|
||||||
|
response: &[u8],
|
||||||
|
principal: &str,
|
||||||
|
iota_id: i64,
|
||||||
|
) -> Result<RegistrationFinish, OpaqueIntegrationError> {
|
||||||
|
let response = RegistrationResponse::<OpaqueProfileV1>::deserialize(response)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
let server = server_identifier(iota_id);
|
||||||
|
let result = self
|
||||||
|
.state
|
||||||
|
.finish(
|
||||||
|
&mut OsRng,
|
||||||
|
&self.password,
|
||||||
|
response,
|
||||||
|
ClientRegistrationFinishParameters::new(
|
||||||
|
Identifiers {
|
||||||
|
client: Some(principal.as_bytes()),
|
||||||
|
server: Some(&server),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(RegistrationFinish {
|
||||||
|
upload: result.message.serialize().to_vec(),
|
||||||
|
export_key: OpaqueExportKey::new(result.export_key.to_vec()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginClient {
|
||||||
|
state: ClientLogin<OpaqueProfileV1>,
|
||||||
|
password: Zeroizing<Vec<u8>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginStart {
|
||||||
|
pub request: Vec<u8>,
|
||||||
|
pub state: LoginClient,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginFinish {
|
||||||
|
pub finalization: Vec<u8>,
|
||||||
|
pub export_key: OpaqueExportKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_start(password: &[u8]) -> Result<LoginStart, OpaqueIntegrationError> {
|
||||||
|
let result = ClientLogin::<OpaqueProfileV1>::start(&mut OsRng, password)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(LoginStart {
|
||||||
|
request: result.message.serialize().to_vec(),
|
||||||
|
state: LoginClient {
|
||||||
|
state: result.state,
|
||||||
|
password: Zeroizing::new(password.to_vec()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LoginClient {
|
||||||
|
pub fn finish(
|
||||||
|
self,
|
||||||
|
response: &[u8],
|
||||||
|
binding: &PasswordLoginBindingV1<'_>,
|
||||||
|
) -> Result<LoginFinish, OpaqueIntegrationError> {
|
||||||
|
let response = CredentialResponse::<OpaqueProfileV1>::deserialize(response)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
let server = server_identifier(binding.iota_id);
|
||||||
|
let context = login_context(binding)?;
|
||||||
|
let result = self
|
||||||
|
.state
|
||||||
|
.finish(
|
||||||
|
&mut OsRng,
|
||||||
|
&self.password,
|
||||||
|
response,
|
||||||
|
ClientLoginFinishParameters::new(
|
||||||
|
Some(&context),
|
||||||
|
Identifiers {
|
||||||
|
client: Some(binding.principal.as_bytes()),
|
||||||
|
server: Some(&server),
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(LoginFinish {
|
||||||
|
finalization: result.message.serialize().to_vec(),
|
||||||
|
export_key: OpaqueExportKey::new(result.export_key.to_vec()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
32
iota-opaque/src/opaque/context.rs
Normal file
32
iota-opaque/src/opaque/context.rs
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use super::{OpaqueIntegrationError, OpaqueProfileV1};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct PasswordLoginBindingV1<'a> {
|
||||||
|
pub principal: &'a str,
|
||||||
|
pub iota_id: i64,
|
||||||
|
pub session_id: Uuid,
|
||||||
|
pub contact_key_sha256: &'a [u8; 32],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn server_identifier(iota_id: i64) -> Vec<u8> {
|
||||||
|
let mut result = OpaqueProfileV1::SERVER_ID_DOMAIN.to_vec();
|
||||||
|
result.extend_from_slice(&iota_id.to_be_bytes());
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_context(
|
||||||
|
binding: &PasswordLoginBindingV1<'_>,
|
||||||
|
) -> Result<Vec<u8>, OpaqueIntegrationError> {
|
||||||
|
let mut result = OpaqueProfileV1::LOGIN_CONTEXT_DOMAIN.to_vec();
|
||||||
|
let principal = binding.principal.as_bytes();
|
||||||
|
let length =
|
||||||
|
u32::try_from(principal.len()).map_err(|_| OpaqueIntegrationError::FieldTooLarge)?;
|
||||||
|
result.extend_from_slice(&length.to_be_bytes());
|
||||||
|
result.extend_from_slice(principal);
|
||||||
|
result.extend_from_slice(&binding.iota_id.to_be_bytes());
|
||||||
|
result.extend_from_slice(binding.session_id.as_bytes());
|
||||||
|
result.extend_from_slice(binding.contact_key_sha256);
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
15
iota-opaque/src/opaque/error.rs
Normal file
15
iota-opaque/src/opaque/error.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum OpaqueIntegrationError {
|
||||||
|
#[error("invalid OPAQUE exchange: {0}")]
|
||||||
|
Opaque(String),
|
||||||
|
#[error("OPAQUE context field is too large")]
|
||||||
|
FieldTooLarge,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum LoginFinishError {
|
||||||
|
#[error("invalid OPAQUE finalization message")]
|
||||||
|
InvalidMessage,
|
||||||
|
#[error("OPAQUE authentication failed")]
|
||||||
|
AuthenticationFailed,
|
||||||
|
}
|
||||||
25
iota-opaque/src/opaque/mod.rs
Normal file
25
iota-opaque/src/opaque/mod.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
mod client;
|
||||||
|
mod context;
|
||||||
|
mod error;
|
||||||
|
mod profile_v1;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
pub use client::{
|
||||||
|
LoginClient, LoginFinish, LoginStart, OpaqueExportKey, RegistrationClient, RegistrationFinish,
|
||||||
|
RegistrationStart, login_start as client_login_start,
|
||||||
|
registration_start as client_registration_start,
|
||||||
|
};
|
||||||
|
pub use context::{PasswordLoginBindingV1, login_context, server_identifier};
|
||||||
|
pub use error::{LoginFinishError, OpaqueIntegrationError};
|
||||||
|
pub use profile_v1::{CURRENT_OPAQUE_PROFILE, OpaqueProfileV1, PasswordKsfV1};
|
||||||
|
pub use server::{
|
||||||
|
LoginStartResult as ServerLoginStartResult, PasswordServerSetup, ServerLoginState,
|
||||||
|
deserialize_server_setup, deserialize_server_setup_owned, generate_server_setup,
|
||||||
|
login_finish as server_login_finish, login_start as server_login_start,
|
||||||
|
registration_finish as server_registration_finish,
|
||||||
|
registration_start as server_registration_start, serialize_server_setup,
|
||||||
|
validate_login_request,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
46
iota-opaque/src/opaque/profile_v1.rs
Normal file
46
iota-opaque/src/opaque/profile_v1.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use generic_array::{ArrayLength, GenericArray};
|
||||||
|
use opaque_ke::{CipherSuite, Ristretto255, TripleDh, ksf::Ksf};
|
||||||
|
|
||||||
|
pub struct OpaqueProfileV1;
|
||||||
|
|
||||||
|
impl OpaqueProfileV1 {
|
||||||
|
pub const ID: i64 = 1;
|
||||||
|
pub const SERVER_ID_DOMAIN: &'static [u8] = b"tensamin:iota-password\0";
|
||||||
|
pub const LOGIN_CONTEXT_DOMAIN: &'static [u8] = b"tensamin:password-provisioning\0";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const CURRENT_OPAQUE_PROFILE: i64 = OpaqueProfileV1::ID;
|
||||||
|
|
||||||
|
/// Persistent profile-1 parameters. Changes require a new profile ID.
|
||||||
|
pub struct PasswordKsfV1(argon2::Argon2<'static>);
|
||||||
|
|
||||||
|
impl Default for PasswordKsfV1 {
|
||||||
|
fn default() -> Self {
|
||||||
|
let params = argon2::Params::new(19 * 1024, 2, 1, None)
|
||||||
|
.expect("PasswordKsfV1 parameters must be valid");
|
||||||
|
Self(argon2::Argon2::new(
|
||||||
|
argon2::Algorithm::Argon2id,
|
||||||
|
argon2::Version::V0x13,
|
||||||
|
params,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ksf for PasswordKsfV1 {
|
||||||
|
fn hash<L: ArrayLength<u8>>(
|
||||||
|
&self,
|
||||||
|
input: GenericArray<u8, L>,
|
||||||
|
) -> Result<GenericArray<u8, L>, opaque_ke::errors::InternalError> {
|
||||||
|
let mut output = GenericArray::<u8, L>::default();
|
||||||
|
self.0
|
||||||
|
.hash_password_into(&input, &[0; argon2::RECOMMENDED_SALT_LEN], &mut output)
|
||||||
|
.map_err(|_| opaque_ke::errors::InternalError::KsfError)?;
|
||||||
|
Ok(output)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CipherSuite for OpaqueProfileV1 {
|
||||||
|
type OprfCs = Ristretto255;
|
||||||
|
type KeyExchange = TripleDh<Ristretto255, sha2::Sha512>;
|
||||||
|
type Ksf = PasswordKsfV1;
|
||||||
|
}
|
||||||
141
iota-opaque/src/opaque/server.rs
Normal file
141
iota-opaque/src/opaque/server.rs
Normal file
|
|
@ -0,0 +1,141 @@
|
||||||
|
use opaque_ke::{
|
||||||
|
CredentialFinalization, CredentialRequest, Identifiers, RegistrationRequest,
|
||||||
|
RegistrationUpload, ServerLogin, ServerLoginParameters, ServerRegistration, ServerSetup,
|
||||||
|
};
|
||||||
|
use rand_core::OsRng;
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{
|
||||||
|
LoginFinishError, OpaqueIntegrationError, OpaqueProfileV1, PasswordLoginBindingV1,
|
||||||
|
login_context, server_identifier,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub type PasswordServerSetup = ServerSetup<OpaqueProfileV1>;
|
||||||
|
|
||||||
|
pub fn generate_server_setup() -> PasswordServerSetup {
|
||||||
|
ServerSetup::new(&mut OsRng)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn serialize_server_setup(setup: &PasswordServerSetup) -> Zeroizing<Vec<u8>> {
|
||||||
|
Zeroizing::new(setup.serialize().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize_server_setup(
|
||||||
|
bytes: &[u8],
|
||||||
|
) -> Result<PasswordServerSetup, OpaqueIntegrationError> {
|
||||||
|
ServerSetup::deserialize(bytes)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deserialize_server_setup_owned(
|
||||||
|
bytes: Vec<u8>,
|
||||||
|
) -> Result<PasswordServerSetup, OpaqueIntegrationError> {
|
||||||
|
let bytes = Zeroizing::new(bytes);
|
||||||
|
deserialize_server_setup(&bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registration_start(
|
||||||
|
setup: &PasswordServerSetup,
|
||||||
|
request: &[u8],
|
||||||
|
principal: &[u8],
|
||||||
|
) -> Result<Vec<u8>, OpaqueIntegrationError> {
|
||||||
|
let message = RegistrationRequest::<OpaqueProfileV1>::deserialize(request)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
let result = ServerRegistration::<OpaqueProfileV1>::start(setup, message, principal)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(result.message.serialize().to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn registration_finish(upload: &[u8]) -> Result<Vec<u8>, OpaqueIntegrationError> {
|
||||||
|
let message = RegistrationUpload::<OpaqueProfileV1>::deserialize(upload)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(ServerRegistration::<OpaqueProfileV1>::finish(message)
|
||||||
|
.serialize()
|
||||||
|
.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_login_request(request: &[u8]) -> Result<(), OpaqueIntegrationError> {
|
||||||
|
CredentialRequest::<OpaqueProfileV1>::deserialize(request)
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secret pending exchange state. Consumed by server_login_finish.
|
||||||
|
pub struct ServerLoginState {
|
||||||
|
// opaque-ke's ServerLogin zeroizes its secret state on drop.
|
||||||
|
state: ServerLogin<OpaqueProfileV1>,
|
||||||
|
context: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LoginStartResult {
|
||||||
|
pub response: Vec<u8>,
|
||||||
|
pub state: ServerLoginState,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_start(
|
||||||
|
setup: &PasswordServerSetup,
|
||||||
|
record: Option<&[u8]>,
|
||||||
|
request: &[u8],
|
||||||
|
binding: &PasswordLoginBindingV1<'_>,
|
||||||
|
) -> Result<LoginStartResult, OpaqueIntegrationError> {
|
||||||
|
let message = CredentialRequest::<OpaqueProfileV1>::deserialize(request)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
let registration = record
|
||||||
|
.map(ServerRegistration::<OpaqueProfileV1>::deserialize)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
let principal = binding.principal.as_bytes();
|
||||||
|
let server = server_identifier(binding.iota_id);
|
||||||
|
let context = login_context(binding)?;
|
||||||
|
let result = ServerLogin::<OpaqueProfileV1>::start(
|
||||||
|
&mut OsRng,
|
||||||
|
setup,
|
||||||
|
registration,
|
||||||
|
message,
|
||||||
|
principal,
|
||||||
|
ServerLoginParameters {
|
||||||
|
context: Some(&context),
|
||||||
|
identifiers: Identifiers {
|
||||||
|
client: Some(principal),
|
||||||
|
server: Some(&server),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|error| OpaqueIntegrationError::Opaque(error.to_string()))?;
|
||||||
|
Ok(LoginStartResult {
|
||||||
|
response: result.message.serialize().to_vec(),
|
||||||
|
state: ServerLoginState {
|
||||||
|
state: result.state,
|
||||||
|
context,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn login_finish(
|
||||||
|
state: ServerLoginState,
|
||||||
|
finalization: &[u8],
|
||||||
|
binding: &PasswordLoginBindingV1<'_>,
|
||||||
|
) -> Result<(), LoginFinishError> {
|
||||||
|
// TripleDH verifies its saved transcript, so also enforce the caller's current binding.
|
||||||
|
let context = login_context(binding).map_err(|_| LoginFinishError::InvalidMessage)?;
|
||||||
|
if context != state.context {
|
||||||
|
return Err(LoginFinishError::AuthenticationFailed);
|
||||||
|
}
|
||||||
|
let message = CredentialFinalization::<OpaqueProfileV1>::deserialize(finalization)
|
||||||
|
.map_err(|_| LoginFinishError::InvalidMessage)?;
|
||||||
|
let server = server_identifier(binding.iota_id);
|
||||||
|
state
|
||||||
|
.state
|
||||||
|
.finish(
|
||||||
|
message,
|
||||||
|
ServerLoginParameters {
|
||||||
|
context: Some(&context),
|
||||||
|
identifiers: Identifiers {
|
||||||
|
client: Some(binding.principal.as_bytes()),
|
||||||
|
server: Some(&server),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|_| LoginFinishError::AuthenticationFailed)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
168
iota-opaque/src/opaque/tests.rs
Normal file
168
iota-opaque/src/opaque/tests.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
use super::*;
|
||||||
|
use generic_array::GenericArray;
|
||||||
|
use opaque_ke::ksf::Ksf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
fn binding() -> PasswordLoginBindingV1<'static> {
|
||||||
|
PasswordLoginBindingV1 {
|
||||||
|
principal: "omega-key:example#7",
|
||||||
|
iota_id: 11,
|
||||||
|
session_id: Uuid::from_bytes([0x33; 16]),
|
||||||
|
contact_key_sha256: &[0x11; 32],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn enroll(setup: &PasswordServerSetup) -> RegistrationFinish {
|
||||||
|
let client = client_registration_start(b"correct horse").unwrap();
|
||||||
|
let response =
|
||||||
|
server_registration_start(setup, &client.request, binding().principal.as_bytes()).unwrap();
|
||||||
|
client
|
||||||
|
.state
|
||||||
|
.finish(&response, binding().principal, 11)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_matches_pre_extraction_vectors() {
|
||||||
|
let vectors: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../../tests/vectors/profile_v1.json")).unwrap();
|
||||||
|
assert_eq!(CURRENT_OPAQUE_PROFILE, vectors["profile"].as_i64().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(server_identifier(11)),
|
||||||
|
vectors["server_identifier"]["expected_hex"]
|
||||||
|
);
|
||||||
|
let vector = &vectors["login_context"];
|
||||||
|
let fingerprint: [u8; 32] = hex::decode(vector["contact_key_sha256_hex"].as_str().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
let context = login_context(&PasswordLoginBindingV1 {
|
||||||
|
principal: vector["principal"].as_str().unwrap(),
|
||||||
|
iota_id: vector["iota_id"].as_i64().unwrap(),
|
||||||
|
session_id: Uuid::parse_str(vector["session_id"].as_str().unwrap()).unwrap(),
|
||||||
|
contact_key_sha256: &fingerprint,
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(hex::encode(context), vector["expected_hex"]);
|
||||||
|
let input: [u8; 64] = hex::decode(vectors["ksf"]["input_hex"].as_str().unwrap())
|
||||||
|
.unwrap()
|
||||||
|
.try_into()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
hex::encode(
|
||||||
|
PasswordKsfV1::default()
|
||||||
|
.hash(GenericArray::from(input))
|
||||||
|
.unwrap()
|
||||||
|
),
|
||||||
|
vectors["ksf"]["expected_hex"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registration_and_login_preserve_export_key_and_setup() {
|
||||||
|
let setup = generate_server_setup();
|
||||||
|
let bytes = serialize_server_setup(&setup);
|
||||||
|
let restored = deserialize_server_setup_owned(bytes.to_vec()).unwrap();
|
||||||
|
assert_eq!(*bytes, *serialize_server_setup(&restored));
|
||||||
|
assert!(deserialize_server_setup(b"damaged").is_err());
|
||||||
|
let registration = enroll(&restored);
|
||||||
|
let record = server_registration_finish(®istration.upload).unwrap();
|
||||||
|
let client = client_login_start(b"correct horse").unwrap();
|
||||||
|
validate_login_request(&client.request).unwrap();
|
||||||
|
let response =
|
||||||
|
server_login_start(&restored, Some(&record), &client.request, &binding()).unwrap();
|
||||||
|
let finish = client.state.finish(&response.response, &binding()).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
registration.export_key.as_bytes(),
|
||||||
|
finish.export_key.as_bytes()
|
||||||
|
);
|
||||||
|
server_login_finish(response.state, &finish.finalization, &binding()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn login_rejects_wrong_password_bindings_missing_record_and_unrelated_setup() {
|
||||||
|
let setup = generate_server_setup();
|
||||||
|
let unrelated = generate_server_setup();
|
||||||
|
let record = server_registration_finish(&enroll(&setup).upload).unwrap();
|
||||||
|
let original = binding();
|
||||||
|
let variants = [
|
||||||
|
PasswordLoginBindingV1 {
|
||||||
|
principal: "omega-key:example#8",
|
||||||
|
..original
|
||||||
|
},
|
||||||
|
PasswordLoginBindingV1 {
|
||||||
|
iota_id: 12,
|
||||||
|
..original
|
||||||
|
},
|
||||||
|
PasswordLoginBindingV1 {
|
||||||
|
session_id: Uuid::from_bytes([0x44; 16]),
|
||||||
|
..original
|
||||||
|
},
|
||||||
|
PasswordLoginBindingV1 {
|
||||||
|
contact_key_sha256: &[0x22; 32],
|
||||||
|
..original
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for changed in variants {
|
||||||
|
assert_ne!(
|
||||||
|
login_context(&original).unwrap(),
|
||||||
|
login_context(&changed).unwrap()
|
||||||
|
);
|
||||||
|
let client = client_login_start(b"correct horse").unwrap();
|
||||||
|
let response =
|
||||||
|
server_login_start(&setup, Some(&record), &client.request, &changed).unwrap();
|
||||||
|
assert!(client.state.finish(&response.response, &original).is_err());
|
||||||
|
}
|
||||||
|
for (server, record, password) in [
|
||||||
|
(&setup, Some(record.as_slice()), b"wrong horse".as_slice()),
|
||||||
|
(&setup, None, b"correct horse".as_slice()),
|
||||||
|
(
|
||||||
|
&unrelated,
|
||||||
|
Some(record.as_slice()),
|
||||||
|
b"correct horse".as_slice(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let client = client_login_start(password).unwrap();
|
||||||
|
let response = server_login_start(server, record, &client.request, &original).unwrap();
|
||||||
|
assert!(client.state.finish(&response.response, &original).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn malformed_messages_and_failed_proofs_are_distinct() {
|
||||||
|
let setup = generate_server_setup();
|
||||||
|
assert!(validate_login_request(b"not KE1").is_err());
|
||||||
|
assert!(server_registration_start(&setup, b"bad", b"principal").is_err());
|
||||||
|
assert!(server_registration_finish(b"bad").is_err());
|
||||||
|
assert!(server_login_start(&setup, None, b"bad", &binding()).is_err());
|
||||||
|
let registration = enroll(&setup);
|
||||||
|
let record = server_registration_finish(®istration.upload).unwrap();
|
||||||
|
let client = client_login_start(b"correct horse").unwrap();
|
||||||
|
let response = server_login_start(&setup, Some(&record), &client.request, &binding()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
server_login_finish(response.state, b"bad", &binding()),
|
||||||
|
Err(LoginFinishError::InvalidMessage)
|
||||||
|
));
|
||||||
|
let client = client_login_start(b"correct horse").unwrap();
|
||||||
|
let response = server_login_start(&setup, Some(&record), &client.request, &binding()).unwrap();
|
||||||
|
let mut finish = client.state.finish(&response.response, &binding()).unwrap();
|
||||||
|
finish.finalization[0] ^= 1;
|
||||||
|
assert!(matches!(
|
||||||
|
server_login_finish(response.state, &finish.finalization, &binding()),
|
||||||
|
Err(LoginFinishError::AuthenticationFailed)
|
||||||
|
));
|
||||||
|
let client = client_login_start(b"correct horse").unwrap();
|
||||||
|
let response = server_login_start(&setup, Some(&record), &client.request, &binding()).unwrap();
|
||||||
|
let finish = client.state.finish(&response.response, &binding()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
server_login_finish(
|
||||||
|
response.state,
|
||||||
|
&finish.finalization,
|
||||||
|
&PasswordLoginBindingV1 {
|
||||||
|
iota_id: 12,
|
||||||
|
..binding()
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Err(LoginFinishError::AuthenticationFailed)
|
||||||
|
));
|
||||||
|
}
|
||||||
19
iota-opaque/tests/vectors/profile_v1.json
Normal file
19
iota-opaque/tests/vectors/profile_v1.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"profile": 1,
|
||||||
|
"source": "Iota iota-auth/src/password.rs, verified against extracted implementation before deletion",
|
||||||
|
"server_identifier": {
|
||||||
|
"iota_id": 11,
|
||||||
|
"expected_hex": "74656e73616d696e3a696f74612d70617373776f726400000000000000000b"
|
||||||
|
},
|
||||||
|
"login_context": {
|
||||||
|
"principal": "omega-key:example#7",
|
||||||
|
"iota_id": 11,
|
||||||
|
"session_id": "00112233-4455-6677-8899-aabbccddeeff",
|
||||||
|
"contact_key_sha256_hex": "0aa84b6c2462c165114b397e4efea57d4a0b512c2e8b7bf556ee591a59ec55f3",
|
||||||
|
"expected_hex": "74656e73616d696e3a70617373776f72642d70726f766973696f6e696e6700000000136f6d6567612d6b65793a6578616d706c652337000000000000000b00112233445566778899aabbccddeeff0aa84b6c2462c165114b397e4efea57d4a0b512c2e8b7bf556ee591a59ec55f3"
|
||||||
|
},
|
||||||
|
"ksf": {
|
||||||
|
"input_hex": "07070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707070707",
|
||||||
|
"expected_hex": "048e86168a7470db58536fdd2c4deffc067875c94757b5308a7e81ae527a2e5f2ad666cb6147d9f86856ee8bf7ed1c40668fc9861eab738fb4491d3579382947"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
opague-integration = { git = "https://git.methanium.net/tensamin/opaque-integration.git", rev = "d996c29366429160103e2a3f99c3e435522456f0" }
|
iota-opaque = { path = "../iota-opaque" }
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
iota-connection = { path = "../iota-connection" }
|
iota-connection = { path = "../iota-connection" }
|
||||||
iota-auth = { path = "../iota-auth" }
|
iota-auth = { path = "../iota-auth" }
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
use std::{sync::Arc, time::Instant};
|
use std::{sync::Arc, time::Instant};
|
||||||
|
|
||||||
|
use iota_opaque::{credential, opaque};
|
||||||
use iota_storage::users::{
|
use iota_storage::users::{
|
||||||
password_credentials::{self, PasswordCredential},
|
password_credentials::{self, PasswordCredential},
|
||||||
user_manager,
|
user_manager,
|
||||||
};
|
};
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use opague_integration::opaque;
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
MAX_CREDENTIAL_BYTES, MAX_OPAQUE_BYTES, OmikronConnection,
|
MAX_CREDENTIAL_BYTES, MAX_OPAQUE_BYTES, OmikronConnection,
|
||||||
app_protection::{PASSWORD_RESPONSE_ENCRYPTION, PASSWORD_RESPONSE_SIGNATURE},
|
app_protection::{PASSWORD_RESPONSE_ENCRYPTION, PASSWORD_RESPONSE_SIGNATURE},
|
||||||
|
runtime::PendingEnrollment,
|
||||||
wire::{bytes, key_fingerprint, now_millis, session, typed},
|
wire::{bytes, key_fingerprint, now_millis, session, typed},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -39,9 +40,7 @@ impl OmikronConnection {
|
||||||
.open_password_command(frame, CommunicationType::PasswordEnrollmentStart)
|
.open_password_command(frame, CommunicationType::PasswordEnrollmentStart)
|
||||||
.await?;
|
.await?;
|
||||||
let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?;
|
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 {
|
let permit = self.password_auth.begin_enrollment()?;
|
||||||
return Err("too many enrollments".into());
|
|
||||||
}
|
|
||||||
let principal = self.password_principal(user_id).await?;
|
let principal = self.password_principal(user_id).await?;
|
||||||
let request = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
|
let request = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
|
||||||
let setup = self
|
let setup = self
|
||||||
|
|
@ -51,21 +50,28 @@ impl OmikronConnection {
|
||||||
let response = opaque::server_registration_start(&setup, &request, principal.as_bytes())
|
let response = opaque::server_registration_start(&setup, &request, principal.as_bytes())
|
||||||
.map_err(|error| error.to_string())?;
|
.map_err(|error| error.to_string())?;
|
||||||
let enrollment_id = Uuid::new_v4();
|
let enrollment_id = Uuid::new_v4();
|
||||||
self.password_auth
|
let response = self
|
||||||
.enrollments
|
.protected_response(
|
||||||
.insert(enrollment_id, (user_id, Instant::now()));
|
frame,
|
||||||
self.protected_response(
|
CommunicationType::PasswordEnrollmentResponse,
|
||||||
frame,
|
user_id,
|
||||||
CommunicationType::PasswordEnrollmentResponse,
|
DataValue::Container(vec![
|
||||||
user_id,
|
typed(DataType::Uuid, DataValue::Str(enrollment_id.to_string()))?,
|
||||||
DataValue::Container(vec![
|
typed(DataType::OpaqueMessage, DataValue::Bytes(response))?,
|
||||||
typed(DataType::Uuid, DataValue::Str(enrollment_id.to_string()))?,
|
]),
|
||||||
typed(DataType::OpaqueMessage, DataValue::Bytes(response))?,
|
PASSWORD_RESPONSE_SIGNATURE,
|
||||||
]),
|
PASSWORD_RESPONSE_ENCRYPTION,
|
||||||
PASSWORD_RESPONSE_SIGNATURE,
|
)
|
||||||
PASSWORD_RESPONSE_ENCRYPTION,
|
.await?;
|
||||||
)
|
self.password_auth.enrollments.insert(
|
||||||
.await
|
enrollment_id,
|
||||||
|
PendingEnrollment {
|
||||||
|
user_id,
|
||||||
|
created: Instant::now(),
|
||||||
|
_permit: permit,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn handle_password_enrollment_finish(
|
pub(crate) async fn handle_password_enrollment_finish(
|
||||||
|
|
@ -93,12 +99,13 @@ impl OmikronConnection {
|
||||||
.await?;
|
.await?;
|
||||||
let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?;
|
let user_id = i64::try_from(opened.signer_id()).map_err(|error| error.to_string())?;
|
||||||
let enrollment_id = session(opened.content())?;
|
let enrollment_id = session(opened.content())?;
|
||||||
let (_, (pending_user, started)) = self
|
let (_, pending) = self
|
||||||
.password_auth
|
.password_auth
|
||||||
.enrollments
|
.enrollments
|
||||||
.remove(&enrollment_id)
|
.remove(&enrollment_id)
|
||||||
.ok_or("enrollment expired")?;
|
.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());
|
return Err("enrollment mismatch".into());
|
||||||
}
|
}
|
||||||
let upload = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
|
let upload = bytes(opened.content(), DataType::OpaqueMessage, MAX_OPAQUE_BYTES)?;
|
||||||
|
|
@ -107,6 +114,7 @@ impl OmikronConnection {
|
||||||
DataType::EncryptedCredential,
|
DataType::EncryptedCredential,
|
||||||
MAX_CREDENTIAL_BYTES,
|
MAX_CREDENTIAL_BYTES,
|
||||||
)?;
|
)?;
|
||||||
|
credential::validate_envelope(&encrypted).map_err(|error| error.to_string())?;
|
||||||
let opaque_record =
|
let opaque_record =
|
||||||
opaque::server_registration_finish(&upload).map_err(|error| error.to_string())?;
|
opaque::server_registration_finish(&upload).map_err(|error| error.to_string())?;
|
||||||
let profile = user_manager::get_user(user_id)
|
let profile = user_manager::get_user(user_id)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use iota_storage::users::user_manager;
|
|
||||||
use mtp::crypto::PublicKeyBundle;
|
use mtp::crypto::PublicKeyBundle;
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
use iota_opaque::opaque::{self, PasswordLoginBindingV1};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mtp::codec::{
|
use mtp::codec::{
|
||||||
CommunicationType, CommunicationValue, DataType, DataValue, ProtectedMessageBuilder,
|
CommunicationType, CommunicationValue, DataType, DataValue, ProtectedMessageBuilder,
|
||||||
|
|
@ -11,8 +12,6 @@ use mtp::codec::{
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use mtp::crypto::DualSigner;
|
use mtp::crypto::DualSigner;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use opague_integration::opaque::{self, PasswordLoginBindingV1};
|
|
||||||
#[cfg(test)]
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -53,12 +52,8 @@ impl OmikronConnection {
|
||||||
// Password login uses Omega's key-scoped principal, including for accounts
|
// Password login uses Omega's key-scoped principal, including for accounts
|
||||||
// whose older hosted-principal record still uses the legacy host locator.
|
// whose older hosted-principal record still uses the legacy host locator.
|
||||||
async fn password_principal(&self, user_id: i64) -> Result<String, String> {
|
async fn password_principal(&self, user_id: i64) -> Result<String, String> {
|
||||||
if user_id <= 0
|
if user_id <= 0 {
|
||||||
|| user_manager::get_user(user_id)
|
return Err("invalid user ID".into());
|
||||||
.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() {
|
let authority = if let Some(cached) = self.password_auth.omega_authority.get() {
|
||||||
cached.clone()
|
cached.clone()
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,13 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use dashmap::mapref::entry::Entry;
|
use dashmap::mapref::entry::Entry;
|
||||||
|
use iota_opaque::{
|
||||||
|
credential,
|
||||||
|
opaque::{self, PasswordLoginBindingV1, PasswordServerSetup},
|
||||||
|
};
|
||||||
use iota_storage::users::{password_credentials, user_manager};
|
use iota_storage::users::{password_credentials, user_manager};
|
||||||
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
use mtp::codec::{CommunicationType, CommunicationValue, DataType, DataValue};
|
||||||
use mtp::crypto::PublicKeyBundle;
|
use mtp::crypto::PublicKeyBundle;
|
||||||
use opague_integration::opaque::{self, PasswordLoginBindingV1, PasswordServerSetup};
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -127,35 +130,44 @@ impl OmikronConnection {
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
}
|
}
|
||||||
let principal = self.password_principal(user_id).await?;
|
let principal = self.password_principal(user_id).await?;
|
||||||
let contact_key_sha256 = public_key_fingerprint(&contact_key)?;
|
let (response, pending) = self
|
||||||
let binding = PasswordLoginBindingV1 {
|
.password_auth
|
||||||
principal: &principal,
|
.run_blocking(move || {
|
||||||
iota_id,
|
let contact_key_sha256 = public_key_fingerprint(&contact_key)?;
|
||||||
session_id,
|
let binding = PasswordLoginBindingV1 {
|
||||||
contact_key_sha256: &contact_key_sha256,
|
principal: &principal,
|
||||||
};
|
iota_id,
|
||||||
let credential = password_credentials::get(user_id).map_err(|error| error.to_string())?;
|
session_id,
|
||||||
let compatible_credential = credential
|
contact_key_sha256: &contact_key_sha256,
|
||||||
.as_ref()
|
};
|
||||||
.filter(|credential| credential.opaque_profile == opaque::CURRENT_OPAQUE_PROFILE);
|
// Run the same lookups for every account and use a dummy record when absent.
|
||||||
let response = opaque::server_login_start(
|
let profile = user_manager::get_user(user_id).map_err(|error| error.to_string())?;
|
||||||
&setup,
|
let credential =
|
||||||
compatible_credential.map(|value| value.opaque_record.as_slice()),
|
password_credentials::get(user_id).map_err(|error| error.to_string())?;
|
||||||
&ke1,
|
let compatible_credential = credential.as_ref().filter(|credential| {
|
||||||
&binding,
|
profile.is_some() && credential.opaque_profile == opaque::CURRENT_OPAQUE_PROFILE
|
||||||
)
|
});
|
||||||
.map_err(|error| error.to_string())?;
|
let response = opaque::server_login_start(
|
||||||
let pending = PendingLogin {
|
&setup,
|
||||||
user_id,
|
compatible_credential.map(|value| value.opaque_record.as_slice()),
|
||||||
participant_id,
|
&ke1,
|
||||||
contact_key,
|
&binding,
|
||||||
state: Some(response.state),
|
)
|
||||||
real_record: compatible_credential.is_some(),
|
.map_err(|error| error.to_string())?;
|
||||||
created: Instant::now(),
|
let pending = PendingLogin {
|
||||||
attempt: Some(attempt),
|
user_id,
|
||||||
record_hash: compatible_credential
|
participant_id,
|
||||||
.map(|value| Sha256::digest(&value.opaque_record).to_vec()),
|
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) {
|
match self.password_auth.logins.entry(session_id) {
|
||||||
Entry::Vacant(slot) => {
|
Entry::Vacant(slot) => {
|
||||||
slot.insert(pending);
|
slot.insert(pending);
|
||||||
|
|
@ -168,7 +180,7 @@ impl OmikronConnection {
|
||||||
CommunicationValue::new(CommunicationType::PasswordProvisioningResponse)
|
CommunicationValue::new(CommunicationType::PasswordProvisioningResponse)
|
||||||
.with_id(request_id)
|
.with_id(request_id)
|
||||||
.add_typed_default(DataType::Uuid, DataValue::Str(session_id.to_string()))
|
.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)
|
.password_principal(pending.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|_| PasswordFinishError::CredentialUnavailable)?;
|
.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
|
let state = pending
|
||||||
.state
|
.state
|
||||||
.take()
|
.take()
|
||||||
.ok_or(PasswordFinishError::InvalidClientMessage)?;
|
.ok_or(PasswordFinishError::InvalidClientMessage)?;
|
||||||
match opaque::server_login_finish(state, &ke3, &binding) {
|
let user_id = pending.user_id;
|
||||||
Ok(()) => {}
|
let contact_key = pending.contact_key.clone();
|
||||||
Err(opaque::LoginFinishError::AuthenticationFailed) => {
|
let real_record = pending.real_record;
|
||||||
return Err(PasswordFinishError::Authentication);
|
let record_hash = pending.record_hash.clone();
|
||||||
}
|
let created = pending.created;
|
||||||
Err(opaque::LoginFinishError::InvalidMessage) => {
|
let pending_ttl = self.password_auth.pending_ttl;
|
||||||
return Err(PasswordFinishError::InvalidClientMessage);
|
let credential = self
|
||||||
}
|
.password_auth
|
||||||
}
|
.run_blocking(move || {
|
||||||
if !pending.real_record {
|
if created.elapsed() >= pending_ttl {
|
||||||
return Err(PasswordFinishError::Authentication);
|
return Err(PasswordFinishError::BindingMismatch);
|
||||||
}
|
}
|
||||||
let credential = password_credentials::get(pending.user_id)
|
let contact_key_sha256 = public_key_fingerprint(&contact_key)
|
||||||
.map_err(|_| PasswordFinishError::Storage)?
|
.map_err(|_| PasswordFinishError::InvalidClientMessage)?;
|
||||||
.ok_or(PasswordFinishError::CredentialUnavailable)?;
|
let binding = PasswordLoginBindingV1 {
|
||||||
if credential.opaque_profile != opaque::CURRENT_OPAQUE_PROFILE {
|
principal: &principal,
|
||||||
return Err(PasswordFinishError::CredentialUnavailable);
|
iota_id,
|
||||||
}
|
session_id: id,
|
||||||
if pending.record_hash.as_deref()
|
contact_key_sha256: &contact_key_sha256,
|
||||||
!= Some(Sha256::digest(&credential.opaque_record).as_slice())
|
};
|
||||||
{
|
match opaque::server_login_finish(state, &ke3, &binding) {
|
||||||
return Err(PasswordFinishError::CredentialChanged);
|
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.
|
// Only the same registered credential can clear password pressure.
|
||||||
if let Some(attempt) = pending.attempt.take() {
|
if let Some(attempt) = pending.attempt.take() {
|
||||||
attempt.success();
|
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 =
|
let wire =
|
||||||
|kind, value| typed(kind, value).map_err(|_| PasswordFinishError::InvalidClientMessage);
|
|kind, value| typed(kind, value).map_err(|_| PasswordFinishError::InvalidClientMessage);
|
||||||
let content = DataValue::Container(vec![
|
let content = DataValue::Container(vec![
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@ use std::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use dashmap::DashMap;
|
use dashmap::DashMap;
|
||||||
|
use iota_opaque::opaque::{self, PasswordServerSetup, ServerLoginState};
|
||||||
use iota_storage::users::password_credentials;
|
use iota_storage::users::password_credentials;
|
||||||
use mtp::crypto::PublicKeyBundle;
|
use mtp::crypto::PublicKeyBundle;
|
||||||
use opague_integration::opaque::{self, PasswordServerSetup, ServerLoginState};
|
|
||||||
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -22,6 +22,12 @@ pub(super) struct PendingLogin {
|
||||||
pub(super) attempt: Option<AttemptLease>,
|
pub(super) attempt: Option<AttemptLease>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) struct PendingEnrollment {
|
||||||
|
pub(super) user_id: i64,
|
||||||
|
pub(super) created: Instant,
|
||||||
|
pub(super) _permit: OwnedSemaphorePermit,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub(super) struct AccountAttemptState {
|
pub(super) struct AccountAttemptState {
|
||||||
pub(super) window_started: Instant,
|
pub(super) window_started: Instant,
|
||||||
|
|
@ -85,10 +91,13 @@ pub(crate) struct PasswordAuthRuntime {
|
||||||
pub(super) setup: OnceLock<Arc<PasswordServerSetup>>,
|
pub(super) setup: OnceLock<Arc<PasswordServerSetup>>,
|
||||||
pub(super) omega_authority: OnceLock<String>,
|
pub(super) omega_authority: OnceLock<String>,
|
||||||
pub(super) logins: DashMap<Uuid, PendingLogin>,
|
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>,
|
pub(super) attempts: DashMap<i64, AccountAttemptState>,
|
||||||
|
#[cfg(test)]
|
||||||
pub(super) max_pending: usize,
|
pub(super) max_pending: usize,
|
||||||
exchange_slots: Arc<Semaphore>,
|
exchange_slots: Arc<Semaphore>,
|
||||||
|
enrollment_slots: Arc<Semaphore>,
|
||||||
|
blocking_slots: Arc<Semaphore>,
|
||||||
pub(super) pending_ttl: Duration,
|
pub(super) pending_ttl: Duration,
|
||||||
pub(super) throttle: PasswordThrottleConfig,
|
pub(super) throttle: PasswordThrottleConfig,
|
||||||
}
|
}
|
||||||
|
|
@ -104,14 +113,22 @@ fn configured_positive<T: std::str::FromStr + PartialOrd + Default>(name: &str,
|
||||||
impl Default for PasswordAuthRuntime {
|
impl Default for PasswordAuthRuntime {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let max_pending = configured_positive("PASSWORD_MAX_PENDING_EXCHANGES", 1024);
|
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 {
|
Self {
|
||||||
setup: OnceLock::new(),
|
setup: OnceLock::new(),
|
||||||
omega_authority: OnceLock::new(),
|
omega_authority: OnceLock::new(),
|
||||||
logins: DashMap::new(),
|
logins: DashMap::new(),
|
||||||
enrollments: DashMap::new(),
|
enrollments: DashMap::new(),
|
||||||
attempts: DashMap::new(),
|
attempts: DashMap::new(),
|
||||||
|
#[cfg(test)]
|
||||||
max_pending,
|
max_pending,
|
||||||
exchange_slots: Arc::new(Semaphore::new(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(
|
pending_ttl: Duration::from_secs(configured_positive(
|
||||||
"PASSWORD_PENDING_TTL_SECONDS",
|
"PASSWORD_PENDING_TTL_SECONDS",
|
||||||
180,
|
180,
|
||||||
|
|
@ -135,6 +152,32 @@ impl Default for PasswordAuthRuntime {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub(crate) fn setup(&self) -> Result<Arc<PasswordServerSetup>, PasswordRuntimeError> {
|
||||||
self.setup
|
self.setup
|
||||||
.get()
|
.get()
|
||||||
|
|
@ -183,7 +226,7 @@ impl PasswordAuthRuntime {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.enrollments
|
self.enrollments
|
||||||
.retain(|_, (_, created)| created.elapsed() < self.pending_ttl);
|
.retain(|_, pending| pending.created.elapsed() < self.pending_ttl);
|
||||||
self.attempts.retain(|_, state| {
|
self.attempts.retain(|_, state| {
|
||||||
state.in_flight > 0 || state.window_started.elapsed() < self.throttle.failure_window
|
state.in_flight > 0 || state.window_started.elapsed() < self.throttle.failure_window
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue