uniffi::setup_scaffolding!();
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use qualia_bioauth as bioauth;
use qualia_bioauth::Backend;
use qualia_dik::keystore::PlatformKeyStore;
use qualia_dik::keystore::platform::{PlatformDirectMasterKeyStore, PlatformWrappedMasterKeyStore};
use qualia_dik::{
DEVICE_ID_LEN, DeviceId, DeviceKeyStore, DevicePublicKey, DeviceSignature, DeviceSigner,
DirectMasterKeyStore, MASTER_KEY_LEN, MasterKey as DikMasterKey, WrappedMasterKey,
WrappedMasterKeyStore,
};
use qualia_transport::lan::transport::Recipient as LanRecipient;
use qualia_transport::lan::transport::{IrohLan, MAX_MESSAGE_LEN, PeerAddress};
use qualia_transport::lan::wire::{ENDPOINT_KEY_LEN, EndpointKey, Peer, PeerStatus};
use qualia_transport::lan::{LanAddress, lobby::Lobby};
use qualia2fa_core::auth;
use qualia2fa_core::flow::{self, DeviceKeys as _, Joiner, Login, SecureStore as _};
use qualia2fa_core::import::apply::{Choice as ImportChoice, Difference as ImportDifference};
use qualia2fa_core::motp::{self, MotpPin};
use qualia2fa_core::otp::{self, Algorithm, Digits, OtpSecret};
use qualia2fa_core::recovery::KdfParams;
use qualia2fa_core::sync::{
Conflict, ConflictKind, Decision, PayloadKind, SeenSyncIds, adjudicate,
};
use qualia2fa_core::transport::{
Recipient, Roster, SharePlan, SyncSession, Transport, TransportCapabilities,
};
use qualia2fa_core::uri;
use qualia2fa_core::vault::{Entry, EntryKind, Group, GroupChild, MasterKey, Vault, VaultFile};
use qualia2fa_core::yaotp::{self, YandexPin};
use qualia2fa_core::{Error, ExposeSecret as _, SecretString};
const AUTH_SESSION_ID_LEN: usize = 32;
const AUTH_CONFIRM_LEN: usize = 32;
const MAX_SECRET_CHARS: usize = 2048;
const MAX_URI_CHARS: usize = 4096;
const MAX_VAULT_FIELD_CHARS: usize = 8 * 1024;
const MAX_LABEL_CHARS: usize = 256;
const MAX_PASSPHRASE_CHARS: usize = 1024;
const MAX_QR_TEXT_CHARS: usize = 512;
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiCoreError {
pub kind: String,
pub message: String,
}
#[must_use]
pub fn core_error_kind(source: &Error) -> &'static str {
match source {
Error::Base32InvalidCharacter { .. } => "base32_invalid_character",
Error::Base32InvalidLength { .. } => "base32_invalid_length",
Error::Base32InvalidPadding => "base32_invalid_padding",
Error::SecretEmpty => "secret_empty",
Error::SecretTooShort { .. } => "secret_too_short",
Error::UnsupportedAlgorithm { .. } => "unsupported_algorithm",
Error::UnsupportedDigits { .. } => "unsupported_digits",
Error::InvalidPeriod => "invalid_period",
Error::RandomnessUnavailable => "randomness_unavailable",
Error::UriUnsupportedScheme => "uri_unsupported_scheme",
Error::UriMissingType => "uri_missing_type",
Error::UriUnsupportedType { .. } => "uri_unsupported_type",
Error::UriMissingSecret => "uri_missing_secret",
Error::UriMissingCounter => "uri_missing_counter",
Error::UriUnexpectedCounter => "uri_unexpected_counter",
Error::UriDuplicateParameter { .. } => "uri_duplicate_parameter",
Error::UriInvalidParameter { .. } => "uri_invalid_parameter",
Error::UriInvalidPercentEncoding => "uri_invalid_percent_encoding",
Error::UriIssuerMismatch { .. } => "uri_issuer_mismatch",
Error::UriAccountContainsColon { .. } => "uri_account_contains_colon",
Error::VaultTruncated => "vault_truncated",
Error::VaultBadMagic => "vault_bad_magic",
Error::VaultUnsupportedVersion { .. } => "vault_unsupported_version",
Error::VaultReservedByteNotZero { .. } => "vault_reserved_byte_not_zero",
Error::VaultUnknownRecordKind { .. } => "vault_unknown_record_kind",
Error::VaultRecordTooLong { .. } => "vault_record_too_long",
Error::VaultTooManyRecords => "vault_too_many_records",
Error::VaultTrailingBytes { .. } => "vault_trailing_bytes",
Error::VaultDuplicateEntryId => "vault_duplicate_entry_id",
Error::VaultEntryNotFound => "vault_entry_not_found",
Error::VaultDuplicateField { .. } => "vault_duplicate_field",
Error::VaultCorrupt => "vault_corrupt",
Error::VaultBadKeyCheck => "vault_bad_key_check",
Error::VaultInvalidUtf8 => "vault_invalid_utf8",
Error::VaultDecryptionFailed => "vault_decryption_failed",
Error::VaultDuplicateGroupId => "vault_duplicate_group_id",
Error::VaultGroupNotFound => "vault_group_not_found",
Error::VaultGroupUnknownEntry => "vault_group_unknown_entry",
Error::VaultTopGroupImmutable => "vault_top_group_immutable",
Error::VaultGroupTooManyChildren { .. } => "vault_group_too_many_children",
Error::VaultGroupDuplicateChild => "vault_group_duplicate_child",
Error::VaultGroupCycle => "vault_group_cycle",
Error::VaultGroupDepthExceeded { .. } => "vault_group_depth_exceeded",
Error::MasterKeyWrongLength { .. } => "master_key_wrong_length",
Error::FieldTooLong { .. } => "field_too_long",
Error::VaultIo { .. } => "vault_io",
Error::DevicePublicKeyInvalid { .. } => "device_public_key_invalid",
Error::DeviceSignatureInvalid { .. } => "device_signature_invalid",
Error::Device(qualia_dik::Error::DevicePublicKeyInvalid { .. }) => {
"device_public_key_invalid"
}
Error::Device(qualia_dik::Error::DeviceSignatureInvalid { .. }) => {
"device_signature_invalid"
}
Error::Device(qualia_dik::Error::InvalidWrappedKey { .. }) => "invalid_wrapped_key",
Error::Device(qualia_dik::Error::InvalidMasterKey { .. }) => "invalid_master_key",
Error::Device(qualia_dik::Error::KeyNotExportable { .. }) => "key_not_exportable",
Error::Device(qualia_dik::Error::HardwareUnavailable { .. }) => "hardware_unavailable",
Error::Device(qualia_dik::Error::UnsupportedPlatform { .. }) => "unsupported_platform",
Error::Device(_) => "device_error",
Error::PairingPayloadMalformed { .. } => "pairing_payload_malformed",
Error::PairingUnsupportedVersion { .. } => "pairing_unsupported_version",
Error::PairingAuthenticationFailed => "pairing_authentication_failed",
Error::RecoveryFileMalformed { .. } => "recovery_file_malformed",
Error::RecoveryParametersInvalid { .. } => "recovery_parameters_invalid",
Error::RecoveryPassphraseEmpty => "recovery_passphrase_empty",
Error::RecoveryAuthenticationFailed => "recovery_authentication_failed",
Error::RecoveryEncryptionFailed => "recovery_encryption_failed",
Error::RecoveryKdfFailed => "recovery_kdf_failed",
Error::LockFileMalformed { .. } => "lock_file_malformed",
Error::LockParametersInvalid { .. } => "lock_parameters_invalid",
Error::LockPassphraseEmpty => "lock_passphrase_empty",
Error::LockPasswordRejected => "lock_password_rejected",
Error::LockEncryptionFailed => "lock_encryption_failed",
Error::LockKdfFailed => "lock_kdf_failed",
Error::UnsupportedFormatVersion { .. } => "unsupported_format_version",
Error::AuthMessageMalformed { .. } => "auth_message_malformed",
Error::AuthProofFailed => "auth_proof_failed",
Error::DeviceKeyMissing => "device_key_missing",
Error::DeviceNotRegistered => "device_not_registered",
Error::DeviceIdentityMismatch => "device_identity_mismatch",
Error::SecureStoreWriteFailed { .. } => "secure_store_write_failed",
Error::TimestampZero => "timestamp_zero",
Error::ImportFileTooLarge { .. } => "import_file_too_large",
Error::ImportTooManyEntries { .. } => "import_too_many_entries",
Error::ImportTooDeeplyNested { .. } => "import_too_deeply_nested",
Error::ImportMalformed { .. } => "import_malformed",
Error::ImportUnsupportedVersion { .. } => "import_unsupported_version",
Error::ImportPasswordRequired => "import_password_required",
Error::ImportEncryptionUnsupported { .. } => "import_encryption_unsupported",
Error::ImportWrongPassword => "import_wrong_password",
Error::ImportCorrupted => "import_corrupted",
Error::ImportInvalidKdfParameters { .. } => "import_invalid_kdf_parameters",
Error::ExportPasswordRequired => "export_password_required",
Error::ExportPasswordEmpty => "export_password_empty",
Error::ExportQrTooLong { .. } => "export_qr_too_long",
Error::ExportTooLarge => "export_too_large",
Error::ExportParameterRefused { .. } => "export_parameter_refused",
Error::PortableBadMagic => "portable_bad_magic",
Error::PortableUnsupportedVersion { .. } => "portable_unsupported_version",
Error::PortableTruncated => "portable_truncated",
Error::PortableLengthMismatch => "portable_length_mismatch",
Error::PortableTooLarge { .. } => "portable_too_large",
Error::PortableVaultFileWithoutKey => "portable_vault_file_without_key",
Error::PortablePasswordRequired => "portable_password_required",
Error::SyncBadMagic => "sync_bad_magic",
Error::SyncUnsupportedVersion { .. } => "sync_unsupported_version",
Error::SyncEmptyPayload => "sync_empty_payload",
Error::SyncTruncated => "sync_truncated",
Error::SyncRecordCountMismatch { .. } => "sync_record_count_mismatch",
Error::SyncTooManyRecords { .. } => "sync_too_many_records",
Error::SyncCompressionFailed => "sync_compression_failed",
Error::SyncDecompressionFailed => "sync_decompression_failed",
Error::SyncPayloadTooLarge { .. } => "sync_payload_too_large",
Error::SyncUnknownRecordKind { .. } => "sync_unknown_record_kind",
Error::SyncMalformed { .. } => "sync_malformed",
Error::SyncAuthenticationFailed => "sync_authentication_failed",
Error::SyncIconRequiresByteTransport => "sync_icon_requires_byte_transport",
Error::SyncAlreadyApplied => "sync_already_applied",
Error::SyncIdLogFull { .. } => "sync_id_log_full",
Error::SyncSenderMismatch => "sync_sender_mismatch",
Error::SyncUnknownPayloadKind { .. } => "sync_unknown_payload_kind",
Error::SyncNoSessionFound => "sync_no_session_found",
Error::TransportFailed { .. } => "transport_failed",
Error::TransportUnsupported { .. } => "transport_unsupported",
Error::TransportAlreadyUsed => "transport_already_used",
Error::TransportMessageTooLarge { .. } => "transport_message_too_large",
_ => "unknown",
}
}
impl FfiError {
#[must_use]
pub fn kind(&self) -> &str {
match self {
FfiError::InputTooLong { .. } => "input_too_long",
FfiError::InputEmpty { .. } => "input_empty",
FfiError::WrongCodeKind { .. } => "wrong_code_kind",
FfiError::Core { detail } => &detail.kind,
FfiError::Qr { kind, .. } => kind,
FfiError::Lan { kind, .. } => kind,
FfiError::AuthBound { kind, .. } => kind,
FfiError::InvalidDeviceId { .. } => "invalid_device_id",
FfiError::UnknownConflict { .. } => "unknown_conflict",
}
}
}
impl From<Error> for FfiCoreError {
fn from(source: Error) -> Self {
FfiCoreError {
kind: core_error_kind(&source).to_owned(),
message: source.to_string(),
}
}
}
impl core::fmt::Display for FfiCoreError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.message)
}
}
#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum FfiError {
#[error("input too long: {kind} limit is {limit} characters, got {actual} characters")]
InputTooLong {
kind: String,
limit: u64,
actual: u64,
},
#[error("this entry computes its code with {wanted}, not as {kind}")]
WrongCodeKind {
kind: String,
wanted: String,
},
#[error("input is empty: {kind}")]
InputEmpty {
kind: String,
},
#[error("{detail}")]
Core {
detail: FfiCoreError,
},
#[error("{detail}")]
Qr {
kind: String,
detail: String,
},
#[error("{detail}")]
Lan {
kind: String,
detail: String,
},
#[error("{detail}")]
AuthBound {
kind: String,
detail: String,
},
#[error("{kind} is not 64 hexadecimal characters")]
InvalidDeviceId {
kind: String,
},
#[error("no conflict is waiting for entry {entry_id}")]
UnknownConflict {
entry_id: String,
},
}
impl From<Error> for FfiError {
fn from(source: Error) -> Self {
FfiError::Core {
detail: source.into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiAlgorithm {
Sha1,
Sha256,
Sha512,
}
impl From<FfiAlgorithm> for Algorithm {
fn from(value: FfiAlgorithm) -> Self {
match value {
FfiAlgorithm::Sha1 => Algorithm::Sha1,
FfiAlgorithm::Sha256 => Algorithm::Sha256,
FfiAlgorithm::Sha512 => Algorithm::Sha512,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiDigits {
Five,
Six,
Seven,
Eight,
Nine,
Other {
digits: u8,
},
}
impl FfiDigits {
fn to_core(self) -> Result<Digits, FfiError> {
match self {
FfiDigits::Five => Ok(Digits::FIVE),
FfiDigits::Six => Ok(Digits::SIX),
FfiDigits::Seven => Ok(Digits::SEVEN),
FfiDigits::Eight => Ok(Digits::EIGHT),
FfiDigits::Nine => Ok(Digits::new(9).expect("nine is a digit count the core accepts")),
FfiDigits::Other { digits } => Ok(Digits::new(digits)?),
}
}
}
impl From<Digits> for FfiDigits {
fn from(value: Digits) -> Self {
match value.as_byte() {
5 => FfiDigits::Five,
6 => FfiDigits::Six,
7 => FfiDigits::Seven,
8 => FfiDigits::Eight,
9 => FfiDigits::Nine,
digits => FfiDigits::Other { digits },
}
}
}
#[derive(Clone, PartialEq, Eq, uniffi::Enum)]
pub enum FfiEntryKind {
Totp {
period: u64,
},
Hotp {
counter: u64,
},
Motp {
period: u64,
pin: Option<String>,
},
Yandex {
period: u64,
pin: Option<String>,
},
Steam,
}
impl core::fmt::Debug for FfiEntryKind {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
let (name, period) = match self {
Self::Totp { period } => ("Totp", Some(*period)),
Self::Motp { period, .. } => ("Motp", Some(*period)),
Self::Yandex { period, .. } => ("Yandex", Some(*period)),
Self::Hotp { .. } => ("Hotp", None),
Self::Steam => ("Steam", None),
};
let mut debug = formatter.debug_struct(name);
if let Some(period) = period {
debug.field("period", &period);
}
if let Self::Hotp { counter } = self {
debug.field("counter", counter);
}
if matches!(
self,
Self::Motp { pin: Some(_), .. } | Self::Yandex { pin: Some(_), .. }
) {
debug.field("pin", &"<set>");
}
debug.finish_non_exhaustive()
}
}
#[derive(Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiOtpAuth {
pub kind: FfiEntryKind,
pub secret_base32: String,
pub issuer: Option<String>,
pub account: String,
pub algorithm: FfiAlgorithm,
pub digits: FfiDigits,
}
impl FfiEntryKind {
fn from_core(kind: &EntryKind) -> Self {
match kind {
EntryKind::Totp { period } => Self::Totp { period: *period },
EntryKind::Hotp { counter } => Self::Hotp { counter: *counter },
EntryKind::Motp { period, .. } => Self::Motp {
period: *period,
pin: None,
},
EntryKind::Yandex { period, .. } => Self::Yandex {
period: *period,
pin: None,
},
EntryKind::Steam => Self::Steam,
}
}
fn into_core(self, existing: Option<EntryKind>) -> Result<EntryKind, FfiError> {
Ok(match self {
Self::Totp { period } => EntryKind::Totp { period },
Self::Hotp { counter } => EntryKind::Hotp { counter },
Self::Steam => EntryKind::Steam,
Self::Motp { period, pin } => EntryKind::Motp {
period,
pin: motp_pin(pin, existing)?,
},
Self::Yandex { period, pin } => EntryKind::Yandex {
period,
pin: yandex_pin(pin, existing)?,
},
})
}
}
fn motp_pin(
supplied: Option<String>,
existing: Option<EntryKind>,
) -> Result<Option<MotpPin>, FfiError> {
match supplied {
Some(pin) => {
check_len("pin", &pin, MAX_VAULT_FIELD_CHARS)?;
if pin.is_empty() {
return Err(FfiError::InputEmpty {
kind: "pin".to_owned(),
});
}
Ok(Some(MotpPin::new(pin)))
}
None => Ok(match existing {
Some(EntryKind::Motp { pin, .. }) => pin,
_ => None,
}),
}
}
fn yandex_pin(
supplied: Option<String>,
existing: Option<EntryKind>,
) -> Result<Option<YandexPin>, FfiError> {
match supplied {
Some(pin) => {
check_len("pin", &pin, MAX_VAULT_FIELD_CHARS)?;
if pin.is_empty() {
return Err(FfiError::InputEmpty {
kind: "pin".to_owned(),
});
}
Ok(Some(YandexPin::new(pin)))
}
None => Ok(match existing {
Some(EntryKind::Yandex { pin, .. }) => pin,
_ => None,
}),
}
}
impl core::fmt::Debug for FfiOtpAuth {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("FfiOtpAuth")
.field("kind", &self.kind)
.field("secret_base32", &"<redacted>")
.field("issuer", &self.issuer)
.field("account", &self.account)
.field("algorithm", &self.algorithm)
.field("digits", &self.digits)
.finish_non_exhaustive()
}
}
fn check_len(kind: &str, value: &str, limit: usize) -> Result<(), FfiError> {
let actual = value.chars().count();
if actual > limit {
return Err(FfiError::InputTooLong {
kind: kind.to_owned(),
limit: limit as u64,
actual: actual as u64,
});
}
Ok(())
}
#[uniffi::export]
pub fn parse_otpauth_uri(uri: String) -> Result<FfiOtpAuth, FfiError> {
check_len("uri", &uri, MAX_URI_CHARS)?;
let parsed = uri::parse(&uri)?;
Ok(FfiOtpAuth {
kind: FfiEntryKind::from_core(&parsed.kind),
secret_base32: parsed.secret.to_base32(),
issuer: parsed.issuer,
account: parsed.account,
algorithm: match parsed.algorithm {
Algorithm::Sha1 => FfiAlgorithm::Sha1,
Algorithm::Sha256 => FfiAlgorithm::Sha256,
Algorithm::Sha512 => FfiAlgorithm::Sha512,
},
digits: parsed.digits.into(),
})
}
#[uniffi::export]
pub fn serialize_otpauth_uri(auth: FfiOtpAuth) -> Result<String, FfiError> {
check_len("secret", &auth.secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&auth.secret_base32)?;
let core_auth = uri::OtpAuth {
kind: auth.kind.into_core(None)?,
secret,
issuer: auth.issuer,
account: auth.account,
algorithm: auth.algorithm.into(),
digits: auth.digits.to_core()?,
};
Ok(uri::serialize(&core_auth)?)
}
#[uniffi::export]
pub fn totp_code(
secret_base32: String,
unix_time: u64,
period: u64,
algorithm: FfiAlgorithm,
digits: FfiDigits,
) -> Result<String, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(otp::totp_code(
&secret,
unix_time,
period,
algorithm.into(),
digits.to_core()?,
)?)
}
#[uniffi::export]
pub fn hotp_code(
secret_base32: String,
counter: u64,
algorithm: FfiAlgorithm,
digits: FfiDigits,
) -> Result<String, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(otp::hotp_code(
&secret,
counter,
algorithm.into(),
digits.to_core()?,
))
}
#[uniffi::export]
pub fn steam_guard_code(
secret_base32: String,
unix_time: u64,
period: u64,
algorithm: FfiAlgorithm,
) -> Result<String, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(otp::steam_guard_code(
&secret,
unix_time,
period,
algorithm.into(),
)?)
}
#[uniffi::export]
pub fn verify_code_with_drift(
secret_base32: String,
expected_counter: u64,
window: u64,
algorithm: FfiAlgorithm,
digits: FfiDigits,
provided_code: String,
) -> Result<Option<i64>, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(otp::verify_with_drift(
&secret,
expected_counter,
window,
algorithm.into(),
digits.to_core()?,
&provided_code,
))
}
#[uniffi::export]
#[must_use]
pub fn codes_match(expected: String, provided: String) -> bool {
otp::verify_code(&expected, &provided)
}
#[uniffi::export]
#[must_use]
pub fn recommended_min_secret_len(algorithm: FfiAlgorithm) -> u64 {
otp::recommended_min_secret_len(algorithm.into()) as u64
}
#[uniffi::export]
pub fn is_secret_shorter_than_recommended(
secret_base32: String,
algorithm: FfiAlgorithm,
) -> Result<bool, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(secret.is_shorter_than_recommended(algorithm.into()))
}
#[uniffi::export]
pub fn secret_byte_len(secret_base32: String) -> Result<u64, FfiError> {
check_len("secret", &secret_base32, MAX_SECRET_CHARS)?;
let secret = OtpSecret::from_base32(&secret_base32)?;
Ok(secret.len() as u64)
}
#[uniffi::export]
pub fn generate_secret_base32() -> Result<String, FfiError> {
let mut bytes = [0_u8; 32];
qualia2fa_core::getrandom::fill(&mut bytes).map_err(|_| FfiError::Core {
detail: Error::RandomnessUnavailable.into(),
})?;
let secret = OtpSecret::from_bytes(bytes.to_vec())?;
Ok(secret.to_base32())
}
#[uniffi::export]
#[must_use]
pub fn core_version() -> String {
qualia2fa_core::VERSION.to_owned()
}
#[derive(Clone, uniffi::Record)]
pub struct FfiQrImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
}
impl core::fmt::Debug for FfiQrImage {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("FfiQrImage")
.field("width", &self.width)
.field("height", &self.height)
.finish_non_exhaustive()
}
}
#[derive(Debug, Clone, Copy, uniffi::Enum)]
pub enum FfiEcLevel {
Low,
Medium,
Quartile,
High,
}
impl From<FfiEcLevel> for qualia_transport::qr::EcLevel {
fn from(value: FfiEcLevel) -> Self {
match value {
FfiEcLevel::Low => Self::Low,
FfiEcLevel::Medium => Self::Medium,
FfiEcLevel::Quartile => Self::Quartile,
FfiEcLevel::High => Self::High,
}
}
}
#[uniffi::export]
pub fn qr_encode(
text: String,
ec_level: FfiEcLevel,
max_version: u8,
scale: u16,
quiet_zone: u16,
) -> Result<FfiQrImage, FfiError> {
let profile =
qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
let symbol = profile.encode_payload(text.as_bytes()).map_err(qr_error)?;
let image = symbol.render(scale, quiet_zone).map_err(qr_error)?;
Ok(FfiQrImage {
width: image.width(),
height: image.height(),
pixels: image.pixels().to_vec(),
})
}
#[uniffi::export]
pub fn qr_decode(pixels: Vec<u8>, width: u32, height: u32) -> Result<String, FfiError> {
let image =
qualia_transport::image::GrayImage::from_luma8(width, height, pixels).map_err(qr_error)?;
let payload = qualia_transport::qr::scan::decode_payload(&image).map_err(qr_error)?;
String::from_utf8(payload).map_err(|_| FfiError::Qr {
kind: "payload_not_utf8".to_owned(),
detail: "the code carried bytes that are not UTF-8 text".to_owned(),
})
}
#[uniffi::export]
pub fn qr_decode_text(pixels: Vec<u8>, width: u32, height: u32) -> Result<String, FfiError> {
let image =
qualia_transport::image::GrayImage::from_luma8(width, height, pixels).map_err(qr_error)?;
qualia_transport::qr::scan::decode_text(&image).map_err(qr_error)
}
#[uniffi::export]
pub fn qr_unwrap_text(text: String) -> Result<String, FfiError> {
let mut reassembler = qualia_transport::frame::Reassembler::from_first_frame();
let payload = reassembler
.push(text.trim())
.map_err(qr_error)?
.ok_or_else(|| FfiError::Qr {
kind: "incomplete_frames".to_owned(),
detail: "the code is one of several, so it carries only part of the payload".to_owned(),
})?;
String::from_utf8(payload).map_err(|_| FfiError::Qr {
kind: "payload_not_utf8".to_owned(),
detail: "the code carried bytes that are not UTF-8 text".to_owned(),
})
}
#[uniffi::export]
pub fn qr_frame_capacity(ec_level: FfiEcLevel, max_version: u8) -> Result<u32, FfiError> {
let profile =
qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
Ok(profile.frame_payload_capacity() as u32)
}
#[derive(uniffi::Record)]
pub struct FfiQrFrame {
pub index: u32,
pub count: u32,
pub image: FfiQrImage,
}
#[uniffi::export]
pub fn qr_encode_frames(
text: String,
ec_level: FfiEcLevel,
max_version: u8,
max_frames: u32,
scale: u16,
quiet_zone: u16,
) -> Result<Vec<FfiQrFrame>, FfiError> {
let profile =
qualia_transport::qr::QrProfile::new(ec_level.into(), max_version).map_err(qr_error)?;
let symbols = profile
.encode_payload_frames(text.as_bytes(), max_frames)
.map_err(qr_error)?;
let count = symbols.len() as u32;
symbols
.into_iter()
.enumerate()
.map(|(position, symbol)| {
let image = symbol.render(scale, quiet_zone).map_err(qr_error)?;
Ok(FfiQrFrame {
index: position as u32 + 1,
count,
image: FfiQrImage {
width: image.width(),
height: image.height(),
pixels: image.pixels().to_vec(),
},
})
})
.collect()
}
#[derive(uniffi::Record)]
pub struct FfiFrameStep {
pub index: u32,
pub count: u32,
pub payload: Option<Vec<u8>>,
}
#[derive(uniffi::Object)]
pub struct FfiFrameCollector {
inner: Mutex<qualia_transport::frame::Reassembler>,
}
#[uniffi::export]
impl FfiFrameCollector {
#[uniffi::constructor]
pub fn new(capacity: u32) -> Arc<Self> {
Arc::new(Self {
inner: Mutex::new(qualia_transport::frame::Reassembler::new(capacity as usize)),
})
}
pub fn push(&self, text: String) -> Result<FfiFrameStep, FfiError> {
let frame = qualia_transport::frame::decode_frame(text.trim()).map_err(qr_error)?;
let mut inner = self.lock();
let payload = inner.push(text.trim()).map_err(qr_error)?;
Ok(FfiFrameStep {
index: frame.index,
count: frame.total,
payload,
})
}
pub fn received(&self) -> u32 {
self.lock().received() as u32
}
pub fn expected(&self) -> Option<u32> {
self.lock().expected_frames()
}
pub fn missing(&self) -> Vec<u32> {
self.lock().missing()
}
}
impl FfiFrameCollector {
fn lock(&self) -> std::sync::MutexGuard<'_, qualia_transport::frame::Reassembler> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn qr_error(source: qualia_transport::Error) -> FfiError {
use qualia_transport::Error as QrError;
let kind = match &source {
QrError::PayloadTooLarge { .. } => "payload_too_large",
QrError::TooManyFrames { .. } => "too_many_frames",
QrError::NotOurQrText => "not_our_qr_text",
QrError::MalformedFrameHeader => "malformed_frame_header",
QrError::MultiFrameUnsupported { .. } => "multi_frame_unsupported",
QrError::UnknownEcLevel { .. } => "unknown_ec_level",
QrError::VersionOutOfRange { .. } => "version_out_of_range",
QrError::BadRenderGeometry { .. } => "bad_render_geometry",
QrError::BadImageGeometry { .. } => "bad_image_geometry",
QrError::NoQrFound => "no_qr_found",
QrError::QrDecoderPanicked => "qr_decoder_panicked",
QrError::QrNotText => "qr_not_text",
_ => "qr_error",
};
FfiError::Qr {
kind: kind.to_owned(),
detail: source.to_string(),
}
}
#[uniffi::export(with_foreign)]
pub trait FfiDeviceKeys: Send + Sync {
fn get_or_create(&self, label: String) -> Result<Vec<u8>, FfiError>;
fn sign(&self, label: String, message: Vec<u8>) -> Result<Vec<u8>, FfiError>;
}
#[uniffi::export(with_foreign)]
pub trait FfiSecureStore: Send + Sync {
fn load_master_key(&self) -> Result<Option<Vec<u8>>, FfiError>;
fn store_master_key(&self, key: Vec<u8>) -> Result<(), FfiError>;
fn delete_master_key(&self) -> Result<(), FfiError>;
fn is_hardware_backed(&self) -> bool;
}
struct ForeignDeviceKeys(Arc<dyn FfiDeviceKeys>);
impl qualia2fa_core::flow::DeviceKeys for ForeignDeviceKeys {
fn get_or_create(&mut self, label: &str) -> Result<DevicePublicKey, Error> {
let bytes = self
.0
.get_or_create(label.to_owned())
.map_err(ffi_to_core_error)?;
Ok(DevicePublicKey::from_sec1_bytes(&bytes)?)
}
fn sign(&mut self, label: &str, message: &[u8]) -> Result<DeviceSignature, Error> {
let bytes = self
.0
.sign(label.to_owned(), message.to_vec())
.map_err(ffi_to_core_error)?;
Ok(DeviceSignature::from_bytes(&bytes)?)
}
}
struct ForeignSecureStore(Arc<dyn FfiSecureStore>);
impl qualia2fa_core::flow::SecureStore for ForeignSecureStore {
fn load_master_key(&self) -> Result<Option<MasterKey>, Error> {
let bytes = self.0.load_master_key().map_err(ffi_to_core_error)?;
bytes
.map(|bytes| flow::master_key_from_bytes(bytes.into()))
.transpose()
}
fn store_master_key(&mut self, key: &MasterKey) -> Result<(), Error> {
self.0
.store_master_key(flow::master_key_to_bytes(key).expose_secret().to_vec())
.map_err(ffi_to_core_error)
}
fn delete_master_key(&mut self) -> Result<(), Error> {
self.0.delete_master_key().map_err(ffi_to_core_error)
}
fn is_hardware_backed(&self) -> bool {
self.0.is_hardware_backed()
}
}
fn ffi_to_core_error(source: FfiError) -> Error {
match source {
FfiError::Core { detail } => kind_to_core_error(&detail.kind),
FfiError::InputTooLong { .. }
| FfiError::InputEmpty { .. }
| FfiError::WrongCodeKind { .. }
| FfiError::Qr { .. }
| FfiError::Lan { .. }
| FfiError::InvalidDeviceId { .. }
| FfiError::AuthBound { .. }
| FfiError::UnknownConflict { .. } => Error::DeviceKeyMissing,
}
}
fn kind_to_core_error(kind: &str) -> Error {
match kind {
"device_key_missing" => Error::DeviceKeyMissing,
"device_signature_invalid" => Error::Device(qualia_dik::Error::DeviceSignatureInvalid {
reason: "signing failed on the client side",
}),
"secure_store_write_failed" => Error::SecureStoreWriteFailed {
reason: "the client key store refused the write",
},
_ => Error::DeviceKeyMissing,
}
}
#[derive(uniffi::Object)]
pub struct FfiPlatformKeys {
store: PlatformKeyStore,
label: String,
}
#[uniffi::export]
impl FfiPlatformKeys {
#[uniffi::constructor]
pub fn open(label: String) -> Result<Arc<Self>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let store = open_platform_key_store()?;
store.open_or_create(&label).map_err(platform_error)?;
Ok(Arc::new(Self { store, label }))
}
pub fn device_public_key(&self) -> Result<Vec<u8>, FfiError> {
let signer = self.signer()?;
Ok(signer.public_key().as_sec1_bytes().to_vec())
}
pub fn device_id(&self) -> Result<String, FfiError> {
Ok(self.signer()?.public_key().device_id().to_hex())
}
pub fn sign(&self, message: Vec<u8>) -> Result<Vec<u8>, FfiError> {
let signer = self.signer()?;
let signature = signer.sign(&message).map_err(platform_error)?;
Ok(signature.as_bytes().to_vec())
}
#[must_use]
pub fn backend(&self) -> String {
self.store.backend().as_str().to_owned()
}
#[must_use]
pub fn is_hardware_backed(&self) -> bool {
self.store.backend().is_hardware_backed()
}
}
impl FfiPlatformKeys {
fn signer(&self) -> Result<Box<dyn DeviceSigner>, FfiError> {
DeviceKeyStore::open(&self.store, &self.label).map_err(platform_error)
}
}
fn open_platform_key_store() -> Result<PlatformKeyStore, FfiError> {
#[cfg(any(target_os = "windows", target_os = "android"))]
{
PlatformKeyStore::open_best_available().map_err(platform_error)
}
#[cfg(not(any(target_os = "windows", target_os = "android")))]
{
Ok(PlatformKeyStore::open_best_available())
}
}
fn platform_error(source: qualia_dik::Error) -> FfiError {
Error::from(source).into()
}
#[derive(uniffi::Object)]
pub struct FfiDirectMasterKeyStore {
store: Box<dyn DirectMasterKeyStore + Send + Sync>,
}
#[uniffi::export]
impl FfiDirectMasterKeyStore {
#[uniffi::constructor]
pub fn open() -> Result<Arc<Self>, FfiError> {
let store = open_platform_direct_master_key_store()?;
Ok(Self::with_store(Box::new(store)))
}
#[must_use]
pub fn backend(&self) -> String {
self.store.backend().as_str().to_owned()
}
#[must_use]
pub fn is_hardware_backed(&self) -> bool {
self.store.backend().is_hardware_backed()
}
pub fn store(&self, label: String, key: Vec<u8>) -> Result<(), FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let key = dik_master_key(key)?;
DirectMasterKeyStore::store(self.store.as_ref(), &label, &key).map_err(platform_error)
}
pub fn load(&self, label: String) -> Result<Vec<u8>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let key =
DirectMasterKeyStore::load(self.store.as_ref(), &label).map_err(platform_error)?;
Ok(key.as_bytes().to_vec())
}
pub fn delete(&self, label: String) -> Result<(), FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
DirectMasterKeyStore::delete(self.store.as_ref(), &label).map_err(platform_error)
}
}
#[derive(uniffi::Object)]
pub struct FfiWrappedMasterKeyStore {
store: Box<dyn WrappedMasterKeyStore + Send + Sync>,
}
#[uniffi::export]
impl FfiWrappedMasterKeyStore {
#[uniffi::constructor]
pub fn open() -> Result<Arc<Self>, FfiError> {
let store = open_platform_wrapped_master_key_store()?;
Ok(Self::with_store(Box::new(store)))
}
#[must_use]
pub fn backend(&self) -> String {
self.store.backend().as_str().to_owned()
}
#[must_use]
pub fn is_hardware_backed(&self) -> bool {
self.store.backend().is_hardware_backed()
}
pub fn store(&self, label: String, key: Vec<u8>) -> Result<Vec<u8>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let key = dik_master_key(key)?;
let wrapped = WrappedMasterKeyStore::store(self.store.as_ref(), &label, &key)
.map_err(platform_error)?;
Ok(wrapped.into_bytes())
}
pub fn load(&self, label: String, wrapped: Vec<u8>) -> Result<Vec<u8>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let wrapped = WrappedMasterKey::from_bytes(wrapped);
let key = WrappedMasterKeyStore::load(self.store.as_ref(), &label, &wrapped)
.map_err(platform_error)?;
Ok(key.as_bytes().to_vec())
}
pub fn delete(&self, label: String) -> Result<(), FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
WrappedMasterKeyStore::delete(self.store.as_ref(), &label).map_err(platform_error)
}
}
impl FfiDirectMasterKeyStore {
fn with_store(store: Box<dyn DirectMasterKeyStore + Send + Sync>) -> Arc<Self> {
Arc::new(Self { store })
}
}
impl FfiWrappedMasterKeyStore {
fn with_store(store: Box<dyn WrappedMasterKeyStore + Send + Sync>) -> Arc<Self> {
Arc::new(Self { store })
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum FfiAuthAvailability {
Available {
backend: String,
hardware_backed: bool,
},
Unavailable {
reason: String,
},
Unsupported {
reason: String,
},
}
#[derive(uniffi::Object)]
pub struct FfiAuthBoundKeyStore {
store: Box<dyn bioauth::AuthBoundKeyStore>,
}
#[uniffi::export]
impl FfiAuthBoundKeyStore {
#[uniffi::constructor]
pub fn with_host(
sealer: Arc<dyn FfiPlatformSealer>,
backend: String,
) -> Result<Arc<Self>, FfiError> {
let backend = backend_from_name(&backend)?;
Ok(Arc::new(Self {
store: Box::new(bioauth::DelegatingKeyStore::new(Arc::new(HostSealer {
host: sealer,
backend,
}))),
}))
}
#[uniffi::constructor]
pub fn open() -> Arc<Self> {
Arc::new(Self {
store: bioauth::platform::open_best_available(),
})
}
#[must_use]
pub fn availability(&self) -> FfiAuthAvailability {
match self.store.availability() {
bioauth::Availability::Available {
backend,
hardware_backed,
} => FfiAuthAvailability::Available {
backend: backend.name().to_owned(),
hardware_backed,
},
bioauth::Availability::Unavailable { reason } => FfiAuthAvailability::Unavailable {
reason: reason.to_owned(),
},
bioauth::Availability::Unsupported { reason } => FfiAuthAvailability::Unsupported {
reason: reason.to_owned(),
},
}
}
pub fn create(&self) -> Result<(), FfiError> {
self.store.create().map_err(auth_bound_error)
}
pub fn wrap(&self, master_key: Vec<u8>) -> Result<Vec<u8>, FfiError> {
let master = bioauth::MasterKey::from_bytes(&master_key).map_err(auth_bound_error)?;
self.store.wrap(&master).map_err(auth_bound_error)
}
pub fn unwrap(&self, blob: Vec<u8>) -> Result<Vec<u8>, FfiError> {
let master = self.store.unwrap(&blob).map_err(auth_bound_error)?;
Ok(master.expose_secret().to_vec())
}
pub fn delete(&self) -> Result<(), FfiError> {
self.store.delete().map_err(auth_bound_error)
}
pub fn delete_blob(&self, blob: Vec<u8>) -> Result<(), FfiError> {
self.store.delete_blob(&blob).map_err(auth_bound_error)
}
}
impl FfiAuthBoundKeyStore {
#[cfg(test)]
fn with_store(store: Box<dyn bioauth::AuthBoundKeyStore>) -> Arc<Self> {
Arc::new(Self { store })
}
}
#[uniffi::export(with_foreign)]
pub trait FfiPlatformSealer: Send + Sync {
fn backend(&self) -> String;
fn availability(&self) -> FfiAuthAvailability;
fn seal(&self, plaintext: Vec<u8>) -> Result<Vec<u8>, FfiError>;
fn unseal(&self, envelope: Vec<u8>) -> Result<Vec<u8>, FfiError>;
fn delete(&self) -> Result<(), FfiError>;
}
impl core::fmt::Debug for HostSealer {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("HostSealer")
}
}
struct HostSealer {
host: Arc<dyn FfiPlatformSealer>,
backend: Backend,
}
fn host_error(error: FfiError) -> bioauth::Error {
match error.kind() {
"bioauth_authentication_failed" => bioauth::Error::AuthenticationFailed,
"bioauth_wrapped_payload_invalid" => bioauth::Error::WrappedPayloadInvalid {
reason: "the host could not authenticate the envelope",
},
"bioauth_unavailable" => bioauth::Error::Unavailable {
reason: "the platform cannot authenticate right now",
},
_ => bioauth::Error::Platform {
operation: "host platform sealer",
code: 0,
},
}
}
impl bioauth::PlatformSealer for HostSealer {
fn backend(&self) -> Backend {
self.backend
}
fn availability(&self) -> bioauth::Availability {
match self.host.availability() {
FfiAuthAvailability::Available {
backend: _,
hardware_backed,
} => bioauth::Availability::Available {
backend: self.backend,
hardware_backed,
},
FfiAuthAvailability::Unavailable { reason } => bioauth::Availability::Unavailable {
reason: leak_reason(&reason),
},
FfiAuthAvailability::Unsupported { reason } => bioauth::Availability::Unsupported {
reason: leak_reason(&reason),
},
}
}
fn seal(&self, plaintext: &[u8]) -> bioauth::Result<Vec<u8>> {
self.host
.seal(plaintext.to_vec())
.map_err(host_error)
.and_then(|sealed| {
if sealed.is_empty() {
Err(bioauth::Error::PayloadEmpty)
} else {
Ok(sealed)
}
})
}
fn unseal(&self, envelope: &[u8]) -> bioauth::Result<Vec<u8>> {
self.host.unseal(envelope.to_vec()).map_err(host_error)
}
fn delete(&self) -> bioauth::Result<()> {
self.host.delete().map_err(host_error)
}
}
fn leak_reason(reason: &str) -> &'static str {
if reason.is_empty() {
return "the platform reported no reason";
}
Box::leak(reason.to_owned().into_boxed_str())
}
fn backend_from_name(name: &str) -> Result<Backend, FfiError> {
match name {
"android-keystore" => Ok(Backend::AndroidKeystore),
"apple-secure-enclave" => Ok(Backend::AppleSecureEnclave),
other => Err(FfiError::AuthBound {
kind: "bioauth_unknown_backend".to_owned(),
detail: format!("{other} is not a backend a host may supply"),
}),
}
}
fn auth_bound_error(error: bioauth::Error) -> FfiError {
use bioauth::Error as E;
let kind = match &error {
E::Malformed { .. } => "bioauth_malformed",
E::UnsupportedVersion { .. } => "bioauth_unsupported_version",
E::UnknownBackend { .. } => "bioauth_unknown_backend",
E::WrongBackend { .. } => "bioauth_wrong_backend",
E::PayloadTooLong { .. } => "bioauth_payload_too_long",
E::PayloadEmpty => "bioauth_payload_empty",
E::MasterKeyWrongLength { .. } => "bioauth_master_key_wrong_length",
E::Unavailable { .. } => "bioauth_unavailable",
E::AuthenticationFailed => "bioauth_authentication_failed",
E::WrappedPayloadInvalid { .. } => "bioauth_wrapped_payload_invalid",
E::Platform { .. } => "bioauth_platform",
E::RandomnessUnavailable => "bioauth_randomness_unavailable",
E::MockAuthenticationFailed => "bioauth_mock_authentication_failed",
_ => "bioauth_unknown",
};
FfiError::AuthBound {
kind: kind.to_owned(),
detail: error.to_string(),
}
}
#[uniffi::export]
#[must_use]
pub fn lock_wrap_len() -> u64 {
qualia2fa_core::LOCK_WRAP_LEN as u64
}
#[uniffi::export]
pub fn wrap_master_key_with_password(
master_key: Vec<u8>,
password: String,
) -> Result<Vec<u8>, FfiError> {
check_len("password", &password, MAX_PASSPHRASE_CHARS)?;
let master = flow::master_key_from_bytes(master_key.into())?;
let passphrase = SecretString::from(password);
qualia2fa_core::lock::wrap(&master, &passphrase, &KdfParams::recommended()).map_err(Into::into)
}
#[uniffi::export]
pub fn unwrap_master_key_with_password(
wrapped: Vec<u8>,
password: String,
) -> Result<Vec<u8>, FfiError> {
check_len("password", &password, MAX_PASSPHRASE_CHARS)?;
let passphrase = SecretString::from(password);
let master = qualia2fa_core::lock::unwrap(&wrapped, &passphrase)?;
Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
}
#[uniffi::export]
pub fn lock_marker(password: bool, biometric: bool, migrated_at: u64) -> Result<Vec<u8>, FfiError> {
Ok(qualia2fa_core::LockMarker::new(password, biometric, migrated_at)?.encode())
}
#[uniffi::export]
pub fn lock_pending(started_at: u64) -> Result<Vec<u8>, FfiError> {
Ok(qualia2fa_core::LockPending::new(started_at)?.encode())
}
#[uniffi::export]
#[must_use]
pub fn is_lock_marker(bytes: Vec<u8>) -> bool {
qualia2fa_core::LockMarker::parse(&bytes).is_ok()
}
#[uniffi::export]
#[must_use]
pub fn is_lock_pending(bytes: Vec<u8>) -> bool {
qualia2fa_core::LockPending::parse(&bytes).is_ok()
}
#[uniffi::export]
#[must_use]
pub fn is_lock_password_wrap(bytes: Vec<u8>) -> bool {
qualia2fa_core::lock::WrapHeader::parse(&bytes).is_ok()
}
fn open_platform_direct_master_key_store() -> Result<PlatformDirectMasterKeyStore, FfiError> {
#[cfg(any(target_os = "windows", target_os = "android"))]
{
PlatformDirectMasterKeyStore::open_best_available().map_err(platform_error)
}
#[cfg(not(any(target_os = "windows", target_os = "android")))]
{
Ok(PlatformDirectMasterKeyStore::open_best_available())
}
}
fn open_platform_wrapped_master_key_store() -> Result<PlatformWrappedMasterKeyStore, FfiError> {
#[cfg(any(target_os = "windows", target_os = "android"))]
{
PlatformWrappedMasterKeyStore::open_best_available().map_err(platform_error)
}
#[cfg(not(any(target_os = "windows", target_os = "android")))]
{
Ok(PlatformWrappedMasterKeyStore::open_best_available())
}
}
fn dik_master_key(bytes: Vec<u8>) -> Result<DikMasterKey, FfiError> {
<[u8; MASTER_KEY_LEN]>::try_from(bytes)
.map(DikMasterKey::from_bytes)
.map_err(|bytes: Vec<u8>| {
Error::MasterKeyWrongLength {
expected: MASTER_KEY_LEN,
actual: bytes.len(),
}
.into()
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiIconMime {
Png,
Jpeg,
Svg,
}
impl From<FfiIconMime> for qualia2fa_core::vault::IconMime {
fn from(value: FfiIconMime) -> Self {
match value {
FfiIconMime::Png => Self::Png,
FfiIconMime::Jpeg => Self::Jpeg,
FfiIconMime::Svg => Self::Svg,
}
}
}
impl From<qualia2fa_core::vault::IconMime> for FfiIconMime {
fn from(value: qualia2fa_core::vault::IconMime) -> Self {
match value {
qualia2fa_core::vault::IconMime::Png => Self::Png,
qualia2fa_core::vault::IconMime::Jpeg => Self::Jpeg,
qualia2fa_core::vault::IconMime::Svg => Self::Svg,
}
}
}
#[derive(Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiEntryIcon {
pub mime: FfiIconMime,
pub bytes: Vec<u8>,
}
impl core::fmt::Debug for FfiEntryIcon {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("FfiEntryIcon")
.field("mime", &self.mime)
.field("bytes", &self.bytes.len())
.finish()
}
}
impl FfiEntryIcon {
fn from_core(icon: &qualia2fa_core::vault::EntryIcon) -> Self {
Self {
mime: icon.mime.into(),
bytes: icon.bytes.clone(),
}
}
fn to_core(&self) -> Result<qualia2fa_core::vault::EntryIcon, FfiError> {
Ok(qualia2fa_core::vault::EntryIcon::new(
self.mime.into(),
self.bytes.clone(),
)?)
}
}
#[derive(Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiVaultEntry {
pub id: String,
pub name: String,
pub issuer: Option<String>,
pub account: String,
pub secret_base32: String,
pub algorithm: FfiAlgorithm,
pub digits: FfiDigits,
pub kind: FfiEntryKind,
pub pin_set: bool,
pub note: String,
pub created_at: u64,
pub updated_at: u64,
pub icon: Option<FfiEntryIcon>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum FfiGroupChild {
Entry {
id: String,
},
Group {
id: String,
},
}
impl FfiGroupChild {
fn from_core(child: &GroupChild) -> Self {
match child {
GroupChild::Entry(id) => Self::Entry { id: id.to_string() },
GroupChild::Group(id) => Self::Group {
id: hex_id(id.as_bytes()),
},
}
}
fn to_core(&self) -> Result<GroupChild, FfiError> {
Ok(match self {
Self::Entry { id } => GroupChild::Entry(parse_entry_id(id)?),
Self::Group { id } => GroupChild::Group(parse_group_id(id)?),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiGroup {
pub id: String,
pub name: String,
pub top: bool,
pub children: Vec<FfiGroupChild>,
pub entry_ids: Vec<String>,
}
impl FfiGroup {
fn from_core(id: &qualia2fa_core::vault::GroupId, group: &Group) -> Self {
Self {
id: hex_id(id.as_bytes()),
name: group.name.clone(),
top: id.is_top(),
children: group
.children
.iter()
.map(FfiGroupChild::from_core)
.collect(),
entry_ids: group.entry_ids().iter().map(ToString::to_string).collect(),
}
}
}
impl core::fmt::Debug for FfiVaultEntry {
fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
formatter
.debug_struct("FfiVaultEntry")
.field("id", &self.id)
.field("name", &self.name)
.field("issuer", &self.issuer)
.field("account", &self.account)
.field("secret_base32", &"<redacted>")
.field("algorithm", &self.algorithm)
.field("digits", &self.digits)
.field("kind", &self.kind)
.field("pin_set", &self.pin_set)
.field("note", &self.note)
.field("created_at", &self.created_at)
.field("updated_at", &self.updated_at)
.field("icon", &self.icon)
.finish_non_exhaustive()
}
}
impl FfiVaultEntry {
fn from_core(id: &qualia2fa_core::vault::EntryId, entry: &Entry) -> Self {
Self {
id: id.to_string(),
name: entry.name.clone(),
issuer: entry.issuer.clone(),
account: entry.account.clone(),
secret_base32: entry.secret.to_base32(),
algorithm: match entry.algorithm {
Algorithm::Sha1 => FfiAlgorithm::Sha1,
Algorithm::Sha256 => FfiAlgorithm::Sha256,
Algorithm::Sha512 => FfiAlgorithm::Sha512,
},
digits: entry.digits.into(),
kind: FfiEntryKind::from_core(&entry.kind),
pin_set: entry.kind.motp_pin().is_some() || entry.kind.yandex_pin().is_some(),
note: entry.note.clone(),
created_at: entry.created_at,
updated_at: entry.updated_at,
icon: entry.icon.as_ref().map(FfiEntryIcon::from_core),
}
}
fn to_core(&self, now: u64, existing: Option<EntryKind>) -> Result<Entry, FfiError> {
check_len("secret", &self.secret_base32, MAX_SECRET_CHARS)?;
check_len("name", &self.name, MAX_VAULT_FIELD_CHARS)?;
check_len("account", &self.account, MAX_VAULT_FIELD_CHARS)?;
check_len("note", &self.note, MAX_VAULT_FIELD_CHARS)?;
if let Some(issuer) = &self.issuer {
check_len("issuer", issuer, MAX_VAULT_FIELD_CHARS)?;
}
let secret = OtpSecret::from_base32(&self.secret_base32)?;
let icon = match &self.icon {
Some(icon) => Some(icon.to_core()?),
None => None,
};
Ok(Entry::new(
self.name.clone(),
self.issuer.clone(),
self.account.clone(),
secret,
self.algorithm.into(),
self.digits.to_core()?,
self.kind.clone().into_core(existing)?,
self.note.clone(),
now,
now,
)?
.with_icon(icon))
}
fn parse_id(&self) -> Result<qualia2fa_core::vault::EntryId, FfiError> {
parse_entry_id(&self.id)
}
}
fn parse_entry_id(text: &str) -> Result<qualia2fa_core::vault::EntryId, FfiError> {
check_len("entry_id", text, 32)?;
let bytes = text.as_bytes();
if bytes.len() != 32 {
return Err(FfiError::Core {
detail: Error::UriInvalidParameter {
name: "entry_id".to_owned(),
reason: "must be 32 hexadecimal characters".to_owned(),
}
.into(),
});
}
let mut id = [0_u8; 16];
for (index, slot) in id.iter_mut().enumerate() {
let hi = hex_digit(bytes[index * 2])?;
let lo = hex_digit(bytes[index * 2 + 1])?;
*slot = (hi << 4) | lo;
}
Ok(qualia2fa_core::vault::EntryId::from_bytes(id))
}
fn parse_group_id(text: &str) -> Result<qualia2fa_core::vault::GroupId, FfiError> {
check_len("group_id", text, 32)?;
let bytes = text.as_bytes();
if bytes.len() != 32 {
return Err(FfiError::Core {
detail: Error::UriInvalidParameter {
name: "group_id".to_owned(),
reason: "must be 32 hexadecimal characters".to_owned(),
}
.into(),
});
}
let mut id = [0_u8; 16];
for (index, slot) in id.iter_mut().enumerate() {
let hi = hex_digit(bytes[index * 2])?;
let lo = hex_digit(bytes[index * 2 + 1])?;
*slot = (hi << 4) | lo;
}
Ok(qualia2fa_core::vault::GroupId::from_bytes(id))
}
fn hex_digit(byte: u8) -> Result<u8, FfiError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(FfiError::Core {
detail: Error::UriInvalidParameter {
name: "entry_id".to_owned(),
reason: "contains a non-hexadecimal character".to_owned(),
}
.into(),
}),
}
}
#[derive(uniffi::Object)]
pub struct FfiVault {
inner: std::sync::Mutex<VaultFile>,
}
impl FfiVault {
fn wrap(inner: VaultFile) -> Arc<Self> {
Arc::new(Self {
inner: std::sync::Mutex::new(inner),
})
}
}
#[uniffi::export]
impl FfiVault {
#[uniffi::constructor]
pub fn from_master_key(master_key: Vec<u8>) -> Result<Arc<Self>, FfiError> {
let master = flow::master_key_from_bytes(master_key.into())?;
Ok(Self::wrap(flow::empty_vault(master)))
}
pub fn rewrap_lock_password(
&self,
old_wrap: Vec<u8>,
old_password: String,
new_password: String,
) -> Result<Vec<u8>, FfiError> {
check_len("old_password", &old_password, MAX_PASSPHRASE_CHARS)?;
check_len("new_password", &new_password, MAX_PASSPHRASE_CHARS)?;
let old = SecretString::from(old_password);
let new = SecretString::from(new_password);
let master = {
let file = self.lock();
MasterKey::from_bytes(flow::master_key_to_bytes(file.master_key()))?
};
let opened = qualia2fa_core::lock::unwrap(&old_wrap, &old)?;
if !opened.matches(&master) {
return Err(Error::LockPasswordRejected.into());
}
qualia2fa_core::lock::wrap(&master, &new, &KdfParams::recommended()).map_err(Into::into)
}
pub fn wrap_master_for_auth_bound(
&self,
store: Arc<FfiAuthBoundKeyStore>,
) -> Result<Vec<u8>, FfiError> {
let master = {
let file = self.lock();
bioauth::MasterKey::from_bytes(file.master_key().expose()).map_err(auth_bound_error)?
};
store.store.wrap(&master).map_err(auth_bound_error)
}
#[uniffi::constructor]
pub fn register(
keys: Arc<dyn FfiDeviceKeys>,
store: Arc<dyn FfiSecureStore>,
label: String,
) -> Result<Arc<Self>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let mut keys = ForeignDeviceKeys(keys);
let mut store = ForeignSecureStore(store);
let registered = flow::registration(&mut keys, &mut store, &label)?;
Ok(Self::wrap(registered.into_vault()))
}
#[uniffi::constructor]
pub fn open(store: Arc<dyn FfiSecureStore>, data: Vec<u8>) -> Result<Arc<Self>, FfiError> {
let store = ForeignSecureStore(store);
let file = flow::open_vault(&store, &data)?;
Ok(Self::wrap(file))
}
#[must_use]
pub fn entry_count(&self) -> u64 {
self.lock().vault().len() as u64
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.lock().vault().is_empty()
}
#[must_use]
pub fn tombstone_count(&self) -> u64 {
self.lock().vault().tombstone_count() as u64
}
#[must_use]
pub fn entries(&self) -> Vec<FfiVaultEntry> {
self.lock()
.vault()
.iter()
.map(|(id, entry)| FfiVaultEntry::from_core(id, entry))
.collect()
}
#[must_use]
pub fn entry(&self, id: String) -> Option<FfiVaultEntry> {
let id = parse_entry_id(&id).ok()?;
self.lock()
.vault()
.get(&id)
.map(|entry| FfiVaultEntry::from_core(&id, entry))
}
pub fn add_entry(&self, entry: FfiVaultEntry, now: u64) -> Result<String, FfiError> {
flow::check_now(now)?;
let core = entry.to_core(now, None)?;
let id = self.lock().vault_mut().insert(core)?;
Ok(id.to_string())
}
pub fn update_entry(&self, entry: FfiVaultEntry, now: u64) -> Result<(), FfiError> {
flow::check_now(now)?;
let id = entry.parse_id()?;
let mut vault = self.lock();
let existing = vault
.vault()
.get(&id)
.ok_or(Error::VaultEntryNotFound)?
.duplicate()?
.kind;
let mut core = entry.to_core(now, Some(existing))?;
let created_at = vault
.vault()
.get(&id)
.ok_or(Error::VaultEntryNotFound)?
.created_at;
core.created_at = created_at;
vault.vault_mut().replace(id, core)?;
Ok(())
}
pub fn remove_entry(&self, id: String, now: u64) -> Result<bool, FfiError> {
flow::check_now(now)?;
let id = parse_entry_id(&id)?;
Ok(self.lock().vault_mut().remove(&id, now).is_some())
}
#[must_use]
pub fn groups(&self) -> Vec<FfiGroup> {
let vault = self.lock();
vault
.vault()
.groups()
.map(|(id, group)| FfiGroup::from_core(id, group))
.collect()
}
#[must_use]
pub fn children_of(&self, group_id: String) -> Option<Vec<FfiGroupChild>> {
let id = parse_group_id(&group_id).ok()?;
let vault = self.lock();
vault
.vault()
.children_of(&id)
.map(|children| children.iter().map(FfiGroupChild::from_core).collect())
}
#[must_use]
pub fn parent_of(&self, group_id: String) -> Option<String> {
let id = parse_group_id(&group_id).ok()?;
let vault = self.lock();
vault
.vault()
.parent_of(&id)
.map(|parent| hex_id(parent.as_bytes()))
}
#[must_use]
pub fn depth_of(&self, group_id: String) -> Option<u64> {
let id = parse_group_id(&group_id).ok()?;
let vault = self.lock();
vault
.vault()
.depth_of(&id)
.map(|depth| u64::try_from(depth).unwrap_or(u64::MAX))
}
#[must_use]
pub fn group_of(&self, entry_id: String) -> Option<String> {
let id = parse_entry_id(&entry_id).ok()?;
let vault = self.lock();
vault
.vault()
.group_of(&id)
.map(|group| hex_id(group.as_bytes()))
}
pub fn create_group(&self, name: String) -> Result<String, FfiError> {
check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
if name.is_empty() {
return Err(FfiError::InputEmpty {
kind: "group_name".to_owned(),
});
}
let id = self.lock().vault_mut().create_group(name)?;
Ok(hex_id(id.as_bytes()))
}
pub fn create_group_in(&self, parent_id: String, name: String) -> Result<String, FfiError> {
check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
if name.is_empty() {
return Err(FfiError::InputEmpty {
kind: "group_name".to_owned(),
});
}
let parent = parse_group_id(&parent_id)?;
let id = self.lock().vault_mut().create_group_in(&parent, name)?;
Ok(hex_id(id.as_bytes()))
}
pub fn rename_group(&self, group_id: String, name: String) -> Result<(), FfiError> {
check_len("group_name", &name, MAX_VAULT_FIELD_CHARS)?;
let id = parse_group_id(&group_id)?;
self.lock().vault_mut().rename_group(&id, name)?;
Ok(())
}
pub fn set_children(
&self,
group_id: String,
children: Vec<FfiGroupChild>,
) -> Result<(), FfiError> {
let id = parse_group_id(&group_id)?;
let mut parsed = Vec::with_capacity(children.len());
for child in &children {
parsed.push(child.to_core()?);
}
self.lock().vault_mut().set_children(&id, parsed)?;
Ok(())
}
pub fn remove_group(&self, group_id: String) -> Result<(), FfiError> {
let id = parse_group_id(&group_id)?;
self.lock().vault_mut().remove_group(&id)?;
Ok(())
}
pub fn set_ordering(&self, group_id: String, entry_ids: Vec<String>) -> Result<(), FfiError> {
let id = parse_group_id(&group_id)?;
let mut parsed = Vec::with_capacity(entry_ids.len());
for entry_id in &entry_ids {
parsed.push(parse_entry_id(entry_id)?);
}
self.lock().vault_mut().set_ordering(&id, parsed)?;
Ok(())
}
pub fn assign_to_group(&self, entry_id: String, group_id: String) -> Result<(), FfiError> {
let entry = parse_entry_id(&entry_id)?;
let group = parse_group_id(&group_id)?;
self.lock().vault_mut().assign_to_group(&entry, &group)?;
Ok(())
}
pub fn serialize(&self) -> Result<Vec<u8>, FfiError> {
Ok(self.lock().serialize()?)
}
pub fn code_for(&self, id: String, unix_time: u64) -> Result<String, FfiError> {
self.with_entry(&id, |entry| match entry.kind {
EntryKind::Totp { period } => Ok(otp::totp_code(
&entry.secret,
unix_time,
period,
entry.algorithm,
entry.digits,
)?),
EntryKind::Motp { period, ref pin } => Ok(motp::motp_code(
&entry.secret,
pin.as_ref(),
period,
unix_time,
)?),
EntryKind::Yandex { period, ref pin } => Ok(yaotp::yaotp_code(
&entry.secret,
pin.as_ref(),
period,
unix_time,
)?),
EntryKind::Steam => Ok(otp::steam_guard_code(
&entry.secret,
unix_time,
otp::STEAM_PERIOD,
entry.algorithm,
)?),
EntryKind::Hotp { .. } => Err(FfiError::WrongCodeKind {
kind: "hotp".to_owned(),
wanted: "time".to_owned(),
}),
})
}
pub fn hotp_for(&self, id: String, counter: u64) -> Result<String, FfiError> {
self.with_entry(&id, |entry| match entry.kind {
EntryKind::Hotp { .. } => Ok(otp::hotp_code(
&entry.secret,
counter,
entry.algorithm,
entry.digits,
)),
_ => Err(FfiError::WrongCodeKind {
kind: "not_hotp".to_owned(),
wanted: "counter".to_owned(),
}),
})
}
pub fn export_recovery_key(
&self,
passphrase: String,
memory_kib: Option<u64>,
iterations: Option<u64>,
parallelism: Option<u64>,
) -> Result<Vec<u8>, FfiError> {
check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
let params = kdf_params(memory_kib, iterations, parallelism)?;
let vault = self.lock();
Ok(flow::export_recovery_key(
vault.master_key(),
&SecretString::from(passphrase),
Some(params),
)?)
}
}
impl FfiVault {
fn lock(&self) -> std::sync::MutexGuard<'_, VaultFile> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn with_entry(
&self,
id: &str,
compute: impl FnOnce(&Entry) -> Result<String, FfiError>,
) -> Result<String, FfiError> {
let id = parse_entry_id(id)?;
let vault = self.lock();
let entry = vault.vault().get(&id).ok_or(Error::VaultEntryNotFound)?;
compute(entry)
}
}
#[uniffi::export]
pub fn export_recovery_key_from_master(
master_key: Vec<u8>,
passphrase: String,
memory_kib: Option<u64>,
iterations: Option<u64>,
parallelism: Option<u64>,
) -> Result<Vec<u8>, FfiError> {
check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
let params = kdf_params(memory_kib, iterations, parallelism)?;
let master = flow::master_key_from_bytes(master_key.into())?;
Ok(flow::export_recovery_key(
&master,
&SecretString::from(passphrase),
Some(params),
)?)
}
#[uniffi::export]
pub fn import_recovery_key(
file: Vec<u8>,
passphrase: String,
store: Arc<dyn FfiSecureStore>,
) -> Result<Vec<u8>, FfiError> {
check_len("passphrase", &passphrase, MAX_PASSPHRASE_CHARS)?;
let mut store = ForeignSecureStore(store);
let master = flow::import_recovery_key(&file, &SecretString::from(passphrase), &mut store)?;
Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
}
#[uniffi::export]
pub fn store_master_key(
master_key: Vec<u8>,
store: Arc<dyn FfiSecureStore>,
) -> Result<(), FfiError> {
let master = flow::master_key_from_bytes(master_key.into())?;
let mut store = ForeignSecureStore(store);
store.store_master_key(&master)?;
Ok(())
}
#[derive(uniffi::Object)]
pub struct FfiLogin {
inner: std::sync::Mutex<Login>,
}
#[uniffi::export]
impl FfiLogin {
#[uniffi::constructor]
pub fn begin() -> Result<Arc<Self>, FfiError> {
Ok(Arc::new(Self {
inner: std::sync::Mutex::new(Login::new()?),
}))
}
pub fn payload(&self) -> String {
self.lock().payload().to_base32()
}
pub fn authorize(&self, vault: Arc<FfiVault>, msg1: Vec<u8>) -> Result<Vec<u8>, FfiError> {
let vault = vault.lock();
let mut login = self.lock();
Ok(login.authorize(&msg1, vault.master_key())?)
}
pub fn joiner_device_id(&self) -> Option<String> {
self.lock().joiner_device().map(|id| id.to_hex())
}
}
impl FfiLogin {
fn lock(&self) -> std::sync::MutexGuard<'_, Login> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(uniffi::Object)]
pub struct FfiJoiner {
inner: std::sync::Mutex<Joiner>,
}
#[uniffi::export]
impl FfiJoiner {
#[uniffi::constructor]
pub fn from_qr(text: String, label: String) -> Result<Arc<Self>, FfiError> {
check_len("qr", &text, MAX_QR_TEXT_CHARS)?;
check_len("label", &label, MAX_LABEL_CHARS)?;
Ok(Arc::new(Self {
inner: std::sync::Mutex::new(Joiner::from_base32(&text, &label)?),
}))
}
pub fn request(&self, keys: Arc<dyn FfiDeviceKeys>) -> Result<Vec<u8>, FfiError> {
let mut keys = ForeignDeviceKeys(keys);
let mut joiner = self.lock();
Ok(joiner.request(&mut keys)?)
}
#[must_use]
pub fn identity(&self) -> Option<Vec<u8>> {
self.lock().identity().map(|bytes| bytes.to_vec())
}
pub fn complete(
&self,
msg2: Vec<u8>,
keys: Arc<dyn FfiDeviceKeys>,
store: Arc<dyn FfiSecureStore>,
) -> Result<Vec<u8>, FfiError> {
let mut keys = ForeignDeviceKeys(keys);
let mut store = ForeignSecureStore(store);
let master = self.lock().complete(&msg2, &mut keys, &mut store)?;
Ok(flow::master_key_to_bytes(&master).expose_secret().to_vec())
}
}
impl FfiJoiner {
fn lock(&self) -> std::sync::MutexGuard<'_, Joiner> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(uniffi::Object)]
pub struct FfiVerifier {
inner: std::sync::Mutex<Option<auth::Verifier>>,
challenge: Vec<u8>,
session_id: std::sync::Mutex<Option<[u8; AUTH_SESSION_ID_LEN]>>,
confirmation: std::sync::Mutex<Option<[u8; AUTH_CONFIRM_LEN]>>,
}
#[uniffi::export]
impl FfiVerifier {
#[uniffi::constructor]
pub fn begin() -> Result<Arc<Self>, FfiError> {
let (challenge, verifier) = auth::begin()?;
Ok(Arc::new(Self {
inner: std::sync::Mutex::new(Some(verifier)),
challenge,
session_id: std::sync::Mutex::new(None),
confirmation: std::sync::Mutex::new(None),
}))
}
#[must_use]
pub fn challenge(&self) -> Vec<u8> {
self.challenge.clone()
}
pub fn verify(&self, response: Vec<u8>, vault: Arc<FfiVault>) -> Result<bool, FfiError> {
let verifier = {
let mut guard = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
guard.take().ok_or(Error::AuthProofFailed)?
};
let vault = vault.lock();
let verified = verifier.verify(&response, vault.master_key())?;
*self
.session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(*verified.session_id());
*self
.confirmation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(verified.confirmation());
Ok(true)
}
#[must_use]
pub fn session_id(&self) -> Option<Vec<u8>> {
self.session_id
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.map(|id| id.to_vec())
}
#[must_use]
pub fn confirmation(&self) -> Option<Vec<u8>> {
self.confirmation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.map(|tag| tag.to_vec())
}
}
#[derive(uniffi::Object)]
pub struct FfiResponder {
inner: std::sync::Mutex<auth::Responder>,
}
#[uniffi::export]
impl FfiResponder {
#[uniffi::constructor]
pub fn accept(challenge: Vec<u8>) -> Result<Arc<Self>, FfiError> {
Ok(Arc::new(Self {
inner: std::sync::Mutex::new(auth::accept(&challenge)?),
}))
}
#[must_use]
pub fn identity(&self) -> Vec<u8> {
self.lock().public_key().to_vec()
}
pub fn respond(
&self,
vault: Arc<FfiVault>,
label: String,
keys: Arc<dyn FfiDeviceKeys>,
) -> Result<Vec<u8>, FfiError> {
check_len("label", &label, MAX_LABEL_CHARS)?;
let vault = vault.lock();
let responder = self.lock();
let mut keys = ForeignDeviceKeys(keys);
let device = keys.get_or_create(&label)?;
let response =
responder.respond(vault.master_key(), device.as_sec1_bytes(), |message| {
keys.sign(&label, message)
})?;
Ok(response)
}
pub fn confirm(
&self,
vault: Arc<FfiVault>,
session_id: Vec<u8>,
confirmation: Vec<u8>,
) -> Result<bool, FfiError> {
let session_id: [u8; AUTH_SESSION_ID_LEN] =
session_id
.as_slice()
.try_into()
.map_err(|_| Error::AuthMessageMalformed {
reason: "wrong session identifier length",
})?;
let vault = vault.lock();
let responder = self.lock();
auth::confirm(vault.master_key(), &responder, &session_id, &confirmation)?;
Ok(true)
}
}
impl FfiResponder {
fn lock(&self) -> std::sync::MutexGuard<'_, auth::Responder> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn kdf_params(
memory_kib: Option<u64>,
iterations: Option<u64>,
parallelism: Option<u64>,
) -> Result<KdfParams, FfiError> {
if memory_kib.is_none() && iterations.is_none() && parallelism.is_none() {
return Ok(KdfParams::recommended());
}
let recommended = KdfParams::recommended();
let narrow = |value: u64, what: &'static str| -> Result<u32, FfiError> {
u32::try_from(value).map_err(|_| Error::RecoveryParametersInvalid { reason: what }.into())
};
Ok(KdfParams::new(
narrow(
memory_kib.unwrap_or(u64::from(recommended.memory_kib())),
"memory limit is outside the representable range",
)?,
narrow(
iterations.unwrap_or(u64::from(recommended.iterations())),
"iteration count is outside the representable range",
)?,
narrow(
parallelism.unwrap_or(u64::from(recommended.parallelism())),
"parallelism is outside the representable range",
)?,
)?)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiRoster {
TwoParty,
MultiParty,
}
impl From<FfiRoster> for Roster {
fn from(value: FfiRoster) -> Self {
match value {
FfiRoster::TwoParty => Self::TwoParty,
FfiRoster::MultiParty => Self::MultiParty,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)]
pub struct FfiTransportCapabilities {
pub roster: FfiRoster,
pub max_message_len: Option<u64>,
pub max_qr_version: Option<u8>,
pub single_use: bool,
pub can_carry_payload: bool,
}
impl From<FfiTransportCapabilities> for TransportCapabilities {
fn from(value: FfiTransportCapabilities) -> Self {
Self {
roster: value.roster.into(),
max_message_len: value
.max_message_len
.map(|len| usize::try_from(len).unwrap_or(usize::MAX)),
max_qr_version: value.max_qr_version,
single_use: value.single_use,
can_carry_payload: value.can_carry_payload,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum FfiRecipient {
One {
device_id: String,
},
All,
}
impl TryFrom<FfiRecipient> for Recipient {
type Error = FfiError;
fn try_from(value: FfiRecipient) -> Result<Self, FfiError> {
Ok(match value {
FfiRecipient::One { device_id } => Self::One(parse_device_id(&device_id)?),
FfiRecipient::All => Self::All,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiReceivedMessage {
pub sender: String,
pub message: Vec<u8>,
}
#[uniffi::export(with_foreign)]
pub trait FfiTransport: Send + Sync {
fn capabilities(&self) -> FfiTransportCapabilities;
fn local_device(&self) -> String;
fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError>;
fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError>;
fn peers(&self) -> Result<Vec<String>, FfiError>;
}
struct ForeignTransport {
channel: Arc<dyn FfiTransport>,
me: DeviceId,
}
impl ForeignTransport {
fn new(channel: Arc<dyn FfiTransport>) -> Result<Self, FfiError> {
let me = parse_device_id(&channel.local_device())?;
Ok(Self { channel, me })
}
}
impl Transport for ForeignTransport {
fn capabilities(&self) -> TransportCapabilities {
self.channel.capabilities().into()
}
fn local_device(&self) -> DeviceId {
self.me
}
fn send(&self, to: Recipient, message: &[u8]) -> Result<(), Error> {
let to = match to {
Recipient::One(id) => FfiRecipient::One {
device_id: id.to_hex(),
},
Recipient::All => FfiRecipient::All,
};
self.channel
.send(to, message.to_vec())
.map_err(transport_error_to_core_error)
}
fn receive(&self) -> Result<(DeviceId, Vec<u8>), Error> {
match self
.channel
.receive()
.map_err(transport_error_to_core_error)?
{
Some(received) => {
let sender =
parse_device_id(&received.sender).map_err(|_| Error::TransportFailed {
reason: "the channel reported a device identifier that is not hexadecimal",
})?;
Ok((sender, received.message))
}
None => Err(Error::SyncNoSessionFound),
}
}
fn peers(&self) -> Result<Vec<DeviceId>, Error> {
self.channel
.peers()
.map_err(transport_error_to_core_error)?
.iter()
.map(|id| {
parse_device_id(id).map_err(|_| Error::TransportFailed {
reason: "the channel reported a device identifier that is not hexadecimal",
})
})
.collect()
}
}
fn parse_device_id(text: &str) -> Result<DeviceId, FfiError> {
let invalid = || FfiError::InvalidDeviceId {
kind: "device_id".to_owned(),
};
let bytes = text.as_bytes();
if bytes.len() != DEVICE_ID_LEN * 2 {
return Err(invalid());
}
let mut id = [0_u8; DEVICE_ID_LEN];
for (index, slot) in id.iter_mut().enumerate() {
let high = hex_digit(bytes[index * 2]).map_err(|_| invalid())?;
let low = hex_digit(bytes[index * 2 + 1]).map_err(|_| invalid())?;
*slot = (high << 4) | low;
}
Ok(DeviceId::from_bytes(id))
}
fn transport_error_to_core_error(source: FfiError) -> Error {
match &source {
FfiError::Qr { kind, .. } | FfiError::Lan { kind, .. }
if kind == "transport_already_used" =>
{
Error::TransportAlreadyUsed
}
FfiError::Qr { kind, .. } | FfiError::Lan { kind, .. }
if kind == "sync_no_session_found" =>
{
Error::SyncNoSessionFound
}
_ => Error::TransportFailed {
reason: "the channel reported a failure",
},
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiPayloadKind {
Vault,
Request,
}
impl From<FfiPayloadKind> for PayloadKind {
fn from(value: FfiPayloadKind) -> Self {
match value {
FfiPayloadKind::Vault => Self::Vault,
FfiPayloadKind::Request => Self::Request,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiSharePlan {
FullPayload {
len: u64,
},
LinkEstablishment {
needed: u64,
limit: u64,
},
}
impl From<SharePlan> for FfiSharePlan {
fn from(value: SharePlan) -> Self {
match value {
SharePlan::FullPayload { len } => Self::FullPayload { len: len as u64 },
SharePlan::LinkEstablishment { needed, limit } => Self::LinkEstablishment {
needed: needed as u64,
limit: limit as u64,
},
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiImportFormat {
Json,
Html,
Text,
}
impl From<FfiImportFormat> for qualia2fa_core::import::ImportFormat {
fn from(value: FfiImportFormat) -> Self {
match value {
FfiImportFormat::Json => Self::Json,
FfiImportFormat::Html => Self::Html,
FfiImportFormat::Text => Self::Text,
}
}
}
#[must_use]
fn import_difference_name(difference: ImportDifference) -> &'static str {
match difference {
ImportDifference::Name => "name",
ImportDifference::Issuer => "issuer",
ImportDifference::Account => "account",
ImportDifference::Note => "note",
ImportDifference::Algorithm => "algorithm",
ImportDifference::Digits => "digits",
ImportDifference::Kind => "kind",
}
}
#[must_use]
fn hex_id(bytes: &[u8; 16]) -> String {
let mut out = String::with_capacity(32);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportedEntry {
pub position: u64,
pub name: String,
pub issuer: Option<String>,
pub account: String,
pub algorithm: FfiAlgorithm,
pub digits: FfiDigits,
pub kind: FfiEntryKind,
pub favorite: bool,
pub icon_dropped: bool,
pub period_assumed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportProblem {
pub position: u64,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportedGroup {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Record)]
pub struct FfiDroppedSummary {
pub icons: u64,
pub periods_assumed: u64,
pub duplicates: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportMatch {
pub position: u64,
pub identical: bool,
pub existing: Vec<String>,
pub differences: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Enum)]
pub enum FfiImportDecision {
Add,
Skip,
Replace {
entry_id: String,
},
KeepBoth,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportChoice {
pub position: u64,
pub decision: FfiImportDecision,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiImportApplied {
pub added: u64,
pub replaced: u64,
pub skipped: u64,
pub groups_created: Vec<String>,
pub groups_existing: Vec<String>,
}
#[derive(uniffi::Object)]
pub struct FfiImport {
report: qualia2fa_core::import::ImportReport,
}
#[uniffi::export]
impl FfiImport {
#[uniffi::constructor]
pub fn preview(
bytes: Vec<u8>,
format: FfiImportFormat,
password: Option<String>,
now: u64,
) -> Result<Arc<Self>, FfiError> {
flow::check_now(now)?;
let report = qualia2fa_core::import::import(
&bytes,
format.into(),
now,
password.as_ref().map(String::as_bytes),
)?;
Ok(Arc::new(Self { report }))
}
#[must_use]
pub fn entries(&self) -> Vec<FfiImportedEntry> {
self.report
.entries
.iter()
.map(|imported| FfiImportedEntry {
position: imported.position as u64,
name: imported.entry.name.clone(),
issuer: imported.entry.issuer.clone(),
account: imported.entry.account.clone(),
algorithm: match imported.entry.algorithm {
Algorithm::Sha1 => FfiAlgorithm::Sha1,
Algorithm::Sha256 => FfiAlgorithm::Sha256,
Algorithm::Sha512 => FfiAlgorithm::Sha512,
},
digits: imported.entry.digits.into(),
kind: FfiEntryKind::from_core(&imported.entry.kind),
favorite: imported.favorite,
icon_dropped: imported
.notes
.contains(&qualia2fa_core::import::ImportNote::IconDropped),
period_assumed: imported.notes.iter().any(|note| {
matches!(
note,
qualia2fa_core::import::ImportNote::PeriodAssumed { .. }
)
}),
})
.collect()
}
#[must_use]
pub fn problems(&self) -> Vec<FfiImportProblem> {
self.report
.problems
.iter()
.map(|problem| FfiImportProblem {
position: problem.position as u64,
reason: problem.reason.clone(),
})
.collect()
}
#[must_use]
pub fn groups(&self) -> Vec<FfiImportedGroup> {
self.report
.groups
.iter()
.map(|group| FfiImportedGroup {
id: hex_id(group.id.as_bytes()),
name: group.name.clone(),
})
.collect()
}
#[must_use]
pub fn dropped(&self) -> FfiDroppedSummary {
FfiDroppedSummary {
icons: self.report.dropped.icons as u64,
periods_assumed: self.report.dropped.periods_assumed as u64,
duplicates: self.report.dropped.duplicates as u64,
}
}
#[must_use]
pub fn compare(&self, vault: Arc<FfiVault>) -> Vec<FfiImportMatch> {
let vault = vault.lock();
qualia2fa_core::import::apply::compare(&self.report, vault.vault())
.into_iter()
.map(|found| FfiImportMatch {
position: found.position as u64,
identical: found.identical,
existing: found.existing.iter().map(ToString::to_string).collect(),
differences: found
.differences
.into_iter()
.map(|difference| import_difference_name(difference).to_owned())
.collect(),
})
.collect()
}
pub fn apply(
&self,
vault: Arc<FfiVault>,
choices: Vec<FfiImportChoice>,
) -> Result<FfiImportApplied, FfiError> {
let mut answers: HashMap<usize, ImportChoice> = HashMap::new();
for choice in choices {
let decision = match choice.decision {
FfiImportDecision::Add => ImportChoice::Add,
FfiImportDecision::Skip => ImportChoice::Skip,
FfiImportDecision::KeepBoth => ImportChoice::KeepBoth,
FfiImportDecision::Replace { entry_id } => {
ImportChoice::Replace(parse_entry_id(&entry_id)?)
}
};
answers.insert(choice.position as usize, decision);
}
let mut vault = vault.lock();
let applied =
qualia2fa_core::import::apply::apply(vault.vault_mut(), &self.report, &answers)?;
Ok(FfiImportApplied {
added: applied.added.len() as u64,
replaced: applied.replaced.len() as u64,
skipped: applied.skipped as u64,
groups_created: applied
.groups_created
.iter()
.map(|id| hex_id(id.as_bytes()))
.collect(),
groups_existing: applied
.groups_existing
.iter()
.map(|id| hex_id(id.as_bytes()))
.collect(),
})
}
}
#[uniffi::export]
#[must_use]
pub fn detect_import_format(bytes: Vec<u8>) -> Option<FfiImportFormat> {
match qualia2fa_core::import::ImportFormat::detect(&bytes)? {
qualia2fa_core::import::ImportFormat::Json => Some(FfiImportFormat::Json),
qualia2fa_core::import::ImportFormat::Html => Some(FfiImportFormat::Html),
qualia2fa_core::import::ImportFormat::Text => Some(FfiImportFormat::Text),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiExportFormat {
Vault,
EncryptedVault,
Text,
Json,
EncryptedJson,
Html,
}
impl From<FfiExportFormat> for qualia2fa_core::export::ExportFormat {
fn from(value: FfiExportFormat) -> Self {
match value {
FfiExportFormat::Vault => Self::Vault,
FfiExportFormat::EncryptedVault => Self::EncryptedVault,
FfiExportFormat::Text => Self::Text,
FfiExportFormat::Json => Self::Json,
FfiExportFormat::EncryptedJson => Self::EncryptedJson,
FfiExportFormat::Html => Self::Html,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiGroupCarrying {
None,
FlatGroups,
FullTree,
}
impl From<qualia2fa_core::export::GroupCarrying> for FfiGroupCarrying {
fn from(value: qualia2fa_core::export::GroupCarrying) -> Self {
match value {
qualia2fa_core::export::GroupCarrying::None => Self::None,
qualia2fa_core::export::GroupCarrying::FlatGroups => Self::FlatGroups,
qualia2fa_core::export::GroupCarrying::FullTree => Self::FullTree,
}
}
}
#[uniffi::export]
#[must_use]
pub fn export_format_needs_password(format: FfiExportFormat) -> bool {
qualia2fa_core::export::ExportFormat::from(format).needs_password()
}
#[uniffi::export]
#[must_use]
pub fn export_format_extension(format: FfiExportFormat) -> String {
qualia2fa_core::export::ExportFormat::from(format)
.extension()
.to_owned()
}
#[uniffi::export]
#[must_use]
pub fn export_format_carries_groups(format: FfiExportFormat) -> bool {
qualia2fa_core::export::ExportFormat::from(format).carries_groups()
}
#[uniffi::export]
#[must_use]
pub fn export_format_group_carrying(format: FfiExportFormat) -> FfiGroupCarrying {
qualia2fa_core::export::ExportFormat::from(format)
.group_carrying()
.into()
}
#[uniffi::export]
#[must_use]
pub fn max_group_depth() -> u64 {
u64::try_from(qualia2fa_core::vault::Vault::MAX_GROUP_DEPTH).unwrap_or(u64::MAX)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiPortableKind {
PlainVault,
EncryptedVault,
KeyOnly,
}
impl From<qualia2fa_core::portable::PortableKind> for FfiPortableKind {
fn from(value: qualia2fa_core::portable::PortableKind) -> Self {
match value {
qualia2fa_core::portable::PortableKind::PlainVault => Self::PlainVault,
qualia2fa_core::portable::PortableKind::EncryptedVault => Self::EncryptedVault,
qualia2fa_core::portable::PortableKind::KeyOnly => Self::KeyOnly,
}
}
}
#[uniffi::export]
#[must_use]
pub fn detect_portable_file(file: Vec<u8>) -> Option<FfiPortableKind> {
qualia2fa_core::portable::detect(&file).map(FfiPortableKind::from)
}
#[derive(uniffi::Record)]
pub struct FfiPortableRestore {
pub vault: Option<Arc<FfiVault>>,
pub key_only: bool,
}
#[uniffi::export]
pub fn open_portable_file(
file: Vec<u8>,
password: Option<String>,
store: Arc<dyn FfiSecureStore>,
) -> Result<FfiPortableRestore, FfiError> {
if let Some(password) = &password {
check_len("passphrase", password, MAX_PASSPHRASE_CHARS)?;
}
let passphrase = SecretString::from(password.unwrap_or_default());
let mut store = ForeignSecureStore(store);
match flow::open_portable(&file, &passphrase, &mut store)? {
flow::PortableRestore::Vault(vault_file) => Ok(FfiPortableRestore {
vault: Some(FfiVault::wrap(vault_file)),
key_only: false,
}),
flow::PortableRestore::KeyOnly => Ok(FfiPortableRestore {
vault: None,
key_only: true,
}),
}
}
#[uniffi::export]
impl FfiVault {
pub fn export(
&self,
format: FfiExportFormat,
password: Option<String>,
) -> Result<Vec<u8>, FfiError> {
let password = password.map(String::into_bytes);
let vault = self.lock();
let bytes = qualia2fa_core::export::export(&vault, format.into(), password.as_deref())?;
Ok(bytes)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)]
pub enum FfiDecision {
KeepLocal,
TakeIncoming,
}
impl From<FfiDecision> for Decision {
fn from(value: FfiDecision) -> Self {
match value {
FfiDecision::KeepLocal => Self::KeepLocal,
FfiDecision::TakeIncoming => Self::TakeIncoming,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiConflictSide {
pub name: String,
pub issuer: Option<String>,
pub account: String,
pub note: String,
pub updated_at: u64,
}
impl FfiConflictSide {
fn from_entry(entry: &Entry) -> Self {
Self {
name: entry.name.clone(),
issuer: entry.issuer.clone(),
account: entry.account.clone(),
note: entry.note.clone(),
updated_at: entry.updated_at,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiOrderingConflict {
pub local_name: String,
pub incoming_name: String,
pub top: bool,
pub local: Vec<FfiGroupChild>,
pub incoming: Vec<FfiGroupChild>,
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiConflict {
pub entry_id: String,
pub kind: String,
pub local: Option<FfiConflictSide>,
pub incoming: Option<FfiConflictSide>,
pub local_is_newer: bool,
pub ordering: Option<FfiOrderingConflict>,
}
impl FfiConflict {
fn from_core(conflict: &Conflict, vault: &Vault) -> Self {
let mut ordering = None;
let (local, incoming, local_is_newer) = match &conflict.kind {
ConflictKind::OrderingMismatch { local, incoming } => {
ordering = Some(FfiOrderingConflict {
local_name: local.name.clone(),
incoming_name: incoming.name.clone(),
top: conflict.group_id().is_some_and(|id| id.is_top()),
local: local
.children
.iter()
.map(FfiGroupChild::from_core)
.collect(),
incoming: incoming
.children
.iter()
.map(FfiGroupChild::from_core)
.collect(),
});
(None, None, false)
}
ConflictKind::EntryMismatch { local, incoming } => (
Some(FfiConflictSide::from_entry(local)),
Some(FfiConflictSide::from_entry(incoming)),
false,
),
ConflictKind::DeletionConflict {
local,
local_is_newer,
..
} => (
vault
.get(&conflict.entry_id)
.map(FfiConflictSide::from_entry),
local
.as_ref()
.map(|entry| FfiConflictSide::from_entry(entry)),
*local_is_newer,
),
};
Self {
entry_id: conflict.entry_id.to_string(),
kind: conflict_kind(conflict).to_owned(),
local,
incoming,
local_is_newer,
ordering,
}
}
}
#[must_use]
pub fn conflict_kind(conflict: &Conflict) -> &'static str {
match &conflict.kind {
ConflictKind::EntryMismatch { .. } => "entry_mismatch",
ConflictKind::DeletionConflict { local: None, .. } => "peer_deleted",
ConflictKind::DeletionConflict { local: Some(_), .. } => "local_deleted",
ConflictKind::OrderingMismatch { .. } => "ordering_mismatch",
}
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiSyncOutcome {
pub sync_id: String,
pub added: u64,
pub tombstones_added: u64,
pub deletions_applied: u64,
pub unchanged: u64,
pub made_no_change: bool,
pub needs_adjudication: bool,
pub conflicts: Vec<FfiConflict>,
}
#[derive(uniffi::Object)]
pub struct FfiSyncSession {
vault: Arc<FfiVault>,
seen: std::sync::Mutex<SeenSyncIds>,
pending: std::sync::Mutex<Vec<Conflict>>,
}
#[uniffi::export]
impl FfiSyncSession {
#[uniffi::constructor]
pub fn open(vault: Arc<FfiVault>) -> Arc<Self> {
Arc::new(Self {
vault,
seen: std::sync::Mutex::new(SeenSyncIds::new()),
pending: std::sync::Mutex::new(Vec::new()),
})
}
pub fn plan_share(&self, transport: Arc<dyn FfiTransport>) -> Result<FfiSharePlan, FfiError> {
let transport = ForeignTransport::new(transport)?;
let mut vault = self.vault.lock();
let mut seen = self.lock_seen();
let (master, contents) = vault.split_mut();
let session = SyncSession::open(master, contents, &mut seen);
Ok(session.plan_share(&transport)?.into())
}
pub fn send(
&self,
transport: Arc<dyn FfiTransport>,
to: FfiRecipient,
kind: FfiPayloadKind,
) -> Result<String, FfiError> {
let transport = ForeignTransport::new(transport)?;
let to = Recipient::try_from(to)?;
let mut vault = self.vault.lock();
let mut seen = self.lock_seen();
let (master, contents) = vault.split_mut();
let session = SyncSession::open(master, contents, &mut seen);
Ok(to_hex(&session.send(&transport, to, kind.into())?))
}
pub fn receive(&self, transport: Arc<dyn FfiTransport>) -> Result<FfiSyncOutcome, FfiError> {
let transport = ForeignTransport::new(transport)?;
let (record, conflicts) = {
let mut vault = self.vault.lock();
let mut seen = self.lock_seen();
let (master, contents) = vault.split_mut();
let mut session = SyncSession::open(master, contents, &mut seen);
let outcome = session.receive(&transport)?;
let made_no_change = outcome.made_no_change();
let needs_adjudication = outcome.needs_adjudication();
let merge = outcome.merge;
let record = FfiSyncOutcome {
sync_id: to_hex(&outcome.sync_id),
added: merge.added as u64,
tombstones_added: merge.tombstones_added as u64,
deletions_applied: merge.deletions_applied as u64,
unchanged: merge.unchanged as u64,
made_no_change,
needs_adjudication,
conflicts: merge
.conflicts
.iter()
.map(|conflict| FfiConflict::from_core(conflict, vault.vault()))
.collect(),
};
(record, merge.conflicts)
};
*self.lock_pending() = conflicts;
Ok(record)
}
#[must_use]
pub fn pending_conflicts(&self) -> Vec<FfiConflict> {
let vault = self.vault.lock();
self.lock_pending()
.iter()
.map(|conflict| FfiConflict::from_core(conflict, vault.vault()))
.collect()
}
pub fn adjudicate(&self, entry_id: String, decision: FfiDecision) -> Result<(), FfiError> {
let conflict = {
let mut pending = self.lock_pending();
let index = pending
.iter()
.position(|conflict| conflict.entry_id.to_string() == entry_id)
.ok_or_else(|| FfiError::UnknownConflict {
entry_id: entry_id.clone(),
})?;
pending.remove(index)
};
let outcome = {
let mut vault = self.vault.lock();
adjudicate(vault.vault_mut(), &conflict, decision.into())
};
match outcome {
Ok(_) => Ok(()),
Err(error) => {
self.lock_pending().push(conflict);
Err(error.into())
}
}
}
}
impl FfiSyncSession {
fn lock_seen(&self) -> std::sync::MutexGuard<'_, SeenSyncIds> {
self.seen
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
fn lock_pending(&self) -> std::sync::MutexGuard<'_, Vec<Conflict>> {
self.pending
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn to_hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
out.push(char::from_digit(u32::from(byte & 0x0F), 16).unwrap_or('0'));
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)]
pub struct FfiLanPeer {
pub device_id: String,
pub record: Vec<u8>,
pub socket: Option<String>,
pub here: bool,
}
#[derive(uniffi::Object)]
pub struct FfiLanChannel {
inner: IrohLan,
me: DeviceId,
peers: Mutex<Vec<DeviceId>>,
}
#[uniffi::export]
impl FfiLanChannel {
#[uniffi::constructor]
pub fn open(device_id: String) -> Result<Arc<Self>, FfiError> {
let me = parse_device_id(&device_id)?;
let mut secret = [0_u8; ENDPOINT_KEY_LEN];
qualia2fa_core::getrandom::fill(&mut secret).map_err(|_| FfiError::Core {
detail: Error::RandomnessUnavailable.into(),
})?;
Ok(Arc::new(Self {
inner: IrohLan::open(me, EndpointKey::from_bytes(secret)).map_err(lan_error)?,
me,
peers: Mutex::new(Vec::new()),
}))
}
pub fn record(&self) -> Result<Vec<u8>, FfiError> {
let port = self.port()?;
Ok(LanAddress::new(self.inner.endpoint_key(), port)
.encode()
.to_vec())
}
pub fn remember_peer(
&self,
device_id: String,
record: Vec<u8>,
socket: String,
) -> Result<(), FfiError> {
let device = parse_device_id(&device_id)?;
let address = LanAddress::decode(&record).map_err(lan_error)?;
let socket = parse_socket(&socket)?;
self.inner.remember_peer(PeerAddress::new(
device,
address.endpoint_key,
Some(SocketAddr::new(socket.ip(), address.port)),
));
let mut peers = self.lock_peers();
if !peers.contains(&device) {
peers.push(device);
}
Ok(())
}
pub fn forget_peer(&self, device_id: String) -> Result<(), FfiError> {
let device = parse_device_id(&device_id)?;
self.inner.forget_peer(&device);
self.lock_peers().retain(|known| *known != device);
Ok(())
}
#[must_use]
pub fn transport(self: &Arc<Self>) -> Arc<dyn FfiTransport> {
Arc::clone(self) as Arc<dyn FfiTransport>
}
pub fn open_lobby(&self) -> Result<Arc<FfiLanLobby>, FfiError> {
let port = self.port()?;
let lobby = Lobby::open(self.me, self.inner.endpoint_key(), port).map_err(lan_error)?;
Ok(Arc::new(FfiLanLobby {
inner: Mutex::new(lobby),
}))
}
pub fn shutdown(&self) {
self.inner.shutdown();
}
}
impl FfiTransport for FfiLanChannel {
fn capabilities(&self) -> FfiTransportCapabilities {
FfiTransportCapabilities {
roster: FfiRoster::MultiParty,
max_message_len: Some(MAX_MESSAGE_LEN as u64),
max_qr_version: None,
single_use: false,
can_carry_payload: true,
}
}
fn local_device(&self) -> String {
self.me.to_hex()
}
fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError> {
let to = match to {
FfiRecipient::One { device_id } => LanRecipient::One(parse_device_id(&device_id)?),
FfiRecipient::All => LanRecipient::All,
};
self.inner.send(to, &message).map_err(lan_error)
}
fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError> {
match self.inner.try_receive() {
Ok((sender, message)) => Ok(Some(FfiReceivedMessage {
sender: sender.to_hex(),
message,
})),
Err(qualia_transport::Error::TransportEmpty) => Ok(None),
Err(other) => Err(lan_error(other)),
}
}
fn peers(&self) -> Result<Vec<String>, FfiError> {
Ok(self.lock_peers().iter().map(|id| id.to_hex()).collect())
}
}
impl FfiLanChannel {
fn port(&self) -> Result<u16, FfiError> {
self.inner.port().ok_or_else(|| {
lan_error(qualia_transport::Error::LanSocket {
operation: "endpoint_port",
})
})
}
fn lock_peers(&self) -> std::sync::MutexGuard<'_, Vec<DeviceId>> {
self.peers
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
#[derive(uniffi::Object)]
pub struct FfiLanLobby {
inner: Mutex<Lobby>,
}
#[uniffi::export]
impl FfiLanLobby {
#[must_use]
pub fn peers(&self) -> Vec<FfiLanPeer> {
let mut lobby = self.lock();
while lobby.try_poll().is_some() {}
lobby.view().all().into_iter().map(lan_peer_of).collect()
}
pub fn announce_now(&self) -> Result<(), FfiError> {
self.lock().announce_now().map_err(lan_error)
}
pub fn shutdown(&self) {
self.lock().shutdown();
}
}
impl FfiLanLobby {
fn lock(&self) -> std::sync::MutexGuard<'_, Lobby> {
self.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn lan_peer_of(peer: Peer) -> FfiLanPeer {
FfiLanPeer {
device_id: peer.device.to_hex(),
record: LanAddress::new(peer.endpoint_key, peer.port)
.encode()
.to_vec(),
socket: peer.socket().map(|socket| socket.to_string()),
here: peer.status == PeerStatus::Here,
}
}
fn parse_socket(text: &str) -> Result<SocketAddr, FfiError> {
text.parse::<SocketAddr>().map_err(|_| FfiError::Lan {
kind: "bad_socket".to_owned(),
detail: "the address is not `ip:port`".to_owned(),
})
}
fn lan_error(source: qualia_transport::Error) -> FfiError {
use qualia_transport::Error as LanError;
let kind = match &source {
LanError::NoConnectionToPeer => "no_connection_to_peer",
LanError::TransportEmpty => "transport_empty",
LanError::PayloadTooLarge { .. } => "payload_too_large",
LanError::NoLanPeersFound => "no_lan_peers_found",
LanError::LanSocket { .. } => "lan_socket",
LanError::LanTransport { .. } => "lan_transport",
LanError::NotOurLanAddress => "not_our_lan_address",
LanError::BadLanAddressLength { .. } => "bad_lan_address_length",
LanError::ZeroPort => "zero_port",
_ => "lan_error",
};
FfiError::Lan {
kind: kind.to_owned(),
detail: source.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use qualia_dik::{MockDirectMasterKeyStore, MockWrappedMasterKeyStore};
#[test]
fn a_qr_code_survives_encoding_and_decoding() {
for text in [
"hello",
"otpauth://totp/Example:alice?secret=MZXW6YTBOI",
"中文内容,含标点。",
] {
let image = qr_encode(text.to_owned(), FfiEcLevel::Medium, 20, 4, 4)
.expect("the text must fit in a version 20 code");
assert_eq!(image.pixels.len(), (image.width * image.height) as usize);
assert!(image.pixels.contains(&0), "the code must have dark modules");
assert!(
image.pixels.contains(&255),
"the code must have light modules"
);
let decoded =
qr_decode(image.pixels, image.width, image.height).expect("the code must decode");
assert_eq!(decoded, text, "the text must come back unchanged");
}
}
#[test]
fn a_buffer_that_does_not_match_the_dimensions_is_an_error() {
let error = qr_decode(vec![0; 10], 100, 100).expect_err("a short buffer must be rejected");
assert_eq!(error.kind(), "bad_image_geometry");
}
#[test]
fn an_image_without_a_code_reports_that_rather_than_panicking() {
let error = qr_decode(vec![255; 64 * 64], 64, 64).expect_err("a blank image has no code");
assert_eq!(error.kind(), "no_qr_found");
}
#[test]
fn text_that_does_not_fit_is_rejected_rather_than_truncated() {
let error = qr_encode("a".repeat(8_000), FfiEcLevel::Medium, 20, 4, 4)
.expect_err("the text must not fit");
assert_eq!(error.kind(), "payload_too_large");
}
#[test]
fn zero_scale_is_rejected() {
let error = qr_encode("x".to_owned(), FfiEcLevel::Medium, 20, 0, 4)
.expect_err("zero scale cannot render");
assert_eq!(error.kind(), "bad_render_geometry");
}
const RFC_SECRET: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";
#[test]
fn rfc6238_vector_survives_the_ffi_boundary() {
let code = totp_code(
RFC_SECRET.to_owned(),
59,
30,
FfiAlgorithm::Sha1,
FfiDigits::Eight,
)
.expect("should compute");
assert_eq!(code, "94287082");
}
#[test]
fn uri_roundtrips_through_ffi_types() {
let original = "otpauth://totp/ACME:alice?secret=JBSWY3DPEHPK3PXP&issuer=ACME&algorithm=SHA256&digits=7&period=45";
let parsed = parse_otpauth_uri(original.to_owned()).expect("should parse");
assert_eq!(parsed.account, "alice");
assert_eq!(parsed.issuer.as_deref(), Some("ACME"));
assert_eq!(parsed.algorithm, FfiAlgorithm::Sha256);
assert_eq!(parsed.digits, FfiDigits::Seven);
assert_eq!(parsed.kind, FfiEntryKind::Totp { period: 45 });
let back = serialize_otpauth_uri(parsed.clone()).expect("should serialize");
let reparsed = parse_otpauth_uri(back).expect("should parse again");
assert_eq!(
reparsed, parsed,
"the entry should be equivalent after an FFI round trip"
);
}
#[test]
fn oversized_input_is_rejected_at_the_boundary() {
let huge = "A".repeat(MAX_SECRET_CHARS + 1);
match secret_byte_len(huge) {
Err(FfiError::InputTooLong {
kind,
limit,
actual,
}) => {
assert_eq!(kind, "secret");
assert_eq!(limit, MAX_SECRET_CHARS as u64);
assert_eq!(actual, (MAX_SECRET_CHARS + 1) as u64);
}
other => panic!("expected InputTooLong, got {other:?}"),
}
}
#[test]
fn length_limit_counts_characters_not_bytes() {
let chinese = "中".repeat(MAX_SECRET_CHARS);
match secret_byte_len(chinese) {
Err(FfiError::InputTooLong { .. }) => {
panic!("counting characters must not hit the limit")
}
Err(FfiError::Core { .. }) => {}
Err(FfiError::Qr { .. }) => {
panic!("the code channel is not involved in a length check")
}
Err(other) => panic!("unexpected error kind {}", other.kind()),
Ok(_) => panic!("non-Base32 input must not parse"),
}
}
#[test]
fn hotp_matches_rfc4226_through_ffi() {
let expected = ["755224", "287082", "359152", "969429"];
for (counter, want) in expected.iter().enumerate() {
let got = hotp_code(
RFC_SECRET.to_owned(),
counter as u64,
FfiAlgorithm::Sha1,
FfiDigits::Six,
)
.expect("should compute");
assert_eq!(&got, want, "counter={counter}");
}
}
#[test]
fn drift_verification_returns_the_offset() {
let code = totp_code(
RFC_SECRET.to_owned(),
59,
30,
FfiAlgorithm::Sha1,
FfiDigits::Eight,
)
.expect("should compute");
let hit = verify_code_with_drift(
RFC_SECRET.to_owned(),
1,
1,
FfiAlgorithm::Sha1,
FfiDigits::Eight,
code.clone(),
)
.expect("should verify");
assert_eq!(hit, Some(0));
let next = totp_code(
RFC_SECRET.to_owned(),
89,
30,
FfiAlgorithm::Sha1,
FfiDigits::Eight,
)
.expect("should compute");
let offset = verify_code_with_drift(
RFC_SECRET.to_owned(),
1,
1,
FfiAlgorithm::Sha1,
FfiDigits::Eight,
next,
)
.expect("should verify");
assert_eq!(offset, Some(1));
}
#[test]
fn generated_secrets_are_32_bytes_and_unique() {
let a = generate_secret_base32().expect("should generate");
let b = generate_secret_base32().expect("should generate");
assert_ne!(a, b, "two generated secrets must differ");
assert_eq!(secret_byte_len(a).expect("should parse"), 32);
}
#[test]
fn colon_in_account_is_rejected_at_serialize() {
let auth = FfiOtpAuth {
kind: FfiEntryKind::Totp { period: 30 },
secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
issuer: None,
account: "a:b".to_owned(),
algorithm: FfiAlgorithm::Sha1,
digits: FfiDigits::Six,
};
assert!(
serialize_otpauth_uri(auth).is_err(),
"an account name containing a colon must be rejected"
);
}
#[derive(Default)]
struct TestKeys {
keys: std::sync::Mutex<std::collections::HashMap<String, p256::ecdsa::SigningKey>>,
}
impl TestKeys {
fn key_for(&self, label: &str) -> p256::ecdsa::SigningKey {
let mut keys = self.keys.lock().expect("no lock poisoning in tests");
keys.entry(label.to_owned())
.or_insert_with(|| {
let scalar = sha2::Digest::finalize({
let mut hasher = <sha2::Sha256 as sha2::Digest>::new();
sha2::Digest::update(&mut hasher, label.as_bytes());
hasher
});
p256::ecdsa::SigningKey::from_slice(&scalar).expect("the scalar must be valid")
})
.clone()
}
}
impl FfiDeviceKeys for TestKeys {
fn get_or_create(&self, label: String) -> Result<Vec<u8>, FfiError> {
let key = self.key_for(&label);
Ok(key.verifying_key().to_sec1_point(false).as_bytes().to_vec())
}
fn sign(&self, label: String, message: Vec<u8>) -> Result<Vec<u8>, FfiError> {
use p256::ecdsa::signature::Signer as _;
let key = self.key_for(&label);
let signature: p256::ecdsa::Signature = key.sign(&message);
Ok(signature.to_bytes().to_vec())
}
}
#[derive(Default)]
struct TestStore {
master: std::sync::Mutex<Option<Vec<u8>>>,
}
impl FfiSecureStore for TestStore {
fn load_master_key(&self) -> Result<Option<Vec<u8>>, FfiError> {
Ok(self
.master
.lock()
.expect("no lock poisoning in tests")
.clone())
}
fn store_master_key(&self, key: Vec<u8>) -> Result<(), FfiError> {
*self.master.lock().expect("no lock poisoning in tests") = Some(key);
Ok(())
}
fn delete_master_key(&self) -> Result<(), FfiError> {
*self.master.lock().expect("no lock poisoning in tests") = None;
Ok(())
}
fn is_hardware_backed(&self) -> bool {
false
}
}
fn sample_entry(name: &str) -> FfiVaultEntry {
FfiVaultEntry {
id: String::new(),
name: name.to_owned(),
issuer: Some("ACME".to_owned()),
account: "alice".to_owned(),
secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
algorithm: FfiAlgorithm::Sha1,
digits: FfiDigits::Six,
kind: FfiEntryKind::Totp { period: 30 },
pin_set: false,
note: String::new(),
created_at: 0,
updated_at: 0,
icon: None,
}
}
const NOW: u64 = 1_700_000_000;
#[test]
fn an_empty_vault_rejects_a_wrong_master_key() {
let store_a = Arc::new(TestStore::default());
let vault_a = FfiVault::register(
Arc::new(TestKeys::default()),
store_a,
"device-a".to_owned(),
)
.expect("registration should succeed");
assert!(
vault_a.is_empty(),
"precondition: this vault really is empty"
);
let data = vault_a.serialize().expect("should serialize");
let store_b = Arc::new(TestStore::default());
FfiVault::register(
Arc::new(TestKeys::default()),
store_b.clone(),
"device-b".to_owned(),
)
.expect("registration should succeed");
let error = FfiVault::open(store_b, data)
.err()
.expect("an empty vault must be rejected with a wrong key");
assert_eq!(
error.kind(),
"vault_decryption_failed",
"it must be \"cannot decrypt\", not \"not registered\": the two lead to completely different user interface actions"
);
}
#[test]
fn a_registered_vault_roundtrips_through_the_boundary() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys.clone(), store.clone(), "device-a".to_owned())
.expect("registration should succeed");
assert!(vault.is_empty(), "registration adds no entries");
let id = vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
assert_eq!(vault.entry_count(), 1);
let code = vault
.code_for(id.clone(), NOW)
.expect("should compute the code");
assert_eq!(code.len(), 6, "six-digit code");
let data = vault.serialize().expect("should serialize");
let reopened =
FfiVault::open(store, data).expect("should open with the master key from the store");
assert_eq!(reopened.entry_count(), 1);
assert_eq!(
reopened.code_for(id, NOW).expect("should compute the code"),
code,
"the same entry must produce the same code after reopening"
);
}
#[test]
fn updating_an_entry_keeps_its_id_and_leaves_no_tombstone() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
let id = vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let mut edited = sample_entry("GitHub (work)");
edited.id = id.clone();
vault
.update_entry(edited, NOW + 60)
.expect("should update the entry");
assert_eq!(vault.entry_count(), 1, "an update must not add an entry");
assert_eq!(vault.tombstone_count(), 0, "an update is not a deletion");
let entry = vault
.entry(id)
.expect("the identifier must still point at the entry");
assert_eq!(entry.name, "GitHub (work)");
assert_eq!(
entry.created_at, NOW,
"the creation time should be preserved"
);
}
#[test]
fn a_missing_entry_id_is_reported_as_not_found() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
let mut ghost = sample_entry("ghost");
ghost.id = "0".repeat(32);
let error = vault
.update_entry(ghost, NOW)
.expect_err("a nonexistent identifier should be reported");
assert_eq!(error.kind(), "vault_entry_not_found");
assert!(
!vault
.remove_entry("0".repeat(32), NOW)
.expect("should be callable"),
"removing a nonexistent entry should return false"
);
assert_eq!(vault.entry_count(), 0);
}
#[test]
fn login_transfers_the_master_key_across_the_boundary() {
let old_keys = Arc::new(TestKeys::default());
let old_store = Arc::new(TestStore::default());
let old_vault =
FfiVault::register(old_keys.clone(), old_store.clone(), "device-a".to_owned())
.expect("registration should succeed");
old_vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let data = old_vault.serialize().expect("should serialize");
let login = FfiLogin::begin().expect("should start the login");
let joiner_keys = Arc::new(TestKeys::default());
let joiner = FfiJoiner::from_qr(login.payload(), "device-b".to_owned())
.expect("should establish the session");
let request = joiner
.request(joiner_keys.clone())
.expect("should send the request");
let reply = login
.authorize(old_vault.clone(), request)
.expect("should authorize");
let joiner_store = Arc::new(TestStore::default());
let master = joiner
.complete(reply, joiner_keys, joiner_store.clone())
.expect("should complete the login");
assert_eq!(master.len(), 32, "the master key is 32 bytes");
assert!(
joiner_store.master.lock().expect("not poisoned").is_some(),
"the master key must already be in the new device's platform store"
);
let new_vault = FfiVault::open(joiner_store, data)
.expect("the new device should open the old device's vault");
assert_eq!(
new_vault.entry_count(),
1,
"the old device's entry should be visible"
);
}
#[test]
fn a_login_cannot_be_completed_twice_across_the_boundary() {
let old_keys = Arc::new(TestKeys::default());
let old_store = Arc::new(TestStore::default());
let old_vault = FfiVault::register(old_keys, old_store, "device-a".to_owned())
.expect("registration should succeed");
let login = FfiLogin::begin().expect("should start the login");
let joiner_keys = Arc::new(TestKeys::default());
let joiner = FfiJoiner::from_qr(login.payload(), "device-b".to_owned())
.expect("should establish the session");
let request = joiner
.request(joiner_keys.clone())
.expect("should send the request");
let reply = login
.authorize(old_vault, request)
.expect("should authorize");
let joiner_store = Arc::new(TestStore::default());
joiner
.complete(reply.clone(), joiner_keys.clone(), joiner_store.clone())
.expect("the first attempt should succeed");
let error = joiner
.complete(reply, joiner_keys, joiner_store)
.expect_err("the second attempt must fail");
assert_eq!(
error.kind(),
"pairing_authentication_failed",
"it must be the same error as a forged reply, so a replayer gets no distinguishing signal"
);
}
#[test]
fn a_recovery_file_restores_the_same_master_key() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let data = vault.serialize().expect("should serialize");
let file = vault
.export_recovery_key("correct horse".to_owned(), Some(8), Some(1), Some(1))
.expect("should export");
let new_store = Arc::new(TestStore::default());
let restored =
import_recovery_key(file.clone(), "correct horse".to_owned(), new_store.clone())
.expect("should import");
assert_eq!(restored.len(), 32);
let reopened = FfiVault::open(new_store, data).expect("should open");
assert_eq!(reopened.entry_count(), 1);
let wrong = import_recovery_key(
file,
"wrong passphrase".to_owned(),
Arc::new(TestStore::default()),
)
.expect_err("a wrong passphrase should fail");
assert_eq!(wrong.kind(), "recovery_authentication_failed");
}
#[test]
fn challenge_response_confirms_both_sides_across_the_boundary() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys.clone(), store, "device-a".to_owned())
.expect("registration should succeed");
let verifier = FfiVerifier::begin().expect("should start the challenge");
let responder =
FfiResponder::accept(verifier.challenge()).expect("should accept the challenge");
let response = responder
.respond(vault.clone(), "device-a".to_owned(), keys)
.expect("should produce the response");
assert!(
verifier
.verify(response, vault.clone())
.expect("should verify the response"),
"the second step should pass"
);
let session_id = verifier
.session_id()
.expect("a session identifier should exist after verification");
let confirmation = verifier
.confirmation()
.expect("a confirmation tag should exist after verification");
assert!(
responder
.confirm(vault, session_id, confirmation)
.expect("should confirm"),
"the third step should pass"
);
}
#[test]
fn a_challenge_cannot_be_verified_twice() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys.clone(), store, "device-a".to_owned())
.expect("registration should succeed");
let verifier = FfiVerifier::begin().expect("should start the challenge");
let responder =
FfiResponder::accept(verifier.challenge()).expect("should accept the challenge");
let response = responder
.respond(vault.clone(), "device-a".to_owned(), keys)
.expect("should produce the response");
verifier
.verify(response.clone(), vault.clone())
.expect("the first attempt should pass");
let error = verifier
.verify(response, vault)
.expect_err("the second attempt must fail");
assert_eq!(error.kind(), "auth_proof_failed");
}
#[test]
fn every_mapped_error_kind_is_distinct_and_known() {
let kinds = [
core_error_kind(&Error::SecretEmpty),
core_error_kind(&Error::VaultTruncated),
core_error_kind(&Error::VaultCorrupt),
core_error_kind(&Error::VaultDecryptionFailed),
core_error_kind(&Error::VaultEntryNotFound),
core_error_kind(&Error::VaultBadKeyCheck),
core_error_kind(&Error::VaultDuplicateGroupId),
core_error_kind(&Error::VaultGroupNotFound),
core_error_kind(&Error::VaultGroupUnknownEntry),
core_error_kind(&Error::VaultTopGroupImmutable),
core_error_kind(&Error::VaultGroupTooManyChildren {
limit: 1,
actual: 2,
}),
core_error_kind(&Error::VaultGroupDuplicateChild),
core_error_kind(&Error::VaultGroupCycle),
core_error_kind(&Error::VaultGroupDepthExceeded {
limit: 3,
actual: 4,
}),
core_error_kind(&Error::MasterKeyWrongLength {
expected: 32,
actual: 1,
}),
core_error_kind(&Error::DeviceKeyMissing),
core_error_kind(&Error::DeviceNotRegistered),
core_error_kind(&Error::DeviceIdentityMismatch),
core_error_kind(&Error::PairingAuthenticationFailed),
core_error_kind(&Error::RecoveryAuthenticationFailed),
core_error_kind(&Error::LockFileMalformed { reason: "test" }),
core_error_kind(&Error::LockParametersInvalid { reason: "test" }),
core_error_kind(&Error::LockPassphraseEmpty),
core_error_kind(&Error::LockPasswordRejected),
core_error_kind(&Error::LockEncryptionFailed),
core_error_kind(&Error::LockKdfFailed),
core_error_kind(&Error::AuthProofFailed),
core_error_kind(&Error::TimestampZero),
core_error_kind(&Error::SyncAlreadyApplied),
core_error_kind(&Error::SyncNoSessionFound),
core_error_kind(&Error::SyncSenderMismatch),
core_error_kind(&Error::SyncAuthenticationFailed),
core_error_kind(&Error::SyncIconRequiresByteTransport),
core_error_kind(&Error::SyncIdLogFull { limit: 4096 }),
core_error_kind(&Error::TransportFailed { reason: "test" }),
core_error_kind(&Error::TransportAlreadyUsed),
core_error_kind(&Error::TransportMessageTooLarge {
needed: 100,
limit: 10,
}),
core_error_kind(&Error::TransportUnsupported { operation: "test" }),
core_error_kind(&Error::ImportFileTooLarge {
limit: 1,
actual: 2,
}),
core_error_kind(&Error::ImportTooManyEntries {
limit: 1,
actual: 2,
}),
core_error_kind(&Error::ImportTooDeeplyNested { limit: 1 }),
core_error_kind(&Error::ImportMalformed { reason: "test" }),
core_error_kind(&Error::ImportUnsupportedVersion {
version: 2,
supported: 1,
}),
core_error_kind(&Error::ImportPasswordRequired),
core_error_kind(&Error::ImportEncryptionUnsupported { reason: "test" }),
core_error_kind(&Error::ImportWrongPassword),
core_error_kind(&Error::ImportCorrupted),
core_error_kind(&Error::ImportInvalidKdfParameters { reason: "test" }),
core_error_kind(&Error::ExportPasswordRequired),
core_error_kind(&Error::ExportPasswordEmpty),
core_error_kind(&Error::ExportQrTooLong { position: 1 }),
core_error_kind(&Error::ExportTooLarge),
core_error_kind(&Error::ExportParameterRefused { parameter: "test" }),
core_error_kind(&Error::PortableBadMagic),
core_error_kind(&Error::PortableUnsupportedVersion { version: 2 }),
core_error_kind(&Error::PortableTruncated),
core_error_kind(&Error::PortableLengthMismatch),
core_error_kind(&Error::PortableTooLarge {
limit: 1,
actual: 2,
}),
core_error_kind(&Error::PortableVaultFileWithoutKey),
core_error_kind(&Error::PortablePasswordRequired),
core_error_kind(&Error::Device(qualia_dik::Error::InvalidWrappedKey {
reason: "test",
code: 0,
})),
core_error_kind(&Error::Device(qualia_dik::Error::InvalidMasterKey {
reason: "test",
})),
core_error_kind(&Error::Device(qualia_dik::Error::KeyNotExportable {
reason: "test",
})),
core_error_kind(&Error::Device(qualia_dik::Error::HardwareUnavailable {
reason: String::from("test"),
})),
core_error_kind(&Error::Device(qualia_dik::Error::UnsupportedPlatform {
platform: "test",
})),
];
for kind in kinds {
assert_ne!(kind, "unknown", "a variant is not mapped to a stable kind");
}
let unique: std::collections::HashSet<_> = kinds.iter().collect();
assert_eq!(
unique.len(),
kinds.len(),
"two variants map to the same kind, so the client side cannot tell them apart"
);
}
#[test]
fn every_boundary_error_kind_is_distinct_and_known() {
let kinds: Vec<String> = vec![
FfiError::InputTooLong {
kind: "secret".to_owned(),
limit: 1,
actual: 2,
}
.kind()
.to_owned(),
FfiError::InputEmpty {
kind: "pin".to_owned(),
}
.kind()
.to_owned(),
FfiError::WrongCodeKind {
kind: "hotp".to_owned(),
wanted: "time".to_owned(),
}
.kind()
.to_owned(),
FfiError::InvalidDeviceId {
kind: "device_id".to_owned(),
}
.kind()
.to_owned(),
FfiError::UnknownConflict {
entry_id: "0".repeat(32),
}
.kind()
.to_owned(),
];
for kind in &kinds {
assert_ne!(kind, "unknown", "a variant is not mapped to a stable kind");
}
let unique: std::collections::HashSet<_> = kinds.iter().collect();
assert_eq!(
unique.len(),
kinds.len(),
"two variants map to the same kind, so the client side cannot tell them apart"
);
}
#[test]
fn each_kind_produces_its_own_shape_of_code() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
let motp = FfiVaultEntry {
kind: FfiEntryKind::Motp {
period: 10,
pin: Some("1234".to_owned()),
},
..sample_entry("mOTP")
};
let motp_id = vault.add_entry(motp, NOW).expect("should add the entry");
let code = vault
.code_for(motp_id.clone(), NOW)
.expect("should compute the mOTP code");
assert_eq!(code.len(), 6, "mOTP codes are six characters");
assert!(
code.chars().all(|character| character.is_ascii_hexdigit()),
"mOTP codes are hexadecimal, actual {code}"
);
let steam = FfiVaultEntry {
kind: FfiEntryKind::Steam,
digits: FfiDigits::Five,
..sample_entry("Steam")
};
let steam_id = vault.add_entry(steam, NOW).expect("should add the entry");
let code = vault
.code_for(steam_id, NOW)
.expect("should compute the Steam code");
assert_eq!(code.len(), 5, "Steam Guard codes are five characters");
let yandex = FfiVaultEntry {
kind: FfiEntryKind::Yandex {
period: 30,
pin: Some("1234".to_owned()),
},
digits: FfiDigits::Eight,
secret_base32: "LA2V6KMCGYMWWVEW64RNP3JA3I".to_owned(),
..sample_entry("Yandex")
};
let yandex_id = vault.add_entry(yandex, NOW).expect("should add the entry");
let code = vault
.code_for(yandex_id, NOW)
.expect("should compute the Yandex code");
assert_eq!(code.len(), 8, "Yandex OTP codes are eight letters");
assert!(
code.chars().all(|character| character.is_ascii_lowercase()),
"Yandex OTP codes are lower-case letters, actual {code}"
);
let stored = vault.entry(motp_id).expect("the entry exists");
assert!(stored.pin_set, "the PIN is reported as set");
assert_eq!(
stored.kind,
FfiEntryKind::Motp {
period: 10,
pin: None
},
"and its value does not cross the boundary"
);
}
#[test]
fn a_code_the_kind_cannot_produce_is_refused() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
let totp_id = vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let error = vault
.hotp_for(totp_id, 1)
.expect_err("a time-based entry has no counter to advance");
assert_eq!(error.kind(), "wrong_code_kind");
let hotp = FfiVaultEntry {
kind: FfiEntryKind::Hotp { counter: 0 },
..sample_entry("Bank")
};
let hotp_id = vault.add_entry(hotp, NOW).expect("should add the entry");
let error = vault
.code_for(hotp_id, NOW)
.expect_err("an event-based entry has no clock to read");
assert_eq!(error.kind(), "wrong_code_kind");
}
#[test]
fn an_export_previews_compares_and_applies() {
let keys = Arc::new(TestKeys::default());
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(keys, store, "device-a".to_owned())
.expect("registration should succeed");
let existing_id = vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let export = "otpauth://totp/ACME:alice?secret=JBSWY3DPEHPK3PXP&issuer=ACME\n\
otpauth://totp/Bank:bob?secret=KRSXG5CTMVRXEZLU&issuer=Bank\n";
assert_eq!(
detect_import_format(export.as_bytes().to_vec()),
Some(FfiImportFormat::Text)
);
let import =
FfiImport::preview(export.as_bytes().to_vec(), FfiImportFormat::Text, None, NOW)
.expect("the export should be read");
let entries = import.entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].position, 1);
assert_eq!(entries[0].account, "alice");
assert!(import.problems().is_empty());
let matches = import.compare(vault.clone());
assert_eq!(matches.len(), 2);
assert_eq!(
matches[0].existing,
vec![existing_id.clone()],
"the same secret identifies the account"
);
assert!(!matches[0].identical, "the name differs");
assert!(matches[0].differences.contains(&"name".to_owned()));
assert!(matches[1].existing.is_empty(), "the second entry is new");
let applied = import
.apply(
vault.clone(),
vec![FfiImportChoice {
position: 1,
decision: FfiImportDecision::Skip,
}],
)
.expect("the import should apply");
assert_eq!(applied.added, 1);
assert_eq!(applied.skipped, 1);
assert_eq!(vault.entry_count(), 2);
assert_eq!(
vault.entry(existing_id).map(|entry| entry.name),
Some("GitHub".to_owned()),
"a skipped entry keeps what the vault held"
);
let again = import
.apply(vault.clone(), Vec::new())
.expect("applies again");
assert_eq!(again.added, 1);
assert_eq!(again.skipped, 1);
assert_eq!(vault.entry_count(), 3);
let settled = import
.apply(
vault.clone(),
vec![FfiImportChoice {
position: 1,
decision: FfiImportDecision::Skip,
}],
)
.expect("applies once more");
assert_eq!(settled.added, 0);
assert_eq!(settled.skipped, 2);
assert_eq!(vault.entry_count(), 3);
let encrypted = br#"{"version":1,"header":{"slots":[],"params":{}},"db":"AAAA"}"#;
assert!(
FfiImport::preview(encrypted.to_vec(), FfiImportFormat::Json, None, NOW).is_err(),
"an encrypted export needs a password"
);
}
fn kind_shape(kind: &FfiEntryKind) -> FfiEntryKind {
match kind {
FfiEntryKind::Motp { period, .. } => FfiEntryKind::Motp {
period: *period,
pin: None,
},
FfiEntryKind::Yandex { period, .. } => FfiEntryKind::Yandex {
period: *period,
pin: None,
},
other => other.clone(),
}
}
#[test]
fn the_export_formats_describe_themselves() {
for format in [
FfiExportFormat::Vault,
FfiExportFormat::Text,
FfiExportFormat::Json,
FfiExportFormat::Html,
] {
assert!(
!export_format_needs_password(format),
"{format:?} is written as it stands"
);
}
for format in [
FfiExportFormat::EncryptedVault,
FfiExportFormat::EncryptedJson,
] {
assert!(
export_format_needs_password(format),
"{format:?} asks for a password"
);
}
assert_eq!(export_format_extension(FfiExportFormat::Vault), "q2fa");
assert_eq!(
export_format_extension(FfiExportFormat::EncryptedVault),
"q2fa",
"both vault containers are one file with and without a password"
);
assert_eq!(export_format_extension(FfiExportFormat::Text), "txt");
assert_eq!(export_format_extension(FfiExportFormat::Json), "json");
assert_eq!(
export_format_extension(FfiExportFormat::EncryptedJson),
"json",
"both JSON forms are one file with and without a password"
);
assert_eq!(export_format_extension(FfiExportFormat::Html), "html");
assert_eq!(
export_format_group_carrying(FfiExportFormat::Vault),
FfiGroupCarrying::FullTree
);
assert_eq!(
export_format_group_carrying(FfiExportFormat::EncryptedVault),
FfiGroupCarrying::FullTree
);
assert_eq!(
export_format_group_carrying(FfiExportFormat::Json),
FfiGroupCarrying::FlatGroups,
"the JSON forms keep the group names and memberships, not the nesting"
);
assert_eq!(
export_format_group_carrying(FfiExportFormat::EncryptedJson),
FfiGroupCarrying::FlatGroups
);
assert_eq!(
export_format_group_carrying(FfiExportFormat::Text),
FfiGroupCarrying::None,
"a list of URIs has nowhere to put a group"
);
assert_eq!(
export_format_group_carrying(FfiExportFormat::Html),
FfiGroupCarrying::None
);
assert!(!export_format_carries_groups(FfiExportFormat::Text));
assert!(export_format_carries_groups(FfiExportFormat::Json));
assert!(export_format_carries_groups(FfiExportFormat::Vault));
assert_eq!(max_group_depth(), 3);
}
#[test]
fn a_plain_portable_vault_restores_through_the_boundary() {
let (vault, _) = registered_vault("device-a");
vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("the entry should be added");
let bytes = vault
.export(FfiExportFormat::Vault, None)
.expect("the plain container is written");
assert_eq!(
detect_portable_file(bytes.clone()),
Some(FfiPortableKind::PlainVault)
);
let store = Arc::new(TestStore::default());
let restored = open_portable_file(bytes, None, store.clone()).expect("it opens");
assert!(!restored.key_only);
let restored = restored.vault.expect("the file carried a vault");
assert_eq!(restored.entry_count(), 1, "the entry came along");
assert!(
store
.master
.lock()
.expect("no lock poisoning in tests")
.is_some(),
"the restored key must be in the platform store"
);
}
#[test]
fn an_encrypted_portable_vault_needs_its_passphrase() {
let (vault, _) = registered_vault("device-a");
vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("the entry should be added");
let bytes = vault
.export(
FfiExportFormat::EncryptedVault,
Some("correct horse".to_owned()),
)
.expect("the encrypted container is written");
assert_eq!(
detect_portable_file(bytes.clone()),
Some(FfiPortableKind::EncryptedVault)
);
let store = Arc::new(TestStore::default());
let missing = open_portable_file(bytes.clone(), None, store.clone());
assert!(
missing.is_err(),
"a passphrase-protected file must not open without one"
);
assert!(
store
.master
.lock()
.expect("no lock poisoning in tests")
.is_none(),
"a failed open must not write a key"
);
let wrong = open_portable_file(bytes.clone(), Some("wrong".to_owned()), store.clone());
assert!(wrong.is_err());
let opened = open_portable_file(bytes, Some("correct horse".to_owned()), store)
.expect("the right passphrase opens it");
assert_eq!(
opened
.vault
.expect("the file carried a vault")
.entry_count(),
1
);
}
#[test]
fn a_bare_vault_file_and_a_key_only_file_are_told_apart() {
let (vault, _) = registered_vault("device-a");
let bare = vault.serialize().expect("should serialize");
assert_eq!(
detect_portable_file(bare.clone()),
None,
"a bare vault file is not a portable container"
);
let refused = match open_portable_file(bare, None, Arc::new(TestStore::default())) {
Ok(_) => panic!("a bare vault file carries no key"),
Err(error) => error,
};
assert_eq!(refused.kind(), "portable_vault_file_without_key");
let key_only = vault
.export_recovery_key("passphrase".to_owned(), None, None, None)
.expect("the old key-only file is written");
assert_eq!(
detect_portable_file(key_only.clone()),
Some(FfiPortableKind::KeyOnly)
);
let store = Arc::new(TestStore::default());
let outcome = open_portable_file(key_only, Some("passphrase".to_owned()), store.clone())
.expect("the file is valid");
assert!(outcome.key_only, "the old shape carries no entries");
assert!(outcome.vault.is_none());
assert!(
store
.master
.lock()
.expect("no lock poisoning in tests")
.is_none(),
"nothing was written, so a local vault cannot be left on a new key"
);
}
#[test]
fn an_export_round_trips_across_the_boundary() {
let (vault, _) = registered_vault("device-a");
let written = [
sample_entry("GitHub"),
FfiVaultEntry {
kind: FfiEntryKind::Hotp { counter: 7 },
..sample_entry("Bank")
},
FfiVaultEntry {
kind: FfiEntryKind::Steam,
digits: FfiDigits::Five,
..sample_entry("Steam")
},
FfiVaultEntry {
kind: FfiEntryKind::Motp {
period: 10,
pin: Some("1234".to_owned()),
},
..sample_entry("mOTP")
},
FfiVaultEntry {
kind: FfiEntryKind::Yandex {
period: 30,
pin: Some("1234".to_owned()),
},
secret_base32: "LA2V6KMCGYMWWVEW64RNP3JA3I".to_owned(),
..sample_entry("Yandex")
},
];
for entry in &written {
vault
.add_entry(entry.clone(), NOW)
.expect("the entry should be added");
}
for (format, import_format) in [
(FfiExportFormat::Text, FfiImportFormat::Text),
(FfiExportFormat::Json, FfiImportFormat::Json),
(FfiExportFormat::Html, FfiImportFormat::Html),
(FfiExportFormat::EncryptedJson, FfiImportFormat::Json),
] {
let password = export_format_needs_password(format).then(|| "correct horse".to_owned());
let bytes = vault
.export(format, password.clone())
.unwrap_or_else(|error| panic!("{format:?} should be written: {error}"));
let import = FfiImport::preview(bytes, import_format, password, NOW)
.unwrap_or_else(|error| panic!("{format:?} should read back: {error}"));
assert!(
import.problems().is_empty(),
"{format:?} produced problems: {:?}",
import.problems()
);
let read = import.entries();
assert_eq!(read.len(), written.len(), "{format:?}");
for entry in &written {
let expected = kind_shape(&entry.kind);
assert!(
read.iter().any(|one| kind_shape(&one.kind) == expected),
"{format:?} came back without {expected:?}: {:?}",
read.iter().map(|one| &one.kind).collect::<Vec<_>>()
);
}
}
}
#[test]
fn an_encrypted_export_needs_a_real_password() {
let (vault, _) = registered_vault("device-a");
vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("the entry should be added");
let missing = vault
.export(FfiExportFormat::EncryptedJson, None)
.expect_err("an encrypted export needs a password");
assert_eq!(missing.kind(), "export_password_required");
let empty = vault
.export(FfiExportFormat::EncryptedJson, Some(String::new()))
.expect_err("an empty password protects nothing");
assert_eq!(empty.kind(), "export_password_empty");
let bytes = vault
.export(
FfiExportFormat::EncryptedJson,
Some("correct horse".to_owned()),
)
.expect("the export should be written");
assert!(
!String::from_utf8_lossy(&bytes).contains("JBSWY3DPEHPK3PXP"),
"the secret must not survive in the file as it stands"
);
let without = match FfiImport::preview(bytes.clone(), FfiImportFormat::Json, None, NOW) {
Err(error) => error,
Ok(_) => panic!("an encrypted export must not open without a password"),
};
assert_eq!(without.kind(), "import_password_required");
let wrong = match FfiImport::preview(
bytes.clone(),
FfiImportFormat::Json,
Some("correct horsf".to_owned()),
NOW,
) {
Err(error) => error,
Ok(_) => panic!("a wrong password must not open the file"),
};
assert_eq!(wrong.kind(), "import_wrong_password");
let opened = FfiImport::preview(
bytes,
FfiImportFormat::Json,
Some("correct horse".to_owned()),
NOW,
)
.expect("the password opens it");
assert_eq!(opened.entries().len(), 1);
}
#[test]
fn an_exported_html_file_keeps_a_step_that_is_not_the_default() {
let (vault, _) = registered_vault("device-a");
vault
.add_entry(
FfiVaultEntry {
kind: FfiEntryKind::Totp { period: 90 },
..sample_entry("GitHub")
},
NOW,
)
.expect("the entry should be added");
let bytes = vault
.export(FfiExportFormat::Html, None)
.expect("the export should be written");
let import = FfiImport::preview(bytes, FfiImportFormat::Html, None, NOW)
.expect("the export should read back");
let read = import.entries();
assert_eq!(read.len(), 1);
assert_eq!(read[0].kind, FfiEntryKind::Totp { period: 90 });
assert!(
!read[0].period_assumed,
"the step was in the code, so nothing was assumed"
);
}
#[test]
fn groups_and_orderings_cross_the_boundary() {
let (vault, _) = registered_vault("device-a");
let top = "0".repeat(32);
let first = vault
.add_entry(sample_entry("GitHub"), NOW)
.expect("should add the entry");
let second = vault
.add_entry(sample_entry("Bank"), NOW)
.expect("should add the entry");
let groups = vault.groups();
assert_eq!(groups.len(), 1);
assert!(groups[0].top);
assert_eq!(groups[0].id, top);
assert!(groups[0].name.is_empty());
assert_eq!(
groups[0].children,
vec![
FfiGroupChild::Entry { id: first.clone() },
FfiGroupChild::Entry { id: second.clone() }
]
);
assert_eq!(groups[0].entry_ids, vec![first.clone(), second.clone()]);
assert_eq!(vault.group_of(first.clone()), Some(top.clone()));
let work = vault
.create_group("Work".to_owned())
.expect("creates the group");
vault
.assign_to_group(first.clone(), work.clone())
.expect("moves the entry");
let groups = vault.groups();
assert_eq!(groups.len(), 2);
assert!(groups[0].top, "the top group stays first");
assert_eq!(groups[1].name, "Work");
assert_eq!(groups[1].entry_ids, vec![first.clone()]);
assert_eq!(
groups[0].entry_ids,
vec![second.clone()],
"the entry left the top group"
);
assert_eq!(vault.group_of(first.clone()), Some(work.clone()));
let sub = vault
.create_group_in(work.clone(), "Sub".to_owned())
.expect("creates the child group");
assert_eq!(vault.parent_of(sub.clone()), Some(work.clone()));
assert_eq!(vault.depth_of(sub.clone()), Some(2));
assert_eq!(vault.parent_of(work.clone()), Some(top.clone()));
assert_eq!(
vault.children_of(work.clone()),
Some(vec![
FfiGroupChild::Entry { id: first.clone() },
FfiGroupChild::Group { id: sub.clone() }
])
);
let names: Vec<String> = vault.groups().into_iter().map(|group| group.name).collect();
assert_eq!(
names,
vec![String::new(), "Work".to_owned(), "Sub".to_owned()]
);
vault
.set_children(
top.clone(),
vec![
FfiGroupChild::Entry { id: second.clone() },
FfiGroupChild::Group { id: work.clone() },
],
)
.expect("the list is valid");
assert_eq!(vault.parent_of(sub.clone()), Some(work.clone()));
vault
.rename_group(work.clone(), "Work stuff".to_owned())
.expect("renames");
assert_eq!(vault.groups()[1].name, "Work stuff");
assert!(
vault.rename_group(top.clone(), "Nope".to_owned()).is_err(),
"the top group has no name"
);
let cycle =
vault.set_children(sub.clone(), vec![FfiGroupChild::Group { id: work.clone() }]);
assert!(
cycle.is_err(),
"a group inside its own ancestor must be refused"
);
vault
.set_ordering(work.clone(), vec![first.clone(), second.clone()])
.expect("sets the ordering");
assert_eq!(
vault.groups()[1].entry_ids,
vec![first.clone(), second.clone()],
"the entries are where the ordering put them"
);
vault.remove_group(work.clone()).expect("removes the group");
let groups = vault.groups();
assert_eq!(groups.len(), 2, "the top group and Sub");
assert_eq!(vault.entry_count(), 2);
assert_eq!(
groups[0].entry_ids,
vec![first.clone(), second.clone()],
"both entries are back in the top group"
);
assert_eq!(
vault.parent_of(sub),
Some(top),
"the child group took its parent's place"
);
assert!(vault.remove_group("0".repeat(32)).is_err(), "the top group");
}
#[test]
fn an_icon_crosses_the_boundary() {
let (vault, _) = registered_vault("device-a");
let icon = FfiEntryIcon {
mime: FfiIconMime::Png,
bytes: vec![0x89, b'P', b'N', b'G'],
};
let id = vault
.add_entry(
FfiVaultEntry {
icon: Some(icon.clone()),
..sample_entry("with icon")
},
NOW,
)
.expect("the entry should be added");
let read = vault.entry(id.clone()).expect("the entry is there");
assert_eq!(read.icon, Some(icon.clone()));
let debug = format!("{read:?}");
assert!(
!debug.contains("137, 80, 78, 71"),
"the icon bytes must not reach a log: {debug}"
);
let bytes = vault.serialize().expect("should serialize");
let store = Arc::new(TestStore::default());
store
.store_master_key(vault.lock().master_key().expose().to_vec())
.expect("the key is stored");
let reopened = FfiVault::open(store, bytes).expect("the file opens");
assert_eq!(
reopened.entry(id).expect("the entry is there").icon,
Some(icon),
"the picture survives the file"
);
}
#[test]
fn key_material_never_reaches_a_debug_printout() {
let entry = sample_entry("GitHub");
let printed = format!("{entry:?}");
assert!(
!printed.contains("JBSWY3DPEHPK3PXP"),
"the secret must not appear: {printed}"
);
assert!(printed.contains("<redacted>"), "actual {printed}");
let auth = FfiOtpAuth {
kind: FfiEntryKind::Motp {
period: 10,
pin: Some("5210481216086702".to_owned()),
},
secret_base32: "JBSWY3DPEHPK3PXP".to_owned(),
issuer: None,
account: "alice".to_owned(),
algorithm: FfiAlgorithm::Sha1,
digits: FfiDigits::Six,
};
let printed = format!("{auth:?}");
assert!(
!printed.contains("JBSWY3DPEHPK3PXP"),
"the secret must not appear: {printed}"
);
assert!(
!printed.contains("5210481216086702"),
"the PIN must not appear: {printed}"
);
assert!(
format!("{:?}", auth.kind).contains("<set>"),
"the fact that a PIN is set is what an operator needs"
);
}
static PLATFORM_LABEL_SEQUENCE: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
fn test_label(name: &str) -> String {
let sequence = PLATFORM_LABEL_SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let pid = std::process::id();
format!("Q2faTest-{name}-{pid}-{sequence}")
}
#[test]
fn an_oversized_label_is_rejected_before_the_platform_is_touched() {
match FfiPlatformKeys::open("a".repeat(MAX_LABEL_CHARS + 1)) {
Err(FfiError::InputTooLong { kind, limit, .. }) => {
assert_eq!(kind, "label");
assert_eq!(limit, MAX_LABEL_CHARS as u64);
}
Err(_) => panic!("expected an InputTooLong error, got another error kind"),
Ok(_) => panic!("an oversized label must not reach the platform key store"),
}
}
#[test]
fn platform_keys_forward_to_the_platform_key_store() {
let label = test_label("platform-keys");
let keys = match FfiPlatformKeys::open(label.clone()) {
Ok(keys) => keys,
Err(error) => {
println!("skipping: no usable platform key store on this machine ({error})");
return;
}
};
let public_key = keys
.device_public_key()
.expect("the public key of the opened identity should be readable");
assert_eq!(
public_key.len(),
65,
"the public key must be a 65-byte SEC1 uncompressed point"
);
let public_key = qualia_dik::DevicePublicKey::from_sec1_bytes(&public_key)
.expect("the platform must return a point on P-256");
let message = b"\x01Qualia2FA platform keys";
let signature = keys.sign(message.to_vec()).expect("signing should succeed");
assert_eq!(
signature.len(),
64,
"the signature must be the 64-byte raw r||s, not DER"
);
let signature = qualia_dik::DeviceSignature::from_bytes(&signature)
.expect("a 64-byte raw signature should parse");
qualia_dik::device::verify(&public_key, message, &signature)
.expect("the signature must verify over the raw message, not over a digest");
let backend = keys.backend();
assert!(
backend == "tpm" || backend == "software",
"an identity that opened must report a real backend, got {backend}"
);
assert_eq!(
keys.is_hardware_backed(),
backend == "tpm",
"is_hardware_backed must agree with the backend that is reported"
);
let reopened = FfiPlatformKeys::open(label.clone()).expect("reopening should succeed");
assert_eq!(
reopened
.device_public_key()
.expect("the public key should be readable again"),
public_key.as_sec1_bytes().to_vec(),
"the identity under one label changed: the key was not persisted"
);
DeviceKeyStore::delete(&keys.store, label.as_str())
.expect("the test identity should be removable");
}
const TEST_MASTER_KEY: [u8; MASTER_KEY_LEN] = [0x5A; MASTER_KEY_LEN];
fn direct_store() -> Arc<FfiDirectMasterKeyStore> {
FfiDirectMasterKeyStore::with_store(Box::new(MockDirectMasterKeyStore::new()))
}
fn wrapped_store() -> Arc<FfiWrappedMasterKeyStore> {
FfiWrappedMasterKeyStore::with_store(Box::new(MockWrappedMasterKeyStore::new()))
}
#[test]
fn the_direct_master_key_round_trips_across_the_boundary() {
let store = direct_store();
let label = String::from("direct-master-key");
assert_eq!(
store.backend(),
"software",
"the store must report the backend that answered"
);
assert!(
!store.is_hardware_backed(),
"a software backend must not be reported as hardware backed"
);
store
.store(label.clone(), TEST_MASTER_KEY.to_vec())
.expect("storing the material should succeed");
assert_eq!(
store
.load(label.clone())
.expect("the material should be readable"),
TEST_MASTER_KEY.to_vec(),
"the direct form returned material other than what was stored"
);
store
.delete(label.clone())
.expect("deleting the key should succeed");
assert!(
store.load(label.clone()).is_err(),
"the key is still readable after it was deleted"
);
}
#[test]
fn the_wrapped_master_key_round_trips_across_the_boundary() {
let store = wrapped_store();
let label = String::from("wrapped-master-key");
assert_eq!(
store.backend(),
"software",
"the store must report the backend that answered"
);
assert!(
!store.is_hardware_backed(),
"a software backend must not be reported as hardware backed"
);
let wrapped = store
.store(label.clone(), TEST_MASTER_KEY.to_vec())
.expect("wrapping the material should succeed");
assert!(
!wrapped.is_empty() && wrapped != TEST_MASTER_KEY.to_vec(),
"the wrapped form has to be bytes of its own, not the material"
);
assert_eq!(
store
.load(label.clone(), wrapped.clone())
.expect("the wrapped bytes should unwrap"),
TEST_MASTER_KEY.to_vec(),
"the wrapped form returned material other than what was stored"
);
assert_eq!(
store
.load(label.clone(), wrapped.clone())
.expect("the wrapped bytes should unwrap again"),
TEST_MASTER_KEY.to_vec(),
"the wrapped bytes stopped unwrapping after a first use"
);
store
.delete(label.clone())
.expect("deleting the key-encryption key should succeed");
assert!(
store.load(label.clone(), wrapped).is_err(),
"the wrapped bytes still unwrap after the key-encryption key was deleted"
);
}
#[test]
fn a_wrapped_blob_that_does_not_unwrap_is_refused() {
let store = wrapped_store();
let label = String::from("damaged-wrapped-key");
let mut wrapped = store
.store(label.clone(), TEST_MASTER_KEY.to_vec())
.expect("wrapping the material should succeed");
let last = wrapped.len() - 1;
wrapped[last] ^= 0x01;
let error = store
.load(label.clone(), wrapped)
.expect_err("a damaged blob must not unwrap");
assert_eq!(
error.kind(),
"invalid_wrapped_key",
"a damaged blob has to be reported as such rather than as a store failure"
);
let error = store
.load(label.clone(), vec![0_u8; 8])
.expect_err("a truncated blob must not unwrap");
assert_eq!(
error.kind(),
"invalid_wrapped_key",
"a truncated blob has to be reported as such"
);
let other = String::from("another-wrapped-key");
store
.store(other.clone(), [0x11_u8; MASTER_KEY_LEN].to_vec())
.expect("the second entry should wrap");
let foreign = store
.store(other, TEST_MASTER_KEY.to_vec())
.expect("the second entry should wrap again");
let error = store
.load(label, foreign)
.expect_err("bytes of another store entry must not unwrap");
assert_eq!(
error.kind(),
"invalid_wrapped_key",
"bytes of another store entry have to be refused"
);
}
#[test]
fn master_key_material_never_reaches_an_error_message() {
let store = wrapped_store();
let label = String::from("material-in-messages");
let material = [0xA5_u8; MASTER_KEY_LEN];
let wrapped = store
.store(label.clone(), material.to_vec())
.expect("wrapping the material should succeed");
let mut damaged = wrapped.clone();
let last = damaged.len() - 1;
damaged[last] ^= 0x01;
let error = store
.load(label, damaged)
.expect_err("a damaged blob must not unwrap");
let rendered = format!("{error} | {error:?}");
for leak in [
to_hex(&material),
format!("{material:?}"),
to_hex(&wrapped),
format!("{wrapped:?}"),
] {
assert!(
!rendered.contains(&leak),
"an error message carries master key material or the blob wrapping it: {rendered}"
);
}
}
#[test]
fn an_oversized_label_is_rejected_before_the_master_key_store_is_reached() {
let store = direct_store();
let error = store
.store("a".repeat(MAX_LABEL_CHARS + 1), vec![0_u8; 1])
.expect_err("an oversized label must be rejected");
assert_eq!(error.kind(), "input_too_long");
}
#[test]
fn a_master_key_of_the_wrong_length_is_refused() {
let store = direct_store();
let error = store
.store(
String::from("short-master-key"),
vec![0_u8; MASTER_KEY_LEN - 1],
)
.expect_err("a short master key must be refused");
assert_eq!(
error.kind(),
"master_key_wrong_length",
"a wrong length has to be reported with the kind the vault paths already use"
);
}
struct MemoryChannel {
me: String,
caps: FfiTransportCapabilities,
outbox: std::sync::Mutex<Vec<(FfiRecipient, Vec<u8>)>>,
inbox: std::sync::Mutex<Vec<FfiReceivedMessage>>,
peers: Vec<String>,
}
impl MemoryChannel {
fn new(me: String) -> Self {
Self {
me,
caps: FfiTransportCapabilities {
roster: FfiRoster::TwoParty,
max_message_len: None,
max_qr_version: None,
single_use: false,
can_carry_payload: true,
},
outbox: std::sync::Mutex::new(Vec::new()),
inbox: std::sync::Mutex::new(Vec::new()),
peers: Vec::new(),
}
}
fn deliver_to(&self, target: &Self, honest: bool) {
let outbox = self.outbox.lock().expect("no lock poisoning in tests");
let mut inbox = target.inbox.lock().expect("no lock poisoning in tests");
for (recipient, bytes) in outbox.iter() {
let addressed = match recipient {
FfiRecipient::All => true,
FfiRecipient::One { device_id } => *device_id == target.me,
};
if !addressed {
continue;
}
inbox.push(FfiReceivedMessage {
sender: if honest {
self.me.clone()
} else {
target.me.clone()
},
message: bytes.clone(),
});
}
}
fn clear_outbox(&self) {
self.outbox
.lock()
.expect("no lock poisoning in tests")
.clear();
}
fn inject(&self, claimed_sender: String, message: Vec<u8>) {
self.inbox
.lock()
.expect("no lock poisoning in tests")
.push(FfiReceivedMessage {
sender: claimed_sender,
message,
});
}
}
impl FfiTransport for MemoryChannel {
fn capabilities(&self) -> FfiTransportCapabilities {
self.caps
}
fn local_device(&self) -> String {
self.me.clone()
}
fn send(&self, to: FfiRecipient, message: Vec<u8>) -> Result<(), FfiError> {
self.outbox
.lock()
.expect("no lock poisoning in tests")
.push((to, message));
Ok(())
}
fn receive(&self) -> Result<Option<FfiReceivedMessage>, FfiError> {
let mut inbox = self.inbox.lock().expect("no lock poisoning in tests");
if inbox.is_empty() {
return Ok(None);
}
Ok(Some(inbox.remove(0)))
}
fn peers(&self) -> Result<Vec<String>, FfiError> {
Ok(self.peers.clone())
}
}
fn test_device_id(digit: char) -> String {
std::iter::repeat_n(digit, 64).collect()
}
fn registered_vault(label: &str) -> (Arc<FfiVault>, Arc<TestStore>) {
let store = Arc::new(TestStore::default());
let vault = FfiVault::register(
Arc::new(TestKeys::default()),
store.clone(),
label.to_owned(),
)
.expect("registration should succeed");
(vault, store)
}
fn vault_of_the_same_account(store: &TestStore) -> Arc<FfiVault> {
let master = store
.master
.lock()
.expect("no lock poisoning in tests")
.clone()
.expect("the account is registered, so a master key exists");
FfiVault::from_master_key(master).expect("32 bytes are a master key")
}
struct TwoDevices {
vault_a: Arc<FfiVault>,
vault_b: Arc<FfiVault>,
session_a: Arc<FfiSyncSession>,
session_b: Arc<FfiSyncSession>,
channel_a: Arc<MemoryChannel>,
channel_b: Arc<MemoryChannel>,
entry_id: String,
}
impl TwoDevices {
fn with_one_shared_entry() -> Self {
let (vault_a, store_a) = registered_vault("device-a");
let vault_b = vault_of_the_same_account(&store_a);
let entry_id = vault_a
.add_entry(sample_entry("GitHub"), NOW)
.expect("the entry should be added");
let devices = Self {
session_a: FfiSyncSession::open(vault_a.clone()),
session_b: FfiSyncSession::open(vault_b.clone()),
channel_a: Arc::new(MemoryChannel::new(test_device_id('a'))),
channel_b: Arc::new(MemoryChannel::new(test_device_id('b'))),
vault_a,
vault_b,
entry_id,
};
let outcome = devices.sync_a_to_b();
assert_eq!(outcome.added, 1, "precondition: B receives the entry");
devices.channel_a.clear_outbox();
devices
}
fn sync_a_to_b(&self) -> FfiSyncOutcome {
self.session_a
.send(
self.channel_a.clone(),
FfiRecipient::All,
FfiPayloadKind::Vault,
)
.expect("sending should succeed");
self.channel_a.deliver_to(&self.channel_b, true);
self.session_b
.receive(self.channel_b.clone())
.expect("receiving should succeed")
}
}
#[test]
fn two_vaults_complete_a_sync_across_the_boundary() {
let (vault_a, store_a) = registered_vault("device-a");
let vault_b = vault_of_the_same_account(&store_a);
let entry_id = vault_a
.add_entry(sample_entry("GitHub"), NOW)
.expect("the entry should be added");
assert_eq!(vault_b.entry_count(), 0, "precondition: B starts empty");
let session_a = FfiSyncSession::open(vault_a.clone());
let session_b = FfiSyncSession::open(vault_b.clone());
let channel_a = Arc::new(MemoryChannel::new(test_device_id('a')));
let channel_b = Arc::new(MemoryChannel::new(test_device_id('b')));
let plan = session_a
.plan_share(channel_a.clone())
.expect("planning should succeed");
assert!(
matches!(plan, FfiSharePlan::FullPayload { len } if len > 0),
"a vault with one entry plans as a full payload: {plan:?}"
);
let sync_id = session_a
.send(channel_a.clone(), FfiRecipient::All, FfiPayloadKind::Vault)
.expect("sending should succeed");
assert_eq!(sync_id.len(), 64, "a payload identifier is 32 bytes in hex");
channel_a.deliver_to(&channel_b, true);
let outcome = session_b
.receive(channel_b.clone())
.expect("receiving should succeed");
assert_eq!(outcome.added, 1, "the entry must arrive: {outcome:?}");
assert!(
!outcome.needs_adjudication,
"an ordinary sync asks the user nothing: {outcome:?}"
);
assert!(!outcome.made_no_change, "something did change: {outcome:?}");
assert_eq!(vault_b.entry_count(), 1);
let arrived = vault_b.entries();
assert_eq!(arrived[0].name, "GitHub");
assert_eq!(
arrived[0].id, entry_id,
"the entry keeps its identifier, which is the only thing the two devices agree on"
);
let replay = session_b
.receive(channel_b.clone())
.expect_err("nothing is queued any more, so the channel reports no session");
assert_eq!(replay.kind(), "sync_no_session_found");
channel_a.deliver_to(&channel_b, true);
let replay = session_b
.receive(channel_b.clone())
.expect_err("the same payload must not be applied twice");
assert_eq!(replay.kind(), "sync_already_applied");
session_b
.send(channel_b.clone(), FfiRecipient::All, FfiPayloadKind::Vault)
.expect("sending back should succeed");
channel_b.deliver_to(&channel_a, true);
let back = session_a
.receive(channel_a.clone())
.expect("receiving should succeed");
assert!(
back.made_no_change,
"A already holds everything B has: {back:?}"
);
assert!(!back.needs_adjudication);
}
#[test]
fn a_payload_whose_sender_disagrees_with_the_channel_is_refused() {
let devices = TwoDevices::with_one_shared_entry();
let mut second_entry = sample_entry("GitLab");
second_entry.name = "GitLab".to_owned();
devices
.vault_a
.add_entry(second_entry, NOW + 10)
.expect("the entry should be added");
devices
.session_a
.send(
devices.channel_a.clone(),
FfiRecipient::All,
FfiPayloadKind::Vault,
)
.expect("sending should succeed");
devices.channel_a.deliver_to(&devices.channel_b, false);
let error = devices
.session_b
.receive(devices.channel_b.clone())
.expect_err("a misreported sender must be refused");
assert_eq!(error.kind(), "sync_sender_mismatch");
assert_eq!(
devices.vault_b.entry_count(),
1,
"a refused payload must not modify the vault"
);
}
#[test]
fn an_identifier_that_is_not_hexadecimal_is_refused() {
let (vault, _) = registered_vault("device-a");
let session = FfiSyncSession::open(vault);
let channel = Arc::new(MemoryChannel::new("not a device identifier".to_owned()));
let error = session
.send(channel, FfiRecipient::All, FfiPayloadKind::Vault)
.expect_err("the identifier must be rejected");
assert_eq!(error.kind(), "invalid_device_id");
}
#[test]
fn a_decision_for_a_conflict_that_is_not_waiting_is_refused() {
let (vault, _) = registered_vault("device-a");
let session = FfiSyncSession::open(vault);
let error = session
.adjudicate("0".repeat(32), FfiDecision::KeepLocal)
.expect_err("nothing is waiting for this identifier");
assert_eq!(error.kind(), "unknown_conflict");
}
#[test]
fn a_peer_deletion_of_an_edited_entry_is_reported_and_closed_by_the_decision() {
let devices = TwoDevices::with_one_shared_entry();
let mut edited = sample_entry("GitHub (work)");
edited.id = devices.entry_id.clone();
devices
.vault_b
.update_entry(edited, NOW + 120)
.expect("the edit should be accepted");
devices
.vault_a
.remove_entry(devices.entry_id.clone(), NOW + 60)
.expect("the deletion should be accepted");
let outcome = devices.sync_a_to_b();
assert!(
outcome.needs_adjudication,
"a deletion against an edited entry must be reported: {outcome:?}"
);
assert_eq!(
outcome.deletions_applied, 0,
"nothing may be deleted before the user decides"
);
assert_eq!(
devices.vault_b.entry_count(),
1,
"the entry is still there until the decision"
);
assert_eq!(outcome.conflicts.len(), 1);
let conflict = &outcome.conflicts[0];
assert_eq!(conflict.kind, "peer_deleted");
assert_eq!(conflict.entry_id, devices.entry_id);
assert_eq!(
conflict
.local
.as_ref()
.expect("the local version is shown")
.name,
"GitHub (work)"
);
assert!(
conflict.incoming.is_none(),
"the peer sent a tombstone, which has nothing to show"
);
assert!(
devices.session_b.pending_conflicts().len() == 1,
"the conflict stays available until it is decided"
);
devices
.session_b
.adjudicate(devices.entry_id.clone(), FfiDecision::TakeIncoming)
.expect("the decision should apply");
assert_eq!(devices.vault_b.entry_count(), 0, "the deletion is applied");
assert_eq!(devices.vault_b.tombstone_count(), 1);
assert!(
devices.session_b.pending_conflicts().is_empty(),
"a decided conflict stops being pending"
);
devices.channel_a.clear_outbox();
let settled = devices.sync_a_to_b();
assert!(
!settled.needs_adjudication,
"the decision must close the conflict: {settled:?}"
);
}
#[test]
fn a_local_deletion_can_be_confirmed_against_a_peer_that_still_has_the_entry() {
let devices = TwoDevices::with_one_shared_entry();
devices
.vault_b
.remove_entry(devices.entry_id.clone(), NOW + 60)
.expect("the deletion should be accepted");
let outcome = devices.sync_a_to_b();
assert!(outcome.needs_adjudication, "{outcome:?}");
let conflict = &outcome.conflicts[0];
assert_eq!(conflict.kind, "local_deleted");
assert!(
conflict.local.is_none(),
"this device holds only a tombstone, so there is no local version to show"
);
assert_eq!(
conflict
.incoming
.as_ref()
.expect("the peer's version is shown")
.name,
"GitHub"
);
devices
.session_b
.adjudicate(devices.entry_id.clone(), FfiDecision::KeepLocal)
.expect("the decision should apply");
assert_eq!(devices.vault_b.entry_count(), 0, "the entry stays deleted");
assert!(
devices.vault_b.tombstone_count() == 1,
"the deletion is still recorded, so it keeps travelling"
);
}
#[test]
fn an_entry_deleted_here_can_be_restored_from_the_peer() {
let devices = TwoDevices::with_one_shared_entry();
devices
.vault_b
.remove_entry(devices.entry_id.clone(), NOW + 60)
.expect("the deletion should be accepted");
let outcome = devices.sync_a_to_b();
assert_eq!(outcome.conflicts[0].kind, "local_deleted");
devices
.session_b
.adjudicate(devices.entry_id.clone(), FfiDecision::TakeIncoming)
.expect("the decision should apply");
assert_eq!(devices.vault_b.entry_count(), 1, "the entry is back");
assert_eq!(
devices.vault_b.tombstone_count(),
0,
"the tombstone must be gone, or the peer would delete the entry again"
);
assert_eq!(
devices
.vault_b
.entry(devices.entry_id.clone())
.expect("the entry is readable")
.name,
"GitHub"
);
devices.channel_a.clear_outbox();
let settled = devices.sync_a_to_b();
assert!(
!settled.needs_adjudication,
"the decision must close the conflict: {settled:?}"
);
assert!(settled.made_no_change, "both sides now hold the same entry");
}
#[test]
fn a_garbage_payload_is_refused_across_the_boundary() {
let devices = TwoDevices::with_one_shared_entry();
devices
.channel_b
.inject(test_device_id('a'), vec![0_u8; 200]);
let error = devices
.session_b
.receive(devices.channel_b.clone())
.expect_err("random bytes are not a payload");
assert_eq!(
error.kind(),
"sync_bad_magic",
"the header is checked before anything is decrypted"
);
assert_eq!(devices.vault_b.entry_count(), 1, "the vault is untouched");
}
#[test]
fn a_payload_too_large_for_one_code_travels_as_several() {
let payload = format!("otpauth://totp/example?secret={}", "A".repeat(3000));
let frames = qr_encode_frames(payload.clone(), FfiEcLevel::Medium, 20, 32, 4, 4)
.expect("the frames should be drawn");
assert!(
frames.len() > 1,
"3000 bytes must need more than one code, not {}",
frames.len()
);
let capacity = qr_frame_capacity(FfiEcLevel::Medium, 20).expect("a capacity");
let collector = FfiFrameCollector::new(capacity);
let mut order: Vec<&FfiQrFrame> = frames.iter().rev().collect();
order.push(frames.first().expect("at least one frame"));
let mut completed: Option<Vec<u8>> = None;
for frame in order {
let text = qr_decode_text(
frame.image.pixels.clone(),
frame.image.width,
frame.image.height,
)
.expect("a frame's own image must be readable");
let step = collector.push(text).expect("a frame of this application");
assert!(step.index >= 1 && step.index <= step.count);
if let Some(bytes) = step.payload {
completed = Some(bytes);
}
}
assert_eq!(
String::from_utf8(completed.expect("the set completes")).expect("UTF-8 text"),
payload
);
assert!(collector.missing().is_empty(), "nothing is left missing");
}
fn lan_device(seed: u8) -> String {
DeviceId::from_bytes([seed; 32]).to_hex()
}
#[test]
fn a_socket_address_round_trips_and_a_wrong_one_is_rejected() {
let socket = parse_socket("192.168.1.5:41234").expect("a printed address must parse");
assert_eq!(socket.to_string(), "192.168.1.5:41234");
assert_eq!(
parse_socket("192.168.1.5")
.expect_err("a missing port is not an address")
.kind(),
"bad_socket"
);
assert_eq!(
parse_socket("not an address")
.expect_err("text is not an address")
.kind(),
"bad_socket"
);
}
#[test]
fn every_lan_error_kind_is_distinct_and_known() {
use qualia_transport::Error as LanError;
let cases = [
(LanError::NoConnectionToPeer, "no_connection_to_peer"),
(LanError::TransportEmpty, "transport_empty"),
(
LanError::PayloadTooLarge {
needed: 10,
limit: 1,
},
"payload_too_large",
),
(LanError::NoLanPeersFound, "no_lan_peers_found"),
(LanError::LanSocket { operation: "bind" }, "lan_socket"),
(
LanError::LanTransport {
operation: "connect",
},
"lan_transport",
),
(LanError::NotOurLanAddress, "not_our_lan_address"),
(
LanError::BadLanAddressLength {
expected: 42,
actual: 4,
},
"bad_lan_address_length",
),
(LanError::ZeroPort, "zero_port"),
];
let mut seen = std::collections::HashSet::new();
for (source, expected) in cases {
assert_eq!(lan_error(source).kind(), expected);
assert!(seen.insert(expected), "{expected} is mapped twice");
}
}
#[test]
fn the_lan_capabilities_describe_a_reusable_stream() {
let Ok(channel) = FfiLanChannel::open(lan_device(1)) else {
return;
};
let caps = channel.capabilities();
assert_eq!(caps.roster, FfiRoster::MultiParty);
assert_eq!(caps.max_message_len, Some(MAX_MESSAGE_LEN as u64));
assert_eq!(caps.max_qr_version, None, "this channel displays no code");
assert!(!caps.single_use, "a connection is not a one-shot artifact");
assert!(caps.can_carry_payload);
assert_eq!(channel.local_device(), lan_device(1));
assert!(
channel
.peers()
.expect("the peer table is readable")
.is_empty(),
"a fresh channel has no peers"
);
assert!(
channel
.receive()
.expect("an empty inbox is not a failure")
.is_none(),
"nothing has been sent to this channel"
);
let transport = channel.transport();
assert_eq!(transport.local_device(), lan_device(1));
assert_eq!(transport.capabilities().roster, FfiRoster::MultiParty);
assert_eq!(
transport.capabilities().max_message_len,
Some(MAX_MESSAGE_LEN as u64)
);
assert!(
transport
.peers()
.expect("the peer table is readable")
.is_empty()
);
channel.shutdown();
}
#[test]
fn the_record_is_the_transports_own_connection_record() {
let Ok(channel) = FfiLanChannel::open(lan_device(2)) else {
return;
};
let record = channel.record().expect("an open endpoint has a record");
let address = LanAddress::decode(&record).expect("a record this layer built must decode");
assert_eq!(address.port, channel.inner.port().expect("a bound port"));
assert_eq!(address.endpoint_key, channel.inner.endpoint_key());
channel.shutdown();
}
#[test]
fn a_peer_must_be_registered_before_it_can_be_sent_to() {
let Ok(channel) = FfiLanChannel::open(lan_device(3)) else {
return;
};
let error = channel
.send(
FfiRecipient::One {
device_id: lan_device(4),
},
b"hello".to_vec(),
)
.expect_err("a peer that was never registered has no connection");
assert_eq!(error.kind(), "no_connection_to_peer");
assert!(
channel.send(FfiRecipient::All, b"hello".to_vec()).is_ok(),
"a broadcast with nobody registered is accepted and reaches nobody"
);
channel.shutdown();
}
#[test]
fn registering_a_peer_takes_a_record_and_an_address() {
let Ok(channel) = FfiLanChannel::open(lan_device(5)) else {
return;
};
let Ok(other) = FfiLanChannel::open(lan_device(6)) else {
return;
};
let record = other.record().expect("a record");
let socket = format!("127.0.0.1:{}", other.inner.port().expect("a port"));
channel
.remember_peer(lan_device(6), record.clone(), socket.clone())
.expect("registering a peer with a real record must work");
assert_eq!(
channel.peers().expect("the peer table is readable"),
vec![lan_device(6)]
);
assert_eq!(
channel
.remember_peer(lan_device(6), vec![0; 4], socket.clone())
.expect_err("four bytes are not a connection record")
.kind(),
"not_our_lan_address"
);
assert_eq!(
channel
.remember_peer(lan_device(6), record.clone(), "not an address".to_owned())
.expect_err("a socket address is `ip:port`")
.kind(),
"bad_socket"
);
assert_eq!(
channel
.remember_peer("nope".to_owned(), record.clone(), socket)
.expect_err("an identifier is 64 hexadecimal characters")
.kind(),
"invalid_device_id"
);
assert_eq!(
channel.peers().expect("the peer table is readable"),
vec![lan_device(6)],
"a rejected registration must not change the peer table"
);
channel
.forget_peer(lan_device(6))
.expect("forgetting works");
assert!(
channel
.peers()
.expect("the peer table is readable")
.is_empty()
);
assert_eq!(
channel
.forget_peer("nope".to_owned())
.expect_err("a malformed identifier is refused")
.kind(),
"invalid_device_id"
);
other.shutdown();
channel.shutdown();
}
#[test]
fn two_channels_exchange_a_message_over_loopback() {
let Ok(a) = FfiLanChannel::open(lan_device(7)) else {
return;
};
let Ok(b) = FfiLanChannel::open(lan_device(8)) else {
return;
};
let record = b.record().expect("a record");
a.remember_peer(lan_device(8), record, "127.0.0.1:1".to_owned())
.expect("registering a peer must work");
a.send(
FfiRecipient::One {
device_id: lan_device(8),
},
b"the payload".to_vec(),
)
.expect("the send must be accepted");
let received = (0..200)
.find_map(|_| {
std::thread::sleep(std::time::Duration::from_millis(25));
b.receive().expect("receiving must not fail")
})
.expect("the message must arrive within five seconds");
assert_eq!(received.sender, lan_device(7), "the sender is the claim");
assert_eq!(received.message, b"the payload");
a.shutdown();
b.shutdown();
}
#[test]
fn a_discovered_peer_is_offered_at_the_endpoints_own_port() {
let device = DeviceId::from_bytes([7; 32]);
let peer = Peer {
device,
endpoint_key: EndpointKey::from_bytes([8; 32]),
port: 41_007,
source: Some(SocketAddr::from(([192, 168, 1, 7], 21_377))),
status: PeerStatus::Here,
last_seen_ms: 0,
sequence: 0,
};
let offered = lan_peer_of(peer);
assert_eq!(offered.device_id, device.to_hex());
assert_eq!(
offered.socket.as_deref(),
Some("192.168.1.7:41007"),
"the IP comes from the datagram and the port from the announcement"
);
assert_eq!(
LanAddress::decode(&offered.record)
.expect("a record this layer built must decode")
.port,
41_007
);
assert!(offered.here);
let source_unknown = lan_peer_of(Peer {
source: None,
..peer
});
assert_eq!(
source_unknown.socket, None,
"a peer whose announcement had no source can be shown but not dialed"
);
}
#[test]
fn a_lobby_reports_its_view_and_stops_cleanly() {
let Ok(channel) = FfiLanChannel::open(lan_device(9)) else {
return;
};
let Ok(lobby) = channel.open_lobby() else {
return;
};
assert!(
lobby.peers().is_empty(),
"a lobby that just opened has heard nobody"
);
lobby
.announce_now()
.expect("announcing must reach the socket");
lobby.shutdown();
lobby.shutdown();
channel.shutdown();
}
#[test]
fn lock_password_wrap_roundtrips() {
let master = MasterKey::generate().expect("randomness should be available");
let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
let file = wrap_master_key_with_password(master_bytes.clone(), "correct horse".to_owned())
.expect("wrapping should succeed");
assert_eq!(
file.len() as u64,
lock_wrap_len(),
"the exposed length must match what the wrap returns"
);
let restored = unwrap_master_key_with_password(file.clone(), "correct horse".to_owned())
.expect("the right password must open it");
assert_eq!(restored, master_bytes);
let error = unwrap_master_key_with_password(file, "wrong password".to_owned())
.expect_err("a wrong password must be rejected");
assert_eq!(error.kind(), "lock_password_rejected");
}
#[test]
fn lock_password_limits_are_enforced() {
let master = MasterKey::generate().expect("randomness should be available");
let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
let error = wrap_master_key_with_password(master_bytes.clone(), String::new())
.expect_err("an empty password must be rejected");
assert_eq!(error.kind(), "lock_passphrase_empty");
let oversized = "x".repeat(MAX_PASSPHRASE_CHARS + 1);
let error = wrap_master_key_with_password(master_bytes, oversized)
.expect_err("an oversized password must be rejected");
assert_eq!(error.kind(), "input_too_long");
}
#[test]
fn lock_state_files_roundtrip() {
let marker = lock_marker(true, false, 1_700_000_000).expect("marker");
assert!(is_lock_marker(marker.clone()));
assert!(!is_lock_marker(vec![0_u8; 19]));
let pending = lock_pending(1_700_000_000).expect("pending");
assert!(is_lock_pending(pending.clone()));
assert!(!is_lock_pending(vec![0_u8; 18]));
assert!(
lock_marker(false, false, 1).is_err(),
"a marker that names no wrap must be rejected"
);
assert!(lock_pending(0).is_err(), "a zero time must be rejected");
}
#[test]
fn lock_password_wrap_is_recognized_without_the_kdf() {
let master = MasterKey::generate().expect("randomness should be available");
let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
let file = wrap_master_key_with_password(master_bytes, "correct horse".to_owned())
.expect("wrapping should succeed");
assert!(is_lock_password_wrap(file.clone()));
assert!(!is_lock_password_wrap(file[..file.len() - 1].to_vec()));
assert!(!is_lock_password_wrap(vec![0_u8; 110]));
}
#[test]
fn rewrap_lock_password_checks_the_old_password() {
let master = MasterKey::generate().expect("randomness should be available");
let master_bytes = flow::master_key_to_bytes(&master).expose_secret().to_vec();
let vault = FfiVault::from_master_key(master_bytes.clone()).expect("the vault should open");
let old_wrap =
wrap_master_key_with_password(master_bytes.clone(), "old password".to_owned())
.expect("the old wrap should be written");
let new_wrap = vault
.rewrap_lock_password(
old_wrap.clone(),
"old password".to_owned(),
"new password".to_owned(),
)
.expect("the new wrap should be written");
assert_eq!(
unwrap_master_key_with_password(new_wrap, "new password".to_owned())
.expect("the new wrap should open"),
master_bytes
);
let error = vault
.rewrap_lock_password(
old_wrap.clone(),
"not the old password".to_owned(),
"new password".to_owned(),
)
.expect_err("a wrong old password must be rejected");
assert_eq!(error.kind(), "lock_password_rejected");
let other_master = MasterKey::generate().expect("randomness should be available");
let other_vault = FfiVault::from_master_key(
flow::master_key_to_bytes(&other_master)
.expose_secret()
.to_vec(),
)
.expect("the other vault should open");
let error = other_vault
.rewrap_lock_password(
old_wrap.clone(),
"old password".to_owned(),
"new password".to_owned(),
)
.expect_err("a wrap from another account must be rejected");
assert_eq!(error.kind(), "lock_password_rejected");
let error = vault
.rewrap_lock_password(old_wrap, "old password".to_owned(), String::new())
.expect_err("an empty new password must be rejected");
assert_eq!(error.kind(), "lock_passphrase_empty");
}
#[derive(Debug)]
struct UnsupportedAuthStore;
impl bioauth::AuthBoundKeyStore for UnsupportedAuthStore {
fn availability(&self) -> bioauth::Availability {
bioauth::Availability::Unsupported {
reason: "test backend",
}
}
fn create(&self) -> bioauth::Result<()> {
Err(bioauth::Error::Unavailable {
reason: "test backend",
})
}
fn wrap(&self, _master: &bioauth::MasterKey) -> bioauth::Result<Vec<u8>> {
Err(bioauth::Error::Unavailable {
reason: "test backend",
})
}
fn unwrap(&self, _blob: &[u8]) -> bioauth::Result<bioauth::MasterKey> {
Err(bioauth::Error::Unavailable {
reason: "test backend",
})
}
fn delete_blob(&self, _blob: &[u8]) -> bioauth::Result<()> {
Ok(())
}
fn delete(&self) -> bioauth::Result<()> {
Ok(())
}
}
#[derive(Debug)]
struct FakeHostSealer {
cancelled: std::sync::atomic::AtomicBool,
}
impl FfiPlatformSealer for FakeHostSealer {
fn backend(&self) -> String {
"android-keystore".to_owned()
}
fn availability(&self) -> FfiAuthAvailability {
FfiAuthAvailability::Available {
backend: "android-keystore".to_owned(),
hardware_backed: true,
}
}
fn seal(&self, plaintext: Vec<u8>) -> Result<Vec<u8>, FfiError> {
if self.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Err(FfiError::AuthBound {
kind: "bioauth_authentication_failed".to_owned(),
detail: "the prompt was dismissed".to_owned(),
});
}
Ok(plaintext.iter().map(|byte| byte ^ 0x5A).collect())
}
fn unseal(&self, envelope: Vec<u8>) -> Result<Vec<u8>, FfiError> {
if self.cancelled.load(std::sync::atomic::Ordering::SeqCst) {
return Err(FfiError::AuthBound {
kind: "bioauth_authentication_failed".to_owned(),
detail: "the prompt was dismissed".to_owned(),
});
}
Ok(envelope.iter().map(|byte| byte ^ 0x5A).collect())
}
fn delete(&self) -> Result<(), FfiError> {
Ok(())
}
}
#[test]
fn a_host_sealer_roundtrips() {
let host = Arc::new(FakeHostSealer {
cancelled: std::sync::atomic::AtomicBool::new(false),
});
let store = FfiAuthBoundKeyStore::with_host(host.clone(), "android-keystore".to_owned())
.expect("the backend name is known");
assert!(matches!(
store.availability(),
FfiAuthAvailability::Available { .. }
));
let master = vec![5_u8; 32];
let blob = store.wrap(master.clone()).expect("seal");
assert_ne!(&blob[blob.len() - 32..], master.as_slice());
assert_eq!(store.unwrap(blob).expect("unseal"), master);
host.cancelled
.store(true, std::sync::atomic::Ordering::SeqCst);
let error = match store.wrap(master.clone()) {
Ok(_) => panic!("a cancelled gesture must fail"),
Err(error) => error,
};
assert_eq!(error.kind(), "bioauth_authentication_failed");
}
#[test]
fn an_unknown_host_backend_is_rejected() {
let host = Arc::new(FakeHostSealer {
cancelled: std::sync::atomic::AtomicBool::new(false),
});
let built = FfiAuthBoundKeyStore::with_host(host, "not-a-backend".to_owned());
let error = match built {
Ok(_) => panic!("an unknown backend must be refused"),
Err(error) => error,
};
assert_eq!(error.kind(), "bioauth_unknown_backend");
}
#[test]
fn auth_bound_errors_keep_their_kind() {
let store = FfiAuthBoundKeyStore::with_store(Box::new(UnsupportedAuthStore));
assert!(matches!(
store.availability(),
FfiAuthAvailability::Unsupported { .. }
));
let error = store
.wrap(vec![0_u8; 31])
.expect_err("a 31-byte key must be rejected");
assert_eq!(error.kind(), "bioauth_master_key_wrong_length");
let error = store.create().expect_err("the test backend is unavailable");
assert_eq!(error.kind(), "bioauth_unavailable");
let vault = FfiVault::from_master_key(vec![9_u8; 32]).expect("the vault should open");
let error = vault
.wrap_master_for_auth_bound(store)
.expect_err("the test backend must not wrap");
assert_eq!(error.kind(), "bioauth_unavailable");
}
}