feat: encrypt workspace secrets with AES-256-GCM

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alexander Petric
2026-10-11 18:58:32 +00:00
co-authored by Claude Opus 5.5
parent 0758bdc46b
commit 91e0aea0fa
11 changed files with 446 additions and 25 deletions
+1
View File
@@ -16229,6 +16229,7 @@ dependencies = [
"git-version",
"globset",
"hex",
"hkdf",
"hmac 0.12.1",
"hyper 1.12.0",
"indexmap 2.14.2",
+1
View File
@@ -595,6 +595,7 @@ rsa = "^0"
spki = { version = "0.7", features = ["pem"] }
pkcs1 = "0.7"
aes-gcm = "0.10.3"
hkdf = "0.12.4"
async_zip = { version = "0.0.17", features = ["tokio", "tokio-fs", "deflate", "chrono"] }
once_cell = "1.17.1"
dashmap = "6.1.0"
+19
View File
@@ -4449,6 +4449,24 @@ pub async fn monitor_db(
}
};
// Re-encrypt the legacy-format secret variables once new values are written in AES-256-GCM.
// Every ~5 min until one pass has gone through every workspace; spawned so a large instance
// does not hold up the join below.
let legacy_secret_upgrade_f = async {
if server_mode
&& iteration.is_some()
&& iteration.as_ref().unwrap().should_run(30)
&& windmill_common::variables::legacy_secret_upgrade_pending()
{
if let Some(db) = conn.as_sql() {
let db = db.clone();
tokio::spawn(async move {
windmill_common::variables::upgrade_legacy_secret_variables(&db).await
});
}
}
};
// Delete the AI session backups older than their workspace's retention. Every ~40 min
// (240 iterations at the default 10 s, the most a u8 `should_run` counts): the retention
// counts in days. Spawned for the same reason as the credential maintenance above, a
@@ -4521,6 +4539,7 @@ pub async fn monitor_db(
git_auto_pull_f,
git_credential_maintenance_f,
ai_session_retention_f,
legacy_secret_upgrade_f,
pipeline_freshness_watchdog_f,
reconcile_unarmed_schedules_f,
stranded_jobs_f,
@@ -25,11 +25,11 @@ use axum::{
};
use chrono::{DateTime, Utc};
use lazy_static::lazy_static;
use magic_crypt::MagicCrypt256;
use regex::Regex;
use serde::{Deserialize, Serialize};
use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened};
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::variables::WorkspaceCrypt;
use windmill_common::{
auth::hash_token,
error::{error_source_chain, Error, Result},
@@ -676,8 +676,8 @@ async fn proxy(
pub(crate) async fn reencrypt_tokens(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &MagicCrypt256,
new: &MagicCrypt256,
old: &WorkspaceCrypt,
new: &WorkspaceCrypt,
) -> Result<()> {
let rows = sqlx::query!(
"SELECT email, token FROM remote_deploy_token
@@ -6317,8 +6317,8 @@ async fn set_encryption_key(
async fn reencrypt_git_sync_secrets(
conn: &mut sqlx::PgConnection,
w_id: &str,
old: &magic_crypt::MagicCrypt256,
new: &magic_crypt::MagicCrypt256,
old: &windmill_common::variables::WorkspaceCrypt,
new: &windmill_common::variables::WorkspaceCrypt,
) -> Result<()> {
let Some((mut credentials, mut git_sync)) =
sqlx::query_as::<_, (serde_json::Value, Option<serde_json::Value>)>(
+3 -3
View File
@@ -24,7 +24,7 @@ use axum::{
Extension, Json, Router,
};
use futures::{StreamExt, TryStreamExt};
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
use magic_crypt::MagicCryptTrait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use std::sync::Arc;
@@ -35,7 +35,7 @@ use windmill_api_workspaces::ai_session_backups::{
use windmill_api_workspaces::workspaces::sessions_retention_days;
use windmill_common::error::{Error, JsonResult, Result};
use windmill_common::utils::calculate_hash;
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key};
use windmill_common::variables::{crypt_from_key_with_suffix, get_workspace_key, WorkspaceCrypt};
use windmill_object_store::object_store_reexports::{
ObjectStore, ObjectStoreError, Path as ObjectPath, PutPayload,
};
@@ -135,7 +135,7 @@ enum Read {
/// The user's prefix in the workspace storage, plus what reads and writes it.
struct Backend {
store: Arc<dyn ObjectStore>,
mc: MagicCrypt256,
mc: WorkspaceCrypt,
prefix: String,
/// Name the storage and the generation the objects are under, for the browser's sync
/// state: a row recorded against another storage or generation is stale, a removal is
+3 -2
View File
@@ -8,7 +8,7 @@ edition.workspace = true
default = []
enterprise = ["dep:aws-config"]
instance_config_schema = ["dep:schemars"]
local_reports = ["dep:rsa", "dep:aes-gcm"]
local_reports = ["dep:rsa"]
private = ["dep:aws-sdk-rds", "dep:aws-sdk-secretsmanager", "dep:aws-config"]
jemalloc = ["dep:tikv-jemalloc-ctl"]
tantivy = []
@@ -118,7 +118,8 @@ size.workspace = true
rsa = { workspace = true, optional = true }
spki = { workspace = true }
pkcs1 = { workspace = true }
aes-gcm = { workspace = true, optional = true }
aes-gcm.workspace = true
hkdf.workspace = true
semver.workspace = true
croner.workspace = true
+1
View File
@@ -155,6 +155,7 @@ pub mod wac;
pub mod webhook;
pub mod worker;
pub mod worker_group_job_stats;
pub mod workspace_crypt;
pub mod workspaces;
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
@@ -23,6 +23,10 @@ pub const MIN_VERSION_SUPPORTS_BUN_LOCKFILE_V2: VC = vc(1, 794, 0, "Bun v2 lockf
// them, so a restriction set while one is live is silently ignored on the jobs it runs. Must
// name the release this ships in.
pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted job tokens");
// The release that reads AES-256-GCM workspace ciphertext. A worker that predates it reads
// secrets from the database with the CBC cipher alone and fails every job using a value
// written in the new format. Must name the release this ships in.
pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 831, 0, "AES-GCM secret encryption");
pub const MIN_VERSION_SUPPORTS_NODE_DEBOUNCING: VC = vc(1, 658, 0, "Flow node debouncing");
pub const MIN_VERSION_SUPPORTS_TOKEN_HASH: VC = vc(1, 659, 0, "Token hash storage");
pub const MIN_VERSION_SUPPORTS_SYNC_JOBS_DEBOUNCING: VC = vc(1, 602, 0, "Sync jobs debouncing");
+154 -15
View File
@@ -12,11 +12,13 @@ use crate::scripts::ScriptHash;
use crate::secret_backend::{get_secret_value, is_external_stored_value};
use crate::utils::WarnAfterExt;
use crate::worker::Connection;
use crate::workspace_crypt::CipherFormat;
pub use crate::workspace_crypt::WorkspaceCrypt;
use crate::{worker::WORKER_GROUP, BASE_URL, DB};
use chrono::{SecondsFormat, Utc};
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
use quick_cache::sync::Cache;
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicBool, Ordering};
lazy_static::lazy_static! {
pub static ref SECRET_SALT: Option<String> = std::env::var("SECRET_SALT").ok();
@@ -211,7 +213,7 @@ pub struct CreateVariable {
pub ws_specific: Option<bool>,
}
pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<MagicCrypt256> {
pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<WorkspaceCrypt> {
// Check cache first (300-second staleness)
let cached_key_o = WORKSPACE_CRYPT_CACHE.get(w_id).and_then(|(ts, key)| {
if ts > chrono::Utc::now().timestamp() - 300 {
@@ -235,7 +237,7 @@ pub async fn build_crypt(db: &DB, w_id: &str) -> crate::error::Result<MagicCrypt
} else {
key
};
let ncrypt = magic_crypt::new_magic_crypt!(crypt_key, 256);
let ncrypt = WorkspaceCrypt::new(&crypt_key);
WORKSPACE_CRYPT_CACHE.insert(
w_id.to_string(),
(chrono::Utc::now().timestamp(), ncrypt.clone()),
@@ -250,19 +252,19 @@ pub async fn build_crypt_with_key_suffix(
db: &DB,
w_id: &str,
key_suffix: &str,
) -> crate::error::Result<MagicCrypt256> {
) -> crate::error::Result<WorkspaceCrypt> {
let key = get_workspace_key(w_id, db).await?;
Ok(crypt_from_key_with_suffix(&key, key_suffix))
}
/// The cipher `build_crypt_with_key_suffix` builds, from a key string in hand.
pub fn crypt_from_key_with_suffix(key: &str, key_suffix: &str) -> MagicCrypt256 {
pub fn crypt_from_key_with_suffix(key: &str, key_suffix: &str) -> WorkspaceCrypt {
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}{}", key, salt, key_suffix)
} else {
format!("{}{}", key, key_suffix)
};
magic_crypt::new_magic_crypt!(crypt_key, 256)
WorkspaceCrypt::new(&crypt_key)
}
pub async fn get_workspace_key(w_id: &str, db: &DB) -> crate::error::Result<String> {
@@ -371,27 +373,156 @@ pub async fn get_secret_value_as_admin(
Ok(r)
}
pub fn encrypt(mc: &MagicCrypt256, value: &str) -> String {
mc.encrypt_str_to_base64(value)
pub fn encrypt(mc: &WorkspaceCrypt, value: &str) -> String {
mc.encrypt_str(value)
}
const DECRYPT_FAILED: &str =
"Could not decrypt value. The value may have been encrypted with a different key.";
/// Every failure returns the same message so responses carry no signal about why decryption
/// failed (padding, base64, UTF-8); the cause is only logged.
pub fn decrypt(mc: &MagicCrypt256, value: String) -> error::Result<String> {
mc.decrypt_base64_to_string(value).map_err(|e| {
/// failed (authentication, padding, base64, UTF-8); the kind of failure is only logged.
pub fn decrypt(mc: &WorkspaceCrypt, value: String) -> error::Result<String> {
mc.decrypt_str(&value).map_err(|e| {
tracing::warn!("decryption failed: {e}");
error::Error::internal_err(DECRYPT_FAILED.to_string())
})
}
static LEGACY_SECRET_UPGRADE_DONE: AtomicBool = AtomicBool::new(false);
static LEGACY_SECRET_UPGRADE_RUNNING: AtomicBool = AtomicBool::new(false);
const LEGACY_SECRET_UPGRADE_BATCH: i64 = 200;
/// Whether this process still has to run [`upgrade_legacy_secret_variables`]: once new values
/// are written in v2, until one pass has gone through every workspace.
pub fn legacy_secret_upgrade_pending() -> bool {
CipherFormat::current() == CipherFormat::V2
&& !LEGACY_SECRET_UPGRADE_DONE.load(Ordering::Relaxed)
}
/// Re-encrypt the secret variables still in the legacy format in v2, workspace by workspace.
/// Idempotent, and emits no git-sync event: the plaintexts do not change, and an event per
/// secret would be a commit per secret in every synced repository.
pub async fn upgrade_legacy_secret_variables(db: &DB) {
if !legacy_secret_upgrade_pending()
|| LEGACY_SECRET_UPGRADE_RUNNING.swap(true, Ordering::AcqRel)
{
return;
}
upgrade_legacy_secret_variables_pass(db).await;
LEGACY_SECRET_UPGRADE_RUNNING.store(false, Ordering::Release);
}
async fn upgrade_legacy_secret_variables_pass(db: &DB) {
let workspaces: Vec<String> = match sqlx::query_scalar("SELECT id FROM workspace ORDER BY id")
.fetch_all(db)
.await
{
Ok(w) => w,
Err(e) => {
tracing::error!("secret encryption upgrade: listing workspaces failed: {e:#}");
return;
}
};
tracing::info!(
"secret encryption upgrade: re-encrypting legacy secret variables of {} workspaces in AES-256-GCM",
workspaces.len()
);
let (mut upgraded, mut skipped, mut failed_workspaces) = (0, 0, 0);
for w_id in workspaces {
match upgrade_workspace_secret_variables(db, &w_id).await {
Ok((u, s)) => {
if u > 0 || s > 0 {
tracing::info!(
"secret encryption upgrade: workspace {w_id}: {u} re-encrypted, {s} skipped"
);
}
upgraded += u;
skipped += s;
}
Err(e) => {
failed_workspaces += 1;
tracing::error!("secret encryption upgrade: workspace {w_id} failed: {e:#}");
}
}
}
tracing::info!(
"secret encryption upgrade: done, {upgraded} re-encrypted, {skipped} skipped, {failed_workspaces} workspaces failed"
);
if failed_workspaces == 0 {
LEGACY_SECRET_UPGRADE_DONE.store(true, Ordering::Relaxed);
}
}
/// The number of values re-encrypted and of values skipped as they do not decrypt.
async fn upgrade_workspace_secret_variables(db: &DB, w_id: &str) -> error::Result<(usize, usize)> {
let (mut upgraded, mut skipped) = (0, 0);
let mut after = String::new();
loop {
let mut tx = db.begin().await?;
// Shared, so a rotation (which locks the key row for update and rewrites every secret)
// never interleaves with a batch, and the key read here is the one the values are under.
let key: Option<String> = sqlx::query_scalar(
"SELECT key FROM workspace_key WHERE workspace_id = $1 AND kind = 'cloud' FOR SHARE",
)
.bind(w_id)
.fetch_optional(&mut *tx)
.await?;
let Some(key) = key else {
return Ok((upgraded, skipped));
};
let mc = crypt_from_key_with_suffix(&key, "");
let rows: Vec<(String, String)> = sqlx::query_as(
"SELECT path, value FROM variable WHERE workspace_id = $1 AND is_secret AND path > $2 \
ORDER BY path LIMIT $3",
)
.bind(w_id)
.bind(&after)
.bind(LEGACY_SECRET_UPGRADE_BATCH)
.fetch_all(&mut *tx)
.await?;
let last_page = (rows.len() as i64) < LEGACY_SECRET_UPGRADE_BATCH;
for (path, value) in rows {
if value.is_empty() || is_external_stored_value(&value) || mc.is_v2(&value) {
after = path;
continue;
}
match mc.upgrade(&value) {
Some(new_value) => {
// Only over the value it was computed from, so a write racing it wins.
upgraded += sqlx::query(
"UPDATE variable SET value = $1 \
WHERE workspace_id = $2 AND path = $3 AND value = $4",
)
.bind(new_value)
.bind(w_id)
.bind(&path)
.bind(&value)
.execute(&mut *tx)
.await?
.rows_affected() as usize;
}
None => {
tracing::warn!(
"secret encryption upgrade: secret variable {path} of workspace {w_id} does not decrypt, leaving it as is"
);
skipped += 1;
}
}
after = path;
}
tx.commit().await?;
if last_page {
return Ok((upgraded, skipped));
}
}
}
pub const WM_SCHEDULED_FOR: &str = "WM_SCHEDULED_FOR";
lazy_static::lazy_static! {
pub static ref CUSTOM_ENVS_CACHE: Cache<String, (i64, Vec<(String, String)>)> = Cache::new(100);
pub static ref WORKSPACE_CRYPT_CACHE: Cache<String, (i64, MagicCrypt256)> = Cache::new(1000);
pub static ref WORKSPACE_CRYPT_CACHE: Cache<String, (i64, WorkspaceCrypt)> = Cache::new(1000);
}
@@ -753,16 +884,24 @@ mod tests {
DECRYPT_FAILED,
};
use crate::error::Error;
use crate::workspace_crypt::WorkspaceCrypt;
use base64::Engine;
use magic_crypt::MagicCryptTrait;
#[test]
fn decrypt_failures_all_return_the_same_message() {
let mc = magic_crypt::new_magic_crypt!("workspace-key", 256);
let other = magic_crypt::new_magic_crypt!("another-key", 256);
let mc = WorkspaceCrypt::new("workspace-key");
let legacy = magic_crypt::new_magic_crypt!("workspace-key", 256);
let other = WorkspaceCrypt::new("another-key");
let b64 = base64::engine::general_purpose::STANDARD;
let v2 = mc.upgrade(&legacy.encrypt_str_to_base64("secret")).unwrap();
let mut tampered = b64.decode(v2).unwrap();
*tampered.last_mut().unwrap() ^= 1;
let failures = [
"not base64 at all!".to_string(),
mc.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]),
legacy.encrypt_bytes_to_base64(&[0xff, 0xfe, 0xfd]),
other.encrypt_str_to_base64("secret"),
b64.encode(&tampered),
];
for value in failures {
match decrypt(&mc, value.clone()) {
@@ -0,0 +1,255 @@
/*
* Author: Ruben Fiszel
* Copyright: Windmill Labs, Inc 2026
* This file and its contents are licensed under the AGPLv3 License.
* Please see the included NOTICE for copyright information and
* LICENSE-AGPL for a copy of the license.
*/
//! The cipher behind `variables::encrypt`/`decrypt`.
//!
//! Values are written as AES-256-GCM ("v2"), standard base64 of
//! `MAGIC (4) || nonce (12) || AES-256-GCM(PKCS#7(plaintext)) || tag (16)`.
//! Values written before are AES-256-CBC (`magic_crypt`) and are still read.
//!
//! The plaintext is padded to the block size so a v2 value decodes to a multiple of 16 bytes,
//! like a CBC one: the CLI and `validate_already_encrypted_secret` tell ciphertext from
//! plaintext by exactly that, and an old CLI that took a v2 value for plaintext would push it
//! to be encrypted again, corrupting the secret.
use aes_gcm::aead::{Aead, AeadCore, KeyInit, OsRng};
use aes_gcm::{Aes256Gcm, Key, Nonce};
use base64::Engine;
use hkdf::Hkdf;
use magic_crypt::{MagicCrypt256, MagicCryptTrait};
use sha2::Sha256;
use crate::min_version::MIN_VERSION_SUPPORTS_AEAD_SECRETS;
pub const V2_MAGIC: [u8; 4] = *b"wmA2";
const NONCE_LEN: usize = 12;
const TAG_LEN: usize = 16;
const BLOCK: usize = 16;
const AEAD_KEY_INFO: &[u8] = b"windmill workspace secret aes-256-gcm v1";
const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::STANDARD;
/// Why a value did not decrypt. Only ever logged, never returned to a caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DecryptFailure {
Encoding,
Authentication,
Utf8,
}
impl std::fmt::Display for DecryptFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
DecryptFailure::Encoding => "not base64",
DecryptFailure::Authentication => "authentication or padding check failed",
DecryptFailure::Utf8 => "plaintext is not UTF-8",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CipherFormat {
LegacyCbc,
V2,
}
impl CipherFormat {
/// What new values are written as. Old workers read secrets straight from the database with
/// the CBC cipher alone, so v2 is written only once every live worker reads it.
pub fn current() -> Self {
if MIN_VERSION_SUPPORTS_AEAD_SECRETS.met_conservatively() {
CipherFormat::V2
} else {
CipherFormat::LegacyCbc
}
}
}
#[derive(Clone)]
pub struct WorkspaceCrypt {
legacy: MagicCrypt256,
aead: Aes256Gcm,
}
/// Code that drives `magic_crypt` directly on this cipher (app link secrets, AI session
/// objects) keeps the legacy format it always had.
impl std::ops::Deref for WorkspaceCrypt {
type Target = MagicCrypt256;
fn deref(&self) -> &MagicCrypt256 {
&self.legacy
}
}
impl WorkspaceCrypt {
/// `crypt_key` is the key material the CBC cipher was always built from (workspace key, salt,
/// suffix). The AEAD key is derived from it under its own label, so the two ciphers never
/// share a key.
pub fn new(crypt_key: &str) -> Self {
let mut aead_key = [0u8; 32];
Hkdf::<Sha256>::new(None, crypt_key.as_bytes())
.expand(AEAD_KEY_INFO, &mut aead_key)
.expect("32 bytes is a valid HKDF-SHA256 output length");
Self {
legacy: magic_crypt::new_magic_crypt!(crypt_key, 256),
aead: Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(&aead_key)),
}
}
pub fn encrypt_str(&self, plaintext: &str) -> String {
self.encrypt_str_in(plaintext, CipherFormat::current())
}
pub fn encrypt_str_in(&self, plaintext: &str, format: CipherFormat) -> String {
match format {
CipherFormat::V2 => B64.encode(self.seal(plaintext.as_bytes())),
CipherFormat::LegacyCbc => self.legacy.encrypt_str_to_base64(plaintext),
}
}
pub fn decrypt_str(&self, ciphertext: &str) -> Result<String, DecryptFailure> {
let bytes = B64
.decode(ciphertext)
.map_err(|_| DecryptFailure::Encoding)?;
String::from_utf8(self.decrypt_bytes(&bytes)?).map_err(|_| DecryptFailure::Utf8)
}
/// Whether `ciphertext` is a v2 value under this key.
pub fn is_v2(&self, ciphertext: &str) -> bool {
B64.decode(ciphertext)
.is_ok_and(|bytes| self.open(&bytes).is_some())
}
/// The v2 form of a legacy `ciphertext`, checked to decrypt back to the same plaintext.
/// `None` for a v2 value and for anything that does not decrypt under this key.
pub fn upgrade(&self, ciphertext: &str) -> Option<String> {
if self.is_v2(ciphertext) {
return None;
}
let plaintext = self.decrypt_str(ciphertext).ok()?;
let upgraded = self.encrypt_str_in(&plaintext, CipherFormat::V2);
(self.decrypt_str(&upgraded).ok()? == plaintext).then_some(upgraded)
}
fn decrypt_bytes(&self, bytes: &[u8]) -> Result<Vec<u8>, DecryptFailure> {
match self.open(bytes) {
Some(plaintext) => Ok(plaintext),
// Also for a value with the magic: a CBC ciphertext starts with it one time in 2^32.
None => self
.legacy
.decrypt_bytes_to_bytes(bytes)
.map_err(|_| DecryptFailure::Authentication),
}
}
fn seal(&self, plaintext: &[u8]) -> Vec<u8> {
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let pad = BLOCK - plaintext.len() % BLOCK;
let mut padded = Vec::with_capacity(plaintext.len() + pad);
padded.extend_from_slice(plaintext);
padded.resize(plaintext.len() + pad, pad as u8);
let sealed = self
.aead
.encrypt(&nonce, padded.as_slice())
.expect("AES-GCM encryption fails only past 64 GiB of plaintext");
let mut out = Vec::with_capacity(V2_MAGIC.len() + NONCE_LEN + sealed.len());
out.extend_from_slice(&V2_MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
out
}
fn open(&self, data: &[u8]) -> Option<Vec<u8>> {
let body = data.strip_prefix(&V2_MAGIC)?;
if body.len() < NONCE_LEN + BLOCK + TAG_LEN || (body.len() - NONCE_LEN) % BLOCK != 0 {
return None;
}
let (nonce, sealed) = body.split_at(NONCE_LEN);
let mut padded = self.aead.decrypt(Nonce::from_slice(nonce), sealed).ok()?;
let pad = *padded.last()? as usize;
if pad == 0
|| pad > BLOCK
|| padded[padded.len() - pad..]
.iter()
.any(|b| *b as usize != pad)
{
return None;
}
padded.truncate(padded.len() - pad);
Some(padded)
}
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &str = "workspace-key";
#[test]
fn v2_round_trips() {
let wc = WorkspaceCrypt::new(KEY);
for plain in [
"",
"s",
"exactly sixteen!",
"a longer secret value with ünïcödé",
] {
let a = wc.encrypt_str_in(plain, CipherFormat::V2);
assert_ne!(a, wc.encrypt_str_in(plain, CipherFormat::V2), "nonce reuse");
assert!(wc.is_v2(&a));
assert_eq!(wc.decrypt_str(&a).unwrap(), plain);
}
}
#[test]
fn legacy_cbc_still_decrypts() {
let wc = WorkspaceCrypt::new(KEY);
let legacy = magic_crypt::new_magic_crypt!(KEY, 256).encrypt_str_to_base64("old secret");
assert!(!wc.is_v2(&legacy));
assert_eq!(wc.decrypt_str(&legacy).unwrap(), "old secret");
let upgraded = wc.upgrade(&legacy).unwrap();
assert!(wc.is_v2(&upgraded));
assert_eq!(wc.decrypt_str(&upgraded).unwrap(), "old secret");
assert_eq!(wc.upgrade(&upgraded), None);
}
#[test]
fn magic_prefixed_cbc_still_decrypts() {
// Too rare to find by search, so built: with magic_crypt's zero IV and SHA-256(key) AES
// key, the first CBC block is AES(P1), so P1 = AES^-1(MAGIC || zeros).
let mut block = [0u8; 16];
block[..4].copy_from_slice(&V2_MAGIC);
let mut plaintext = {
use aes_gcm::aes::cipher::{BlockDecrypt, KeyInit};
use sha2::Digest;
let cipher = aes_gcm::aes::Aes256::new_from_slice(&Sha256::digest(KEY)).unwrap();
let mut b = aes_gcm::aes::Block::clone_from_slice(&block);
cipher.decrypt_block(&mut b);
b.to_vec()
};
plaintext.extend_from_slice(b"tail of the secret value");
let ciphertext = magic_crypt::new_magic_crypt!(KEY, 256).encrypt_bytes_to_bytes(&plaintext);
assert!(ciphertext.starts_with(&V2_MAGIC));
assert_eq!(
WorkspaceCrypt::new(KEY).decrypt_bytes(&ciphertext).unwrap(),
plaintext
);
}
#[test]
fn v2_passes_the_block_multiple_ciphertext_shape_check() {
let wc = WorkspaceCrypt::new(KEY);
for len in 0..64 {
let decoded = B64
.decode(wc.encrypt_str_in(&"x".repeat(len), CipherFormat::V2))
.unwrap();
assert!(!decoded.is_empty() && decoded.len() % 16 == 0, "len {len}");
}
}
}