feat(notgraph): heatmap coloring, symbol counts, per-crate module graphs, cycle callouts
This commit is contained in:
59
crates/notsecrets/examples/age_smoke.rs
Normal file
59
crates/notsecrets/examples/age_smoke.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
use notsecrets::{Decryptor, Encryptor, X25519Identity, X25519Recipient};
|
||||
use rand::rngs::OsRng;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
fn main() {
|
||||
// 1. Generate a fresh keypair
|
||||
let secret = StaticSecret::random_from_rng(OsRng);
|
||||
let public = PublicKey::from(&secret);
|
||||
let identity = X25519Identity::from_static_secret(secret);
|
||||
let recipient = X25519Recipient::from_public_key(public);
|
||||
|
||||
println!("Recipient: {}", recipient.to_bech32());
|
||||
println!("Identity: {}", identity.to_bech32());
|
||||
|
||||
// 2. Build plaintext from env
|
||||
let plaintext = [
|
||||
(
|
||||
"ANTHROPIC_API_KEY",
|
||||
std::env::var("ANTHROPIC_API_KEY").unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"OPENAI_API_KEY",
|
||||
std::env::var("OPENAI_API_KEY").unwrap_or_default(),
|
||||
),
|
||||
(
|
||||
"GEMINI_API_KEY",
|
||||
std::env::var("GEMINI_API_KEY").unwrap_or_default(),
|
||||
),
|
||||
]
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
let plaintext = plaintext.as_bytes();
|
||||
|
||||
// 3. Encrypt
|
||||
let encryptor = Encryptor::with_recipients(vec![Box::new(recipient)]).unwrap();
|
||||
let ciphertext = encryptor.encrypt(plaintext).unwrap();
|
||||
|
||||
println!("\nCiphertext: {} bytes", ciphertext.len());
|
||||
println!(
|
||||
"Header: {}",
|
||||
std::str::from_utf8(&ciphertext[..22]).unwrap()
|
||||
);
|
||||
|
||||
// 4. Write to file
|
||||
let out_path = "/tmp/secrets.age";
|
||||
std::fs::write(out_path, &ciphertext).unwrap();
|
||||
println!("Written to: {out_path}");
|
||||
|
||||
// 5. Decrypt back
|
||||
let decryptor = Decryptor::with_identities(vec![Box::new(identity)]);
|
||||
let decrypted = decryptor.decrypt(&ciphertext).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
|
||||
println!("\nDecrypted:\n{}", std::str::from_utf8(&decrypted).unwrap());
|
||||
println!("\n✓ Round-trip success");
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::format::{header_bytes_up_to_footer, parse_header};
|
||||
use crate::identities::{FileKey, Identity};
|
||||
use chacha20poly1305::{
|
||||
aead::Aead,
|
||||
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
||||
};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
@@ -86,9 +83,9 @@ fn derive_payload_key(file_key: &FileKey, nonce: &[u8; 16]) -> Result<[u8; 32],
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Encryptor;
|
||||
use crate::identities::x25519::X25519Identity;
|
||||
use crate::recipients::x25519::X25519Recipient;
|
||||
use crate::Encryptor;
|
||||
use rand::rngs::OsRng;
|
||||
use x25519_dalek::{PublicKey, StaticSecret};
|
||||
|
||||
@@ -116,11 +113,8 @@ mod tests {
|
||||
fn decryptor_roundtrip_multiple_recipients() {
|
||||
let (identity1, recipient1) = make_x25519_pair();
|
||||
let (identity2, recipient2) = make_x25519_pair();
|
||||
let encryptor = Encryptor::with_recipients(vec![
|
||||
Box::new(recipient1),
|
||||
Box::new(recipient2),
|
||||
])
|
||||
.unwrap();
|
||||
let encryptor =
|
||||
Encryptor::with_recipients(vec![Box::new(recipient1), Box::new(recipient2)]).unwrap();
|
||||
let plaintext = b"multi-recipient test";
|
||||
let ciphertext = encryptor.encrypt(plaintext).unwrap();
|
||||
let decryptor1 = Decryptor::with_identities(vec![Box::new(identity1)]);
|
||||
|
||||
@@ -2,10 +2,7 @@ use crate::error::AgeError;
|
||||
use crate::format::serialize_header;
|
||||
use crate::identities::{FileKey, Header};
|
||||
use crate::recipients::Recipient;
|
||||
use chacha20poly1305::{
|
||||
aead::Aead,
|
||||
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
||||
};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
|
||||
use hkdf::Hkdf;
|
||||
use hmac::{Hmac, Mac};
|
||||
use rand::RngCore;
|
||||
@@ -129,7 +126,9 @@ mod tests {
|
||||
Box::new(X25519Recipient::from_public_key(public));
|
||||
let encryptor = Encryptor::with_recipients(vec![recipient]).unwrap();
|
||||
let plaintext = b"hello, age!";
|
||||
let ciphertext = encryptor.encrypt(plaintext).expect("encrypt should succeed");
|
||||
let ciphertext = encryptor
|
||||
.encrypt(plaintext)
|
||||
.expect("encrypt should succeed");
|
||||
assert!(
|
||||
ciphertext.starts_with(b"age-encryption.org/v1\n"),
|
||||
"output must start with age version line"
|
||||
|
||||
@@ -11,9 +11,7 @@ const FOOTER_PREFIX: &[u8] = b"--- ";
|
||||
/// index into `input` where the payload (nonce + ciphertext) begins.
|
||||
pub fn parse_header(input: &[u8]) -> Result<(Header, usize), AgeError> {
|
||||
if !input.starts_with(VERSION_LINE) {
|
||||
return Err(AgeError::ParseError(
|
||||
"missing age version line".to_string(),
|
||||
));
|
||||
return Err(AgeError::ParseError("missing age version line".to_string()));
|
||||
}
|
||||
|
||||
let mut pos = VERSION_LINE.len();
|
||||
@@ -176,11 +174,15 @@ mod tests {
|
||||
assert_eq!(header.recipients[0].args.len(), 1);
|
||||
assert!(header.recipients[0].body.is_empty());
|
||||
// payload offset should point to the 32 bytes at the end
|
||||
assert_eq!(&input[payload_offset..], {
|
||||
let mut v = vec![0x01u8; 16];
|
||||
v.extend_from_slice(&[0x02u8; 16]);
|
||||
v
|
||||
}.as_slice());
|
||||
assert_eq!(
|
||||
&input[payload_offset..],
|
||||
{
|
||||
let mut v = vec![0x01u8; 16];
|
||||
v.extend_from_slice(&[0x02u8; 16]);
|
||||
v
|
||||
}
|
||||
.as_slice()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -208,7 +210,10 @@ mod tests {
|
||||
fn parse_missing_version_line_returns_error() {
|
||||
let input = b"not-age-encryption\n-> X25519 arg\n\n--- AAAA\n";
|
||||
let result = parse_header(input);
|
||||
assert!(result.is_err(), "expected ParseError for missing version line");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected ParseError for missing version line"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -233,7 +238,10 @@ mod tests {
|
||||
args: vec!["salt".to_string(), "18".to_string()],
|
||||
body: body.clone(),
|
||||
};
|
||||
let header = Header { recipients: vec![stanza], mac: vec![0u8; 32] };
|
||||
let header = Header {
|
||||
recipients: vec![stanza],
|
||||
mac: vec![0u8; 32],
|
||||
};
|
||||
let serialized = serialize_header(&header);
|
||||
let mut with_payload = serialized;
|
||||
with_payload.extend_from_slice(&[0u8; 32]);
|
||||
|
||||
@@ -36,7 +36,7 @@ impl Identity for EncryptedIdentity {
|
||||
Err(_) => {
|
||||
return Some(Err(AgeError::ParseError(
|
||||
"decrypted identity file is not valid UTF-8".to_string(),
|
||||
)))
|
||||
)));
|
||||
}
|
||||
};
|
||||
// Parse inner identity — only X25519 supported for now
|
||||
|
||||
@@ -39,9 +39,9 @@ impl FileKey {
|
||||
impl TryFrom<&[u8]> for FileKey {
|
||||
type Error = AgeError;
|
||||
fn try_from(b: &[u8]) -> Result<Self, AgeError> {
|
||||
b.try_into()
|
||||
.map(Self)
|
||||
.map_err(|_| AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len())))
|
||||
b.try_into().map(Self).map_err(|_| {
|
||||
AgeError::ParseError(format!("file key must be 16 bytes, got {}", b.len()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,8 @@ pub(crate) fn derive_scrypt_key(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identities::FileKey;
|
||||
use crate::recipients::scrypt::ScryptRecipient;
|
||||
use crate::recipients::Recipient;
|
||||
use crate::recipients::scrypt::ScryptRecipient;
|
||||
|
||||
#[test]
|
||||
fn scrypt_wrap_unwrap_roundtrip() {
|
||||
@@ -73,7 +73,9 @@ mod tests {
|
||||
let identity = ScryptIdentity::new(passphrase);
|
||||
|
||||
let file_key = FileKey::new([0x11u8; 16]);
|
||||
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
||||
let stanza = recipient
|
||||
.wrap_file_key(&file_key)
|
||||
.expect("wrap should succeed");
|
||||
assert_eq!(stanza.tag, "scrypt");
|
||||
assert_eq!(stanza.args.len(), 2);
|
||||
|
||||
|
||||
@@ -46,7 +46,11 @@ impl Identity for SshEd25519Identity {
|
||||
}
|
||||
let fp_bytes = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
|
||||
Err(e) => {
|
||||
return Some(Err(AgeError::ParseError(format!(
|
||||
"fingerprint base64: {e}"
|
||||
))));
|
||||
}
|
||||
};
|
||||
if fp_bytes.as_slice() != self.fingerprint() {
|
||||
return None;
|
||||
@@ -68,8 +72,11 @@ fn unwrap(stanza: &Stanza, identity: &SshEd25519Identity) -> Result<FileKey, Age
|
||||
let shared = x25519_secret.diffie_hellman(&ephemeral_pub);
|
||||
let x25519_pub = X25519PublicKey::from(&x25519_secret);
|
||||
|
||||
let wrap_key =
|
||||
derive_wrap_key(shared.as_bytes(), ephemeral_pub.as_bytes(), x25519_pub.as_bytes())?;
|
||||
let wrap_key = derive_wrap_key(
|
||||
shared.as_bytes(),
|
||||
ephemeral_pub.as_bytes(),
|
||||
x25519_pub.as_bytes(),
|
||||
)?;
|
||||
|
||||
let cipher = ChaCha20Poly1305::new(Key::from_slice(&wrap_key));
|
||||
let nonce = Nonce::default();
|
||||
@@ -104,8 +111,8 @@ pub(crate) fn derive_wrap_key(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::identities::FileKey;
|
||||
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
|
||||
use crate::recipients::Recipient;
|
||||
use crate::recipients::ssh_ed25519::SshEd25519Recipient;
|
||||
|
||||
fn test_signing_key() -> SigningKey {
|
||||
use rand::rngs::OsRng;
|
||||
@@ -123,7 +130,9 @@ mod tests {
|
||||
let identity = SshEd25519Identity::from_signing_key(signing_key);
|
||||
|
||||
let file_key = FileKey::new([0x33u8; 16]);
|
||||
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
||||
let stanza = recipient
|
||||
.wrap_file_key(&file_key)
|
||||
.expect("wrap should succeed");
|
||||
assert_eq!(stanza.tag, "ssh-ed25519");
|
||||
assert_eq!(stanza.args.len(), 2);
|
||||
|
||||
|
||||
@@ -32,7 +32,11 @@ impl Identity for SshRsaIdentity {
|
||||
}
|
||||
let fp = match STANDARD_NO_PAD.decode(&stanza.args[0]) {
|
||||
Ok(b) => b,
|
||||
Err(e) => return Some(Err(AgeError::ParseError(format!("fingerprint base64: {e}")))),
|
||||
Err(e) => {
|
||||
return Some(Err(AgeError::ParseError(format!(
|
||||
"fingerprint base64: {e}"
|
||||
))));
|
||||
}
|
||||
};
|
||||
if fp.as_slice() != self.fingerprint() {
|
||||
return None;
|
||||
@@ -62,8 +66,8 @@ pub(crate) fn rsa_pubkey_fingerprint(public_key: &RsaPublicKey) -> [u8; 4] {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::recipients::ssh_rsa::SshRsaRecipient;
|
||||
use crate::recipients::Recipient;
|
||||
use crate::recipients::ssh_rsa::SshRsaRecipient;
|
||||
use rand::rngs::OsRng;
|
||||
|
||||
fn test_rsa_keypair() -> (RsaPrivateKey, RsaPublicKey) {
|
||||
@@ -79,7 +83,9 @@ mod tests {
|
||||
let identity = SshRsaIdentity::from_private_key(private_key);
|
||||
|
||||
let file_key = FileKey::new([0x55u8; 16]);
|
||||
let stanza = recipient.wrap_file_key(&file_key).expect("wrap should succeed");
|
||||
let stanza = recipient
|
||||
.wrap_file_key(&file_key)
|
||||
.expect("wrap should succeed");
|
||||
assert_eq!(stanza.tag, "ssh-rsa");
|
||||
assert_eq!(stanza.args.len(), 1);
|
||||
|
||||
|
||||
@@ -119,7 +119,9 @@ mod tests {
|
||||
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");
|
||||
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
|
||||
@@ -148,7 +150,8 @@ mod tests {
|
||||
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();
|
||||
let identity_str =
|
||||
X25519Identity::from_static_secret(StaticSecret::from(secret_bytes)).to_bech32();
|
||||
assert!(identity_str.starts_with("AGE-SECRET-KEY-1"));
|
||||
}
|
||||
|
||||
|
||||
@@ -90,10 +90,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_identities_partial_success_returns_loaded() {
|
||||
let sources: Vec<Box<dyn IdentitySource>> = vec![
|
||||
Box::new(AlwaysFailSource),
|
||||
Box::new(StaticX25519Source),
|
||||
];
|
||||
let sources: Vec<Box<dyn IdentitySource>> =
|
||||
vec![Box::new(AlwaysFailSource), Box::new(StaticX25519Source)];
|
||||
let ids = resolve_identities(sources).expect("at least one source succeeded");
|
||||
assert_eq!(ids.len(), 1);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::identities::scrypt::derive_scrypt_key;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::recipients::Recipient;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Key, KeyInit, Nonce, aead::Aead};
|
||||
@@ -17,7 +17,10 @@ pub struct ScryptRecipient {
|
||||
impl ScryptRecipient {
|
||||
/// `work_factor` is log2(N). Use 18 in production, 14 in tests.
|
||||
pub fn new(passphrase: String, work_factor: u8) -> Self {
|
||||
Self { passphrase, work_factor }
|
||||
Self {
|
||||
passphrase,
|
||||
work_factor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,10 +38,7 @@ impl Recipient for ScryptRecipient {
|
||||
|
||||
Ok(Stanza {
|
||||
tag: TAG.to_string(),
|
||||
args: vec![
|
||||
STANDARD_NO_PAD.encode(salt),
|
||||
self.work_factor.to_string(),
|
||||
],
|
||||
args: vec![STANDARD_NO_PAD.encode(salt), self.work_factor.to_string()],
|
||||
body: wrapped,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::identities::ssh_rsa::rsa_pubkey_fingerprint;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::recipients::Recipient;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use rsa::{Oaep, RsaPublicKey};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::identities::x25519::derive_wrap_key;
|
||||
use crate::identities::{FileKey, Stanza};
|
||||
use crate::recipients::Recipient;
|
||||
use base64::{Engine, engine::general_purpose::STANDARD_NO_PAD};
|
||||
use bech32::{Bech32, Hrp};
|
||||
|
||||
@@ -10,7 +10,9 @@ pub struct BitwardenSource {
|
||||
|
||||
impl BitwardenSource {
|
||||
pub fn new(item_name: impl Into<String>) -> Self {
|
||||
Self { item_name: item_name.into() }
|
||||
Self {
|
||||
item_name: item_name.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn retrieve_key(&self) -> Result<String, AgeError> {
|
||||
@@ -28,10 +30,12 @@ impl BitwardenSource {
|
||||
|
||||
let session = std::env::var("BW_SESSION").unwrap_or_default();
|
||||
let session = if session.is_empty() {
|
||||
let password = rpassword::prompt_password("Bitwarden master password: ")
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read password: {e}"),
|
||||
let password =
|
||||
rpassword::prompt_password("Bitwarden master password: ").map_err(|e| {
|
||||
AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read password: {e}"),
|
||||
}
|
||||
})?;
|
||||
let output = Command::new("bw")
|
||||
.args(["unlock", "--raw", &password])
|
||||
@@ -101,10 +105,11 @@ impl IdentitySource for BitwardenSource {
|
||||
|
||||
fn load(&self) -> Result<Box<dyn Identity>, AgeError> {
|
||||
let key = self.retrieve_key()?;
|
||||
let identity = X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
let identity =
|
||||
X25519Identity::from_bech32(key.trim()).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("key is not a valid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(identity))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::Identity;
|
||||
use crate::identities::encrypted::EncryptedIdentity;
|
||||
use crate::identities::x25519::X25519Identity;
|
||||
use crate::identities::Identity;
|
||||
use crate::sources::IdentitySource;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -33,19 +33,19 @@ impl IdentitySource for FileSource {
|
||||
source: anyhow::anyhow!("key file UTF-8: {e}"),
|
||||
})?
|
||||
.trim();
|
||||
let identity = X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
let identity =
|
||||
X25519Identity::from_bech32(key_str).map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("invalid AGE-SECRET-KEY-1: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(identity))
|
||||
} else if content.starts_with(b"age-encryption.org/v1") {
|
||||
let passphrase = rpassword::prompt_password(
|
||||
"Enter passphrase for encrypted identity file: ",
|
||||
)
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
||||
})?;
|
||||
let passphrase =
|
||||
rpassword::prompt_password("Enter passphrase for encrypted identity file: ")
|
||||
.map_err(|e| AgeError::SourceError {
|
||||
name: self.name().to_string(),
|
||||
source: anyhow::anyhow!("could not read passphrase: {e}"),
|
||||
})?;
|
||||
Ok(Box::new(EncryptedIdentity::new(content, passphrase)))
|
||||
} else {
|
||||
Err(AgeError::UnsupportedKeyType(format!(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::error::AgeError;
|
||||
use crate::identities::scrypt::ScryptIdentity;
|
||||
use crate::identities::Identity;
|
||||
use crate::identities::scrypt::ScryptIdentity;
|
||||
use crate::sources::IdentitySource;
|
||||
|
||||
pub struct PromptSource;
|
||||
|
||||
Reference in New Issue
Block a user