Relay: the relay keys wrapped under a key file (RL-35) #274
No files matched your search
@@ -11,6 +11,7 @@
|
||||
|
||||
use super::{
|
||||
DownstreamKeys, FEATURE, Json, KIND_INGESTED, KIND_STATUS, Relay, get_versioned, replace,
|
||||
wrap::KeyError,
|
||||
};
|
||||
use inbuxa_relay_proto::{
|
||||
pin::{Pin, pins_match},
|
||||
@@ -57,7 +58,7 @@ pub fn issue_node_certificate(
|
||||
node_id: u64,
|
||||
now: u64,
|
||||
) -> trc::Result<NodeCertificate> {
|
||||
let ca_key = rcgen::KeyPair::from_pem(&keys.link_ca_key_pem).map_err(cert_error)?;
|
||||
let ca_key = rcgen::KeyPair::from_pem(&keys.link_ca_key_pem.open()?).map_err(cert_error)?;
|
||||
let issuer =
|
||||
rcgen::Issuer::from_ca_cert_pem(&keys.link_ca_cert_pem, ca_key).map_err(cert_error)?;
|
||||
let key = rcgen::KeyPair::generate_for(&rcgen::PKCS_ECDSA_P256_SHA256).map_err(cert_error)?;
|
||||
@@ -221,16 +222,17 @@ impl RecipientPlan {
|
||||
|
||||
/// What the relay is sent with `Relay/set`: its settings as an
|
||||
/// administrator made them, with the served domains, the keys and the
|
||||
/// STARTTLS certificate added.
|
||||
/// STARTTLS certificate added. Fails only when the recipient key is
|
||||
/// wrapped and can't be opened (RL-35).
|
||||
pub fn pushed_settings(
|
||||
relay: &Relay,
|
||||
keys: &DownstreamKeys,
|
||||
plan: &RecipientPlan,
|
||||
) -> RelaySettings {
|
||||
) -> Result<RelaySettings, KeyError> {
|
||||
let mut settings = relay.settings.clone();
|
||||
settings.domains = plan.domains.keys().cloned().collect();
|
||||
settings.sealing_key = Some(keys.sealing_public);
|
||||
settings.recipient_key = Some(relay.recipient_key);
|
||||
settings.recipient_key = Some(relay.recipient_key.open()?);
|
||||
// While the link CA is replaced, the relay trusts the next one too (RL-34)
|
||||
settings.next_link_ca_pin = keys.next_link_ca.as_ref().map(|next| next.pin);
|
||||
settings.smtp_certificate = match (&relay.smtp_certificate, &relay.smtp_private_key) {
|
||||
@@ -240,7 +242,7 @@ pub fn pushed_settings(
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
settings
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// A node's link to a relay, as that node sees it.
|
||||
@@ -502,7 +504,7 @@ pub async fn forget_ingested(data: &Store, relay_id: u32, before: u64) -> trc::R
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::relay::{Join, new_downstream_keys};
|
||||
use crate::relay::{Join, new_downstream_keys, wrap::Secret};
|
||||
use inbuxa_relay_proto::time;
|
||||
|
||||
fn relay(domains: &[&str]) -> Relay {
|
||||
@@ -523,7 +525,7 @@ mod tests {
|
||||
},
|
||||
smtp_certificate: None,
|
||||
smtp_private_key: None,
|
||||
recipient_key: [9; 32],
|
||||
recipient_key: Secret::new([9; 32]),
|
||||
join: Join::default(),
|
||||
created_by: String::new(),
|
||||
created_at: 0,
|
||||
@@ -575,7 +577,7 @@ mod tests {
|
||||
let hashes = one.hashes(&[9; 32]);
|
||||
assert_eq!(hashes, vec![recipient::hash(&[9; 32], "[email protected]")]);
|
||||
let keys = new_downstream_keys(time::now()).unwrap();
|
||||
let settings = pushed_settings(&relay(&["example.com"]), &keys, &one);
|
||||
let settings = pushed_settings(&relay(&["example.com"]), &keys, &one).unwrap();
|
||||
assert_eq!(settings.domains, ["example.com"]);
|
||||
assert_eq!(settings.sealing_key, Some(keys.sealing_public));
|
||||
assert_eq!(settings.recipient_key, Some([9; 32]));
|
||||
@@ -609,7 +611,7 @@ mod tests {
|
||||
let keys = new_downstream_keys(time::now()).unwrap();
|
||||
let mut replacing = keys.clone();
|
||||
replacing.next_link_ca = Some(super::super::NextLinkCa {
|
||||
key_pem: String::new(),
|
||||
key_pem: Secret::new(String::new()),
|
||||
cert_pem: String::new(),
|
||||
pin: [7; 32],
|
||||
started_at: 1,
|
||||
|
||||
@@ -26,12 +26,15 @@
|
||||
//! ([`dns::dns_change`]).
|
||||
//!
|
||||
//! Numbers are big-endian. There are few relays, so they're read whole.
|
||||
//! The secrets in `k` and `r` are wrapped under the key file
|
||||
//! `INBUXA_RELAY_KEY_FILE` names, when it names one ([`wrap`], RL-35).
|
||||
|
||||
pub mod certificate;
|
||||
pub mod dns;
|
||||
pub mod link;
|
||||
pub mod messages;
|
||||
pub mod outbound;
|
||||
pub mod wrap;
|
||||
|
||||
use inbuxa_relay_proto::{
|
||||
b64,
|
||||
@@ -47,6 +50,7 @@ use store::{
|
||||
};
|
||||
use trc::AddContext;
|
||||
use types::id::Id;
|
||||
use wrap::Secret;
|
||||
|
||||
/// Wakes the link service on this node when a relay is added, changed or
|
||||
/// removed here. Other nodes see it at their next scan.
|
||||
@@ -77,13 +81,12 @@ const MAX_DOMAINS: usize = 5_000;
|
||||
#[derive(Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DownstreamKeys {
|
||||
pub link_ca_key_pem: String,
|
||||
pub link_ca_key_pem: Secret<String>,
|
||||
pub link_ca_cert_pem: String,
|
||||
/// The pin of the link CA's key, which every relay keeps.
|
||||
#[serde(with = "b64")]
|
||||
pub link_ca_pin: Pin,
|
||||
#[serde(with = "b64")]
|
||||
pub sealing_secret: [u8; 32],
|
||||
pub sealing_secret: Secret<[u8; 32]>,
|
||||
#[serde(with = "b64")]
|
||||
pub sealing_public: [u8; 32],
|
||||
pub created_at: u64,
|
||||
@@ -98,7 +101,7 @@ pub struct DownstreamKeys {
|
||||
#[derive(Clone, PartialEq, Eq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NextLinkCa {
|
||||
pub key_pem: String,
|
||||
pub key_pem: Secret<String>,
|
||||
pub cert_pem: String,
|
||||
#[serde(with = "b64")]
|
||||
pub pin: Pin,
|
||||
@@ -125,6 +128,16 @@ impl LinkCaChange {
|
||||
}
|
||||
|
||||
impl DownstreamKeys {
|
||||
/// Some secret is in the clear while a key file is named (RL-35).
|
||||
pub fn needs_wrapping(&self) -> bool {
|
||||
self.link_ca_key_pem.needs_wrapping()
|
||||
|| self.sealing_secret.needs_wrapping()
|
||||
|| self
|
||||
.next_link_ca
|
||||
.as_ref()
|
||||
.is_some_and(|next| next.key_pem.needs_wrapping())
|
||||
}
|
||||
|
||||
/// The keys with the next link CA as the link CA, if there is one.
|
||||
pub fn finished(&self) -> Option<DownstreamKeys> {
|
||||
let next = self.next_link_ca.as_ref()?;
|
||||
@@ -223,8 +236,8 @@ pub struct Relay {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub smtp_private_key: Option<String>,
|
||||
/// The key this relay's recipient hashes are made with (RL-11).
|
||||
#[serde(with = "b64", default)]
|
||||
pub recipient_key: [u8; 32],
|
||||
#[serde(default)]
|
||||
pub recipient_key: Secret<[u8; 32]>,
|
||||
#[serde(default)]
|
||||
pub join: Join,
|
||||
#[serde(default)]
|
||||
@@ -401,7 +414,7 @@ impl Relay {
|
||||
/// to show the administrator once.
|
||||
pub fn start_join(&mut self, keys: &DownstreamKeys, now: u64) -> String {
|
||||
let token = JoinToken::issue(self.relay_id(), keys.link_ca_pin, now);
|
||||
self.recipient_key = random_key();
|
||||
self.recipient_key = Secret::new(random_key());
|
||||
self.join = Join {
|
||||
secret: Some(token.secret),
|
||||
expires_at: token.expires_at,
|
||||
@@ -429,9 +442,9 @@ pub fn new_downstream_keys(now: u64) -> trc::Result<DownstreamKeys> {
|
||||
let (sealing_secret, sealing_public) = seal::generate_keypair();
|
||||
Ok(DownstreamKeys {
|
||||
link_ca_pin: pin,
|
||||
link_ca_key_pem: key_pem,
|
||||
link_ca_key_pem: Secret::new(key_pem),
|
||||
link_ca_cert_pem: cert_pem,
|
||||
sealing_secret: *sealing_secret,
|
||||
sealing_secret: Secret::new(*sealing_secret),
|
||||
sealing_public,
|
||||
created_at: now,
|
||||
next_link_ca: None,
|
||||
@@ -635,7 +648,7 @@ pub async fn change_link_ca(data: &Store, change: LinkCaChange, now: u64) -> trc
|
||||
let (key_pem, cert_pem, pin) = new_link_ca()?;
|
||||
DownstreamKeys {
|
||||
next_link_ca: Some(NextLinkCa {
|
||||
key_pem,
|
||||
key_pem: Secret::new(key_pem),
|
||||
cert_pem,
|
||||
pin,
|
||||
started_at: now,
|
||||
@@ -711,6 +724,79 @@ pub async fn replace(data: &Store, relay: &Relay, version: u64) -> trc::Result<b
|
||||
}
|
||||
}
|
||||
|
||||
/// Wraps every relay secret stored in the clear, once a key file is named
|
||||
/// (RL-35), and says whether there were any. Each is written again, which
|
||||
/// wraps it, unless something changed it first.
|
||||
pub async fn wrap_stored(data: &Store) -> trc::Result<bool> {
|
||||
if !wrap::turned_on() {
|
||||
return Ok(false);
|
||||
}
|
||||
let mut wrapped = false;
|
||||
while let Some(VersionedKeys(keys, version)) = data
|
||||
.get_value::<VersionedKeys>(ValueKey::from(class(KIND_KEYS, None)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.filter(|VersionedKeys(keys, _)| keys.needs_wrapping())
|
||||
{
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(class(KIND_KEYS, None), AssertValue::Hash(version));
|
||||
batch.set(class(KIND_KEYS, None), Json(&keys).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => {
|
||||
wrapped = true;
|
||||
break;
|
||||
}
|
||||
Err(err)
|
||||
if matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
for relay in all(data).await? {
|
||||
while let Some((relay, version)) = get_versioned(data, relay.id)
|
||||
.await?
|
||||
.filter(|(relay, _)| relay.recipient_key.needs_wrapping())
|
||||
{
|
||||
if replace(data, &relay, version).await? {
|
||||
wrapped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(wrapped)
|
||||
}
|
||||
|
||||
/// The downstream keys and every relay as the store holds them: what a copy
|
||||
/// of the store gives away (RL-T27).
|
||||
pub async fn stored_bytes(data: &Store) -> trc::Result<Vec<Vec<u8>>> {
|
||||
struct Raw(Vec<u8>);
|
||||
impl Deserialize for Raw {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
Ok(Raw(bytes.to_vec()))
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if let Some(Raw(bytes)) = data
|
||||
.get_value::<Raw>(ValueKey::from(class(KIND_KEYS, None)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
out.push(bytes);
|
||||
}
|
||||
for relay in all(data).await? {
|
||||
if let Some(Raw(bytes)) = data
|
||||
.get_value::<Raw>(relay_key(relay.id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
{
|
||||
out.push(bytes);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Removes a relay, and every node's record of its link.
|
||||
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||
let nodes = link::statuses(data, id).await?;
|
||||
@@ -747,7 +833,7 @@ mod tests {
|
||||
},
|
||||
smtp_certificate: None,
|
||||
smtp_private_key: None,
|
||||
recipient_key: [0; 32],
|
||||
recipient_key: Secret::new([0; 32]),
|
||||
join: Join::default(),
|
||||
created_by: String::new(),
|
||||
created_at: 0,
|
||||
@@ -809,16 +895,16 @@ mod tests {
|
||||
assert_eq!(Some(decoded.secret), r.join.secret);
|
||||
assert_eq!(r.join.state(now), JoinState::Waiting);
|
||||
assert_eq!(r.join.state(decoded.expires_at), JoinState::Expired);
|
||||
assert_ne!(r.recipient_key, [0; 32]);
|
||||
assert!(r.recipient_key != Secret::new([0; 32]));
|
||||
|
||||
// A new join forgets the pinned key and the old recipient key
|
||||
r.join.relay_pin = Some([7; 32]);
|
||||
assert_eq!(r.join.state(decoded.expires_at), JoinState::Joined);
|
||||
let old_key = r.recipient_key;
|
||||
let old_key = r.recipient_key.clone();
|
||||
let second = r.start_join(&keys, now);
|
||||
assert_ne!(second, token);
|
||||
assert_eq!(r.join.relay_pin, None);
|
||||
assert_ne!(r.recipient_key, old_key);
|
||||
assert!(r.recipient_key != old_key);
|
||||
|
||||
// The pin is of the key inside the CA certificate the relay sees
|
||||
use base64::Engine;
|
||||
@@ -830,7 +916,7 @@ mod tests {
|
||||
.collect::<String>(),
|
||||
)
|
||||
.unwrap();
|
||||
let ca_key = rcgen::KeyPair::from_pem(&keys.link_ca_key_pem).unwrap();
|
||||
let ca_key = rcgen::KeyPair::from_pem(&keys.link_ca_key_pem.open().unwrap()).unwrap();
|
||||
let spki = rcgen::PublicKeyData::subject_public_key_info(&ca_key);
|
||||
assert!(der.windows(spki.len()).any(|w| w == spki));
|
||||
assert_eq!(spki_pin(&spki), keys.link_ca_pin);
|
||||
@@ -838,7 +924,7 @@ mod tests {
|
||||
// The sealing key opens what's sealed to its public half
|
||||
let sealed = seal::seal(&keys.sealing_public, b"aad", b"hello").unwrap();
|
||||
assert_eq!(
|
||||
seal::open(&keys.sealing_secret, b"aad", &sealed).unwrap(),
|
||||
seal::open(&keys.sealing_secret.open().unwrap(), b"aad", &sealed).unwrap(),
|
||||
b"hello"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! The downstream's relay keys, wrapped (RL-35): the link CA key, the
|
||||
//! sealing key and each relay's recipient key are stored encrypted under a
|
||||
//! key the store doesn't hold, from the file `INBUXA_RELAY_KEY_FILE` names.
|
||||
//!
|
||||
//! A [`Secret`] is stored as before, a string, while no file is named, and
|
||||
//! as `{"wrapped": nonce || ciphertext}` once one is. A secret read in the
|
||||
//! clear is wrapped the next time it is written, which [`wrap_stored`] does
|
||||
//! for every one. Opening a wrapped secret without the file fails, saying
|
||||
//! so: the link service then starts no link, and the node shows why.
|
||||
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, LazyLock, RwLock},
|
||||
};
|
||||
|
||||
use aws_lc_rs::aead::{AES_256_GCM, Aad, NONCE_LEN, Nonce, RandomizedNonceKey};
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE},
|
||||
};
|
||||
use inbuxa_relay_proto::b64;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer, de, ser};
|
||||
|
||||
/// Names the key file.
|
||||
pub const ENV: &str = "INBUXA_RELAY_KEY_FILE";
|
||||
|
||||
/// Bound to every wrapped secret, so nothing else under the key opens as one.
|
||||
const AAD: &[u8] = b"inbuxa relay key v1";
|
||||
|
||||
/// The key from the file, none when no file is named, or why it can't be
|
||||
/// read.
|
||||
type KeyFile = Result<Option<RandomizedNonceKey>, String>;
|
||||
|
||||
/// Read from [`ENV`] when first needed.
|
||||
static KEY_FILE: LazyLock<RwLock<Option<Arc<KeyFile>>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
fn key_file() -> Arc<KeyFile> {
|
||||
if let Some(loaded) = KEY_FILE.read().unwrap_or_else(|e| e.into_inner()).as_ref() {
|
||||
return loaded.clone();
|
||||
}
|
||||
let path = std::env::var_os(ENV)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from);
|
||||
set_key_file(path.as_deref())
|
||||
}
|
||||
|
||||
/// Reads the key from `path`, or none, in place of what [`ENV`] names. The
|
||||
/// server reads [`ENV`] itself; the tests use this to turn the file on and
|
||||
/// off.
|
||||
pub fn set_key_file(path: Option<&Path>) -> Arc<KeyFile> {
|
||||
let loaded = Arc::new(path.map_or(Ok(None), |path| load(path).map(Some)));
|
||||
if let Err(why) = loaded.as_ref() {
|
||||
trc::error!(trc::Error::from(KeyError(why.clone())));
|
||||
}
|
||||
*KEY_FILE.write().unwrap_or_else(|e| e.into_inner()) = Some(loaded.clone());
|
||||
loaded
|
||||
}
|
||||
|
||||
/// 32 random bytes in base64, as `openssl rand -base64 32` writes them.
|
||||
fn load(path: &Path) -> Result<RandomizedNonceKey, String> {
|
||||
let text = std::fs::read_to_string(path)
|
||||
.map_err(|err| format!("{ENV}: can't read {}: {err}", path.display()))?;
|
||||
let text = text.trim();
|
||||
let bytes = STANDARD
|
||||
.decode(text)
|
||||
.or_else(|_| URL_SAFE.decode(text))
|
||||
.or_else(|_| b64::decode(text).ok_or(()))
|
||||
.ok()
|
||||
.filter(|bytes| bytes.len() == 32)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"{ENV}: {} doesn't hold 32 bytes in base64, as `openssl rand -base64 32` writes them.",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
key(&bytes)
|
||||
}
|
||||
|
||||
fn key(bytes: &[u8]) -> Result<RandomizedNonceKey, String> {
|
||||
RandomizedNonceKey::new(&AES_256_GCM, bytes).map_err(|_| format!("{ENV}: not a key."))
|
||||
}
|
||||
|
||||
/// Whether a key file is named, and reads.
|
||||
pub fn turned_on() -> bool {
|
||||
matches!(key_file().as_ref(), Ok(Some(_)))
|
||||
}
|
||||
|
||||
/// A value that can be a [`Secret`]: how it is stored in the clear, and as
|
||||
/// bytes to wrap.
|
||||
pub trait SecretValue: Sized + Clone {
|
||||
fn to_bytes(&self) -> Vec<u8>;
|
||||
fn from_bytes(bytes: Vec<u8>) -> Option<Self>;
|
||||
fn to_text(&self) -> String;
|
||||
fn from_text(text: String) -> Option<Self>;
|
||||
}
|
||||
|
||||
/// A PEM private key.
|
||||
impl SecretValue for String {
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
|
||||
String::from_utf8(bytes).ok()
|
||||
}
|
||||
|
||||
fn to_text(&self) -> String {
|
||||
self.clone()
|
||||
}
|
||||
|
||||
fn from_text(text: String) -> Option<Self> {
|
||||
Some(text)
|
||||
}
|
||||
}
|
||||
|
||||
/// A key, in base64url.
|
||||
impl SecretValue for [u8; 32] {
|
||||
fn to_bytes(&self) -> Vec<u8> {
|
||||
self.to_vec()
|
||||
}
|
||||
|
||||
fn from_bytes(bytes: Vec<u8>) -> Option<Self> {
|
||||
bytes.try_into().ok()
|
||||
}
|
||||
|
||||
fn to_text(&self) -> String {
|
||||
b64::encode(self)
|
||||
}
|
||||
|
||||
fn from_text(text: String) -> Option<Self> {
|
||||
b64::decode(&text).and_then(|bytes| bytes.try_into().ok())
|
||||
}
|
||||
}
|
||||
|
||||
/// A secret as the store holds it.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub enum Secret<T> {
|
||||
/// In the clear: no key file was named when it was written.
|
||||
Open(T),
|
||||
/// The nonce, then the ciphertext and its tag.
|
||||
Wrapped(Vec<u8>),
|
||||
}
|
||||
|
||||
impl<T: SecretValue> Secret<T> {
|
||||
pub fn new(value: T) -> Self {
|
||||
Secret::Open(value)
|
||||
}
|
||||
|
||||
/// The secret itself, or why it can't be opened.
|
||||
pub fn open(&self) -> Result<T, KeyError> {
|
||||
match self {
|
||||
Secret::Open(value) => Ok(value.clone()),
|
||||
Secret::Wrapped(wrapped) => match key_file().as_ref() {
|
||||
Ok(Some(key)) => unwrap_with(key, wrapped).ok_or_else(|| {
|
||||
KeyError(format!(
|
||||
"The relay keys were wrapped with another key than the one {ENV} names."
|
||||
))
|
||||
}),
|
||||
Ok(None) => Err(KeyError(format!(
|
||||
"The relay keys are wrapped: set {ENV} to the key file they were wrapped with."
|
||||
))),
|
||||
Err(why) => Err(KeyError(why.clone())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// In the clear, while a key file is named: written again, it is
|
||||
/// wrapped.
|
||||
pub fn needs_wrapping(&self) -> bool {
|
||||
matches!(self, Secret::Open(_)) && turned_on()
|
||||
}
|
||||
|
||||
pub fn is_wrapped(&self) -> bool {
|
||||
matches!(self, Secret::Wrapped(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: SecretValue + Default> Default for Secret<T> {
|
||||
fn default() -> Self {
|
||||
Secret::Open(T::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a relay key can't be opened, or the key file read: in words for
|
||||
/// the node's link status.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct KeyError(pub String);
|
||||
|
||||
impl std::fmt::Display for KeyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyError> for trc::Error {
|
||||
fn from(err: KeyError) -> Self {
|
||||
trc::EventType::Server(trc::ServerEvent::StartupError)
|
||||
.into_err()
|
||||
.details(err.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_with(key: &RandomizedNonceKey, value: &[u8]) -> Option<Vec<u8>> {
|
||||
let mut data = value.to_vec();
|
||||
let nonce = key
|
||||
.seal_in_place_append_tag(Aad::from(AAD), &mut data)
|
||||
.ok()?;
|
||||
let mut wrapped = nonce.as_ref().to_vec();
|
||||
wrapped.append(&mut data);
|
||||
Some(wrapped)
|
||||
}
|
||||
|
||||
fn unwrap_with<T: SecretValue>(key: &RandomizedNonceKey, wrapped: &[u8]) -> Option<T> {
|
||||
let (nonce, data) = wrapped.split_at_checked(NONCE_LEN)?;
|
||||
let nonce = Nonce::try_assume_unique_for_key(nonce).ok()?;
|
||||
let mut data = data.to_vec();
|
||||
let len = key
|
||||
.open_in_place(nonce, Aad::from(AAD), &mut data)
|
||||
.ok()?
|
||||
.len();
|
||||
data.truncate(len);
|
||||
T::from_bytes(data)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Stored {
|
||||
Open(String),
|
||||
Wrapped { wrapped: String },
|
||||
}
|
||||
|
||||
impl<T: SecretValue> Serialize for Secret<T> {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let wrapped = match self {
|
||||
Secret::Wrapped(wrapped) => wrapped.clone(),
|
||||
Secret::Open(value) => match key_file().as_ref() {
|
||||
Ok(None) => return Stored::Open(value.to_text()).serialize(serializer),
|
||||
Ok(Some(key)) => wrap_with(key, &value.to_bytes())
|
||||
.ok_or_else(|| ser::Error::custom("can't wrap a relay key"))?,
|
||||
// Never in the clear while a file is named
|
||||
Err(why) => return Err(ser::Error::custom(why)),
|
||||
},
|
||||
};
|
||||
Stored::Wrapped {
|
||||
wrapped: b64::encode(&wrapped),
|
||||
}
|
||||
.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de, T: SecretValue> Deserialize<'de> for Secret<T> {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
match Stored::deserialize(deserializer)? {
|
||||
Stored::Open(text) => T::from_text(text)
|
||||
.map(Secret::Open)
|
||||
.ok_or_else(|| de::Error::custom("not a relay key")),
|
||||
Stored::Wrapped { wrapped } => b64::decode(&wrapped)
|
||||
.map(Secret::Wrapped)
|
||||
.ok_or_else(|| de::Error::custom("not a wrapped relay key")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wraps_and_opens() {
|
||||
let key = key(&[7; 32]).unwrap();
|
||||
let wrapped = wrap_with(&key, b"-----BEGIN PRIVATE KEY-----").unwrap();
|
||||
assert!(!wrapped.windows(5).any(|w| w == b"BEGIN"));
|
||||
assert_eq!(
|
||||
unwrap_with::<String>(&key, &wrapped).as_deref(),
|
||||
Some("-----BEGIN PRIVATE KEY-----")
|
||||
);
|
||||
// A new nonce every time
|
||||
assert_ne!(
|
||||
wrap_with(&key, b"same").unwrap(),
|
||||
wrap_with(&key, b"same").unwrap()
|
||||
);
|
||||
// Another key, or a changed byte, opens nothing
|
||||
assert_eq!(
|
||||
unwrap_with::<String>(&self::key(&[8; 32]).unwrap(), &wrapped),
|
||||
None
|
||||
);
|
||||
let mut changed = wrapped.clone();
|
||||
*changed.last_mut().unwrap() ^= 1;
|
||||
assert_eq!(unwrap_with::<String>(&key, &changed), None);
|
||||
assert_eq!(unwrap_with::<String>(&key, &wrapped[..4]), None);
|
||||
// A 32-byte key comes back as one
|
||||
let wrapped = wrap_with(&key, &[3; 32]).unwrap();
|
||||
assert_eq!(unwrap_with::<[u8; 32]>(&key, &wrapped), Some([3; 32]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_forms() {
|
||||
// In the clear, as before RL-35
|
||||
let open: Secret<[u8; 32]> =
|
||||
serde_json::from_str(&format!("\"{}\"", b64::encode(&[5; 32]))).unwrap();
|
||||
assert!(open == Secret::Open([5; 32]));
|
||||
let open: Secret<String> = serde_json::from_str("\"-----BEGIN\"").unwrap();
|
||||
assert!(open == Secret::Open("-----BEGIN".to_string()));
|
||||
// Wrapped stays as it was, whatever the key file
|
||||
let wrapped: Secret<String> = serde_json::from_str("{\"wrapped\":\"AAEC\"}").unwrap();
|
||||
assert!(wrapped == Secret::Wrapped(vec![0, 1, 2]));
|
||||
assert_eq!(
|
||||
serde_json::to_string(&wrapped).unwrap(),
|
||||
"{\"wrapped\":\"AAEC\"}"
|
||||
);
|
||||
assert!(serde_json::from_str::<Secret<[u8; 32]>>("\"short\"").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn key_files() {
|
||||
let dir = std::env::temp_dir().join(format!("inbuxa-relay-key-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("relay.key");
|
||||
for good in [
|
||||
STANDARD.encode([1; 32]) + "\n",
|
||||
URL_SAFE.encode([250; 32]),
|
||||
b64::encode(&[250; 32]),
|
||||
] {
|
||||
std::fs::write(&path, good).unwrap();
|
||||
assert!(load(&path).is_ok());
|
||||
}
|
||||
for bad in [
|
||||
STANDARD.encode([1; 16]),
|
||||
"not base64".to_string(),
|
||||
String::new(),
|
||||
] {
|
||||
std::fs::write(&path, bad).unwrap();
|
||||
assert!(load(&path).unwrap_err().contains("32 bytes"));
|
||||
}
|
||||
assert!(
|
||||
load(&dir.join("missing"))
|
||||
.unwrap_err()
|
||||
.contains("can't read")
|
||||
);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,9 @@ pub enum RelayProperty {
|
||||
LinkCa,
|
||||
/// When a replacement of the link CA began, while one is under way.
|
||||
LinkCaReplacing,
|
||||
/// Whether the relay keys are wrapped under the key file
|
||||
/// `INBUXA_RELAY_KEY_FILE` names (RL-35).
|
||||
KeysWrapped,
|
||||
/// In a create or rejoin response only.
|
||||
JoinToken,
|
||||
/// Each node's link to the relay, and what the relay last said.
|
||||
@@ -114,6 +117,7 @@ impl Property for RelayProperty {
|
||||
RelayProperty::ReplaceLinkCa => "replaceLinkCa",
|
||||
RelayProperty::LinkCa => "linkCa",
|
||||
RelayProperty::LinkCaReplacing => "linkCaReplacing",
|
||||
RelayProperty::KeysWrapped => "keysWrapped",
|
||||
RelayProperty::JoinToken => "joinToken",
|
||||
RelayProperty::Links => "links",
|
||||
RelayProperty::CreatedBy => "createdBy",
|
||||
@@ -151,6 +155,7 @@ impl RelayProperty {
|
||||
b"replaceLinkCa" => RelayProperty::ReplaceLinkCa,
|
||||
b"linkCa" => RelayProperty::LinkCa,
|
||||
b"linkCaReplacing" => RelayProperty::LinkCaReplacing,
|
||||
b"keysWrapped" => RelayProperty::KeysWrapped,
|
||||
b"joinToken" => RelayProperty::JoinToken,
|
||||
b"links" => RelayProperty::Links,
|
||||
b"createdBy" => RelayProperty::CreatedBy,
|
||||
|
||||
@@ -69,6 +69,7 @@ const ALL: &[P] = &[
|
||||
P::OlderThanServer,
|
||||
P::LinkCa,
|
||||
P::LinkCaReplacing,
|
||||
P::KeysWrapped,
|
||||
P::CreatedBy,
|
||||
P::CreatedAt,
|
||||
P::UpdatedAt,
|
||||
@@ -91,6 +92,7 @@ const SERVER_SET: &[P] = &[
|
||||
P::OlderThanServer,
|
||||
P::LinkCa,
|
||||
P::LinkCaReplacing,
|
||||
P::KeysWrapped,
|
||||
P::CreatedBy,
|
||||
P::CreatedAt,
|
||||
P::UpdatedAt,
|
||||
@@ -220,6 +222,9 @@ fn to_value(
|
||||
P::LinkCaReplacing => keys
|
||||
.and_then(|keys| keys.next_link_ca.as_ref())
|
||||
.map_or(Value::Null, |next| date(next.started_at)),
|
||||
P::KeysWrapped => {
|
||||
Value::Bool(keys.is_some_and(|keys| keys.link_ca_key_pem.is_wrapped()))
|
||||
}
|
||||
P::CreatedBy => text(Some(&relay.created_by)),
|
||||
P::CreatedAt => date(relay.created_at),
|
||||
P::UpdatedAt => date(relay.updated_at),
|
||||
@@ -413,7 +418,7 @@ fn apply(current: &StoredRelay, sent: &Sent) -> Result<StoredRelay, SetError<P>>
|
||||
let next = parse(json)?;
|
||||
Ok(StoredRelay {
|
||||
id: current.id,
|
||||
recipient_key: current.recipient_key,
|
||||
recipient_key: current.recipient_key.clone(),
|
||||
join: current.join.clone(),
|
||||
created_by: current.created_by.clone(),
|
||||
created_at: current.created_at,
|
||||
|
||||
@@ -50,6 +50,7 @@ use inbuxa_features::{
|
||||
record_ingested, record_join, set_status, was_ingested,
|
||||
},
|
||||
outbound::{self, Call, HandOver, Handed, Linked, Refused, Sender},
|
||||
wrap::KeyError,
|
||||
},
|
||||
};
|
||||
use inbuxa_relay_proto::{
|
||||
@@ -184,6 +185,8 @@ async fn reconcile(
|
||||
links: &mut HashMap<u32, Running>,
|
||||
) -> trc::Result<()> {
|
||||
let data = server.store();
|
||||
// Keys stored in the clear, wrapped once a key file is named (RL-35)
|
||||
relays::wrap_stored(data).await?;
|
||||
let (keys, all) = match relays::keys(data).await? {
|
||||
Some(keys) => (Some(keys), relays::all(data).await?),
|
||||
None => (None, Vec::new()),
|
||||
@@ -301,6 +304,13 @@ impl LinkError {
|
||||
}
|
||||
}
|
||||
|
||||
/// A relay key that can't be opened (RL-35): the node says why, in words.
|
||||
impl From<KeyError> for LinkError {
|
||||
fn from(err: KeyError) -> Self {
|
||||
LinkError::down(err.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<trc::Error> for LinkError {
|
||||
fn from(err: trc::Error) -> Self {
|
||||
LinkError::down(format!("This server failed: {err}"))
|
||||
@@ -401,6 +411,9 @@ async fn link_keys(
|
||||
let keys = relays::keys(server.store())
|
||||
.await?
|
||||
.ok_or_else(|| LinkError::down("This server has no relay keys yet."))?;
|
||||
// Wrapped, and no key file to open them with (RL-35): no link starts
|
||||
keys.sealing_secret.open()?;
|
||||
keys.link_ca_key_pem.open()?;
|
||||
let cert = certs.get(&keys, server.core.network.node_id)?;
|
||||
Ok((keys, cert))
|
||||
}
|
||||
@@ -1010,7 +1023,7 @@ async fn session(
|
||||
dialer: dialer.clone(),
|
||||
relay_id: relay.id,
|
||||
relay_name: relay.name.clone(),
|
||||
secret: keys.sealing_secret,
|
||||
secret: keys.sealing_secret.open()?,
|
||||
taking: HashSet::new(),
|
||||
answers: answers_tx,
|
||||
};
|
||||
@@ -1807,7 +1820,7 @@ async fn sync(
|
||||
}
|
||||
|
||||
// Settings, whole, when they changed
|
||||
let settings = pushed_settings(relay, keys, &plan);
|
||||
let settings = pushed_settings(relay, keys, &plan)?;
|
||||
if sent.settings.as_ref() != Some(&settings) {
|
||||
let mut patch = serde_json::to_value(&settings).unwrap_or_default();
|
||||
// The relay merges what it is sent: a setting left out stays as it
|
||||
@@ -1872,7 +1885,10 @@ async fn sync(
|
||||
|
||||
// Recipients: in full on a new link or after many changes, else as
|
||||
// changes
|
||||
let hashes: BTreeSet<RecipientHash> = plan.hashes(&relay.recipient_key).into_iter().collect();
|
||||
let hashes: BTreeSet<RecipientHash> = plan
|
||||
.hashes(&relay.recipient_key.open()?)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let added: Vec<&RecipientHash> = hashes.difference(&sent.hashes).collect();
|
||||
let removed: Vec<&RecipientHash> = sent.hashes.difference(&hashes).collect();
|
||||
if !sent.started || added.len() + removed.len() > MAX_DELTA {
|
||||
|
||||
@@ -205,6 +205,19 @@ the protocol the rest of the suite already speaks.
|
||||
turned on. Without the setting, keys are stored as today, as registry
|
||||
secrets.
|
||||
|
||||
How it goes:
|
||||
- The file holds 32 random bytes in base64, as `openssl rand -base64 32`
|
||||
writes them. Each key is wrapped with AES-256-GCM and a random nonce,
|
||||
stored as `{"wrapped": …}` where it was a string.
|
||||
- Once the setting names a file, the link service wraps every key still
|
||||
stored in the clear, within a scan. Keys stay wrapped: taking the
|
||||
setting away later doesn't unwrap them.
|
||||
- A node that can't open them, because the setting is missing, the file
|
||||
can't be read, or it holds another key, starts no link. Each of its
|
||||
links shows why in `lastError`. While the setting names a file that
|
||||
can't be read, no key is written in the clear.
|
||||
- `inbuxa:Relay` shows `keysWrapped`.
|
||||
|
||||
### Joining and reset
|
||||
|
||||
1. In inbuxa-admin, an administrator adds a relay: a name and the address the
|
||||
@@ -537,7 +550,7 @@ When no link is up, or after RL-13:
|
||||
|
||||
| Object | What it holds |
|
||||
|---|---|
|
||||
| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), `mxPreference` (its MX preference, 10 by default, RL-25), `certificateNotAfter` and `certificateError` (when its certificate runs out, and why the last order failed, RL-27), `olderThanServer` (a link last heard an older release than this server's, RL-28), `linkCa` and `linkCaReplacing` (RL-34), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once. An update with `upgrade: {blobId, signature}`, sent on its own, hands the relay a release over the node's link (RL-29). An update with `replaceLinkCa`, sent on its own, starts, finishes or cancels a replacement of the shared link CA (RL-34) |
|
||||
| `inbuxa:Relay` | One per relay: `name`, `address`, `linkPort`, `enabled`, the settings pushed to the relay, `sendThrough` (route outbound mail through relays), `inboundOnly` (takes mail in but sends none, e.g. while its IP isn't warmed up or isn't in SPF yet), `mxPreference` (its MX preference, 10 by default, RL-25), `certificateNotAfter` and `certificateError` (when its certificate runs out, and why the last order failed, RL-27), `olderThanServer` (a link last heard an older release than this server's, RL-28), `linkCa` and `linkCaReplacing` (RL-34), `keysWrapped` (RL-35), and read-only status mirrored from the relay: link state per node, version, queue counts, `lastSeen`. Create, or an update with `rejoin: true`, returns `joinToken` once. An update with `upgrade: {blobId, signature}`, sent on its own, hands the relay a release over the node's link (RL-29). An update with `replaceLinkCa`, sent on its own, starts, finishes or cancels a replacement of the shared link CA (RL-34) |
|
||||
| `inbuxa:RelayMessage` | Read-only view of a relay's `Inbound` and `Outbound` queues, fetched over the link when asked. Never message content. `/get`, `/query` (filter `relayId`, `direction`; newest first) and `/set` destroy only: discards held mail, or cancels an outbound message, whose recipients fail here with no DSN as a delivery cancelled in the queue does. Each id is the relay's id and a hash of the relay's own message id, so nothing is stored |
|
||||
|
||||
Permissions: `sysRelayGet`, `sysRelayUpdate`, `sysRelayQueueGet`,
|
||||
|
||||
@@ -24,7 +24,7 @@ use crate::utils::{
|
||||
dns::DnsCache,
|
||||
server::{TestServer, TestServerBuilder},
|
||||
};
|
||||
use inbuxa_relay_proto::{time, token::JoinToken};
|
||||
use inbuxa_relay_proto::{b64, time, token::JoinToken};
|
||||
use mail_auth::{common::parse::TxtRecordParser, mta_sts::TlsRpt};
|
||||
use registry::{
|
||||
schema::{
|
||||
@@ -279,6 +279,8 @@ pub async fn test(test: &mut TestServer) {
|
||||
);
|
||||
// A relay still joining has said nothing of its CA
|
||||
assert_eq!(get(admin, &id).await["linkCa"], Value::Null);
|
||||
// No key file: the keys are stored as before RL-35
|
||||
assert_eq!(get(admin, &id).await["keysWrapped"], false);
|
||||
let (_, response) = call(
|
||||
admin,
|
||||
"inbuxa:Relay/set",
|
||||
@@ -1453,6 +1455,92 @@ pub async fn link(test: &mut TestServer) {
|
||||
assert_eq!(identity["caPin"], new_pin, "the relay keeps the new CA");
|
||||
assert_eq!(relay["joinState"], "joined", "without a new join");
|
||||
|
||||
// RL-T27: with a key file, a copy of the store holds none of the relay
|
||||
// keys in the clear...
|
||||
use inbuxa_features::relay as relays;
|
||||
let data = test.server.store().clone();
|
||||
let keys = relays::keys(&data).await.unwrap().unwrap();
|
||||
let mut clear = vec![
|
||||
keys.link_ca_key_pem
|
||||
.open()
|
||||
.unwrap()
|
||||
.lines()
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.to_string(),
|
||||
b64::encode(&keys.sealing_secret.open().unwrap()),
|
||||
];
|
||||
for relay in relays::all(&data).await.unwrap() {
|
||||
clear.push(b64::encode(&relay.recipient_key.open().unwrap()));
|
||||
}
|
||||
let in_the_clear = async || {
|
||||
relays::stored_bytes(&data)
|
||||
.await
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|bytes| {
|
||||
clear
|
||||
.iter()
|
||||
.any(|secret| bytes.windows(secret.len()).any(|w| w == secret.as_bytes()))
|
||||
})
|
||||
};
|
||||
assert!(in_the_clear().await, "in the clear without one, as before");
|
||||
let key_file = test.temp_dir.path.join("relay.key");
|
||||
std::fs::write(&key_file, b64::encode(&relays::random_key())).unwrap();
|
||||
relays::wrap::set_key_file(Some(&key_file));
|
||||
relays::changed();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while in_the_clear().await {
|
||||
assert!(
|
||||
std::time::Instant::now() < deadline,
|
||||
"the relay keys were never wrapped"
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
wait_for(
|
||||
admin,
|
||||
&id,
|
||||
"the keys wrapped, and still linked",
|
||||
30,
|
||||
|relay| relay["keysWrapped"] == true && relay["links"][0]["state"] == "linked",
|
||||
)
|
||||
.await;
|
||||
// ...and a node without the file starts no relay link, and says why
|
||||
call(
|
||||
admin,
|
||||
"inbuxa:Relay/set",
|
||||
json!({"update": {&id: {"enabled": false}}}),
|
||||
)
|
||||
.await;
|
||||
wait_for(admin, &id, "the link ended", 30, |relay| {
|
||||
relay["links"][0]["state"] == "down"
|
||||
})
|
||||
.await;
|
||||
relays::wrap::set_key_file(None);
|
||||
let reply = send_mail(smtp_port, "[email protected]", &message("Wrapped one", "")).await;
|
||||
assert!(reply.starts_with("250"), "held: {reply}");
|
||||
call(
|
||||
admin,
|
||||
"inbuxa:Relay/set",
|
||||
json!({"update": {&id: {"enabled": true}}}),
|
||||
)
|
||||
.await;
|
||||
wait_for(admin, &id, "no link without the key file", 30, |relay| {
|
||||
relay["links"][0]["state"] == "down"
|
||||
&& relay["links"][0]["lastError"]
|
||||
.as_str()
|
||||
.is_some_and(|e| e.contains("INBUXA_RELAY_KEY_FILE"))
|
||||
})
|
||||
.await;
|
||||
// With it again, the link is back, and the mail held meanwhile opens
|
||||
relays::wrap::set_key_file(Some(&key_file));
|
||||
relays::changed();
|
||||
wait_for_mail(&jane, "Wrapped one", 1).await;
|
||||
wait_for(admin, &id, "linked again", 30, |relay| {
|
||||
relay["links"][0]["state"] == "linked"
|
||||
})
|
||||
.await;
|
||||
|
||||
// The relay's events were taken as this server's relay.* trace events,
|
||||
// and acknowledged, so the relay forgot them
|
||||
let events = state.join("events.jsonl");
|
||||
|
||||
Reference in new issue
Block a user