init
This commit is contained in:
commit
79a13d3941
6 changed files with 2009 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
@ -0,0 +1,6 @@
|
|||
/target
|
||||
/mosquitto/data
|
||||
/mosquitto/log
|
||||
/homeserver/age.key
|
||||
/homeserver/sign.key
|
||||
/homeserver/db
|
1694
Cargo.lock
generated
Normal file
1694
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
14
Cargo.toml
Normal file
14
Cargo.toml
Normal 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
74
README.md
Normal 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
18
examples/basic.rs
Normal 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
203
src/lib.rs
Normal 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()
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue