1020 lines
34 KiB
Rust
1020 lines
34 KiB
Rust
//! Storage-owned OpenPGP key transfer for native mobile QR adapters.
|
|
|
|
use std::{collections::BTreeMap, error::Error, fmt, fs, io::Write as _, path::Path};
|
|
|
|
use crate::atomic_file::TempFile;
|
|
use cap_std::{ambient_authority, fs::Dir};
|
|
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 = load_keys_or_empty(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 {
|
|
destination: MobileKeyTransferDestination,
|
|
digest: Option<String>,
|
|
total: Option<usize>,
|
|
chunks: BTreeMap<usize, Vec<u8>>,
|
|
complete: Option<SecretBytes>,
|
|
key: Option<MobileKeyTransferKey>,
|
|
}
|
|
|
|
enum MobileKeyTransferDestination {
|
|
Configured(Box<Config>),
|
|
LocalSetup,
|
|
}
|
|
|
|
impl MobileKeyTransferImport {
|
|
fn new(config: Config) -> Self {
|
|
Self {
|
|
destination: MobileKeyTransferDestination::Configured(Box::new(config)),
|
|
digest: None,
|
|
total: None,
|
|
chunks: BTreeMap::new(),
|
|
complete: None,
|
|
key: None,
|
|
}
|
|
}
|
|
|
|
pub fn local_setup() -> Self {
|
|
Self {
|
|
destination: MobileKeyTransferDestination::LocalSetup,
|
|
digest: None,
|
|
total: None,
|
|
chunks: BTreeMap::new(),
|
|
complete: None,
|
|
key: None,
|
|
}
|
|
}
|
|
|
|
pub fn add_image(
|
|
&mut self,
|
|
image: SecretBytes,
|
|
) -> Result<MobileKeyTransferProgress, MobileKeyTransferError> {
|
|
let payload =
|
|
QrMatrix::decode_image(&image).map_err(|_| MobileKeyTransferError::InvalidFrame)?;
|
|
self.add_frame(payload)
|
|
}
|
|
|
|
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,
|
|
overwrite: 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);
|
|
}
|
|
|
|
if matches!(self.destination, MobileKeyTransferDestination::LocalSetup) {
|
|
if key.kind != MobileKeyTransferKind::Private {
|
|
return Err(MobileKeyTransferError::PublicDefault);
|
|
}
|
|
let operation = crate::mobile_onboarding::MobileOnboardingOperation::default();
|
|
let outcome = if make_default {
|
|
operation.setup_local_imported(
|
|
SecretBytes::new(armor.expose().to_vec()),
|
|
passphrase,
|
|
overwrite,
|
|
)
|
|
} else {
|
|
operation.stage_local_imported(
|
|
SecretBytes::new(armor.expose().to_vec()),
|
|
passphrase,
|
|
overwrite,
|
|
)
|
|
}
|
|
.map_err(MobileKeyTransferError::LocalSetup)?;
|
|
return Ok(MobileKeyTransferOutcome {
|
|
title: outcome.title().to_owned(),
|
|
detail: outcome.detail().to_owned(),
|
|
});
|
|
}
|
|
let MobileKeyTransferDestination::Configured(config) = &self.destination else {
|
|
unreachable!("local setup returned above")
|
|
};
|
|
let existing = load_keys_or_empty(config.key_material())?;
|
|
if let Some(found) = existing
|
|
.infos()
|
|
.find(|info| info.fingerprint().as_str() == key.fingerprint)
|
|
&& (found.has_secret() || key.kind == MobileKeyTransferKind::Public)
|
|
&& !overwrite
|
|
{
|
|
return Err(MobileKeyTransferError::ExistingKey);
|
|
}
|
|
|
|
let current = Config::load(Some(config.source()))?;
|
|
let candidate = if make_default {
|
|
let mut settings = current.settings();
|
|
settings.set_default_key(key.fingerprint.clone());
|
|
Some(current.with_settings(settings)?)
|
|
} else {
|
|
None
|
|
};
|
|
let target = if config.key_material().is_dir() {
|
|
let suffix = if key.kind == MobileKeyTransferKind::Private {
|
|
"secret"
|
|
} else {
|
|
"public"
|
|
};
|
|
config.key_material().join(format!(
|
|
"{}-{suffix}.asc",
|
|
key.fingerprint.to_ascii_lowercase()
|
|
))
|
|
} else {
|
|
config.key_material().to_owned()
|
|
};
|
|
let backup = match fs::read(&target) {
|
|
Ok(value) => Some(SecretBytes::new(value)),
|
|
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
|
Err(_) => return Err(MobileKeyTransferError::Write),
|
|
};
|
|
persist_armored_key(
|
|
config.key_material(),
|
|
&key.fingerprint,
|
|
key.kind == MobileKeyTransferKind::Private,
|
|
armor.expose(),
|
|
overwrite,
|
|
)?;
|
|
if let Some(candidate) = candidate
|
|
&& let Err(error) = candidate.persist()
|
|
{
|
|
if !matches!(error, ConfigError::DurabilityUncertain { .. }) {
|
|
let parent = target.parent().ok_or(MobileKeyTransferError::Write)?;
|
|
let name = Path::new(target.file_name().ok_or(MobileKeyTransferError::Write)?);
|
|
let restored = match backup {
|
|
Some(ref value) => atomic_replace(parent, name, value.expose()),
|
|
None => fs::remove_file(&target)
|
|
.and_then(|()| fs::File::open(parent)?.sync_all())
|
|
.map_err(|_| MobileKeyTransferError::Write),
|
|
};
|
|
restored.map_err(|_| MobileKeyTransferError::RecoveryRequired)?;
|
|
}
|
|
return Err(error.into());
|
|
}
|
|
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(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn load_keys_or_empty(path: &Path) -> Result<KeyStore, CryptoError> {
|
|
match KeyStore::load(path) {
|
|
Err(CryptoError::NoKeyMaterial) => Ok(KeyStore::new()),
|
|
result => result,
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub enum MobileKeyTransferError {
|
|
Configuration,
|
|
KeyUnavailable,
|
|
InvalidFrame,
|
|
MixedTransfers,
|
|
ConflictingFrame,
|
|
IncompleteTransfer,
|
|
AlreadyComplete,
|
|
ChecksumMismatch,
|
|
MismatchedKeys,
|
|
ExistingKey,
|
|
PublicDefault,
|
|
InvalidKeyMaterial,
|
|
IncorrectPassphrase,
|
|
QrPayload,
|
|
Write,
|
|
RecoveryRequired,
|
|
LocalSetup(crate::mobile_onboarding::MobileOnboardingError),
|
|
}
|
|
|
|
impl fmt::Display for MobileKeyTransferError {
|
|
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
formatter.write_str(match self {
|
|
Self::RecoveryRequired => "key import could not be rolled back; retain the key material and recover configuration before retrying",
|
|
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::ExistingKey => {
|
|
"this GPG key is already installed; confirm replacement to continue"
|
|
}
|
|
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",
|
|
Self::LocalSetup(error) => return error.fmt(formatter),
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
pub(crate) 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(())
|
|
}
|
|
|
|
pub(crate) fn persist_armored_key(
|
|
configured: &Path,
|
|
fingerprint: &str,
|
|
secret: bool,
|
|
armor: &[u8],
|
|
overwrite: bool,
|
|
) -> Result<(), MobileKeyTransferError> {
|
|
if configured.is_dir() {
|
|
let suffix = if secret { "secret" } else { "public" };
|
|
let name = format!("{}-{suffix}.asc", fingerprint.to_ascii_lowercase());
|
|
if configured.join(&name).exists() && !overwrite {
|
|
return Err(MobileKeyTransferError::ExistingKey);
|
|
}
|
|
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, load_keys_or_empty,
|
|
};
|
|
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 qr_images_reconstruct_the_same_key_and_reject_invalid_images()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let fixture = configured_public_key()?;
|
|
let mut importer = MobileKeyTransferImport::new(fixture.config);
|
|
assert_eq!(
|
|
importer.add_image(SecretBytes::new(b"not an image".to_vec())),
|
|
Err(MobileKeyTransferError::InvalidFrame)
|
|
);
|
|
for frame in encode_multipart(SecretBytes::new(ALICE_SECRET.to_vec()), 128)? {
|
|
let matrix = frame.matrix();
|
|
let size = u32::try_from(matrix.width() + 8)? * 4;
|
|
let image = image::GrayImage::from_fn(size, size, |x, y| {
|
|
let x = (x / 4) as usize;
|
|
let y = (y / 4) as usize;
|
|
let dark = x >= 4 && y >= 4 && matrix.is_dark(x - 4, y - 4).unwrap_or(false);
|
|
image::Luma([if dark { 0 } else { 255 }])
|
|
});
|
|
let mut png = std::io::Cursor::new(Vec::new());
|
|
image.write_to(&mut png, image::ImageFormat::Png)?;
|
|
importer.add_image(SecretBytes::new(png.into_inner()))?;
|
|
}
|
|
assert_eq!(
|
|
importer.complete.as_ref().map(SecretBytes::expose),
|
|
Some(ALICE_SECRET)
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[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, 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, false),
|
|
Err(MobileKeyTransferError::IncorrectPassphrase)
|
|
);
|
|
assert_eq!(std::fs::read_dir(&fixture.key_path)?.count(), 1);
|
|
fixture
|
|
.config
|
|
.update_git_identity(&crate::git::GitIdentity::new(
|
|
"Changed since QR opened",
|
|
"changed@example.test",
|
|
)?)?;
|
|
importer.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
false,
|
|
)?;
|
|
assert_eq!(
|
|
Config::load(Some(fixture.config.source()))?
|
|
.git_identity()
|
|
.name(),
|
|
"Changed since QR opened"
|
|
);
|
|
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
|
|
);
|
|
let before = std::fs::read(fixture.key_path.join(format!(
|
|
"{}-secret.asc",
|
|
ALICE_FINGERPRINT.to_ascii_lowercase()
|
|
)))?;
|
|
let mut replacement = MobileKeyTransferImport::new(fixture.config);
|
|
replacement.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
|
assert_eq!(
|
|
replacement.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
false,
|
|
),
|
|
Err(MobileKeyTransferError::ExistingKey)
|
|
);
|
|
assert_eq!(
|
|
std::fs::read(fixture.key_path.join(format!(
|
|
"{}-secret.asc",
|
|
ALICE_FINGERPRINT.to_ascii_lowercase()
|
|
)))?,
|
|
before
|
|
);
|
|
replacement.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
true,
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn default_key_import_rolls_back_when_config_cannot_be_replaced()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let fixture = configured_public_key()?;
|
|
let mut importer = MobileKeyTransferImport::new(fixture.config.clone());
|
|
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
|
let before = std::fs::read(fixture.config.source())?;
|
|
let parent = fixture.config.source().parent().unwrap();
|
|
let permissions = std::fs::metadata(parent)?.permissions();
|
|
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o500))?;
|
|
let result = importer.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
false,
|
|
);
|
|
std::fs::set_permissions(parent, permissions)?;
|
|
assert_eq!(result, Err(MobileKeyTransferError::Configuration));
|
|
assert_eq!(std::fs::read(fixture.config.source())?, before);
|
|
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn corrupt_configuration_does_not_install_an_imported_key()
|
|
-> 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()))?;
|
|
std::fs::write(fixture.config.source(), b"not a TOML document [")?;
|
|
assert_eq!(
|
|
importer.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
false
|
|
),
|
|
Err(MobileKeyTransferError::Configuration)
|
|
);
|
|
assert_eq!(std::fs::read_dir(fixture.key_path)?.count(), 1);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn cloned_store_with_empty_key_directory_can_import_its_private_key()
|
|
-> Result<(), Box<dyn std::error::Error>> {
|
|
let fixture = configured_key("placeholder", b"")?;
|
|
std::fs::remove_file(fixture.key_path.join("placeholder"))?;
|
|
let service = MobileKeyTransferService {
|
|
keys: load_keys_or_empty(&fixture.key_path)?,
|
|
config: fixture.config.clone(),
|
|
};
|
|
assert!(service.keys().is_empty());
|
|
|
|
let mut importer = service.importer();
|
|
importer.add_frame(SecretBytes::new(ALICE_SECRET.to_vec()))?;
|
|
importer.import(
|
|
Some(SecretBytes::new(b"fixture-alice-passphrase".to_vec())),
|
|
true,
|
|
false,
|
|
)?;
|
|
|
|
let keys = KeyStore::load(&fixture.key_path)?;
|
|
assert!(keys.resolve(ALICE_FINGERPRINT).is_ok());
|
|
assert_eq!(
|
|
Config::load(Some(fixture.config.source()))?
|
|
.default_key()
|
|
.as_str(),
|
|
ALICE_FINGERPRINT
|
|
);
|
|
let config = std::fs::read_to_string(fixture.config.source())?;
|
|
assert!(config.contains("vault = \"vault\""));
|
|
assert!(config.contains("key_material = \"keys\""));
|
|
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 = \"vault\"\ndefault_key = {:?}\nkey_material = \"keys\"\n",
|
|
ALICE_FINGERPRINT
|
|
),
|
|
)?;
|
|
let config = Config::load(Some(&config_path))?;
|
|
Ok(Fixture {
|
|
_root: root,
|
|
config,
|
|
key_path: keys,
|
|
})
|
|
}
|
|
}
|