Security to-do list: accepted items, kept on the server
This commit is contained in:
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! Accepted security to-do items (security to-do list spec, SS-23 to SS-26).
|
||||
//!
|
||||
//! The console runs the checks; the server only keeps what an administrator
|
||||
//! accepted, so every administrator sees the same accepted risks. An
|
||||
//! acceptance names the check, what within it (a domain, a certificate…),
|
||||
//! the value the check saw, and why. It holds only while the check still
|
||||
//! sees that value, which the console compares. Acceptances are created and
|
||||
//! removed, never edited.
|
||||
//!
|
||||
//! Kept in the fork's subspace (`store::SUBSPACE_INBUXA`). Every key starts
|
||||
//! with `Q`, then one byte for the kind:
|
||||
//!
|
||||
//! - `a` + acceptance id (u32): the acceptance, as JSON.
|
||||
//!
|
||||
//! Numbers are big-endian. There are at most [`MAX_ACCEPTANCES`], so
|
||||
//! they're read whole.
|
||||
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Serialize, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass, assert::AssertValue},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
const FEATURE: u8 = b'Q';
|
||||
const KIND_ACCEPTANCE: u8 = b'a';
|
||||
const CREATE_ATTEMPTS: usize = 5;
|
||||
|
||||
pub const MAX_ACCEPTANCES: usize = 200;
|
||||
/// The checks are SS-1 to SS-18; a few spare for checks added later.
|
||||
const MAX_CHECK: u32 = 40;
|
||||
const MAX_SUBJECT: usize = 255;
|
||||
const MAX_VALUE_BYTES: usize = 4096;
|
||||
const MAX_NOTE: usize = 500;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Acceptance {
|
||||
#[serde(default)]
|
||||
pub id: u32,
|
||||
/// Which check: `SS-1`, `SS-2`…
|
||||
pub check: String,
|
||||
/// What within the check: empty for a server-wide setting, else the
|
||||
/// domain, strategy or certificate it names.
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
/// The value the check saw when it was accepted.
|
||||
#[serde(default)]
|
||||
pub accepted_value: serde_json::Value,
|
||||
/// Why. Required.
|
||||
pub note: String,
|
||||
#[serde(default)]
|
||||
pub accepted_by: String,
|
||||
/// Seconds since the epoch.
|
||||
#[serde(default)]
|
||||
pub accepted_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Invalid {
|
||||
pub property: &'static str,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
fn invalid(property: &'static str, reason: impl Into<String>) -> Invalid {
|
||||
Invalid {
|
||||
property,
|
||||
reason: reason.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Acceptance {
|
||||
/// What an administrator sends is checked whole before it's kept.
|
||||
pub fn validate(&self) -> Result<(), Invalid> {
|
||||
let check_ok = self
|
||||
.check
|
||||
.strip_prefix("SS-")
|
||||
.and_then(|n| n.parse::<u32>().ok())
|
||||
.is_some_and(|n| (1..=MAX_CHECK).contains(&n));
|
||||
if !check_ok {
|
||||
return Err(invalid("check", "A check is named SS-1, SS-2 and so on."));
|
||||
}
|
||||
if self.subject.chars().count() > MAX_SUBJECT {
|
||||
return Err(invalid(
|
||||
"subject",
|
||||
format!("At most {MAX_SUBJECT} characters."),
|
||||
));
|
||||
}
|
||||
let value_bytes = serde_json::to_vec(&self.accepted_value)
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(usize::MAX);
|
||||
if value_bytes > MAX_VALUE_BYTES {
|
||||
return Err(invalid(
|
||||
"acceptedValue",
|
||||
format!("At most {MAX_VALUE_BYTES} bytes."),
|
||||
));
|
||||
}
|
||||
let note = self.note.trim();
|
||||
if note.is_empty() {
|
||||
return Err(invalid("note", "Say why this is accepted."));
|
||||
}
|
||||
if note.chars().count() > MAX_NOTE {
|
||||
return Err(invalid("note", format!("At most {MAX_NOTE} characters.")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- Storage --------------------------------------------------------------
|
||||
|
||||
struct Json<T>(T);
|
||||
|
||||
impl<T: SerdeSerialize> Serialize for Json<T> {
|
||||
fn serialize(&self) -> trc::Result<Vec<u8>> {
|
||||
serde_json::to_vec(&self.0).map_err(|err| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to serialize a security acceptance")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Deserialize for Json<Acceptance> {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||
trc::StoreEvent::DataCorruption
|
||||
.into_err()
|
||||
.details("Invalid security acceptance")
|
||||
.reason(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn class(id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.push(FEATURE);
|
||||
key.push(KIND_ACCEPTANCE);
|
||||
key.extend_from_slice(&id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
fn key(id: u32) -> ValueKey<ValueClass> {
|
||||
ValueKey::from(class(id))
|
||||
}
|
||||
|
||||
pub async fn get(data: &Store, id: u32) -> trc::Result<Option<Acceptance>> {
|
||||
Ok(data
|
||||
.get_value::<Json<Acceptance>>(key(id))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(acceptance)| acceptance))
|
||||
}
|
||||
|
||||
/// Every acceptance, oldest first.
|
||||
pub async fn all(data: &Store) -> trc::Result<Vec<Acceptance>> {
|
||||
let mut out = Vec::new();
|
||||
data.iterate(IterateParams::new(key(0), key(u32::MAX)), |_, value| {
|
||||
if let Ok(Json(acceptance)) = Json::<Acceptance>::deserialize(value) {
|
||||
out.push(acceptance);
|
||||
}
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
out.sort_by_key(|a| a.id);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub enum Created {
|
||||
Id(u32),
|
||||
/// There are already [`MAX_ACCEPTANCES`].
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Keeps a new acceptance under the next free id. Two nodes creating at
|
||||
/// once can't take the same id: the key must be absent.
|
||||
pub async fn create(data: &Store, acceptance: &Acceptance) -> trc::Result<Created> {
|
||||
let mut attempt = 0;
|
||||
loop {
|
||||
attempt += 1;
|
||||
let existing = all(data).await?;
|
||||
if existing.len() >= MAX_ACCEPTANCES {
|
||||
return Ok(Created::Full);
|
||||
}
|
||||
let id = existing.iter().map(|a| a.id).max().unwrap_or(0) + 1;
|
||||
let stored = Acceptance {
|
||||
id,
|
||||
..acceptance.clone()
|
||||
};
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.assert_value(class(id), AssertValue::None);
|
||||
batch.set(class(id), Json(&stored).serialize()?);
|
||||
match data.write(batch.build_all()).await {
|
||||
Ok(_) => return Ok(Created::Id(id)),
|
||||
Err(err)
|
||||
if attempt < CREATE_ATTEMPTS
|
||||
&& matches!(
|
||||
err.as_ref(),
|
||||
trc::EventType::Store(trc::StoreEvent::AssertValueFailed)
|
||||
) => {}
|
||||
Err(err) => return Err(err.caused_by(trc::location!())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn delete(data: &Store, id: u32) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(class(id));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn acceptance() -> Acceptance {
|
||||
Acceptance {
|
||||
id: 0,
|
||||
check: "SS-1".into(),
|
||||
subject: String::new(),
|
||||
accepted_value: serde_json::json!(true),
|
||||
note: "Old clients on the LAN; closed by 2027.".into(),
|
||||
accepted_by: String::new(),
|
||||
accepted_at: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_note_is_required() {
|
||||
assert!(acceptance().validate().is_ok());
|
||||
let blank = Acceptance {
|
||||
note: " ".into(),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(blank.validate().unwrap_err().property, "note");
|
||||
let long = Acceptance {
|
||||
note: "x".repeat(501),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(long.validate().unwrap_err().property, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_named_checks() {
|
||||
for bad in ["", "SS-0", "SS-41", "ss-1", "SS-x", "1"] {
|
||||
let a = Acceptance {
|
||||
check: bad.into(),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "check", "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_and_value_are_bounded() {
|
||||
let a = Acceptance {
|
||||
subject: "d".repeat(256),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "subject");
|
||||
let a = Acceptance {
|
||||
accepted_value: serde_json::json!("v".repeat(4096)),
|
||||
..acceptance()
|
||||
};
|
||||
assert_eq!(a.validate().unwrap_err().property, "acceptedValue");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user