use std::io::{Read, Write};
pub use age;
use age::armor::{ArmoredReader, ArmoredWriter, Format};
use zeroize::Zeroizing;
use crate::keyring::{Keyring, KeyringError};
pub use crate::members::Level;
use crate::members::{KeyKind, MemberKey, Members};
use crate::policy::{self, Refusal};
#[derive(Debug)]
pub enum WrapError {
NoMembers,
BadKey(String, String),
Age(String),
}
impl std::fmt::Display for WrapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
WrapError::NoMembers => write!(f, "the key ring needs at least one member key"),
WrapError::BadKey(name, why) => write!(f, "{name}'s key cannot be used: {why}"),
WrapError::Age(e) => write!(f, "age: {e}"),
}
}
}
impl std::error::Error for WrapError {}
#[derive(Debug)]
pub enum UnwrapError {
NotARecipient,
Malformed(String),
NotAKeyring(KeyringError),
Mismatch(Refusal),
}
impl std::fmt::Display for UnwrapError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
UnwrapError::NotARecipient => write!(
f,
"your encryption key is not one this key ring is wrapped to: you are not a member \
of this encrypted repository (or were removed before this key ring)"
),
UnwrapError::Malformed(e) => write!(f, "the key file is not a readable age file: {e}"),
UnwrapError::NotAKeyring(e) => write!(f, "the unwrapped payload is refused: {e}"),
UnwrapError::Mismatch(r) => write!(f, "the key ring is refused: {r}"),
}
}
}
impl std::error::Error for UnwrapError {}
pub const SSH_IS_NOT_PQ: &str = "SSH keys cannot be quantum-safe recipients: an ed25519 \
key's wrap is X25519 and an RSA key's is RSA-OAEP, and a quantum computer breaks both. \
A quantum-safe repository is wrapped to its members' quantum-safe encryption keys \
(age1pq1…, ML-KEM-768 + X25519), which their SSH keys unlock";
pub fn recipient(key: &MemberKey) -> Result<Box<dyn age::Recipient + Send>, String> {
match key.kind {
KeyKind::Age => key
.key
.trim()
.parse::<age::x25519::Recipient>()
.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
.map_err(|e| e.to_string()),
#[cfg(feature = "pq")]
KeyKind::AgePq => key
.key
.parse::<crate::pq::Recipient>()
.map(|r| Box::new(r) as Box<dyn age::Recipient + Send>)
.map_err(str::to_string),
#[cfg(not(feature = "pq"))]
KeyKind::AgePq => {
Err("a quantum-safe key ring needs gunnar-crypt's `pq` feature".to_string())
}
KeyKind::SshEd25519 | KeyKind::SshRsa => Err(
"an SSH key is an unlock method of its owner's encryption key, never a member key"
.to_string(),
),
}
}
pub fn wrap(ring: &Keyring, members: &Members) -> Result<String, WrapError> {
if members.is_empty() {
return Err(WrapError::NoMembers);
}
let mut recipients = Vec::new();
for m in members.entries() {
recipients.push(recipient(m).map_err(|e| WrapError::BadKey(m.name.clone(), e))?);
}
let encryptor = age::Encryptor::with_recipients(
recipients.iter().map(|r| r.as_ref() as &dyn age::Recipient),
)
.map_err(|e| WrapError::Age(e.to_string()))?;
let mut out = Vec::new();
let armor = ArmoredWriter::wrap_output(&mut out, Format::AsciiArmor)
.map_err(|e| WrapError::Age(e.to_string()))?;
let mut w = encryptor
.wrap_output(armor)
.map_err(|e| WrapError::Age(e.to_string()))?;
w.write_all(ring.to_text().as_bytes())
.map_err(|e| WrapError::Age(e.to_string()))?;
w.finish()
.and_then(|a| a.finish())
.map_err(|e| WrapError::Age(e.to_string()))?;
String::from_utf8(out).map_err(|e| WrapError::Age(e.to_string()))
}
pub fn unwrap(keys_file: &[u8], identities: &[&dyn age::Identity]) -> Result<Keyring, UnwrapError> {
let decryptor = age::Decryptor::new_buffered(ArmoredReader::new(keys_file))
.map_err(|e| UnwrapError::Malformed(e.to_string()))?;
let mut reader = match decryptor.decrypt(identities.iter().copied()) {
Ok(r) => r,
Err(age::DecryptError::NoMatchingKeys) => return Err(UnwrapError::NotARecipient),
Err(e) => return Err(UnwrapError::Malformed(e.to_string())),
};
let mut buf = Zeroizing::new(Vec::new());
reader
.read_to_end(&mut buf)
.map_err(|e| UnwrapError::Malformed(e.to_string()))?;
let text = std::str::from_utf8(&buf)
.map_err(|_| UnwrapError::NotAKeyring(KeyringError::NotAKeyring))?;
Keyring::parse(text).map_err(UnwrapError::NotAKeyring)
}
pub fn unwrap_checked(
keys_file: &[u8],
members: &str,
identities: &[&dyn age::Identity],
) -> Result<Keyring, UnwrapError> {
policy::check_ring(members, keys_file).map_err(UnwrapError::Mismatch)?;
unwrap(keys_file, identities)
}
#[cfg(feature = "pq")]
pub fn pq_identity(text: &str) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
crate::pq::parse_identity_file(text)
.map(|id| Box::new(id) as Box<dyn age::Identity + Send + Sync>)
.map_err(|e| IdentityError::Read(e.to_string()))
}
#[derive(Debug)]
pub enum IdentityError {
Read(String),
Unsupported(String),
}
impl std::fmt::Display for IdentityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IdentityError::Read(e) => write!(f, "{e}"),
IdentityError::Unsupported(e) => write!(
f,
"{e}. A security-key (sk-) SSH key can only sign and cannot decrypt; \
use an ordinary ed25519 key"
),
}
}
}
impl std::error::Error for IdentityError {}
#[cfg(feature = "wrap")]
pub use ssh::*;
#[cfg(feature = "wrap")]
mod ssh {
use super::*;
pub trait Passphrase: Clone + Send + Sync + 'static {
fn ask(&self, description: &str) -> Option<String>;
}
#[derive(Clone)]
struct Callbacks<P: Passphrase>(P);
impl<P: Passphrase> age::Callbacks for Callbacks<P> {
fn display_message(&self, message: &str) {
eprintln!("{message}");
}
fn confirm(&self, _: &str, _: &str, _: Option<&str>) -> Option<bool> {
None
}
fn request_public_string(&self, _: &str) -> Option<String> {
None
}
fn request_passphrase(&self, description: &str) -> Option<age::secrecy::SecretString> {
self.0
.ask(description)
.map(age::secrecy::SecretString::from)
}
}
pub fn ssh_identity<P: Passphrase>(
pem: &[u8],
filename: Option<String>,
passphrase: P,
) -> Result<Box<dyn age::Identity>, IdentityError> {
let id = age::ssh::Identity::from_buffer(pem, filename)
.map_err(|e| IdentityError::Read(e.to_string()))?;
if let age::ssh::Identity::Unsupported(u) = &id {
return Err(IdentityError::Unsupported(format!("{u:?}")));
}
Ok(Box::new(id.with_callbacks(Callbacks(passphrase))))
}
pub fn ssh_identity_unlocked(
pem: &[u8],
passphrase: impl Fn(&str) -> Option<String>,
) -> Result<Box<dyn age::Identity + Send + Sync>, IdentityError> {
let id = age::ssh::Identity::from_buffer(pem, None)
.map_err(|e| IdentityError::Read(e.to_string()))?;
match id {
age::ssh::Identity::Unencrypted(_) => Ok(Box::new(id)),
age::ssh::Identity::Encrypted(enc) => {
let pass = passphrase("Passphrase for the SSH key")
.ok_or_else(|| IdentityError::Read("no passphrase given".into()))?;
let key = enc
.decrypt(age::secrecy::SecretString::from(pass))
.map_err(|_| IdentityError::Read("wrong passphrase".into()))?;
Ok(Box::new(age::ssh::Identity::from(key)))
}
age::ssh::Identity::Unsupported(u) => Err(IdentityError::Unsupported(format!("{u:?}"))),
}
}
#[derive(Clone, Copy, Debug)]
pub struct NoPassphrase;
impl Passphrase for NoPassphrase {
fn ask(&self, _: &str) -> Option<String> {
None
}
}
}