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