This commit is contained in:
JMARyA 2025-04-29 20:43:42 +02:00
commit 79a13d3941
Signed by: jmarya
GPG key ID: 901B2ADDF27C2263
6 changed files with 2009 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/target
/mosquitto/data
/mosquitto/log
/homeserver/age.key
/homeserver/sign.key
/homeserver/db

1694
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

14
Cargo.toml Normal file
View file

@ -0,0 +1,14 @@
[package]
name = "sage"
version = "0.1.0"
edition = "2024"
[lib]
name = "sage"
path = "src/lib.rs"
[dependencies]
age = { version = "0.11.1", features = ["aes", "aes-gcm", "armor", "async", "ssh"] }
chrono = "0.4.41"
log = "0.4.27"
minisign = "0.7.9"

74
README.md Normal file
View file

@ -0,0 +1,74 @@
# 🔮 Sage
**Sage** is a lightweight cryptographic library built with Rust that layers [age](https://github.com/FiloSottile/age) encryption and [minisign](https://jedisct1.github.io/minisign/) signatures to enable **signed encryption** — ensuring both confidentiality **and** authenticity.
Sage signs a message before encryption and again after encryption, verifying both layers on decryption. This offers **end-to-end message integrity, trust, and non-repudiation**.
## 🚀 Features
- 🔐 Encryption with age
- ✍️ Dual minisign signatures (before and after encryption)
- ✅ Signature verification on both layers during decryption
- 📦 Easy key serialization and identity management
## 🌱 Getting Started
### Generate a New Identity
```rust
let id = sage::Identity::new();
let persona = id.public();
```
- `Identity`: holds your private encryption and signing keys.
- `Persona`: a tuple of `(age_public_key, minisign_public_key)` for sharing public keys.
### Encrypt and Sign
```rust
let message = b"Hello, secure world!";
let recipient = persona.enc_key().unwrap(); // Recipient's age public key
let ciphertext = id.encrypt(message, &recipient);
```
### Verify and Decrypt
```rust
let sender_pk = persona.sign_key().unwrap(); // Sender's minisign public key
match id.decrypt(&ciphertext, &sender_pk) {
Ok(msg) => {
println!("Decrypted: {}", String::from_utf8_lossy(&msg.payload));
println!("Signed at timestamp: {}", msg.timestamp);
}
Err(err) => eprintln!("Failed to decrypt: {:?}", err),
}
```
## 📁 Saving & Loading Identities
```rust
id.save("my_keys/");
let loaded = sage::Identity::try_load("my_keys/").unwrap();
```
- Saves two files: `age.key` and `sign.key`
## 🔍 How It Works
### Encryption Flow
1. **Sign the plaintext** with minisign and add meta information (timestamp)
2. **Encrypt** the signed payload using age
3. **Sign the encrypted blob** with minisign again
### Decryption Flow
1. **Verify the outer signature** (authenticity of ciphertext)
2. **Decrypt** using the age identity
3. **Verify the inner signature** (authenticity of plaintext)
4. **Extract** message and timestamp
## 🙏 Credits
- [age](https://github.com/FiloSottile/age) by Filippo Valsorda
- [minisign](https://jedisct1.github.io/minisign/) by Frank Denis (jedisct1)

18
examples/basic.rs Normal file
View file

@ -0,0 +1,18 @@
use sage::{Identity, PersonaIdentity};
fn main() {
let i = Identity::new();
let receiver = i.public().enc_key().unwrap();
let e = i.encrypt("Hello World!".as_bytes(), &receiver);
println!("Cipher: {e:?}");
let pk_of_sender = i.public().sign_key().unwrap();
let d = i.decrypt(&e, &pk_of_sender).unwrap();
println!("Message made at {}", d.timestamp);
let ds = String::from_utf8(d.payload).unwrap();
println!("Clear: {ds}");
}

203
src/lib.rs Normal file
View file

@ -0,0 +1,203 @@
use age::{Recipient, secrecy::ExposeSecret};
use minisign::{PError, PublicKey};
use std::{io::Cursor, path::PathBuf, str::FromStr};
/// A cryptographic `Identity` consisting of an encryption and signing key
pub struct Identity {
pub age: age::x25519::Identity,
pub sign: minisign::KeyPair,
}
/// Save two parts into one.
///
/// # Errors
/// `part1` should NOT be bigger than `2^32`
fn save_parts(part1: &[u8], part2: &[u8]) -> Vec<u8> {
let mut vec = Vec::new();
let offset = part1.len() as u32;
vec.extend_from_slice(&offset.to_le_bytes());
vec.extend_from_slice(part1);
vec.extend_from_slice(part2);
vec
}
/// Load back two parts from one
fn load_parts(data: &[u8]) -> (Vec<u8>, Vec<u8>) {
let offset_bytes: [u8; 4] = data[..4].try_into().unwrap();
let offset = u32::from_le_bytes(offset_bytes) as usize;
let part1 = data[4..4 + offset].to_vec();
let part2 = data[4 + offset..].to_vec();
(part1, part2)
}
impl Default for Identity {
fn default() -> Self {
Self::new()
}
}
impl Identity {
/// Generate a new `Identity`
#[must_use]
pub fn new() -> Self {
let age = age::x25519::Identity::generate();
let sign = minisign::KeyPair::generate_encrypted_keypair(Some(String::new())).unwrap();
Self { age, sign }
}
/// Export public keys.
#[must_use]
pub fn public(&self) -> Persona {
(self.pub_key_age(), self.pub_key_sign())
}
/// Get the age encryption public key
#[must_use]
pub fn pub_key_age(&self) -> String {
self.age.to_public().to_string()
}
/// Get the minisign public key
#[must_use]
pub fn pub_key_sign(&self) -> String {
self.sign.pk.to_box().unwrap().to_string()
}
/// Save the `Identity` to `dir`.
///
/// `dir` will contain two files `age.key` and `sign.key`
pub fn save<P: Into<PathBuf>>(&self, dir: P) {
let dir = dir.into();
let age_key = self.age.to_string();
std::fs::write(dir.join("age.key"), age_key.expose_secret()).unwrap();
let kbx = self.sign.sk.to_box(None).unwrap();
std::fs::write(dir.join("sign.key"), kbx.into_string()).unwrap();
log::info!("Saved identity to {}", dir.display());
}
/// Load an `Identity` from `dir`.
///
/// `dir` should contain the files `age.key` and `sign.key`
pub fn try_load<P: Into<PathBuf>>(dir: P) -> Option<Self> {
let dir = dir.into();
let age =
age::x25519::Identity::from_str(&std::fs::read_to_string(dir.join("age.key")).ok()?)
.unwrap();
let kbx = minisign::SecretKeyBox::from_string(
&std::fs::read_to_string(dir.join("sign.key")).ok()?,
)
.unwrap();
let sign = kbx.into_secret_key(Some(String::new())).unwrap();
let pk = minisign::PublicKey::from_secret_key(&sign).ok()?;
let kp = minisign::KeyPair { pk, sk: sign };
log::info!("Loaded identity from {}", dir.display());
Some(Self { age, sign: kp })
}
/// Encrypt `data` for a `recipient`
pub fn encrypt(&self, data: &[u8], recipient: &impl Recipient) -> Vec<u8> {
// STEP 1 : Sign
let signed = minisign::sign(
None,
&self.sign.sk,
data,
Some(&chrono::Utc::now().timestamp().to_string()),
None,
)
.unwrap();
let signed = save_parts(&signed.to_bytes(), data);
// STEP 2 : Encrypt
let enc = age::encrypt(recipient, &signed).unwrap();
// STEP 3: Sign
let signed = minisign::sign(None, &self.sign.sk, Cursor::new(&enc), None, None).unwrap();
save_parts(&signed.to_bytes(), &enc)
}
/// Decrypt `data` and verify the signatures using `pk`
pub fn decrypt(&self, data: &[u8], pk: &PublicKey) -> Result<Message, DecryptError> {
// STEP 1 : Verify outer sign
let (sig, data) = load_parts(data);
let sign_box = minisign::SignatureBox::from_string(
&String::from_utf8(sig).map_err(|_| DecryptError::Encoding)?,
)
.map_err(|_| DecryptError::OuterSign)?;
if minisign::verify(pk, &sign_box, Cursor::new(&data), true, false, false).is_err() {
return Err(DecryptError::OuterSign);
}
// STEP 2 : Decrypt
let dec = age::decrypt(&self.age, &data).map_err(DecryptError::Decrypt)?;
// STEP 3 : Verify inner sign
let (sig, data) = load_parts(&dec);
let sign_box = minisign::SignatureBox::from_string(
&String::from_utf8(sig).map_err(|_| DecryptError::Encoding)?,
)
.map_err(|_| DecryptError::InnerSign)?;
if minisign::verify(pk, &sign_box, Cursor::new(&data), true, false, false).is_err() {
return Err(DecryptError::InnerSign);
}
let tc = sign_box
.trusted_comment()
.map_err(|_| DecryptError::Encoding)?;
let timestamp: i64 = tc.parse().map_err(|_| DecryptError::Encoding)?;
Ok(Message {
payload: data,
timestamp,
})
}
}
/// A signed encrypted message
pub struct Message {
/// The unencrypted payload
pub payload: Vec<u8>,
/// A timestamp from the signature
pub timestamp: i64,
}
/// General decryption error
#[derive(Debug)]
pub enum DecryptError {
/// Decryption failed on the outer signature
OuterSign,
/// Decryption failed on the encrypted payload
Decrypt(age::DecryptError),
/// Decryption failed on the inner signature
InnerSign,
/// Wrong encoding
Encoding,
}
/// A public persona `(AgePubKey, MinisignPubKey)`
type Persona = (String, String);
pub trait PersonaIdentity {
fn sign_key(&self) -> Result<minisign::PublicKey, minisign::PError>;
fn enc_key(&self) -> Option<age::x25519::Recipient>;
}
impl PersonaIdentity for Persona {
/// Get the public sign key
fn sign_key(&self) -> Result<minisign::PublicKey, PError> {
let kbx = minisign::PublicKeyBox::from_string(&self.1)?;
kbx.into_public_key()
}
/// Get the public encryption key
fn enc_key(&self) -> Option<age::x25519::Recipient> {
age::x25519::Recipient::from_str(&self.0).ok()
}
}