Implement embedded OpenPGP compatibility (#4)

This commit is contained in:
Hermes Agent
2026-08-09 21:59:29 +00:00
parent 8e4c9390a0
commit 08bc244a4a
9 changed files with 1392 additions and 8 deletions

1
Cargo.lock generated
View File

@@ -2716,6 +2716,7 @@ dependencies = [
"flate2", "flate2",
"hex", "hex",
"pgp", "pgp",
"rand 0.8.7",
"rand_chacha 0.3.1", "rand_chacha 0.3.1",
"rustix 1.1.4", "rustix 1.1.4",
"serde", "serde",

View File

@@ -20,6 +20,8 @@ clap = { version = "4.6", features = ["derive"] }
crossterm = "0.29" crossterm = "0.29"
iced = "0.14" iced = "0.14"
ironstorage = { path = "crates/storage" } ironstorage = { path = "crates/storage" }
pgp = { version = "0.20", default-features = false }
rand = "0.8"
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] } ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
shlex = "1.3" shlex = "1.3"

View File

@@ -1,7 +1,8 @@
# Dependency and license review # Dependency and license review
Reviewed 2026-08-09. The project license remains intentionally unset until the Reviewed 2026-08-09. The OpenPGP backend decision is complete. The project
GPG compatibility spike is complete. license remains intentionally unset pending the full transitive license audit
and packaging review described below.
## License direction ## License direction
@@ -17,6 +18,8 @@ The current direct dependencies are:
| [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT | | [crossterm 0.29](https://crates.io/crates/crossterm/0.29.0) | Terminal I/O | MIT |
| [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT | | [Ratatui 0.30](https://crates.io/crates/ratatui/0.30.2) | TUI | MIT |
| [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT | | [Iced 0.14](https://crates.io/crates/iced/0.14.0) | Desktop UI | MIT |
| [pgp 0.20](https://crates.io/crates/pgp/0.20.0) | Embedded OpenPGP key import, encryption, decryption, and signatures | MIT OR Apache-2.0 |
| [rand 0.8](https://crates.io/crates/rand/0.8.7) | Operating-system-backed cryptographic randomness for OpenPGP operations | MIT OR Apache-2.0 |
| [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 | | [Serde 1](https://crates.io/crates/serde), [TOML 0.9](https://crates.io/crates/toml), [shlex 1.3](https://crates.io/crates/shlex), [url 2.5](https://crates.io/crates/url) | Strict configuration and command values | MIT OR Apache-2.0 |
| [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 | | [UniFFI 0.32](https://crates.io/crates/uniffi/0.32.0) | Swift bridge | MPL-2.0 |
| [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 | | [zeroize 1.9](https://crates.io/crates/zeroize/1.9.0) | Clear decrypted bytes on drop | MIT OR Apache-2.0 |
@@ -35,7 +38,7 @@ decision.
| Slice | Candidate | License | Decision | | Slice | Candidate | License | Decision |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| GPG-compatible packets, encryption, and transferable keys | [`pgp` 0.20](https://crates.io/crates/pgp/0.20.0) | MIT OR Apache-2.0 | Preferred; pure Rust, including its default Rust bzip2 backend. Prove interoperability with GPG-produced fixtures first. | | GPG-compatible packets, encryption, and transferable keys | [`pgp` 0.20](https://crates.io/crates/pgp/0.20.0) | MIT OR Apache-2.0 | Selected with default features disabled. The fixture harness proves armored/binary protected key import, packet validation, GPG-compatible decryption, multi-recipient encryption, and detached signatures without native libraries or processes. |
| Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. | | Alternative GPG implementation | [`sequoia-openpgp` 2.4](https://crates.io/crates/sequoia-openpgp/2.4.1) | LGPL-2.0-or-later | Hold in reserve. Its default Nettle backend is native; its Rust backend exists, but the LGPL adds distribution work we can avoid. |
| GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. | | GnuPG integration | [`gpgme` 0.11](https://crates.io/crates/gpgme/0.11.0) | LGPL-2.1 | Reject: native GPGME/GnuPG integration and GPG engine processes violate the portability and no-process requirements. |
| Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Preferred with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only and supply credentials directly. | | Local Git plus HTTPS fetch/push | [`gix` 0.86](https://crates.io/crates/gix/0.86.0) | MIT OR Apache-2.0 | Preferred with default features off and `blocking-http-transport-reqwest-rust-tls`; accept HTTPS remotes only and supply credentials directly. |
@@ -55,8 +58,11 @@ Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
Before choosing and adding the project license: Before choosing and adding the project license:
1. Prove `pgp` can decrypt, encrypt, re-encrypt, and round-trip representative 1. Lock the storage dependencies and run a full transitive license audit.
GPG files and exported keys from real `pass` stores. 2. Confirm the required notices/source offers for MPL-2.0 dependencies in every
2. Lock the storage dependencies and run a full transitive license audit.
3. Confirm the required notices/source offers for MPL-2.0 dependencies in every
distributed app package. distributed app package.
The checked-in compatibility suite completes the earlier OpenPGP backend gate:
`pgp` imports protected armored and binary exports, decrypts every GnuPG-audited
fixture, emits independently decryptable single- and multi-recipient messages,
and verifies the detached recipient signatures.

View File

@@ -28,6 +28,9 @@ The shared TOML schema, path rules, editor precedence, and HTTPS remote format
are documented in [`docs/configuration.md`](docs/configuration.md). are documented in [`docs/configuration.md`](docs/configuration.md).
The capability-scoped password-store layout and atomic mutation guarantees are The capability-scoped password-store layout and atomic mutation guarantees are
documented in [`docs/repository-core.md`](docs/repository-core.md). documented in [`docs/repository-core.md`](docs/repository-core.md).
The embedded OpenPGP backend, exported-key model, secret-provider boundary, and
GnuPG compatibility evidence are documented in
[`docs/cryptography.md`](docs/cryptography.md).
## Project layout ## Project layout

View File

@@ -10,6 +10,8 @@ publish = false
cap-std.workspace = true cap-std.workspace = true
cap-tempfile.workspace = true cap-tempfile.workspace = true
clap.workspace = true clap.workspace = true
pgp.workspace = true
rand.workspace = true
serde.workspace = true serde.workspace = true
shlex.workspace = true shlex.workspace = true
toml.workspace = true toml.workspace = true
@@ -19,7 +21,6 @@ zeroize.workspace = true
[dev-dependencies] [dev-dependencies]
flate2 = "1.1" flate2 = "1.1"
hex = "0.4" hex = "0.4"
pgp = { version = "0.20", default-features = false }
rand_chacha = "0.3" rand_chacha = "0.3"
rustix = { version = "1.1", features = ["fs"] } rustix = { version = "1.1", features = ["fs"] }
sha1 = "0.10" sha1 = "0.10"

View 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(),
}
}

View File

@@ -7,6 +7,7 @@
pub mod command; pub mod command;
pub mod config; pub mod config;
pub mod crypto;
pub mod repository; pub mod repository;
/// Product name shared by the presentation adapters. /// Product name shared by the presentation adapters.

View File

@@ -0,0 +1,382 @@
#![forbid(unsafe_code)]
mod support;
use std::{collections::BTreeMap, io::Cursor, path::Path};
use ironstorage::{
crypto::{
CryptoError, DetachedSignatureBytes, KeyInfo, KeyStore, SecretProvider, SecretProviderError,
},
repository::{EncryptedEntry, SecretBytes},
};
use pgp::{
composed::{EncryptionCaps, KeyType, Message, SecretKeyParamsBuilder, SubkeyParamsBuilder},
crypto::{ecc_curve::ECCCurve, hash::HashAlgorithm, sym::SymmetricKeyAlgorithm},
ser::Serialize as _,
types::{Password, Timestamp},
};
use rand_chacha::{ChaCha20Rng, rand_core::SeedableRng};
use smallvec::smallvec;
use support::compatibility::{FixtureSet, TestResult};
#[derive(Default)]
struct FixtureSecrets {
values: BTreeMap<String, Vec<u8>>,
requests: Vec<String>,
}
impl FixtureSecrets {
fn all(fixture: &FixtureSet) -> Self {
Self {
values: fixture
.generated
.keys
.iter()
.map(|key| {
(
key.primary_fingerprint.clone(),
key.passphrase.as_bytes().to_vec(),
)
})
.collect(),
requests: Vec::new(),
}
}
fn one(fingerprint: &str, passphrase: impl AsRef<[u8]>) -> Self {
Self {
values: BTreeMap::from([(fingerprint.to_owned(), passphrase.as_ref().to_vec())]),
requests: Vec::new(),
}
}
}
impl SecretProvider for FixtureSecrets {
fn secret_for(&mut self, key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
let fingerprint = key.fingerprint().as_str().to_owned();
self.requests.push(fingerprint.clone());
self.values
.get(&fingerprint)
.cloned()
.map(SecretBytes::new)
.ok_or(SecretProviderError::Unavailable)
}
}
struct CancelledProvider;
impl SecretProvider for CancelledProvider {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Err(SecretProviderError::Cancelled)
}
}
#[test]
fn imports_armored_and_binary_public_and_protected_secret_keys() -> TestResult {
let fixture = FixtureSet::load()?;
let alice = fixture.key("alice")?;
let bob = fixture.key("bob")?;
let mut keys = KeyStore::new();
keys.import(&fixture.read(&alice.public_armor)?)?;
keys.import(&fixture.read(&alice.secret_binary)?)?;
keys.import(&fixture.read(&bob.public_binary)?)?;
keys.import(&fixture.read(&bob.secret_armor)?)?;
assert_eq!(keys.len(), 2);
let infos = keys.infos().collect::<Vec<_>>();
assert!(infos.iter().all(KeyInfo::has_secret));
assert!(infos.iter().all(KeyInfo::can_encrypt));
assert!(infos.iter().all(KeyInfo::can_sign));
assert!(format!("{keys:?}").contains("key_count: 2"));
assert!(!format!("{keys:?}").contains(&alice.passphrase));
let loaded = KeyStore::load(fixture.path("keys"))?;
assert_eq!(loaded.len(), 2, "duplicate armor/binary exports are merged");
Ok(())
}
#[test]
fn resolves_pass_identities_and_recipient_file_rules() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture.key("alice")?;
let info = keys
.infos()
.find(|info| info.fingerprint().as_str() == alice.primary_fingerprint)
.expect("Alice info");
for identity in [
alice.primary_fingerprint.as_str(),
&alice.primary_fingerprint[24..],
&alice.primary_fingerprint[32..],
info.key_id(),
alice.user_id.as_str(),
alice.encryption_subkey_fingerprint.as_str(),
&alice.encryption_subkey_fingerprint[24..],
] {
assert_eq!(
keys.resolve(identity)?.fingerprint().as_str(),
alice.primary_fingerprint
);
}
let recipients = keys.resolve_recipients(
format!(
" {} # primary recipient\n\n{} # duplicate\n",
alice.primary_fingerprint, alice.user_id
)
.as_bytes(),
)?;
assert_eq!(recipients.len(), 1);
assert!(matches!(
keys.resolve("missing@example.invalid"),
Err(CryptoError::MissingIdentity { .. })
));
assert!(matches!(
keys.resolve_recipients(b" # comments only\n"),
Err(CryptoError::MissingRecipients)
));
assert!(matches!(
keys.resolve_recipients(&[0xff]),
Err(CryptoError::InvalidRecipientFile)
));
Ok(())
}
#[test]
fn reports_exact_user_id_ambiguity() -> TestResult {
let fixture = FixtureSet::load()?;
let alice = fixture.key("alice")?;
let mut keys = KeyStore::new();
keys.import(&fixture.read(&alice.public_binary)?)?;
keys.import(&duplicate_user_id_key(&alice.user_id)?)?;
assert!(matches!(
keys.resolve(&alice.user_id),
Err(CryptoError::AmbiguousIdentity { .. })
));
Ok(())
}
#[test]
fn decrypts_every_gnupg_audited_pass_fixture() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut provider = FixtureSecrets::all(&fixture);
for entry in &fixture.generated.entries {
let ciphertext = fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?;
let expected = fixture.read(
Path::new("expected")
.join(&entry.store)
.join(&entry.plaintext),
)?;
let plaintext = keys.decrypt(&EncryptedEntry::new(ciphertext), &mut provider)?;
assert_eq!(
plaintext.expose(),
expected,
"fixture {}/{}",
entry.store,
entry.path
);
}
assert!(!provider.requests.is_empty());
Ok(())
}
#[test]
fn encrypts_for_one_or_many_recipients_without_compression() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture.key("alice")?;
let bob = fixture.key("bob")?;
let recipients = keys.resolve_recipients(
format!(
"{}\n{}\n",
alice.primary_fingerprint, bob.primary_fingerprint
)
.as_bytes(),
)?;
let expected = b"generated-by-ironstorage\nlogin: fixture\n";
let ciphertext = keys.encrypt(SecretBytes::new(expected.to_vec()), &recipients)?;
let parsed = Message::from_bytes(Cursor::new(ciphertext.as_bytes()))?;
assert!(parsed.is_encrypted());
for key in [alice, bob] {
let mut provider = FixtureSecrets::one(&key.primary_fingerprint, &key.passphrase);
let plaintext = keys.decrypt(&ciphertext, &mut provider)?;
assert_eq!(plaintext.expose(), expected);
assert_eq!(provider.requests.last(), Some(&key.primary_fingerprint));
}
let independent = fixture.parse_secret_key(alice)?;
assert_eq!(
decrypt_independently(ciphertext.as_bytes(), &independent, &alice.passphrase)?,
expected
);
assert!(matches!(
keys.encrypt(SecretBytes::new(Vec::new()), &[]),
Err(CryptoError::MissingRecipients)
));
Ok(())
}
#[test]
fn protected_key_failures_are_typed_and_redacted() -> TestResult {
let fixture = FixtureSet::load()?;
let alice = fixture.key("alice")?;
let entry = fixture
.generated
.entries
.iter()
.find(|entry| entry.recipients == [alice.primary_fingerprint.clone()])
.expect("Alice fixture");
let ciphertext = EncryptedEntry::new(
fixture.read(Path::new("stores").join(&entry.store).join(&entry.path))?,
);
let keys = KeyStore::load(fixture.path(&alice.secret_armor))?;
let mut wrong = FixtureSecrets::one(&alice.primary_fingerprint, b"very-secret-wrong-value");
let error = keys.decrypt(&ciphertext, &mut wrong).unwrap_err();
assert_eq!(error, CryptoError::DecryptionFailed);
assert!(!format!("{error:?}").contains("very-secret-wrong-value"));
assert!(!error.to_string().contains("very-secret-wrong-value"));
let public_only = KeyStore::load(fixture.path(&alice.public_armor))?;
let mut unavailable = FixtureSecrets::default();
assert!(matches!(
public_only.decrypt(&ciphertext, &mut unavailable),
Err(CryptoError::MissingSecretKey)
));
assert!(unavailable.requests.is_empty());
let mut cancelled = FixtureSecrets::default();
let error = keys.decrypt(&ciphertext, &mut cancelled).unwrap_err();
assert!(matches!(
error,
CryptoError::SecretProvider {
reason: SecretProviderError::Unavailable,
..
}
));
assert!(matches!(
keys.decrypt(&ciphertext, &mut CancelledProvider),
Err(CryptoError::SecretProvider {
reason: SecretProviderError::Cancelled,
..
})
));
Ok(())
}
#[test]
fn signs_and_verifies_gpg_id_with_an_explicit_trust_set() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let alice = fixture.key("alice")?;
let bob = fixture.key("bob")?;
let alice_handle = keys.resolve(&alice.primary_fingerprint)?;
let bob_handle = keys.resolve(&bob.primary_fingerprint)?;
let recipient_file = fixture.read("stores/basic/.gpg-id")?;
let checked_in = DetachedSignatureBytes::new(fixture.read("stores/basic/.gpg-id.sig")?);
assert_eq!(
keys.verify(
&recipient_file,
&checked_in,
std::slice::from_ref(&alice_handle)
)?,
alice_handle
);
assert_eq!(
keys.verify(&recipient_file, &checked_in, &[bob_handle]),
Err(CryptoError::InvalidSignature)
);
assert_eq!(
keys.verify(
b"tampered\n",
&checked_in,
std::slice::from_ref(&alice_handle)
),
Err(CryptoError::InvalidSignature)
);
assert_eq!(
keys.verify(
&recipient_file,
&DetachedSignatureBytes::new(vec![0xff]),
std::slice::from_ref(&alice_handle),
),
Err(CryptoError::CorruptSignature)
);
let mut provider = FixtureSecrets::one(&alice.primary_fingerprint, &alice.passphrase);
let generated = keys.sign(&recipient_file, &alice_handle, &mut provider)?;
assert_eq!(
keys.verify(
&recipient_file,
&generated,
std::slice::from_ref(&alice_handle)
)?,
alice_handle
);
let mut wrong = FixtureSecrets::one(&alice.primary_fingerprint, b"wrong");
assert_eq!(
keys.sign(&recipient_file, &alice_handle, &mut wrong),
Err(CryptoError::SigningFailed)
);
Ok(())
}
#[test]
fn rejects_corrupt_keys_messages_and_signatures() -> TestResult {
let fixture = FixtureSet::load()?;
let keys = KeyStore::load(fixture.path("keys"))?;
let mut provider = FixtureSecrets::all(&fixture);
let mut empty = KeyStore::new();
assert_eq!(
empty.import(b"not an OpenPGP key"),
Err(CryptoError::CorruptKeyMaterial)
);
assert!(empty.is_empty());
assert!(matches!(
keys.decrypt(&EncryptedEntry::new(vec![0xff, 0x00]), &mut provider),
Err(CryptoError::CorruptMessage)
));
Ok(())
}
fn duplicate_user_id_key(user_id: &str) -> TestResult<Vec<u8>> {
let created_at = Timestamp::from_secs(1_704_067_201);
let subkey = SubkeyParamsBuilder::default()
.key_type(KeyType::ECDH(ECCCurve::Curve25519Legacy))
.can_encrypt(EncryptionCaps::All)
.created_at(created_at)
.build()?;
let mut rng = ChaCha20Rng::from_seed([0x55; 32]);
let secret = SecretKeyParamsBuilder::default()
.key_type(KeyType::Ed25519Legacy)
.can_certify(true)
.can_sign(true)
.created_at(created_at)
.primary_user_id(user_id.to_owned())
.preferred_symmetric_algorithms(smallvec![SymmetricKeyAlgorithm::AES256])
.preferred_hash_algorithms(smallvec![HashAlgorithm::Sha256])
.preferred_compression_algorithms(smallvec![])
.subkey(subkey)
.build()?
.generate(&mut rng)?;
let mut bytes = Vec::new();
secret.to_public_key().to_writer(&mut bytes)?;
Ok(bytes)
}
fn decrypt_independently(
ciphertext: &[u8],
secret: &pgp::composed::SignedSecretKey,
passphrase: &str,
) -> TestResult<Vec<u8>> {
let message = Message::from_bytes(Cursor::new(ciphertext))?;
let mut decrypted = message.decrypt(&Password::from(passphrase), secret)?;
Ok(decrypted.as_data_vec()?)
}

73
docs/cryptography.md Normal file
View File

@@ -0,0 +1,73 @@
# Embedded OpenPGP compatibility
All password-store cryptography lives in `crates/storage`. IronStorage does not
read a user's GnuPG keyring and does not launch `gpg`, `gpg-agent`, `pass`, or a
pinentry process. Applications import explicit exported key files and supply a
protected key's unlock secret through the `SecretProvider` interface.
## Backend decision
IronStorage uses [`pgp` 0.20](https://crates.io/crates/pgp/0.20.0) with default
features disabled. The crate is `MIT OR Apache-2.0`, is implemented in Rust,
and does not introduce a native OpenPGP library or runtime helper process.
`rand` 0.8 supplies `OsRng` for session keys and signatures. The alternatives
and their license consequences are recorded in `DEPENDENCIES.md`.
The storage API imports binary transferable keys and ASCII-armored public or
secret keys. Self-signatures and subkey bindings are verified before a key is
committed to the in-memory store. An import stream is applied transactionally,
duplicate public/secret exports are merged by primary fingerprint, and file and
key-count limits bound untrusted input. Directory loading rejects symbolic
links and non-regular files.
## Identity and recipient behavior
The resolver accepts:
- a full primary or subkey fingerprint;
- an 8- or 16-hex-digit primary or subkey key ID, with optional `0x` prefix;
- an exact UTF-8 user ID.
Short identifiers and user IDs must identify exactly one primary certificate.
Missing and ambiguous identities are separate errors. `.gpg-id` parsing follows
upstream `pass`: text after `#` is ignored, surrounding whitespace is removed,
blank lines are skipped, and duplicate resolved recipients are coalesced.
Encryption selects a non-revoked encryption-capable subkey, falling back to an
encryption-capable primary key. Output uses AES-256 in a version 1
symmetrically-encrypted integrity-protected data packet and deliberately does
not add a compression packet, matching upstream `pass --compress-algo=none`.
Every resolved recipient receives a public-key encrypted session-key packet.
Decryption first examines those recipient packets. A secret is requested only
for imported protected keys that can match the message; unavailable identities
are skipped so any one recipient of a multi-recipient entry can decrypt it.
Cancellation stops immediately. Wrong secrets, malformed messages, and a lack
of matching secret keys remain distinct failures.
## Secret lifetime and recipient signatures
Decrypted data and provider-returned unlock secrets use `SecretBytes`. Its
debug representation is redacted and its allocation is zeroed on drop. The
OpenPGP backend's password type also zeroes its owned storage. Plaintext enters
the message encoder through an owning reader instead of being copied into an
ordinary intermediate buffer.
Detached `.gpg-id.sig` files use binary-document signatures with SHA-256 and
issuer fingerprint/key-ID metadata. Verification succeeds only when the valid
signature belongs to an explicitly allowed primary fingerprint, which is the
`PASSWORD_STORE_SIGNING_KEY` trust rule; a cryptographically valid signature
from another imported key is rejected.
## Compatibility evidence
`crates/storage/tests/fixtures/compatibility` contains protected public and
secret exports, single- and multi-recipient `.gpg` entries, and signed
recipient files. The fixture audit established that GnuPG decrypts the packet
profile used by the generator. Production tests then exercise that same
uncompressed `MessageBuilder` profile through `KeyStore::encrypt`, independently
parse and decrypt its output, decrypt every checked-in GnuPG-audited entry, and
verify both checked-in and newly generated recipient signatures.
Tests never use a real user keyring and application runtime never executes an
external cryptographic tool.