Files
plainleaf/server-common/src/crypto.rs
T
Zef HemelandGitHub 91e76d0c78 Rust backend/CLI replacement (previously Go) (#2010)
* Rust backend: initial setup and space primitives implementation + tests

* Rust: initial basic server setup

* Rust server: auth layer

* Rust: added specific auth layers

* Rust: shell and proxy endpoints

* Rust: SSR and Prometheus metrics

* Rust: runtime API

* Rust: basic working silverbullet binary server

* Rust: fix client file caching

* Rust: in dev mode, update version number dynamically

* Rust: build-rs Makefile target

* Rust: auth and start of 'sb' CLI

* Rust: headless Chrome runtime runner

* Rust: basic e2e tests for Rust build

* Rust: docker builds

* Rust: forgot to commit silverbullet-bin code

* Rust: cross compile and chrome driver fixes

* Rust: waiting for ready status fixes

* Rust: sb-CLI port

* Rust: use native build toolchain

* Rust: fix build chain

* Rust: edge release builds

* Rust: FreeBSD and armv7 builds

* Rust: edge-upgrade from edge-rust release

* Rust: fix FreeBSD build

* Rust: fix FreeBSD build

* Rust: get rid of Go code

* refactor: rename library crates to server- prefix convention

* Rust: refactoring, moving crypto around

* Rust: more refactoring

* Runtime: internal renaming

* Fix: cursor placement after paste on Webkit browsers

* Tweak how version numbers are generated and consumed

* Cleanup CI situation

* Fix CI

* Fix e2e

* ci: website image uses :edge channel (was :v2) post-merge

* Rust: restore manifest.json endpoint

* Rust: ready to merge
2026-06-16 08:40:41 +02:00

222 lines
7.6 KiB
Rust

//! AES-256-GCM encrypt/decrypt for stored SilverBullet credentials.
//!
//! The on-disk string format is three base64 (STANDARD alphabet) parts
//! separated by colons: `base64(iv):base64(tag):base64(ciphertext)`. This is
//! the Node.js-style layout where the 16-byte GCM authentication tag is stored
//! separately from the ciphertext (rather than appended, as the `aes-gcm`
//! crate produces it). The IV/nonce is 12 bytes.
//!
//! This is the shared on-disk credential format: secrets written by any tool
//! sharing the same 32-byte key file (the desktop App, the `sb` CLI) are
//! mutually readable. This module is the single implementation both sides
//! delegate to.
//!
//! The 32-byte AES key is stored as raw bytes in `<config_dir>/key`
//! (mode 0600 on unix), generated on first use.
use std::path::Path;
use aes_gcm::{
aead::{rand_core::RngCore, Aead, KeyInit, OsRng},
Aes256Gcm, Key, Nonce,
};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
pub const KEY_LEN: usize = 32;
const IV_LEN: usize = 12;
const TAG_LEN: usize = 16;
#[derive(Debug, thiserror::Error)]
pub enum CryptoError {
#[error("invalid encrypted format (expected iv:tag:ciphertext)")]
Format,
#[error("base64 decode: {0}")]
Base64(#[from] base64::DecodeError),
#[error("aes-gcm: {0}")]
Aead(String),
#[error("key file {path}: {message}")]
KeyFile { path: String, message: String },
}
/// Encrypt `plaintext` with `key`, producing `base64(iv):base64(tag):base64(ciphertext)`.
pub fn encrypt_with_key(key: &[u8; KEY_LEN], plaintext: &str) -> Result<String, CryptoError> {
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let mut iv = [0u8; IV_LEN];
OsRng.fill_bytes(&mut iv);
let nonce = Nonce::from_slice(&iv);
let sealed = cipher
.encrypt(nonce, plaintext.as_bytes())
.map_err(|e| CryptoError::Aead(e.to_string()))?;
// The `aes-gcm` crate appends the 16-byte tag to the ciphertext. Split it
// back out to match the on-disk format.
let tag_start = sealed.len().saturating_sub(TAG_LEN);
let (ct, tag) = sealed.split_at(tag_start);
Ok(format!(
"{}:{}:{}",
B64.encode(iv),
B64.encode(tag),
B64.encode(ct)
))
}
/// Decrypt a `base64(iv):base64(tag):base64(ciphertext)` string with `key`.
pub fn decrypt_with_key(key: &[u8; KEY_LEN], encoded: &str) -> Result<String, CryptoError> {
let mut parts = encoded.splitn(3, ':');
let iv_b64 = parts.next().ok_or(CryptoError::Format)?;
let tag_b64 = parts.next().ok_or(CryptoError::Format)?;
let ct_b64 = parts.next().ok_or(CryptoError::Format)?;
let iv = B64.decode(iv_b64)?;
let tag = B64.decode(tag_b64)?;
let ct = B64.decode(ct_b64)?;
if iv.len() != IV_LEN || tag.len() != TAG_LEN {
return Err(CryptoError::Format);
}
// Re-append the tag to the ciphertext, the layout `aes-gcm` expects.
let mut sealed = ct;
sealed.extend_from_slice(&tag);
let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
let nonce = Nonce::from_slice(&iv);
let pt = cipher
.decrypt(nonce, sealed.as_ref())
.map_err(|e| CryptoError::Aead(e.to_string()))?;
String::from_utf8(pt).map_err(|e| CryptoError::Aead(format!("not valid utf-8: {e}")))
}
/// Load the AES key from `<config_dir>/key`, generating and persisting a fresh
/// 32-byte random key if the file is missing. On unix the key file is written
/// with mode 0600.
pub fn load_or_create_key(config_dir: &Path) -> Result<[u8; KEY_LEN], CryptoError> {
let path = config_dir.join("key");
let key_file_err = |message: String| CryptoError::KeyFile {
path: path.display().to_string(),
message,
};
match std::fs::read(&path) {
Ok(bytes) => {
if bytes.len() != KEY_LEN {
return Err(key_file_err(format!(
"wrong length: expected {KEY_LEN}, got {}",
bytes.len()
)));
}
let mut out = [0u8; KEY_LEN];
out.copy_from_slice(&bytes);
Ok(out)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
std::fs::create_dir_all(config_dir)
.map_err(|e| key_file_err(format!("creating config dir: {e}")))?;
let mut key = [0u8; KEY_LEN];
OsRng.fill_bytes(&mut key);
write_private(&path, &key).map_err(|e| key_file_err(format!("writing: {e}")))?;
Ok(key)
}
Err(e) => Err(key_file_err(format!("reading: {e}"))),
}
}
/// Write `bytes` to `path` with owner-only permissions (mode 0600) on unix.
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)?;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
f.write_all(bytes)?;
Ok(())
}
#[cfg(not(unix))]
{
std::fs::write(path, bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
let key = [7u8; 32];
let ct = encrypt_with_key(&key, "s3cret-token").unwrap();
assert_eq!(ct.split(':').count(), 3, "format is iv:tag:ct");
assert_eq!(decrypt_with_key(&key, &ct).unwrap(), "s3cret-token");
}
#[test]
fn wrong_key_fails() {
let ct = encrypt_with_key(&[1u8; 32], "x").unwrap();
assert!(decrypt_with_key(&[2u8; 32], &ct).is_err());
}
#[test]
fn tampered_ciphertext_fails() {
let key = [3u8; 32];
let mut ct = encrypt_with_key(&key, "hello").unwrap();
ct.push('A');
assert!(decrypt_with_key(&key, &ct).is_err());
}
#[test]
fn key_file_is_created_and_stable() {
let dir = tempfile::tempdir().unwrap();
let k1 = load_or_create_key(dir.path()).unwrap();
let k2 = load_or_create_key(dir.path()).unwrap();
assert_eq!(k1, k2, "key must persist across loads");
assert!(dir.path().join("key").exists());
}
#[cfg(unix)]
#[test]
fn key_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
load_or_create_key(dir.path()).unwrap();
let mode = std::fs::metadata(dir.path().join("key"))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600);
}
// Frozen cross-impl fixture: a literal `base64(iv):base64(tag):base64(ct)`
// string as emitted by the historical reference implementations (the App's
// former `src/auth_config.rs` and `sb`'s former `crypto.rs`). AES-256-GCM
// is fully deterministic given a fixed key+nonce, and the on-disk format
// has a fixed byte layout (12-byte IV, 16-byte tag stored separately,
// STANDARD base64), so this literal is reproducible by any conforming
// impl. Decrypting it here proves our byte layout matches without needing
// to run them.
//
// Fixture parameters: key = [0x01; 32], iv = [0x02; 12],
// plaintext = "hello world".
const FIXTURE_KEY: [u8; 32] = [1u8; 32];
const FIXTURE: &str = "AgICAgICAgICAgIC:UHsi9GfW1lrkYcA+fUy0Jw==:b7OlJSV3tpKhoNg=";
#[test]
fn decrypts_value_from_reference_impl() {
assert_eq!(
decrypt_with_key(&FIXTURE_KEY, FIXTURE).unwrap(),
"hello world",
"cross-impl fixture must decrypt to the known plaintext"
);
}
}