feat(notsecrets): X25519 identity and recipient with wrap/unwrap
Implements ECDH key wrapping via HKDF-SHA256 + ChaCha20Poly1305, bech32 encode/decode for age1... recipients and AGE-SECRET-KEY-1... identities. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
use crate::error::AgeError;
|
||||
|
||||
pub mod x25519;
|
||||
pub use x25519::X25519Identity;
|
||||
|
||||
/// The symmetric file encryption key — 16 random bytes.
|
||||
#[derive(Clone)]
|
||||
pub struct FileKey([u8; 16]);
|
||||
|
||||
163
crates/notsecrets/src/identities/x25519.rs
Normal file
163
crates/notsecrets/src/identities/x25519.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Identity, Stanza};
|
||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use bech32::{Bech32, Hrp};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use sha2::Sha256;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
const TAG: &str = "X25519";
|
||||
const HKDF_INFO: &[u8] = b"age-encryption.org/v1/X25519";
|
||||
const IDENTITY_HRP: &str = "age-secret-key-";
|
||||
|
||||
pub struct X25519Identity {
|
||||
secret: StaticSecret,
|
||||
}
|
||||
|
||||
impl X25519Identity {
|
||||
pub fn from_static_secret(secret: StaticSecret) -> Self {
|
||||
Self { secret }
|
||||
}
|
||||
|
||||
pub fn from_bech32(s: &str) -> Result<Self, AgeError> {
|
||||
let s_lower = s.to_lowercase();
|
||||
let (hrp, data) = bech32::decode(&s_lower)
|
||||
.map_err(|e| AgeError::ParseError(format!("bech32 decode identity: {e}")))?;
|
||||
if hrp.as_str() != IDENTITY_HRP {
|
||||
return Err(AgeError::ParseError(format!(
|
||||
"expected hrp '{IDENTITY_HRP}', got '{}'",
|
||||
hrp.as_str()
|
||||
)));
|
||||
}
|
||||
let bytes: [u8; 32] = data
|
||||
.try_into()
|
||||
.map_err(|_| AgeError::ParseError("identity key must be 32 bytes".to_string()))?;
|
||||
Ok(Self {
|
||||
secret: StaticSecret::from(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_bech32(&self) -> String {
|
||||
let hrp = Hrp::parse(IDENTITY_HRP).expect("static hrp is valid");
|
||||
bech32::encode::<Bech32>(hrp, self.secret.as_bytes())
|
||||
.expect("bech32 encode cannot fail for valid input")
|
||||
.to_uppercase()
|
||||
}
|
||||
}
|
||||
|
||||
impl Identity for X25519Identity {
|
||||
fn unwrap_file_key(&self, stanza: &Stanza) -> Option<Result<FileKey, AgeError>> {
|
||||
if stanza.tag != TAG {
|
||||
return None;
|
||||
}
|
||||
Some(unwrap_stanza(stanza, &self.secret))
|
||||
}
|
||||
}
|
||||
|
||||
fn unwrap_stanza(stanza: &Stanza, secret: &StaticSecret) -> Result<FileKey, AgeError> {
|
||||
if stanza.args.len() != 1 {
|
||||
return Err(AgeError::ParseError(
|
||||
"X25519 stanza must have exactly 1 arg".to_string(),
|
||||
));
|
||||
}
|
||||
let ephemeral_pub_bytes = STANDARD_NO_PAD
|
||||
.decode(&stanza.args[0])
|
||||
.map_err(|e| AgeError::ParseError(format!("ephemeral pubkey base64: {e}")))?;
|
||||
let ephemeral_pub_bytes: [u8; 32] = ephemeral_pub_bytes
|
||||
.try_into()
|
||||
.map_err(|_| AgeError::ParseError("ephemeral pubkey must be 32 bytes".to_string()))?;
|
||||
let ephemeral_pub = PublicKey::from(ephemeral_pub_bytes);
|
||||
|
||||
let shared = secret.diffie_hellman(&ephemeral_pub);
|
||||
let recipient_pub = PublicKey::from(secret);
|
||||
|
||||
let wrap_key = derive_wrap_key(
|
||||
shared.as_bytes(),
|
||||
ephemeral_pub.as_bytes(),
|
||||
recipient_pub.as_bytes(),
|
||||
)?;
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
||||
let nonce = Nonce::default();
|
||||
let file_key_bytes = cipher
|
||||
.decrypt(&nonce, stanza.body.as_slice())
|
||||
.map_err(|_| AgeError::CryptoError("X25519 file key decryption failed".to_string()))?;
|
||||
|
||||
FileKey::try_from(file_key_bytes.as_slice())
|
||||
}
|
||||
|
||||
pub(crate) fn derive_wrap_key(
|
||||
shared: &[u8],
|
||||
ephemeral_pub: &[u8],
|
||||
recipient_pub: &[u8],
|
||||
) -> Result<[u8; 32], AgeError> {
|
||||
let mut ikm = Vec::with_capacity(shared.len() + ephemeral_pub.len() + recipient_pub.len());
|
||||
ikm.extend_from_slice(shared);
|
||||
ikm.extend_from_slice(ephemeral_pub);
|
||||
ikm.extend_from_slice(recipient_pub);
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(None, &ikm);
|
||||
let mut wrap_key = [0u8; 32];
|
||||
hk.expand(HKDF_INFO, &mut wrap_key)
|
||||
.map_err(|e| AgeError::CryptoError(format!("HKDF expand: {e}")))?;
|
||||
Ok(wrap_key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identities::FileKey;
|
||||
use crate::recipients::Recipient;
|
||||
use crate::recipients::x25519::X25519Recipient;
|
||||
|
||||
#[test]
|
||||
fn x25519_wrap_unwrap_roundtrip() {
|
||||
let secret_bytes = [1u8; 32];
|
||||
let secret = StaticSecret::from(secret_bytes);
|
||||
let public = PublicKey::from(&secret);
|
||||
let recipient = X25519Recipient::from_public_key(public);
|
||||
let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes));
|
||||
let file_key = FileKey::new([0x42u8; 16]);
|
||||
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
||||
assert_eq!(stanza.tag, "X25519");
|
||||
assert_eq!(stanza.args.len(), 1);
|
||||
let unwrapped = identity
|
||||
.unwrap_file_key(&stanza)
|
||||
.expect("identity should match")
|
||||
.expect("unwrap should succeed");
|
||||
assert_eq!(unwrapped.as_bytes(), file_key.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x25519_wrong_tag_returns_none() {
|
||||
let secret = StaticSecret::from([2u8; 32]);
|
||||
let identity = X25519Identity::from_static_secret(secret);
|
||||
let stanza = Stanza {
|
||||
tag: "scrypt".to_string(),
|
||||
args: vec!["salt".to_string(), "18".to_string()],
|
||||
body: vec![0u8; 32],
|
||||
};
|
||||
assert!(identity.unwrap_file_key(&stanza).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x25519_bech32_roundtrip() {
|
||||
let secret_bytes = [3u8; 32];
|
||||
let secret = StaticSecret::from(secret_bytes);
|
||||
let public = PublicKey::from(&secret);
|
||||
let recipient_str = X25519Recipient::from_public_key(public).to_bech32();
|
||||
assert!(recipient_str.starts_with("age1"));
|
||||
let identity_str = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
|
||||
assert!(identity_str.starts_with("AGE-SECRET-KEY-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn x25519_bech32_identity_roundtrip() {
|
||||
let secret_bytes = [4u8; 32];
|
||||
let identity = X25519Identity::from_static_secret(StaticSecret::from(secret_bytes));
|
||||
let encoded = identity.to_bech32();
|
||||
let decoded = X25519Identity::from_bech32(&encoded).expect("decode should succeed");
|
||||
assert_eq!(decoded.secret.as_bytes(), &secret_bytes);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@ pub mod sources;
|
||||
pub mod _legacy;
|
||||
|
||||
pub use error::AgeError;
|
||||
pub use identities::{FileKey, Header, Identity, Stanza};
|
||||
pub use recipients::Recipient;
|
||||
pub use identities::{FileKey, Header, Identity, Stanza, X25519Identity};
|
||||
pub use recipients::{Recipient, X25519Recipient};
|
||||
pub use sources::{BitwardenSource, FileSource, PromptSource};
|
||||
|
||||
// Legacy API — kept for notstrap compatibility until Task 12.
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
|
||||
pub mod x25519;
|
||||
pub use x25519::X25519Recipient;
|
||||
|
||||
/// Domain port: a recipient that can wrap a file key into a stanza.
|
||||
pub trait Recipient {
|
||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError>;
|
||||
|
||||
71
crates/notsecrets/src/recipients/x25519.rs
Normal file
71
crates/notsecrets/src/recipients/x25519.rs
Normal file
@@ -0,0 +1,71 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::identities::x25519::derive_wrap_key;
|
||||
use crate::recipients::Recipient;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use bech32::{Bech32, Hrp};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
|
||||
use rand::rngs::OsRng;
|
||||
use x25519_dalek::{EphemeralSecret, PublicKey};
|
||||
|
||||
const TAG: &str = "X25519";
|
||||
const RECIPIENT_HRP: &str = "age";
|
||||
|
||||
pub struct X25519Recipient {
|
||||
public_key: PublicKey,
|
||||
}
|
||||
|
||||
impl X25519Recipient {
|
||||
pub fn from_public_key(public_key: PublicKey) -> Self {
|
||||
Self { public_key }
|
||||
}
|
||||
|
||||
pub fn from_bech32(s: &str) -> Result<Self, AgeError> {
|
||||
let (hrp, data) = bech32::decode(s)
|
||||
.map_err(|e| AgeError::ParseError(format!("bech32 decode recipient: {e}")))?;
|
||||
if hrp.as_str() != RECIPIENT_HRP {
|
||||
return Err(AgeError::ParseError(format!(
|
||||
"expected hrp '{RECIPIENT_HRP}', got '{}'",
|
||||
hrp.as_str()
|
||||
)));
|
||||
}
|
||||
let bytes: [u8; 32] = data
|
||||
.try_into()
|
||||
.map_err(|_| AgeError::ParseError("recipient key must be 32 bytes".to_string()))?;
|
||||
Ok(Self {
|
||||
public_key: PublicKey::from(bytes),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn to_bech32(&self) -> String {
|
||||
let hrp = Hrp::parse(RECIPIENT_HRP).expect("static hrp is valid");
|
||||
bech32::encode::<Bech32>(hrp, self.public_key.as_bytes())
|
||||
.expect("bech32 encode cannot fail for valid input")
|
||||
}
|
||||
}
|
||||
|
||||
impl Recipient for X25519Recipient {
|
||||
fn wrap_file_key(&self, file_key: &FileKey) -> Result<Stanza, AgeError> {
|
||||
let ephemeral_secret = EphemeralSecret::random_from_rng(OsRng);
|
||||
let ephemeral_pub = PublicKey::from(&ephemeral_secret);
|
||||
let shared = ephemeral_secret.diffie_hellman(&self.public_key);
|
||||
|
||||
let wrap_key = derive_wrap_key(
|
||||
shared.as_bytes(),
|
||||
ephemeral_pub.as_bytes(),
|
||||
self.public_key.as_bytes(),
|
||||
)?;
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
||||
let nonce = Nonce::default();
|
||||
let wrapped = cipher
|
||||
.encrypt(&nonce, file_key.as_bytes().as_slice())
|
||||
.map_err(|_| AgeError::CryptoError("X25519 file key encryption failed".to_string()))?;
|
||||
|
||||
Ok(Stanza {
|
||||
tag: TAG.to_string(),
|
||||
args: vec![STANDARD_NO_PAD.encode(ephemeral_pub.as_bytes())],
|
||||
body: wrapped,
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user