Implement embedded OpenPGP compatibility (#4)
This commit is contained in:
915
crates/storage/src/crypto.rs
Normal file
915
crates/storage/src/crypto.rs
Normal file
@@ -0,0 +1,915 @@
|
||||
//! Embedded OpenPGP operations for existing exported key material.
|
||||
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet},
|
||||
error::Error,
|
||||
fmt, fs,
|
||||
io::{self, Cursor, Read},
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use cap_std::{ambient_authority, fs::Dir};
|
||||
use pgp::{
|
||||
composed::{
|
||||
Deserializable, DetachedSignature, Esk, Message, MessageBuilder, PublicOrSecret,
|
||||
SignedPublicKey, SignedPublicSubKey, SignedSecretKey, SubpacketConfig,
|
||||
},
|
||||
crypto::{hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
|
||||
packet::{SignatureType, Subpacket, SubpacketData},
|
||||
ser::Serialize as _,
|
||||
types::{KeyDetails as _, Password, SigningKey, Timestamp, VerifyingKey},
|
||||
};
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
use crate::repository::{EncryptedEntry, SecretBytes};
|
||||
|
||||
const MAX_KEY_FILE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
const MAX_IMPORTED_KEYS: usize = 1024;
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct KeyFingerprint(String);
|
||||
|
||||
impl KeyFingerprint {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyFingerprint {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
pub struct KeyHandle(KeyFingerprint);
|
||||
|
||||
impl KeyHandle {
|
||||
pub fn fingerprint(&self) -> &KeyFingerprint {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct KeyInfo {
|
||||
fingerprint: KeyFingerprint,
|
||||
key_id: String,
|
||||
user_ids: Vec<String>,
|
||||
has_secret: bool,
|
||||
can_encrypt: bool,
|
||||
can_sign: bool,
|
||||
}
|
||||
|
||||
impl KeyInfo {
|
||||
pub fn fingerprint(&self) -> &KeyFingerprint {
|
||||
&self.fingerprint
|
||||
}
|
||||
|
||||
pub fn key_id(&self) -> &str {
|
||||
&self.key_id
|
||||
}
|
||||
|
||||
pub fn user_ids(&self) -> &[String] {
|
||||
&self.user_ids
|
||||
}
|
||||
|
||||
pub fn has_secret(&self) -> bool {
|
||||
self.has_secret
|
||||
}
|
||||
|
||||
pub fn can_encrypt(&self) -> bool {
|
||||
self.can_encrypt
|
||||
}
|
||||
|
||||
pub fn can_sign(&self) -> bool {
|
||||
self.can_sign
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum SecretProviderError {
|
||||
Unavailable,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Supplies an unlock secret without exposing it to configuration or the key store.
|
||||
pub trait SecretProvider {
|
||||
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Eq, PartialEq)]
|
||||
pub struct DetachedSignatureBytes(Vec<u8>);
|
||||
|
||||
impl DetachedSignatureBytes {
|
||||
pub fn new(bytes: Vec<u8>) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub fn as_bytes(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn into_bytes(self) -> Vec<u8> {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for DetachedSignatureBytes {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("DetachedSignatureBytes")
|
||||
.field("length", &self.0.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
struct KeyMaterial {
|
||||
public: SignedPublicKey,
|
||||
secret: Option<SignedSecretKey>,
|
||||
}
|
||||
|
||||
/// Imported and verified OpenPGP certificates, indexed by primary fingerprint.
|
||||
#[derive(Default)]
|
||||
pub struct KeyStore {
|
||||
keys: BTreeMap<KeyFingerprint, KeyMaterial>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for KeyStore {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("KeyStore")
|
||||
.field("key_count", &self.keys.len())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Load a regular exported-key file or a directory tree made only of exported-key files.
|
||||
pub fn load(path: impl AsRef<Path>) -> Result<Self, CryptoError> {
|
||||
let path = path.as_ref();
|
||||
let metadata = fs::symlink_metadata(path)
|
||||
.map_err(|error| crypto_io("inspect key material", path, error))?;
|
||||
if metadata.file_type().is_symlink() || (!metadata.is_file() && !metadata.is_dir()) {
|
||||
return Err(CryptoError::UnsupportedKeyFileType {
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
let mut store = Self::new();
|
||||
if metadata.is_file() {
|
||||
let bytes = read_ambient_key_file(path, metadata.len())?;
|
||||
store.import(&bytes)?;
|
||||
} else {
|
||||
let canonical = fs::canonicalize(path)
|
||||
.map_err(|error| crypto_io("canonicalize key directory", path, error))?;
|
||||
let directory = Dir::open_ambient_dir(&canonical, ambient_authority())
|
||||
.map_err(|error| crypto_io("open key directory", &canonical, error))?;
|
||||
import_key_directory(&mut store, &directory, Path::new(""))?;
|
||||
}
|
||||
if store.keys.is_empty() {
|
||||
return Err(CryptoError::NoKeyMaterial);
|
||||
}
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Transactionally import one armored or binary stream containing public and/or secret keys.
|
||||
pub fn import(&mut self, bytes: &[u8]) -> Result<Vec<KeyInfo>, CryptoError> {
|
||||
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
|
||||
return Err(CryptoError::KeyMaterialTooLarge);
|
||||
}
|
||||
let (parsed, _) = PublicOrSecret::from_reader_many(Cursor::new(bytes))
|
||||
.map_err(|_| CryptoError::CorruptKeyMaterial)?;
|
||||
let mut pending = BTreeMap::<KeyFingerprint, KeyMaterial>::new();
|
||||
for parsed_key in parsed {
|
||||
let parsed_key = parsed_key.map_err(|_| CryptoError::CorruptKeyMaterial)?;
|
||||
parsed_key
|
||||
.verify_bindings()
|
||||
.map_err(|_| CryptoError::InvalidKeyBindings)?;
|
||||
let (public, secret) = match parsed_key {
|
||||
PublicOrSecret::Public(public) => (public, None),
|
||||
PublicOrSecret::Secret(secret) => (secret.to_public_key(), Some(secret)),
|
||||
};
|
||||
let fingerprint = fingerprint_of(&public);
|
||||
if let Some(existing) = pending.get_mut(&fingerprint) {
|
||||
if existing.public.primary_key != public.primary_key {
|
||||
return Err(CryptoError::DuplicateFingerprint { fingerprint });
|
||||
}
|
||||
if existing.secret.is_none() {
|
||||
existing.secret = secret;
|
||||
}
|
||||
} else {
|
||||
pending.insert(fingerprint, KeyMaterial { public, secret });
|
||||
}
|
||||
if self.keys.len() + pending.len() > MAX_IMPORTED_KEYS {
|
||||
return Err(CryptoError::TooManyKeys);
|
||||
}
|
||||
}
|
||||
if pending.is_empty() {
|
||||
return Err(CryptoError::NoKeyMaterial);
|
||||
}
|
||||
for (fingerprint, material) in &pending {
|
||||
if self
|
||||
.keys
|
||||
.get(fingerprint)
|
||||
.is_some_and(|existing| existing.public.primary_key != material.public.primary_key)
|
||||
{
|
||||
return Err(CryptoError::DuplicateFingerprint {
|
||||
fingerprint: fingerprint.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (fingerprint, material) in pending {
|
||||
if let Some(existing) = self.keys.get_mut(&fingerprint) {
|
||||
if existing.secret.is_none() {
|
||||
existing.secret = material.secret;
|
||||
}
|
||||
} else {
|
||||
self.keys.insert(fingerprint, material);
|
||||
}
|
||||
}
|
||||
Ok(self.infos().collect())
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.keys.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.keys.is_empty()
|
||||
}
|
||||
|
||||
pub fn infos(&self) -> impl Iterator<Item = KeyInfo> + '_ {
|
||||
self.keys.values().map(key_info)
|
||||
}
|
||||
|
||||
/// 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);
|
||||
let mut matches = BTreeSet::new();
|
||||
if let Some(hex_identity) = hex_identity {
|
||||
for (fingerprint, key) in &self.keys {
|
||||
if key_matches_hex(key, &hex_identity) {
|
||||
matches.insert(fingerprint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches.is_empty() {
|
||||
for (fingerprint, key) in &self.keys {
|
||||
if key
|
||||
.public
|
||||
.details
|
||||
.users
|
||||
.iter()
|
||||
.any(|user| user.id.as_str() == Some(identity))
|
||||
{
|
||||
matches.insert(fingerprint.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
match matches.len() {
|
||||
0 => Err(CryptoError::MissingIdentity {
|
||||
identity: identity.to_owned(),
|
||||
}),
|
||||
1 => Ok(KeyHandle(matches.pop_first().expect("one match"))),
|
||||
_ => Err(CryptoError::AmbiguousIdentity {
|
||||
identity: identity.to_owned(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply upstream `.gpg-id` comment and whitespace rules, then resolve every recipient.
|
||||
pub fn resolve_recipients(&self, contents: &[u8]) -> Result<Vec<KeyHandle>, CryptoError> {
|
||||
let contents =
|
||||
std::str::from_utf8(contents).map_err(|_| CryptoError::InvalidRecipientFile)?;
|
||||
let mut recipients = Vec::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
for line in contents.lines() {
|
||||
let identity = line.split('#').next().unwrap_or_default().trim();
|
||||
if identity.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let recipient = self.resolve(identity)?;
|
||||
if seen.insert(recipient.clone()) {
|
||||
recipients.push(recipient);
|
||||
}
|
||||
}
|
||||
if recipients.is_empty() {
|
||||
return Err(CryptoError::MissingRecipients);
|
||||
}
|
||||
Ok(recipients)
|
||||
}
|
||||
|
||||
/// Encrypt a plaintext for every resolved recipient using pass-compatible uncompressed
|
||||
/// SEIPD v1 with AES-256.
|
||||
pub fn encrypt(
|
||||
&self,
|
||||
plaintext: SecretBytes,
|
||||
recipients: &[KeyHandle],
|
||||
) -> Result<EncryptedEntry, CryptoError> {
|
||||
if recipients.is_empty() {
|
||||
return Err(CryptoError::MissingRecipients);
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
let mut targets = Vec::new();
|
||||
for recipient in recipients {
|
||||
if !unique.insert(recipient) {
|
||||
continue;
|
||||
}
|
||||
let material = self.material(recipient)?;
|
||||
let target = encryption_target(&material.public).ok_or_else(|| {
|
||||
CryptoError::MissingEncryptionKey {
|
||||
fingerprint: recipient.0.clone(),
|
||||
}
|
||||
})?;
|
||||
targets.push(target);
|
||||
}
|
||||
let reader = SecretReader::new(plaintext);
|
||||
let mut rng = OsRng;
|
||||
let mut message = MessageBuilder::from_reader("", reader)
|
||||
.seipd_v1(&mut rng, SymmetricKeyAlgorithm::AES256);
|
||||
for target in targets {
|
||||
match target {
|
||||
EncryptionTarget::Primary(key) => message
|
||||
.encrypt_to_key(&mut rng, key)
|
||||
.map_err(|_| CryptoError::EncryptionFailed)?,
|
||||
EncryptionTarget::Subkey(key) => message
|
||||
.encrypt_to_key(&mut rng, key)
|
||||
.map_err(|_| CryptoError::EncryptionFailed)?,
|
||||
};
|
||||
}
|
||||
message
|
||||
.to_vec(rng)
|
||||
.map(EncryptedEntry::new)
|
||||
.map_err(|_| CryptoError::EncryptionFailed)
|
||||
}
|
||||
|
||||
/// Decrypt a pass entry with only the secret keys named by its PKESK packets.
|
||||
pub fn decrypt(
|
||||
&self,
|
||||
ciphertext: &EncryptedEntry,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<SecretBytes, CryptoError> {
|
||||
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
||||
.map_err(|_| CryptoError::CorruptMessage)?;
|
||||
if !message.is_encrypted() {
|
||||
return Err(CryptoError::CorruptMessage);
|
||||
}
|
||||
let candidates = self
|
||||
.keys
|
||||
.iter()
|
||||
.filter(|(_, material)| {
|
||||
material
|
||||
.secret
|
||||
.as_ref()
|
||||
.is_some_and(|secret| message_matches_secret(&message, secret))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if candidates.is_empty() {
|
||||
return Err(CryptoError::MissingSecretKey);
|
||||
}
|
||||
|
||||
let mut unavailable = None;
|
||||
let mut attempted = false;
|
||||
for (fingerprint, material) in &candidates {
|
||||
let secret = material.secret.as_ref().expect("filtered secret key");
|
||||
let supplied = if secret_requires_password(secret) {
|
||||
let info = key_info(material);
|
||||
match provider.secret_for(&info) {
|
||||
Ok(passphrase) => passphrase,
|
||||
Err(SecretProviderError::Unavailable) => {
|
||||
unavailable.get_or_insert_with(|| CryptoError::SecretProvider {
|
||||
fingerprint: (*fingerprint).clone(),
|
||||
reason: SecretProviderError::Unavailable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
Err(reason @ SecretProviderError::Cancelled) => {
|
||||
return Err(CryptoError::SecretProvider {
|
||||
fingerprint: (*fingerprint).clone(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
SecretBytes::new(Vec::new())
|
||||
};
|
||||
attempted = true;
|
||||
let password = Password::from(supplied.expose());
|
||||
let message = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))
|
||||
.map_err(|_| CryptoError::CorruptMessage)?;
|
||||
if let Ok(mut decrypted) = message.decrypt(&password, secret)
|
||||
&& let Ok(plaintext) = decrypted.as_data_vec()
|
||||
{
|
||||
return Ok(SecretBytes::new(plaintext));
|
||||
}
|
||||
}
|
||||
if attempted {
|
||||
Err(CryptoError::DecryptionFailed)
|
||||
} else {
|
||||
Err(unavailable.expect("every candidate was unavailable"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sign(
|
||||
&self,
|
||||
data: &[u8],
|
||||
signer: &KeyHandle,
|
||||
provider: &mut impl SecretProvider,
|
||||
) -> Result<DetachedSignatureBytes, CryptoError> {
|
||||
let material = self.material(signer)?;
|
||||
let secret = material
|
||||
.secret
|
||||
.as_ref()
|
||||
.ok_or_else(|| CryptoError::MissingSigningKey {
|
||||
fingerprint: signer.0.clone(),
|
||||
})?;
|
||||
let target = signing_target(secret).ok_or_else(|| CryptoError::MissingSigningKey {
|
||||
fingerprint: signer.0.clone(),
|
||||
})?;
|
||||
let supplied = if signing_target_requires_password(target) {
|
||||
provider.secret_for(&key_info(material)).map_err(|reason| {
|
||||
CryptoError::SecretProvider {
|
||||
fingerprint: signer.0.clone(),
|
||||
reason,
|
||||
}
|
||||
})?
|
||||
} else {
|
||||
SecretBytes::new(Vec::new())
|
||||
};
|
||||
let password = Password::from(supplied.expose());
|
||||
let signature = match target {
|
||||
SigningTarget::Primary(key) => sign_data(key, &password, data),
|
||||
SigningTarget::Subkey(key) => sign_data(key, &password, data),
|
||||
}?;
|
||||
let mut bytes = Vec::new();
|
||||
signature
|
||||
.to_writer(&mut bytes)
|
||||
.map_err(|_| CryptoError::SigningFailed)?;
|
||||
Ok(DetachedSignatureBytes(bytes))
|
||||
}
|
||||
|
||||
/// Verify a detached `.gpg-id.sig` against an explicit set of allowed primary identities.
|
||||
pub fn verify(
|
||||
&self,
|
||||
data: &[u8],
|
||||
signature: &DetachedSignatureBytes,
|
||||
allowed_signers: &[KeyHandle],
|
||||
) -> Result<KeyHandle, CryptoError> {
|
||||
if allowed_signers.is_empty() {
|
||||
return Err(CryptoError::InvalidSignature);
|
||||
}
|
||||
let signature = DetachedSignature::from_bytes(Cursor::new(signature.as_bytes()))
|
||||
.map_err(|_| CryptoError::CorruptSignature)?;
|
||||
for signer in allowed_signers {
|
||||
let material = self.material(signer)?;
|
||||
if verify_with_public(&signature, &material.public, data) {
|
||||
return Ok(signer.clone());
|
||||
}
|
||||
}
|
||||
Err(CryptoError::InvalidSignature)
|
||||
}
|
||||
|
||||
fn material(&self, handle: &KeyHandle) -> Result<&KeyMaterial, CryptoError> {
|
||||
self.keys
|
||||
.get(&handle.0)
|
||||
.ok_or_else(|| CryptoError::MissingIdentity {
|
||||
identity: handle.0.0.clone(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum CryptoError {
|
||||
UnsupportedKeyFileType {
|
||||
path: PathBuf,
|
||||
},
|
||||
KeyMaterialTooLarge,
|
||||
TooManyKeys,
|
||||
NoKeyMaterial,
|
||||
CorruptKeyMaterial,
|
||||
InvalidKeyBindings,
|
||||
DuplicateFingerprint {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
MissingIdentity {
|
||||
identity: String,
|
||||
},
|
||||
AmbiguousIdentity {
|
||||
identity: String,
|
||||
},
|
||||
InvalidRecipientFile,
|
||||
MissingRecipients,
|
||||
MissingEncryptionKey {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
CorruptMessage,
|
||||
MissingSecretKey,
|
||||
SecretProvider {
|
||||
fingerprint: KeyFingerprint,
|
||||
reason: SecretProviderError,
|
||||
},
|
||||
DecryptionFailed,
|
||||
MissingSigningKey {
|
||||
fingerprint: KeyFingerprint,
|
||||
},
|
||||
EncryptionFailed,
|
||||
SigningFailed,
|
||||
CorruptSignature,
|
||||
InvalidSignature,
|
||||
Io {
|
||||
operation: &'static str,
|
||||
path: PathBuf,
|
||||
source: io::ErrorKind,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for CryptoError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::UnsupportedKeyFileType { path } => {
|
||||
write!(
|
||||
formatter,
|
||||
"unsupported exported-key file type: {}",
|
||||
path.display()
|
||||
)
|
||||
}
|
||||
Self::KeyMaterialTooLarge => formatter.write_str("exported key material is too large"),
|
||||
Self::TooManyKeys => formatter.write_str("too many exported keys"),
|
||||
Self::NoKeyMaterial => formatter.write_str("no OpenPGP key material was found"),
|
||||
Self::CorruptKeyMaterial => formatter.write_str("OpenPGP key material is malformed"),
|
||||
Self::InvalidKeyBindings => formatter.write_str("OpenPGP key bindings are invalid"),
|
||||
Self::DuplicateFingerprint { fingerprint } => {
|
||||
write!(
|
||||
formatter,
|
||||
"conflicting OpenPGP key fingerprint: {fingerprint}"
|
||||
)
|
||||
}
|
||||
Self::MissingIdentity { identity } => {
|
||||
write!(formatter, "OpenPGP identity was not found: {identity}")
|
||||
}
|
||||
Self::AmbiguousIdentity { identity } => {
|
||||
write!(formatter, "OpenPGP identity is ambiguous: {identity}")
|
||||
}
|
||||
Self::InvalidRecipientFile => formatter.write_str(".gpg-id is not valid UTF-8"),
|
||||
Self::MissingRecipients => formatter.write_str("no OpenPGP recipients were provided"),
|
||||
Self::MissingEncryptionKey { fingerprint } => {
|
||||
write!(formatter, "OpenPGP key cannot encrypt: {fingerprint}")
|
||||
}
|
||||
Self::CorruptMessage => formatter.write_str("encrypted OpenPGP message is malformed"),
|
||||
Self::MissingSecretKey => {
|
||||
formatter.write_str("no imported secret key can decrypt the message")
|
||||
}
|
||||
Self::SecretProvider {
|
||||
fingerprint,
|
||||
reason,
|
||||
} => write!(
|
||||
formatter,
|
||||
"secret provider could not unlock OpenPGP key {fingerprint}: {reason:?}"
|
||||
),
|
||||
Self::DecryptionFailed => formatter.write_str("OpenPGP decryption failed"),
|
||||
Self::MissingSigningKey { fingerprint } => {
|
||||
write!(formatter, "OpenPGP key cannot sign: {fingerprint}")
|
||||
}
|
||||
Self::EncryptionFailed => formatter.write_str("OpenPGP encryption failed"),
|
||||
Self::SigningFailed => formatter.write_str("OpenPGP signing failed"),
|
||||
Self::CorruptSignature => {
|
||||
formatter.write_str("detached OpenPGP signature is malformed")
|
||||
}
|
||||
Self::InvalidSignature => formatter.write_str("detached OpenPGP signature is invalid"),
|
||||
Self::Io {
|
||||
operation,
|
||||
path,
|
||||
source,
|
||||
} => write!(formatter, "cannot {operation} {}: {source}", path.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for CryptoError {}
|
||||
|
||||
fn fingerprint_of(key: &SignedPublicKey) -> KeyFingerprint {
|
||||
KeyFingerprint(format!("{:X}", key.fingerprint()))
|
||||
}
|
||||
|
||||
fn key_info(material: &KeyMaterial) -> KeyInfo {
|
||||
KeyInfo {
|
||||
fingerprint: fingerprint_of(&material.public),
|
||||
key_id: material
|
||||
.public
|
||||
.legacy_key_id()
|
||||
.to_string()
|
||||
.to_ascii_uppercase(),
|
||||
user_ids: material
|
||||
.public
|
||||
.details
|
||||
.users
|
||||
.iter()
|
||||
.filter_map(|user| user.id.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
has_secret: material.secret.is_some(),
|
||||
can_encrypt: encryption_target(&material.public).is_some(),
|
||||
can_sign: can_sign(&material.public),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_hex_identity(identity: &str) -> Option<String> {
|
||||
let identity = identity
|
||||
.strip_prefix("0x")
|
||||
.or_else(|| identity.strip_prefix("0X"))
|
||||
.unwrap_or(identity);
|
||||
if matches!(identity.len(), 8 | 16 | 40 | 64)
|
||||
&& identity.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
{
|
||||
Some(identity.to_ascii_uppercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn key_matches_hex(key: &KeyMaterial, identity: &str) -> bool {
|
||||
let mut identities = vec![
|
||||
format!("{:X}", key.public.fingerprint()),
|
||||
key.public.legacy_key_id().to_string().to_ascii_uppercase(),
|
||||
];
|
||||
for subkey in &key.public.public_subkeys {
|
||||
identities.push(format!("{:X}", subkey.fingerprint()));
|
||||
identities.push(subkey.legacy_key_id().to_string().to_ascii_uppercase());
|
||||
}
|
||||
identities
|
||||
.iter()
|
||||
.any(|candidate| candidate == identity || candidate.ends_with(identity))
|
||||
}
|
||||
|
||||
enum EncryptionTarget<'a> {
|
||||
Primary(&'a SignedPublicKey),
|
||||
Subkey(&'a SignedPublicSubKey),
|
||||
}
|
||||
|
||||
fn encryption_target(key: &SignedPublicKey) -> Option<EncryptionTarget<'_>> {
|
||||
for subkey in &key.public_subkeys {
|
||||
let revoked = subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.any(|signature| signature.typ() == Some(SignatureType::SubkeyRevocation));
|
||||
let encrypts = subkey.signatures.iter().any(|signature| {
|
||||
let flags = signature.key_flags();
|
||||
flags.encrypt_comms() || flags.encrypt_storage()
|
||||
});
|
||||
if encrypts && !revoked {
|
||||
return Some(EncryptionTarget::Subkey(subkey));
|
||||
}
|
||||
}
|
||||
if primary_flags(key, |flags| {
|
||||
flags.encrypt_comms() || flags.encrypt_storage()
|
||||
}) {
|
||||
Some(EncryptionTarget::Primary(key))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SigningTarget<'a> {
|
||||
Primary(&'a pgp::packet::SecretKey),
|
||||
Subkey(&'a pgp::packet::SecretSubkey),
|
||||
}
|
||||
|
||||
fn signing_target(key: &SignedSecretKey) -> Option<SigningTarget<'_>> {
|
||||
for subkey in &key.secret_subkeys {
|
||||
let revoked = subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.any(|signature| signature.typ() == Some(SignatureType::SubkeyRevocation));
|
||||
let signs = subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.any(|signature| signature.key_flags().sign());
|
||||
if signs && !revoked {
|
||||
return Some(SigningTarget::Subkey(&subkey.key));
|
||||
}
|
||||
}
|
||||
let public = key.to_public_key();
|
||||
if primary_flags(&public, |flags| flags.sign()) {
|
||||
Some(SigningTarget::Primary(&key.primary_key))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn can_sign(key: &SignedPublicKey) -> bool {
|
||||
key.public_subkeys.iter().any(|subkey| {
|
||||
let revoked = subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.any(|signature| signature.typ() == Some(SignatureType::SubkeyRevocation));
|
||||
!revoked
|
||||
&& subkey
|
||||
.signatures
|
||||
.iter()
|
||||
.any(|signature| signature.key_flags().sign())
|
||||
}) || primary_flags(key, |flags| flags.sign())
|
||||
}
|
||||
|
||||
fn primary_flags(
|
||||
key: &SignedPublicKey,
|
||||
predicate: impl Fn(&pgp::packet::KeyFlags) -> bool,
|
||||
) -> bool {
|
||||
key.details
|
||||
.direct_signatures
|
||||
.iter()
|
||||
.chain(
|
||||
key.details
|
||||
.users
|
||||
.iter()
|
||||
.flat_map(|user| user.signatures.iter()),
|
||||
)
|
||||
.any(|signature| predicate(&signature.key_flags()))
|
||||
}
|
||||
|
||||
fn secret_requires_password(key: &SignedSecretKey) -> bool {
|
||||
key.primary_key.secret_params().is_encrypted()
|
||||
|| key
|
||||
.secret_subkeys
|
||||
.iter()
|
||||
.any(|subkey| subkey.secret_params().is_encrypted())
|
||||
}
|
||||
|
||||
fn signing_target_requires_password(target: SigningTarget<'_>) -> bool {
|
||||
match target {
|
||||
SigningTarget::Primary(key) => key.secret_params().is_encrypted(),
|
||||
SigningTarget::Subkey(key) => key.secret_params().is_encrypted(),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_matches_secret(message: &Message<'_>, key: &SignedSecretKey) -> bool {
|
||||
let Message::Encrypted { esk, .. } = message else {
|
||||
return false;
|
||||
};
|
||||
esk.iter().any(|esk| {
|
||||
let Esk::PublicKeyEncryptedSessionKey(esk) = esk else {
|
||||
return false;
|
||||
};
|
||||
esk.match_identity(key.primary_key.public_key())
|
||||
|| key
|
||||
.secret_subkeys
|
||||
.iter()
|
||||
.any(|subkey| esk.match_identity(subkey.public_key()))
|
||||
})
|
||||
}
|
||||
|
||||
fn sign_data<K: SigningKey>(
|
||||
key: &K,
|
||||
password: &Password,
|
||||
data: &[u8],
|
||||
) -> Result<DetachedSignature, CryptoError> {
|
||||
let hashed = vec![
|
||||
Subpacket::regular(SubpacketData::IssuerFingerprint(key.fingerprint()))
|
||||
.map_err(|_| CryptoError::SigningFailed)?,
|
||||
Subpacket::regular(SubpacketData::SignatureCreationTime(Timestamp::now()))
|
||||
.map_err(|_| CryptoError::SigningFailed)?,
|
||||
];
|
||||
let unhashed = vec![
|
||||
Subpacket::regular(SubpacketData::IssuerKeyId(key.legacy_key_id()))
|
||||
.map_err(|_| CryptoError::SigningFailed)?,
|
||||
];
|
||||
DetachedSignature::sign_binary_data_with_subpackets(
|
||||
OsRng,
|
||||
key,
|
||||
password,
|
||||
HashAlgorithm::Sha256,
|
||||
data,
|
||||
SubpacketConfig::UserDefined { hashed, unhashed },
|
||||
)
|
||||
.map_err(|_| CryptoError::SigningFailed)
|
||||
}
|
||||
|
||||
fn verify_with_public(signature: &DetachedSignature, key: &SignedPublicKey, data: &[u8]) -> bool {
|
||||
if verify_data(signature, &key.primary_key, data) {
|
||||
return true;
|
||||
}
|
||||
key.public_subkeys
|
||||
.iter()
|
||||
.any(|subkey| verify_data(signature, &subkey.key, data))
|
||||
}
|
||||
|
||||
fn verify_data<K: VerifyingKey>(signature: &DetachedSignature, key: &K, data: &[u8]) -> bool {
|
||||
signature.verify(key, data).is_ok()
|
||||
}
|
||||
|
||||
struct SecretReader {
|
||||
secret: SecretBytes,
|
||||
position: usize,
|
||||
}
|
||||
|
||||
impl SecretReader {
|
||||
fn new(secret: SecretBytes) -> Self {
|
||||
Self {
|
||||
secret,
|
||||
position: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for SecretReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
let remaining = &self.secret.expose()[self.position..];
|
||||
let length = remaining.len().min(buffer.len());
|
||||
buffer[..length].copy_from_slice(&remaining[..length]);
|
||||
self.position += length;
|
||||
Ok(length)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_ambient_key_file(path: &Path, length: u64) -> Result<Vec<u8>, CryptoError> {
|
||||
if length > MAX_KEY_FILE_BYTES {
|
||||
return Err(CryptoError::KeyMaterialTooLarge);
|
||||
}
|
||||
let parent = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| CryptoError::UnsupportedKeyFileType {
|
||||
path: path.to_owned(),
|
||||
})?;
|
||||
let parent = fs::canonicalize(parent)
|
||||
.map_err(|error| crypto_io("canonicalize key parent", parent, error))?;
|
||||
let directory = Dir::open_ambient_dir(&parent, ambient_authority())
|
||||
.map_err(|error| crypto_io("open key parent", &parent, error))?;
|
||||
let metadata = directory
|
||||
.symlink_metadata(name)
|
||||
.map_err(|error| crypto_io("inspect key file", path, error))?;
|
||||
if metadata.file_type().is_symlink() || !metadata.is_file() {
|
||||
return Err(CryptoError::UnsupportedKeyFileType {
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
read_cap_file(&directory, name, path, metadata.len())
|
||||
}
|
||||
|
||||
fn import_key_directory(
|
||||
store: &mut KeyStore,
|
||||
directory: &Dir,
|
||||
relative: &Path,
|
||||
) -> Result<(), CryptoError> {
|
||||
let mut entries = directory
|
||||
.read_dir(".")
|
||||
.map_err(|error| crypto_io("read key directory", relative, error))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|error| crypto_io("read key directory entry", relative, error))?;
|
||||
entries.sort_by_key(cap_std::fs::DirEntry::file_name);
|
||||
for entry in entries {
|
||||
let name = entry.file_name();
|
||||
let path = relative.join(&name);
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|error| crypto_io("inspect key directory entry", &path, error))?;
|
||||
if file_type.is_symlink() || (!file_type.is_file() && !file_type.is_dir()) {
|
||||
return Err(CryptoError::UnsupportedKeyFileType { path });
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
let child = entry
|
||||
.open_dir()
|
||||
.map_err(|error| crypto_io("open key directory", &path, error))?;
|
||||
import_key_directory(store, &child, &path)?;
|
||||
} else {
|
||||
let length = entry
|
||||
.metadata()
|
||||
.map_err(|error| crypto_io("inspect key file", &path, error))?
|
||||
.len();
|
||||
let bytes = read_cap_file(directory, &name, &path, length)?;
|
||||
store.import(&bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_cap_file(
|
||||
directory: &Dir,
|
||||
name: impl AsRef<Path>,
|
||||
path: &Path,
|
||||
length: u64,
|
||||
) -> Result<Vec<u8>, CryptoError> {
|
||||
if length > MAX_KEY_FILE_BYTES {
|
||||
return Err(CryptoError::KeyMaterialTooLarge);
|
||||
}
|
||||
let mut file = directory
|
||||
.open(name)
|
||||
.map_err(|error| crypto_io("open key file", path, error))?;
|
||||
let mut bytes = Vec::with_capacity(length as usize);
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|error| crypto_io("read key file", path, error))?;
|
||||
if bytes.len() as u64 > MAX_KEY_FILE_BYTES {
|
||||
return Err(CryptoError::KeyMaterialTooLarge);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn crypto_io(operation: &'static str, path: &Path, error: io::Error) -> CryptoError {
|
||||
CryptoError::Io {
|
||||
operation,
|
||||
path: path.to_owned(),
|
||||
source: error.kind(),
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod crypto;
|
||||
pub mod repository;
|
||||
|
||||
/// Product name shared by the presentation adapters.
|
||||
|
||||
Reference in New Issue
Block a user