Implement embedded OpenPGP compatibility (#4)
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2716,6 +2716,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
"pgp",
|
||||
"rand 0.8.7",
|
||||
"rand_chacha 0.3.1",
|
||||
"rustix 1.1.4",
|
||||
"serde",
|
||||
|
||||
@@ -20,6 +20,8 @@ clap = { version = "4.6", features = ["derive"] }
|
||||
crossterm = "0.29"
|
||||
iced = "0.14"
|
||||
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"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
shlex = "1.3"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Dependency and license review
|
||||
|
||||
Reviewed 2026-08-09. The project license remains intentionally unset until the
|
||||
GPG compatibility spike is complete.
|
||||
Reviewed 2026-08-09. The OpenPGP backend decision is complete. The project
|
||||
license remains intentionally unset pending the full transitive license audit
|
||||
and packaging review described below.
|
||||
|
||||
## 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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
| [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 |
|
||||
@@ -35,7 +38,7 @@ 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. |
|
||||
| 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. |
|
||||
@@ -55,8 +58,11 @@ Linux Secret Service, and Apple camera APIs remain operating-system boundaries.
|
||||
|
||||
Before choosing and adding the project license:
|
||||
|
||||
1. Prove `pgp` can decrypt, encrypt, re-encrypt, and round-trip representative
|
||||
GPG files and exported keys from real `pass` stores.
|
||||
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
|
||||
1. Lock the storage dependencies and run a full transitive license audit.
|
||||
2. Confirm the required notices/source offers for MPL-2.0 dependencies in every
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
The capability-scoped password-store layout and atomic mutation guarantees are
|
||||
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
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ publish = false
|
||||
cap-std.workspace = true
|
||||
cap-tempfile.workspace = true
|
||||
clap.workspace = true
|
||||
pgp.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
shlex.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -19,7 +21,6 @@ zeroize.workspace = true
|
||||
[dev-dependencies]
|
||||
flate2 = "1.1"
|
||||
hex = "0.4"
|
||||
pgp = { version = "0.20", default-features = false }
|
||||
rand_chacha = "0.3"
|
||||
rustix = { version = "1.1", features = ["fs"] }
|
||||
sha1 = "0.10"
|
||||
|
||||
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.
|
||||
|
||||
382
crates/storage/tests/crypto_compatibility.rs
Normal file
382
crates/storage/tests/crypto_compatibility.rs
Normal 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
73
docs/cryptography.md
Normal 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.
|
||||
Reference in New Issue
Block a user