Implement GPG key QR transfer

This commit is contained in:
2026-08-11 21:25:32 +02:00
parent b01cc8bb6d
commit 358ba7d46d
9 changed files with 2653 additions and 5 deletions

View File

@@ -265,6 +265,52 @@ impl KeyStore {
self.keys.values().map(key_info)
}
/// Serialize one verified certificate as transferable ASCII armor.
pub fn export_armored(
&self,
identity: &str,
include_secret: bool,
passphrase: Option<&SecretBytes>,
) -> Result<SecretBytes, CryptoError> {
let handle = self.resolve(identity)?;
let material = self.material(&handle)?;
let bytes = if include_secret {
let secret = material
.secret
.as_ref()
.ok_or(CryptoError::MissingSecretKey)?;
let password = Password::from(passphrase.map_or(&[][..], |value| value.expose()));
if !secret_unlocks(secret, &password) {
return Err(CryptoError::InvalidKeyPassphrase);
}
secret.to_armored_bytes(ArmorOptions::default())
} else {
material.public.to_armored_bytes(ArmorOptions::default())
}
.map_err(|_| CryptoError::CorruptKeyMaterial)?;
Ok(SecretBytes::new(bytes))
}
/// Prove that a supplied passphrase unlocks every protected packet in a secret key.
pub fn validate_secret_passphrase(
&self,
identity: &str,
passphrase: Option<&SecretBytes>,
) -> Result<(), CryptoError> {
let handle = self.resolve(identity)?;
let secret = self
.material(&handle)?
.secret
.as_ref()
.ok_or(CryptoError::MissingSecretKey)?;
let password = Password::from(passphrase.map_or(&[][..], |value| value.expose()));
if secret_unlocks(secret, &password) {
Ok(())
} else {
Err(CryptoError::InvalidKeyPassphrase)
}
}
/// Resolve a full primary/subkey fingerprint, 8/16-digit key ID, or exact UTF-8 user ID.
pub fn resolve(&self, identity: &str) -> Result<KeyHandle, CryptoError> {
let hex_identity = normalize_hex_identity(identity);
@@ -646,6 +692,7 @@ pub enum CryptoError {
reason: SecretProviderError,
},
DecryptionFailed,
InvalidKeyPassphrase,
MissingSigningKey {
fingerprint: KeyFingerprint,
},
@@ -704,6 +751,9 @@ impl fmt::Display for CryptoError {
"secret provider could not unlock OpenPGP key {fingerprint}: {reason:?}"
),
Self::DecryptionFailed => formatter.write_str("OpenPGP decryption failed"),
Self::InvalidKeyPassphrase => {
formatter.write_str("the OpenPGP key passphrase is incorrect")
}
Self::MissingSigningKey { fingerprint } => {
write!(formatter, "OpenPGP key cannot sign: {fingerprint}")
}
@@ -875,6 +925,14 @@ fn secret_requires_password(key: &SignedSecretKey) -> bool {
.any(|subkey| subkey.secret_params().is_encrypted())
}
fn secret_unlocks(key: &SignedSecretKey, password: &Password) -> bool {
matches!(key.primary_key.unlock(password, |_, _| Ok(())), Ok(Ok(())))
&& key
.secret_subkeys
.iter()
.all(|subkey| matches!(subkey.key.unlock(password, |_, _| Ok(())), Ok(Ok(()))))
}
fn signing_target_requires_password(target: SigningTarget<'_>) -> bool {
match target {
SigningTarget::Primary(key) => key.secret_params().is_encrypted(),

View File

@@ -18,6 +18,7 @@ pub mod mobile;
pub mod mobile_authentication;
pub mod mobile_entry;
pub mod mobile_home;
pub mod mobile_key_transfer;
pub mod mobile_onboarding;
pub mod mobile_passwords;
pub mod mobile_totp;

View File

@@ -0,0 +1,764 @@
//! Storage-owned OpenPGP key transfer for native mobile QR adapters.
use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
use cap_std::{ambient_authority, fs::Dir};
use cap_tempfile::TempFile;
use data_encoding::{BASE64URL_NOPAD, HEXLOWER};
use sha2::{Digest as _, Sha256};
use crate::{
config::{Config, ConfigError},
crypto::{CryptoError, KeyInfo, KeyStore},
presentation::{QrError, QrMatrix},
repository::SecretBytes,
};
const FRAME_PREFIX: &str = "ISKT1";
const FRAME_CHUNK_BYTES: usize = 1_600;
const MAX_TRANSFER_FRAMES: usize = 11_000;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MobileKeyTransferKind {
Public,
Private,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileKeyTransferKey {
fingerprint: String,
title: String,
detail: String,
kind: MobileKeyTransferKind,
requires_passphrase: bool,
}
impl MobileKeyTransferKey {
pub fn fingerprint(&self) -> &str {
&self.fingerprint
}
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
pub fn kind(&self) -> MobileKeyTransferKind {
self.kind
}
pub fn requires_passphrase(&self) -> bool {
self.requires_passphrase
}
}
pub struct MobileKeyTransferFrame {
sequence: usize,
total: usize,
payload: SecretBytes,
matrix: QrMatrix,
}
impl MobileKeyTransferFrame {
pub fn sequence(&self) -> usize {
self.sequence
}
pub fn total(&self) -> usize {
self.total
}
pub fn payload(&self) -> &[u8] {
self.payload.expose()
}
pub fn matrix(&self) -> &QrMatrix {
&self.matrix
}
}
pub struct MobileKeyTransferExport {
key: MobileKeyTransferKey,
frames: Vec<MobileKeyTransferFrame>,
}
impl MobileKeyTransferExport {
pub fn key(&self) -> &MobileKeyTransferKey {
&self.key
}
pub fn frames(&self) -> &[MobileKeyTransferFrame] {
&self.frames
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileKeyTransferProgress {
received: usize,
total: usize,
duplicate: bool,
key: Option<MobileKeyTransferKey>,
}
impl MobileKeyTransferProgress {
pub fn received(&self) -> usize {
self.received
}
pub fn total(&self) -> usize {
self.total
}
pub fn duplicate(&self) -> bool {
self.duplicate
}
pub fn key(&self) -> Option<&MobileKeyTransferKey> {
self.key.as_ref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MobileKeyTransferOutcome {
title: String,
detail: String,
}
impl MobileKeyTransferOutcome {
pub fn title(&self) -> &str {
&self.title
}
pub fn detail(&self) -> &str {
&self.detail
}
}
pub struct MobileKeyTransferService {
config: Config,
keys: KeyStore,
}
impl MobileKeyTransferService {
pub fn load() -> Result<Self, MobileKeyTransferError> {
let config = Config::load(None)?;
let keys = KeyStore::load(config.key_material())?;
Ok(Self { config, keys })
}
pub fn keys(&self) -> Vec<MobileKeyTransferKey> {
self.keys.infos().map(|key| transfer_key(&key)).collect()
}
pub fn export(
&self,
fingerprint: &str,
kind: MobileKeyTransferKind,
passphrase: Option<SecretBytes>,
) -> Result<MobileKeyTransferExport, MobileKeyTransferError> {
let include_secret = kind == MobileKeyTransferKind::Private;
let armor = self
.keys
.export_armored(fingerprint, include_secret, passphrase.as_ref())?;
let info = self
.keys
.infos()
.find(|key| key.fingerprint().as_str() == fingerprint)
.ok_or(MobileKeyTransferError::KeyUnavailable)?;
let frames = encode_frames(armor)?;
Ok(MobileKeyTransferExport {
key: transfer_key_with_kind(&info, kind),
frames,
})
}
pub fn importer(&self) -> MobileKeyTransferImport {
MobileKeyTransferImport::new(self.config.clone())
}
}
pub struct MobileKeyTransferImport {
config: Config,
digest: Option<String>,
total: Option<usize>,
chunks: BTreeMap<usize, Vec<u8>>,
complete: Option<SecretBytes>,
key: Option<MobileKeyTransferKey>,
}
impl MobileKeyTransferImport {
fn new(config: Config) -> Self {
Self {
config,
digest: None,
total: None,
chunks: BTreeMap::new(),
complete: None,
key: None,
}
}
pub fn add_frame(
&mut self,
payload: SecretBytes,
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
if self.complete.is_some() {
return Err(MobileKeyTransferError::AlreadyComplete);
}
if payload.expose().starts_with(b"-----BEGIN PGP ") {
if self.total.is_some() {
return Err(MobileKeyTransferError::MixedTransfers);
}
self.finish(payload)?;
return Ok(self.progress(false));
}
let text = std::str::from_utf8(payload.expose())
.map_err(|_| MobileKeyTransferError::InvalidFrame)?;
let mut fields = text.splitn(5, ':');
if fields.next() != Some(FRAME_PREFIX) {
return Err(MobileKeyTransferError::InvalidFrame);
}
let digest = fields.next().ok_or(MobileKeyTransferError::InvalidFrame)?;
if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(MobileKeyTransferError::InvalidFrame);
}
let sequence = parse_positive(fields.next())?;
let total = parse_positive(fields.next())?;
if sequence > total || total > MAX_TRANSFER_FRAMES {
return Err(MobileKeyTransferError::InvalidFrame);
}
let chunk = BASE64URL_NOPAD
.decode(
fields
.next()
.ok_or(MobileKeyTransferError::InvalidFrame)?
.as_bytes(),
)
.map_err(|_| MobileKeyTransferError::InvalidFrame)?;
if chunk.is_empty() || chunk.len() > FRAME_CHUNK_BYTES {
return Err(MobileKeyTransferError::InvalidFrame);
}
if self.digest.as_deref().is_some_and(|value| value != digest)
|| self.total.is_some_and(|value| value != total)
{
return Err(MobileKeyTransferError::MixedTransfers);
}
self.digest
.get_or_insert_with(|| digest.to_ascii_lowercase());
self.total.get_or_insert(total);
if let Some(existing) = self.chunks.get(&sequence) {
if existing == &chunk {
return Ok(self.progress(true));
}
return Err(MobileKeyTransferError::ConflictingFrame);
}
self.chunks.insert(sequence, chunk);
if self.chunks.len() == total {
let mut armor = Vec::new();
for sequence in 1..=total {
armor.extend(
self.chunks
.get(&sequence)
.ok_or(MobileKeyTransferError::IncompleteTransfer)?,
);
}
let actual = HEXLOWER.encode(&Sha256::digest(&armor));
if self.digest.as_deref() != Some(actual.as_str()) {
return Err(MobileKeyTransferError::ChecksumMismatch);
}
self.finish(SecretBytes::new(armor))?;
}
Ok(self.progress(false))
}
pub fn import(
&mut self,
passphrase: Option<SecretBytes>,
make_default: bool,
) -> Result<MobileKeyTransferOutcome, MobileKeyTransferError> {
let armor = self
.complete
.as_ref()
.ok_or(MobileKeyTransferError::IncompleteTransfer)?;
let key = self
.key
.as_ref()
.ok_or(MobileKeyTransferError::IncompleteTransfer)?;
let mut imported = KeyStore::new();
imported.import(armor.expose())?;
if key.kind == MobileKeyTransferKind::Private {
imported.validate_secret_passphrase(&key.fingerprint, passphrase.as_ref())?;
}
if make_default && key.kind != MobileKeyTransferKind::Private {
return Err(MobileKeyTransferError::PublicDefault);
}
let existing = KeyStore::load(self.config.key_material())?;
if let Some(found) = existing
.infos()
.find(|info| info.fingerprint().as_str() == key.fingerprint)
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
{
return Err(MobileKeyTransferError::DuplicateKey);
}
persist_key_material(self.config.key_material(), key, armor.expose())?;
if make_default {
let mut settings = self.config.settings();
settings.set_default_key(key.fingerprint.clone());
self.config.with_settings(settings)?.persist()?;
}
let kind = if key.kind == MobileKeyTransferKind::Private {
"private"
} else {
"public"
};
Ok(MobileKeyTransferOutcome {
title: "GPG Key Imported".to_owned(),
detail: format!("Imported the {kind} key {}.", key.fingerprint),
})
}
fn finish(&mut self, armor: SecretBytes) -> Result<(), MobileKeyTransferError> {
validate_transfer_armor(armor.expose())?;
let mut store = KeyStore::new();
let infos = store.import(armor.expose())?;
if infos.len() != 1 {
return Err(MobileKeyTransferError::MismatchedKeys);
}
self.key = infos.first().map(transfer_key);
self.complete = Some(armor);
Ok(())
}
fn progress(&self, duplicate: bool) -> MobileKeyTransferProgress {
MobileKeyTransferProgress {
received: self.chunks.len().max(usize::from(self.complete.is_some())),
total: self.total.unwrap_or(1),
duplicate,
key: self.key.clone(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MobileKeyTransferError {
Configuration,
KeyUnavailable,
InvalidFrame,
MixedTransfers,
ConflictingFrame,
IncompleteTransfer,
AlreadyComplete,
ChecksumMismatch,
MismatchedKeys,
DuplicateKey,
PublicDefault,
InvalidKeyMaterial,
IncorrectPassphrase,
QrPayload,
Write,
}
impl fmt::Display for MobileKeyTransferError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::Configuration => "the IronStorage configuration is unavailable",
Self::KeyUnavailable => "the selected GPG key is unavailable",
Self::InvalidFrame => "this is not a valid IronStorage key-transfer QR code",
Self::MixedTransfers => "the scanned QR codes belong to different key transfers",
Self::ConflictingFrame => "a scanned QR frame conflicts with an earlier frame",
Self::IncompleteTransfer => {
"more QR frames are required before this key can be imported"
}
Self::AlreadyComplete => "this key transfer is already complete",
Self::ChecksumMismatch => {
"the reconstructed key transfer did not pass its integrity check"
}
Self::MismatchedKeys => "the transfer contains multiple or mismatched GPG keys",
Self::DuplicateKey => "this GPG key is already installed",
Self::PublicDefault => "a public-only key cannot become the default GPG key",
Self::InvalidKeyMaterial => "the GPG key material is invalid",
Self::IncorrectPassphrase => "the GPG key passphrase is incorrect",
Self::QrPayload => "the GPG key could not be represented as QR codes",
Self::Write => "the GPG key could not be stored safely",
})
}
}
impl Error for MobileKeyTransferError {}
impl From<ConfigError> for MobileKeyTransferError {
fn from(_: ConfigError) -> Self {
Self::Configuration
}
}
impl From<CryptoError> for MobileKeyTransferError {
fn from(error: CryptoError) -> Self {
match error {
CryptoError::InvalidKeyPassphrase => Self::IncorrectPassphrase,
CryptoError::MissingIdentity { .. } | CryptoError::MissingSecretKey => {
Self::KeyUnavailable
}
_ => Self::InvalidKeyMaterial,
}
}
}
impl From<QrError> for MobileKeyTransferError {
fn from(_: QrError) -> Self {
Self::QrPayload
}
}
fn transfer_key(info: &KeyInfo) -> MobileKeyTransferKey {
transfer_key_with_kind(
info,
if info.has_secret() {
MobileKeyTransferKind::Private
} else {
MobileKeyTransferKind::Public
},
)
}
fn transfer_key_with_kind(info: &KeyInfo, kind: MobileKeyTransferKind) -> MobileKeyTransferKey {
let title = info
.user_ids()
.first()
.cloned()
.unwrap_or_else(|| format!("GPG key {}", info.key_id()));
MobileKeyTransferKey {
fingerprint: info.fingerprint().as_str().to_owned(),
title,
detail: format!("Fingerprint {}", info.fingerprint()),
kind,
requires_passphrase: info.requires_passphrase(),
}
}
fn encode_frames(
armor: SecretBytes,
) -> Result<Vec<MobileKeyTransferFrame>, MobileKeyTransferError> {
if let Ok(matrix) = QrMatrix::encode(&armor) {
return Ok(vec![MobileKeyTransferFrame {
sequence: 1,
total: 1,
payload: armor,
matrix,
}]);
}
encode_multipart(armor, FRAME_CHUNK_BYTES)
}
fn encode_multipart(
armor: SecretBytes,
chunk_bytes: usize,
) -> Result<Vec<MobileKeyTransferFrame>, MobileKeyTransferError> {
if chunk_bytes == 0 {
return Err(MobileKeyTransferError::QrPayload);
}
let digest = HEXLOWER.encode(&Sha256::digest(armor.expose()));
let total = armor.expose().len().div_ceil(chunk_bytes);
if total == 0 || total > MAX_TRANSFER_FRAMES {
return Err(MobileKeyTransferError::QrPayload);
}
let mut frames = Vec::with_capacity(total);
for (index, chunk) in armor.expose().chunks(chunk_bytes).enumerate() {
let payload = SecretBytes::new(
format!(
"{FRAME_PREFIX}:{digest}:{}:{total}:{}",
index + 1,
BASE64URL_NOPAD.encode(chunk)
)
.into_bytes(),
);
let matrix = QrMatrix::encode(&payload)?;
frames.push(MobileKeyTransferFrame {
sequence: index + 1,
total,
payload,
matrix,
});
}
Ok(frames)
}
fn parse_positive(value: Option<&str>) -> Result<usize, MobileKeyTransferError> {
value
.ok_or(MobileKeyTransferError::InvalidFrame)?
.parse::<usize>()
.ok()
.filter(|value| *value > 0)
.ok_or(MobileKeyTransferError::InvalidFrame)
}
fn validate_transfer_armor(bytes: &[u8]) -> Result<(), MobileKeyTransferError> {
let text =
std::str::from_utf8(bytes).map_err(|_| MobileKeyTransferError::InvalidKeyMaterial)?;
let end = if text.starts_with("-----BEGIN PGP PUBLIC KEY BLOCK-----") {
"-----END PGP PUBLIC KEY BLOCK-----"
} else if text.starts_with("-----BEGIN PGP PRIVATE KEY BLOCK-----") {
"-----END PGP PRIVATE KEY BLOCK-----"
} else {
return Err(MobileKeyTransferError::InvalidKeyMaterial);
};
if text.matches("-----BEGIN PGP ").count() != 1 {
return Err(MobileKeyTransferError::MismatchedKeys);
}
let end_offset = text
.find(end)
.map(|offset| offset + end.len())
.ok_or(MobileKeyTransferError::InvalidKeyMaterial)?;
if !text[end_offset..].trim().is_empty() {
return Err(MobileKeyTransferError::InvalidKeyMaterial);
}
Ok(())
}
fn persist_key_material(
configured: &Path,
key: &MobileKeyTransferKey,
armor: &[u8],
) -> Result<(), MobileKeyTransferError> {
if configured.is_dir() {
let suffix = if key.kind == MobileKeyTransferKind::Private {
"secret"
} else {
"public"
};
let name = format!("{}-{suffix}.asc", key.fingerprint.to_ascii_lowercase());
atomic_replace(configured, Path::new(&name), armor)
} else {
let existing = fs::read(configured).map_err(|_| MobileKeyTransferError::Write)?;
let mut combined = Vec::with_capacity(existing.len() + armor.len() + 1);
combined.extend_from_slice(&existing);
if !combined.ends_with(b"\n") {
combined.push(b'\n');
}
combined.extend_from_slice(armor);
let parent = configured.parent().ok_or(MobileKeyTransferError::Write)?;
let name = configured
.file_name()
.ok_or(MobileKeyTransferError::Write)?;
atomic_replace(parent, Path::new(name), &combined)
}
}
fn atomic_replace(
parent: &Path,
name: &Path,
contents: &[u8],
) -> Result<(), MobileKeyTransferError> {
let directory = Dir::open_ambient_dir(parent, ambient_authority())
.map_err(|_| MobileKeyTransferError::Write)?;
if let Ok(metadata) = directory.symlink_metadata(name)
&& (metadata.file_type().is_symlink() || !metadata.is_file())
{
return Err(MobileKeyTransferError::Write);
}
let mut temporary = TempFile::new(&directory).map_err(|_| MobileKeyTransferError::Write)?;
set_private_permissions(&temporary)?;
temporary
.write_all(contents)
.and_then(|()| temporary.as_file().sync_all())
.and_then(|()| temporary.replace(name))
.and_then(|()| directory.open(".").and_then(|file| file.sync_all()))
.map_err(|_| MobileKeyTransferError::Write)
}
#[cfg(unix)]
fn set_private_permissions(temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> {
use cap_std::fs::{Permissions, PermissionsExt as _};
temporary
.as_file()
.set_permissions(Permissions::from_mode(0o600))
.map_err(|_| MobileKeyTransferError::Write)
}
#[cfg(not(unix))]
fn set_private_permissions(_temporary: &TempFile<'_>) -> Result<(), MobileKeyTransferError> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
MobileKeyTransferError, MobileKeyTransferImport, MobileKeyTransferKind,
MobileKeyTransferService, encode_multipart,
};
use crate::{config::Config, crypto::KeyStore, repository::SecretBytes};
use tempfile::tempdir;
const ALICE_PUBLIC: &[u8] =
include_bytes!("../tests/fixtures/compatibility/keys/alice-public.asc");
const ALICE_SECRET: &[u8] =
include_bytes!("../tests/fixtures/compatibility/keys/alice-secret.asc");
const ALICE_FINGERPRINT: &str = "7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30";
#[test]
fn multipart_frames_reconstruct_exact_armor_out_of_order()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_public_key()?;
let mut importer = MobileKeyTransferImport::new(fixture.config);
let mut frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
frames.reverse();
for frame in frames {
importer.add_frame(SecretBytes::new(frame.payload().to_vec()))?;
}
assert_eq!(
importer.complete.as_ref().map(SecretBytes::expose),
Some(ALICE_SECRET)
);
assert_eq!(
importer.key.as_ref().map(|key| key.kind()),
Some(MobileKeyTransferKind::Private)
);
Ok(())
}
#[test]
fn single_frame_export_validates_private_passphrase_and_reconstructs_exactly()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_secret_key()?;
let service = MobileKeyTransferService {
config: fixture.config.clone(),
keys: KeyStore::load(&fixture.key_path)?,
};
assert!(matches!(
service.export(
ALICE_FINGERPRINT,
MobileKeyTransferKind::Private,
Some(SecretBytes::new(b"wrong".to_vec())),
),
Err(MobileKeyTransferError::IncorrectPassphrase)
));
let exported = service.export(
ALICE_FINGERPRINT,
MobileKeyTransferKind::Private,
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
)?;
assert_eq!(exported.frames().len(), 1);
let expected = exported.frames()[0].payload().to_vec();
let mut importer = service.importer();
let progress = importer.add_frame(SecretBytes::new(expected.clone()))?;
assert!(progress.key().is_some());
assert_eq!(
importer.complete.as_ref().map(SecretBytes::expose),
Some(expected.as_slice())
);
Ok(())
}
#[test]
fn mismatched_public_keys_are_rejected_as_one_transfer()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_public_key()?;
let mut importer = MobileKeyTransferImport::new(fixture.config);
let mut mismatched = ALICE_PUBLIC.to_vec();
mismatched.extend_from_slice(include_bytes!(
"../tests/fixtures/compatibility/keys/bob-public.asc"
));
assert_eq!(
importer.add_frame(SecretBytes::new(mismatched)),
Err(MobileKeyTransferError::MismatchedKeys)
);
Ok(())
}
#[test]
fn invalid_duplicate_and_incomplete_frames_do_not_replace_keys()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_public_key()?;
let before = std::fs::read(fixture.key_path.join("alice-public.asc"))?;
let mut importer = MobileKeyTransferImport::new(fixture.config);
let frames = encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)?;
let first = frames[0].payload();
let progress = importer.add_frame(SecretBytes::new(first.to_vec()))?;
assert!(
importer
.add_frame(SecretBytes::new(first.to_vec()))?
.duplicate()
);
assert!(progress.received() < progress.total());
assert_eq!(
importer.import(None, false),
Err(MobileKeyTransferError::IncompleteTransfer)
);
assert_eq!(
std::fs::read(fixture.key_path.join("alice-public.asc"))?,
before
);
Ok(())
}
#[test]
fn private_import_validates_passphrase_before_atomic_persistence()
-> Result<(), Box<dyn std::error::Error>> {
let fixture = configured_public_key()?;
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
assert_eq!(
importer.import(Some(SecretBytes::new(b"wrong".to_vec())), true),
Err(MobileKeyTransferError::IncorrectPassphrase)
);
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
importer.import(
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
true,
)?;
let keys = KeyStore::load(&fixture.key_path)?;
assert!(keys.infos().any(|key| key.has_secret()));
assert_eq!(
Config::load(Some(fixture.config.source()))?
.default_key()
.as_str(),
ALICE_FINGERPRINT
);
Ok(())
}
struct Fixture {
_root: tempfile::TempDir,
config: Config,
key_path: std::path::PathBuf,
}
fn configured_public_key() -> Result<Fixture, Box<dyn std::error::Error>> {
configured_key("alice-public.asc", ALICE_PUBLIC)
}
fn configured_secret_key() -> Result<Fixture, Box<dyn std::error::Error>> {
configured_key("alice-secret.asc", ALICE_SECRET)
}
fn configured_key(name: &str, bytes: &[u8]) -> Result<Fixture, Box<dyn std::error::Error>> {
let root = tempdir()?;
let vault = root.path().join("vault");
let keys = root.path().join("keys");
std::fs::create_dir(&vault)?;
std::fs::create_dir(&keys)?;
std::fs::write(keys.join(name), bytes)?;
let config_path = root.path().join("config.toml");
std::fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = {:?}\nkey_material = {:?}\n",
vault, ALICE_FINGERPRINT, keys
),
)?;
let config = Config::load(Some(&config_path))?;
Ok(Fixture {
_root: root,
config,
key_path: keys,
})
}
}