use std::fmt;
use aes_gcm::Aes256Gcm;
use aes_gcm::KeyInit;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use sha2::Sha256;
use thiserror::Error;
use zeroize::Zeroize as _;
const MAGIC: u8 = 0xCF;
const VERSION: u8 = 0x01;
const ALG_AES_256_GCM: u8 = 0x01;
const NONCE_LEN: usize = 12;
const HEADER_LEN: usize = 3 + NONCE_LEN;
const TAG_LEN: usize = 16;
const TOKEN_BYTES: usize = 16;
type HmacSha256 = Hmac<Sha256>;
fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] {
hmac_sha256_parts(key, msg, &[])
}
fn hmac_sha256_parts(key: &[u8], first: &[u8], second: &[u8]) -> [u8; 32] {
let mut mac = <HmacSha256 as Mac>::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(first);
mac.update(second);
mac.finalize().into_bytes().into()
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ConfidentialError {
#[error("invalid root key: expected 64 hex characters, got {len}")]
InvalidKeyFormat {
len: usize,
},
#[error("malformed sealed envelope: {0}")]
MalformedEnvelope(&'static str),
#[error("unsupported sealed envelope (version={version:#04x}, alg={alg:#04x})")]
UnsupportedEnvelope {
version: u8,
alg: u8,
},
#[error("seal failed: the value is too large for one AES-GCM message")]
SealFailed,
#[error("unseal failed: wrong key, wrong field context, or corrupted ciphertext")]
UnsealFailed,
#[error("unsealed value is not valid UTF-8")]
NotUtf8,
#[error("invalid blind-index token: expected {expected} lowercase hex characters")]
InvalidToken {
expected: usize,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct FieldContext {
scope: Vec<u8>,
record: Vec<u8>,
}
fn push_part(out: &mut Vec<u8>, part: &str) {
let len = u32::try_from(part.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(part.as_bytes());
}
impl FieldContext {
#[must_use]
pub fn new(table: &str, column: &str, owner: &str) -> Self {
let mut scope = Vec::with_capacity(table.len() + column.len() + owner.len() + 12);
push_part(&mut scope, table);
push_part(&mut scope, column);
push_part(&mut scope, owner);
Self {
scope,
record: Vec::new(),
}
}
#[must_use]
pub fn for_record(table: &str, column: &str, owner: &str, record: &str) -> Self {
let mut ctx = Self::new(table, column, owner);
push_part(&mut ctx.record, record);
ctx
}
fn scope_bytes(&self) -> &[u8] {
&self.scope
}
fn aad(&self) -> Vec<u8> {
let mut aad = Vec::with_capacity(3 + self.scope.len() + self.record.len());
aad.push(MAGIC);
aad.push(VERSION);
aad.push(ALG_AES_256_GCM);
aad.extend_from_slice(&self.scope);
aad.extend_from_slice(&self.record);
aad
}
}
impl fmt::Debug for FieldContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("FieldContext(<redacted>)")
}
}
pub struct RootKey {
bytes: [u8; 32],
}
impl RootKey {
#[must_use]
pub fn generate() -> Self {
let mut bytes = [0u8; 32];
getrandom::fill(&mut bytes).expect("OS RNG failed");
Self { bytes }
}
#[must_use]
pub const fn from_bytes(bytes: [u8; 32]) -> Self {
Self { bytes }
}
pub fn from_hex(hex_str: &str) -> Result<Self, ConfidentialError> {
let hex_str = hex_str.trim();
let mut bytes = [0u8; 32];
if hex_str.len() != 64 || hex::decode_to_slice(hex_str, &mut bytes).is_err() {
bytes.zeroize();
return Err(ConfidentialError::InvalidKeyFormat { len: hex_str.len() });
}
Ok(Self { bytes })
}
fn derive(&self, domain: &[u8], ctx: &FieldContext) -> [u8; 32] {
let mut msg = Vec::with_capacity(domain.len() + ctx.scope_bytes().len());
msg.extend_from_slice(domain);
msg.extend_from_slice(ctx.scope_bytes());
hmac_sha256(&self.bytes, &msg)
}
pub fn seal(&self, ctx: &FieldContext, plaintext: &str) -> Result<Sealed, ConfidentialError> {
use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, Payload};
use base64::Engine as _;
let mut seal_key = self.derive(b"autumn:confidential:seal:v1:", ctx);
let cipher = Aes256Gcm::new(&seal_key.into());
seal_key.zeroize();
let mut nonce = [0u8; NONCE_LEN];
getrandom::fill(&mut nonce).expect("OS RNG failed");
let ciphertext = cipher
.encrypt(
<&Nonce<_>>::from(&nonce),
Payload {
msg: plaintext.as_bytes(),
aad: &ctx.aad(),
},
)
.map_err(|_| ConfidentialError::SealFailed)?;
let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
out.push(MAGIC);
out.push(VERSION);
out.push(ALG_AES_256_GCM);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
Ok(Sealed(
base64::engine::general_purpose::STANDARD.encode(out),
))
}
pub fn unseal(&self, ctx: &FieldContext, sealed: &Sealed) -> Result<String, ConfidentialError> {
use aes_gcm::Nonce;
use aes_gcm::aead::{Aead, Payload};
let raw = sealed.to_bytes()?;
let nonce = &raw[3..HEADER_LEN];
let ciphertext = &raw[HEADER_LEN..];
let mut seal_key = self.derive(b"autumn:confidential:seal:v1:", ctx);
let cipher = Aes256Gcm::new(&seal_key.into());
seal_key.zeroize();
let plaintext = cipher
.decrypt(
<&Nonce<_>>::from(nonce),
Payload {
msg: ciphertext,
aad: &ctx.aad(),
},
)
.map_err(|_| ConfidentialError::UnsealFailed)?;
String::from_utf8(plaintext).map_err(|e| {
let _wiped = zeroize::Zeroizing::new(e.into_bytes());
ConfidentialError::NotUtf8
})
}
#[must_use]
pub fn blind_index(&self, ctx: &FieldContext, plaintext: &str) -> BlindIndex {
let mut index_key = self.derive(b"autumn:confidential:index:v1:", ctx);
let tag = hmac_sha256_parts(
&index_key,
b"autumn:confidential:bidx:v1:",
plaintext.as_bytes(),
);
index_key.zeroize();
BlindIndex(hex::encode(&tag[..TOKEN_BYTES]))
}
}
impl Drop for RootKey {
fn drop(&mut self) {
self.bytes.zeroize();
}
}
impl fmt::Debug for RootKey {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("RootKey(<redacted>)")
}
}
#[derive(Clone, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "db", derive(diesel::AsExpression, diesel::FromSqlRow))]
#[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Text))]
pub struct Sealed(String);
impl Sealed {
pub fn from_envelope(mut envelope: String) -> Result<Self, ConfidentialError> {
envelope.truncate(envelope.trim_end().len());
let leading = envelope.len() - envelope.trim_start().len();
envelope.drain(..leading);
let candidate = Self(envelope);
candidate.to_bytes()?;
Ok(candidate)
}
#[must_use]
pub fn as_envelope(&self) -> &str {
&self.0
}
pub fn to_bytes(&self) -> Result<Vec<u8>, ConfidentialError> {
use base64::Engine as _;
let raw = base64::engine::general_purpose::STANDARD
.decode(&self.0)
.map_err(|_| ConfidentialError::MalformedEnvelope("not valid base64"))?;
if raw.len() < HEADER_LEN + TAG_LEN {
return Err(ConfidentialError::MalformedEnvelope("truncated envelope"));
}
if raw[0] != MAGIC {
return Err(ConfidentialError::MalformedEnvelope("bad magic byte"));
}
if raw[1] != VERSION || raw[2] != ALG_AES_256_GCM {
return Err(ConfidentialError::UnsupportedEnvelope {
version: raw[1],
alg: raw[2],
});
}
Ok(raw)
}
}
impl Default for Sealed {
fn default() -> Self {
use base64::Engine as _;
let mut raw = Vec::with_capacity(HEADER_LEN + TAG_LEN);
raw.push(MAGIC);
raw.push(VERSION);
raw.push(ALG_AES_256_GCM);
raw.resize(HEADER_LEN + TAG_LEN, 0);
Self(base64::engine::general_purpose::STANDARD.encode(raw))
}
}
impl fmt::Debug for Sealed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("Sealed(<sealed>)")
}
}
impl Serialize for Sealed {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Sealed {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let envelope = String::deserialize(deserializer)?;
Self::from_envelope(envelope).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Eq)]
#[cfg_attr(feature = "db", derive(diesel::AsExpression, diesel::FromSqlRow))]
#[cfg_attr(feature = "db", diesel(sql_type = diesel::sql_types::Text))]
pub struct BlindIndex(String);
impl std::hash::Hash for BlindIndex {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.0.hash(state);
}
}
impl fmt::Debug for BlindIndex {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("BlindIndex(<token>)")
}
}
impl PartialEq for BlindIndex {
fn eq(&self, other: &Self) -> bool {
use subtle::ConstantTimeEq as _;
self.0.len() == other.0.len() && bool::from(self.0.as_bytes().ct_eq(other.0.as_bytes()))
}
}
impl BlindIndex {
pub const TOKEN_LEN: usize = TOKEN_BYTES * 2;
pub fn from_token(token: String) -> Result<Self, ConfidentialError> {
let valid = token.len() == Self::TOKEN_LEN
&& token
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
if valid {
Ok(Self(token))
} else {
Err(ConfidentialError::InvalidToken {
expected: Self::TOKEN_LEN,
})
}
}
#[must_use]
pub fn as_token(&self) -> &str {
&self.0
}
}
impl Default for BlindIndex {
fn default() -> Self {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes).expect("OS RNG failed");
Self(hex::encode(bytes))
}
}
impl Serialize for BlindIndex {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.0)
}
}
impl<'de> Deserialize<'de> for BlindIndex {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let token = String::deserialize(deserializer)?;
Self::from_token(token).map_err(serde::de::Error::custom)
}
}
#[derive(Debug)]
pub struct ConfidentialColumnDescriptor {
pub model: &'static str,
pub table: &'static str,
pub column: &'static str,
pub blind_index: Option<&'static str>,
}
inventory::collect!(ConfidentialColumnDescriptor);
#[must_use]
pub fn registered_confidential_columns() -> Vec<&'static ConfidentialColumnDescriptor> {
inventory::iter::<ConfidentialColumnDescriptor>
.into_iter()
.collect()
}
#[must_use]
pub fn registered_confidential_column_names() -> Vec<String> {
let mut names: Vec<String> = Vec::new();
for d in registered_confidential_columns() {
names.push(d.column.to_owned());
if let Some(bidx) = d.blind_index {
names.push(bidx.to_owned());
}
}
names.sort_unstable();
names.dedup();
names
}
#[must_use]
pub fn is_confidential_column(table: &str, column: &str) -> bool {
registered_confidential_columns()
.iter()
.any(|d| d.table == table && d.column == column)
}
#[must_use]
pub fn is_confidential_column_name(column: &str) -> bool {
registered_confidential_columns()
.iter()
.any(|d| d.column == column || d.blind_index == Some(column))
}
#[must_use]
pub fn confidential_columns_for_table(table: &str) -> Vec<&'static str> {
registered_confidential_columns()
.iter()
.filter(|d| d.table == table)
.map(|d| d.column)
.collect()
}
pub fn merge_confidential_columns_for_table(table: &str, columns: &mut Vec<&'static str>) {
for d in registered_confidential_columns() {
if d.table != table {
continue;
}
for column in [Some(d.column), d.blind_index].into_iter().flatten() {
if !columns.contains(&column) {
columns.push(column);
}
}
}
}
#[doc(hidden)]
#[must_use]
pub const fn __column_is_confidential(columns: &[&str], column: &str) -> bool {
let mut i = 0;
while i < columns.len() {
if const_str_eq(columns[i], column) {
return true;
}
i += 1;
}
false
}
const fn const_str_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OperatorSink {
pub id: &'static str,
pub why: &'static str,
}
pub const OPERATOR_BLIND_SINKS: &[OperatorSink] = &[
OperatorSink {
id: "database",
why: "the column type is `Sealed`, so the only value bound into an INSERT \
or UPDATE is the envelope",
},
OperatorSink {
id: "access_log",
why: "the access log carries no bodies, and confidential column names are \
folded into the log parameter filter, which matches parameter names \
rather than message text: a handler that interpolates a request body \
into an error message puts the envelope and its token there itself",
},
OperatorSink {
id: "db_backup",
why: "a backup is a dump of the database, which holds only envelopes",
},
OperatorSink {
id: "replay_capsule",
why: "a capsule copies the request body and the SQL binds, both of which \
carry envelopes",
},
OperatorSink {
id: "version_history",
why: "confidential columns are version-sensitive, so a revision records \
that the column changed, not what it changed to",
},
OperatorSink {
id: "admin_ui",
why: "the admin cell renderer redacts registered confidential columns, and \
never offers an editable control for one",
},
OperatorSink {
id: "admin_csv_export",
why: "the CSV export drops confidential columns and their blind-index \
companions, so a downloaded file carries neither",
},
];
pub const OPERATOR_VISIBLE: &[&str] = &[
"whether one value equals another, for one owner and one column, from the \
blind-index token. An application that lets the operator make a client seal \
a value of the operator's choosing turns that into a confirmation oracle \
for a guessed plaintext",
"with a `FieldContext::new` context, the operator can move, copy or roll back \
one owner's envelope among that owner's own rows in the same column, and the \
client cannot tell. `FieldContext::for_record` closes this",
"that the row exists, and its id, timestamps and foreign keys",
"the approximate length of the plaintext, from the length of the envelope",
"every column the application did not mark `#[confidential]`",
];
#[cfg(feature = "db")]
mod diesel_types;