use std::path::{Path, PathBuf};
use aws_lc_rs::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use aws_lc_rs::rand::{SecureRandom as _, SystemRandom};
use gunnar_store::{Error as StoreError, Result as StoreResult, SecretStore};
use zeroize::Zeroize as _;
pub const SEAL_MAGIC: &[u8; 4] = b"gsk1";
pub const KEK_LEN: usize = 32;
const TAG_LEN: usize = 16;
pub const KEK_FILE: &str = "secrets.kek";
pub const KEK_ENV: &str = "GUNNAR_SECRET_KEK";
#[derive(Clone)]
pub struct Kek([u8; KEK_LEN]);
impl std::fmt::Debug for Kek {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Kek(<redacted>)")
}
}
impl Drop for Kek {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl Kek {
pub fn from_bytes(bytes: [u8; KEK_LEN]) -> Self {
Kek(bytes)
}
pub fn generate() -> crate::Result<Self> {
let mut bytes = [0u8; KEK_LEN];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| crate::Error::Key("the system CSPRNG refused to produce a KEK".into()))?;
Ok(Kek(bytes))
}
pub fn from_env() -> crate::Result<Option<Self>> {
let Ok(hex_text) = std::env::var(KEK_ENV) else {
return Ok(None);
};
let hex_text = hex_text.trim();
let raw = hex::decode(hex_text)
.map_err(|err| crate::Error::Key(format!("{KEK_ENV} is not hexadecimal: {err}")))?;
let bytes: [u8; KEK_LEN] = raw.as_slice().try_into().map_err(|_| {
crate::Error::Key(format!(
"{KEK_ENV} decoded to {} bytes; a KEK is {KEK_LEN}",
raw.len()
))
})?;
tracing::info!("using the key-encrypting key from {KEK_ENV}; none is written to disk");
Ok(Some(Kek(bytes)))
}
pub fn from_file(path: &Path) -> crate::Result<Self> {
match std::fs::read(path) {
Ok(raw) => {
let bytes: [u8; KEK_LEN] = raw.as_slice().try_into().map_err(|_| {
crate::Error::Key(format!(
"the KEK at {} is {} bytes; a KEK is {KEK_LEN}. Refusing to \
guess, because guessing here means every secret on this \
machine becomes unreadable",
path.display(),
raw.len()
))
})?;
Ok(Kek(bytes))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
let kek = Kek::generate()?;
write_kek_file(path, &kek.0)?;
tracing::info!(
path = %path.display(),
"no key-encrypting key found; minted one and sealed the secret store"
);
Ok(kek)
}
Err(err) => Err(crate::Error::Key(format!(
"could not read the KEK at {}: {err}",
path.display()
))),
}
}
pub fn for_control_dir(control_dir: &Path) -> crate::Result<Self> {
if let Some(kek) = Kek::from_env()? {
return Ok(kek);
}
Kek::from_file(&kek_path(control_dir))
}
pub fn to_hex(&self) -> String {
hex::encode(self.0)
}
fn aead(&self) -> crate::Result<LessSafeKey> {
UnboundKey::new(&AES_256_GCM, &self.0)
.map(LessSafeKey::new)
.map_err(|_| crate::Error::Key("aws-lc-rs rejected the KEK for AES-256-GCM".into()))
}
}
pub fn kek_path(control_dir: &Path) -> PathBuf {
control_dir.join(KEK_FILE)
}
fn write_kek_file(path: &Path, bytes: &[u8; KEK_LEN]) -> crate::Result<()> {
use std::io::Write as _;
let io = |err: std::io::Error| {
crate::Error::Key(format!(
"could not write the KEK at {}: {err}",
path.display()
))
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(io)?;
}
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options.open(path).map_err(io)?;
file.write_all(bytes).map_err(io)?;
file.sync_all().map_err(io)?;
drop(file);
if let Some(parent) = path.parent() {
if let Ok(dir) = std::fs::File::open(parent) {
let _ = dir.sync_all();
}
}
Ok(())
}
pub fn is_sealed(value: &[u8]) -> bool {
value.len() >= SEAL_MAGIC.len() + NONCE_LEN + TAG_LEN && value.starts_with(SEAL_MAGIC)
}
pub fn seal(kek: &Kek, key_name: &str, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
let aead = kek.aead()?;
let mut nonce = [0u8; NONCE_LEN];
SystemRandom::new()
.fill(&mut nonce)
.map_err(|_| crate::Error::Key("the system CSPRNG refused to produce a nonce".into()))?;
let mut body = plaintext.to_vec();
aead.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce),
Aad::from(key_name.as_bytes()),
&mut body,
)
.map_err(|_| crate::Error::Key(format!("could not seal the secret {key_name}")))?;
let mut out = Vec::with_capacity(SEAL_MAGIC.len() + NONCE_LEN + body.len());
out.extend_from_slice(SEAL_MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&body);
Ok(out)
}
pub fn unseal(kek: &Kek, key_name: &str, sealed: &[u8]) -> crate::Result<Vec<u8>> {
if !is_sealed(sealed) {
return Err(crate::Error::Key(format!(
"the stored value for {key_name} is not a sealed secret"
)));
}
let aead = kek.aead()?;
let nonce_start = SEAL_MAGIC.len();
let body_start = nonce_start + NONCE_LEN;
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&sealed[nonce_start..body_start]);
let mut body = sealed[body_start..].to_vec();
let plaintext = aead
.open_in_place(
Nonce::assume_unique_for_key(nonce),
Aad::from(key_name.as_bytes()),
&mut body,
)
.map_err(|_| {
crate::Error::Key(format!(
"the secret {key_name} did not authenticate: wrong key-encrypting key, wrong \
secret name, or modified bytes"
))
})?
.to_vec();
Ok(plaintext)
}
pub struct SealedSecretStore<S> {
inner: S,
kek: Kek,
plaintext: PlaintextPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PlaintextPolicy {
#[default]
Refuse,
SealOnRead,
}
impl<S> std::fmt::Debug for SealedSecretStore<S> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("SealedSecretStore")
}
}
impl<S: SecretStore> SealedSecretStore<S> {
pub fn new(inner: S, kek: Kek) -> Self {
Self {
inner,
kek,
plaintext: PlaintextPolicy::Refuse,
}
}
pub fn migrating(inner: S, kek: Kek) -> Self {
Self {
inner,
kek,
plaintext: PlaintextPolicy::SealOnRead,
}
}
pub fn plaintext_policy(&self) -> PlaintextPolicy {
self.plaintext
}
pub fn inner(&self) -> &S {
&self.inner
}
fn to_store_err(err: crate::Error) -> StoreError {
StoreError::backend(err)
}
fn open_value(&self, key: &str, raw: Vec<u8>) -> StoreResult<Vec<u8>> {
if is_sealed(&raw) {
return unseal(&self.kek, key, &raw).map_err(Self::to_store_err);
}
self.accept_plaintext(key)?;
tracing::warn!(
key,
"read an UNSEALED secret written before sealing existed; it will be sealed on the \
next get_or_create for this key"
);
Ok(raw)
}
fn accept_plaintext(&self, key: &str) -> StoreResult<()> {
match self.plaintext {
PlaintextPolicy::SealOnRead => Ok(()),
PlaintextPolicy::Refuse => Err(Self::to_store_err(crate::Error::Key(format!(
"the stored value for {key} carries no seal. Nothing holding the \
key-encrypting key wrote it: this store seals on the way in, and there is no \
pre-sealing secret log on this machine for it to have come from. Refusing to \
use it — an unsealed value in a sealed slot is what an attacker who can write \
the store but not read the KEK would leave behind"
)))),
}
}
}
impl<S: SecretStore> SecretStore for SealedSecretStore<S> {
fn get(&self, key: &str) -> StoreResult<Option<Vec<u8>>> {
match self.inner.get(key)? {
Some(raw) => self.open_value(key, raw).map(Some),
None => Ok(None),
}
}
fn put(&self, key: &str, value: &[u8]) -> StoreResult<()> {
let sealed = seal(&self.kek, key, value).map_err(Self::to_store_err)?;
self.inner.put(key, &sealed)
}
fn delete(&self, key: &str) -> StoreResult<()> {
self.inner.delete(key)
}
fn get_or_create(
&self,
key: &str,
mk: &mut dyn FnMut() -> StoreResult<Vec<u8>>,
) -> StoreResult<Vec<u8>> {
let stored = self.inner.get_or_create(key, &mut || {
let plaintext = mk()?;
seal(&self.kek, key, &plaintext).map_err(Self::to_store_err)
})?;
if is_sealed(&stored) {
return unseal(&self.kek, key, &stored).map_err(Self::to_store_err);
}
self.accept_plaintext(key)?;
tracing::warn!(
key,
"sealing a secret that was stored in the clear by an earlier gunnar"
);
let sealed = seal(&self.kek, key, &stored).map_err(Self::to_store_err)?;
self.inner.put(key, &sealed)?;
Ok(stored)
}
}