diff --git a/Cargo.lock b/Cargo.lock index 8eccb23..6b39034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -511,6 +511,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "csilgen-transport" +version = "0.1.0" +dependencies = [ + "ciborium", + "ciborium-io", + "serde", + "thiserror 1.0.69", +] + [[package]] name = "ctr" version = "0.9.2" @@ -704,6 +714,17 @@ dependencies = [ "syn", ] +[[package]] +name = "diesel_migrations" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + [[package]] name = "diesel_table_macro_syntax" version = "0.3.0" @@ -867,7 +888,7 @@ dependencies = [ "atomic 0.6.1", "pear", "serde", - "toml", + "toml 0.8.23", "uncased", "version_check", ] @@ -1688,13 +1709,16 @@ dependencies = [ "chrono", "ciborium", "clap", + "csilgen-transport", "diesel", + "diesel_migrations", "ed25519-dalek", "env_logger", "hickory-resolver", "liblinkkeys", "log", "num_cpus", + "qrcode", "r2d2", "rand 0.8.5", "rcgen", @@ -1706,7 +1730,7 @@ dependencies = [ "serde_json", "threadpool", "tokio", - "toml", + "toml 0.8.23", "urlencoding", "uuid", "x509-parser", @@ -1784,6 +1808,27 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + [[package]] name = "mime" version = "0.3.17" @@ -2166,6 +2211,12 @@ dependencies = [ "yansi", ] +[[package]] +name = "qrcode" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" + [[package]] name = "quinn" version = "0.11.9" @@ -2789,6 +2840,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3210,11 +3270,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3224,6 +3297,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3232,10 +3314,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.15", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", ] [[package]] @@ -3931,6 +4022,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 2ecb7e6..2e34ec2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "crates/csilgen-transport", "crates/liblinkkeys", "crates/linkkeys", "demoappsite", diff --git a/crates/csilgen-transport/Cargo.toml b/crates/csilgen-transport/Cargo.toml new file mode 100644 index 0000000..73f3581 --- /dev/null +++ b/crates/csilgen-transport/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "csilgen-transport" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +# VENDORED from catalystcommunity/csilgen @ 17c59ef (transports/rust). Temporary: +# the git dependency already works (public repo), but we run the copy while the +# crate is still being iterated, then swap to the git dep at the end. Do NOT +# hand-edit beyond the conformance test below — re-sync from upstream instead. +description = "Vendored CSIL transport family reference implementation." + +[dependencies] +ciborium = "0.2" +ciborium-io = "0.2" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" diff --git a/crates/csilgen-transport/src/carrier.rs b/crates/csilgen-transport/src/carrier.rs new file mode 100644 index 0000000..c805d7b --- /dev/null +++ b/crates/csilgen-transport/src/carrier.rs @@ -0,0 +1,169 @@ +//! Carrier seams — the bring-your-own-carrier boundary (conventions doc §7). +//! +//! The library owns envelope codecs, framing, and lifecycle; the *carrier* (the +//! byte/datagram transport) is injected. A host supplies QUIC, WebRTC, a platform +//! media stack, or anything else by implementing one of these traits — without +//! changing the library. + +use crate::conventions::{MAX_FRAME_DEFAULT, Result, TransportError}; +use std::io::{Read, Write}; + +/// A stream/frame carrier: sends and receives one *delimited message* at a time. +/// Used by CSIL-RPC and CSIL-Events. Built-in implementations frame with a 4-byte +/// big-endian length prefix; a host may implement this over WebSocket binary +/// frames, a WebTransport stream, etc. +pub trait FrameCarrier { + fn send_frame(&mut self, bytes: &[u8]) -> Result<()>; + /// Receive the next frame, or `None` at a clean end of stream. + fn recv_frame(&mut self) -> Result>>; +} + +/// A datagram carrier: sends and receives one self-contained datagram (each within +/// the channel MTU), with no delivery or ordering guarantee. Used by CSIL-Datagrams. +/// Built-in over UDP; a host plugs WebRTC unreliable channels, QUIC datagrams, etc. +pub trait DatagramCarrier { + fn send_datagram(&mut self, bytes: &[u8]) -> Result<()>; + /// Receive the next datagram, or `None` if the carrier is closed. + fn recv_datagram(&mut self) -> Result>>; +} + +/// Write a 4-byte big-endian length prefix followed by `bytes` (CSIL stream framing). +pub fn write_length_prefixed(w: &mut W, bytes: &[u8], max: usize) -> Result<()> { + if bytes.len() > max { + return Err(TransportError::FrameTooLarge { + got: bytes.len(), + max, + }); + } + let len = u32::try_from(bytes.len()).map_err(|_| TransportError::FrameTooLarge { + got: bytes.len(), + max, + })?; + w.write_all(&len.to_be_bytes()) + .map_err(|e| TransportError::Carrier(e.to_string()))?; + w.write_all(bytes) + .map_err(|e| TransportError::Carrier(e.to_string()))?; + w.flush() + .map_err(|e| TransportError::Carrier(e.to_string()))?; + Ok(()) +} + +/// Read one length-prefixed frame, enforcing the max-frame guard before allocating. +/// Returns `None` at a clean EOF before any byte of a frame. +pub fn read_length_prefixed(r: &mut R, max: usize) -> Result>> { + let mut len_buf = [0u8; 4]; + match r.read_exact(&mut len_buf) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => return Ok(None), + Err(e) => return Err(TransportError::Carrier(e.to_string())), + } + let len = u32::from_be_bytes(len_buf) as usize; + if len > max { + return Err(TransportError::FrameTooLarge { got: len, max }); + } + let mut buf = vec![0u8; len]; + r.read_exact(&mut buf) + .map_err(|e| TransportError::Carrier(e.to_string()))?; + Ok(Some(buf)) +} + +/// A `FrameCarrier` over any `Read + Write` byte stream (TCP, TLS, Unix socket), +/// using the canonical 4-byte length-prefix framing. +pub struct StreamCarrier { + stream: S, + max_frame: usize, +} + +impl StreamCarrier { + pub fn new(stream: S) -> Self { + Self { + stream, + max_frame: MAX_FRAME_DEFAULT, + } + } + + pub fn with_max_frame(stream: S, max_frame: usize) -> Self { + Self { stream, max_frame } + } + + pub fn into_inner(self) -> S { + self.stream + } +} + +impl FrameCarrier for StreamCarrier { + fn send_frame(&mut self, bytes: &[u8]) -> Result<()> { + write_length_prefixed(&mut self.stream, bytes, self.max_frame) + } + + fn recv_frame(&mut self) -> Result>> { + read_length_prefixed(&mut self.stream, self.max_frame) + } +} + +/// An in-memory `FrameCarrier` backed by a queue of frames — for tests and for +/// driving the codec without a socket. +#[derive(Default)] +pub struct LoopbackFrameCarrier { + pub outbound: std::collections::VecDeque>, + pub inbound: std::collections::VecDeque>, +} + +impl LoopbackFrameCarrier { + pub fn new() -> Self { + Self::default() + } + + /// Queue a frame that a subsequent `recv_frame` will return. + pub fn push_inbound(&mut self, bytes: Vec) { + self.inbound.push_back(bytes); + } + + /// Take the next frame that was sent via `send_frame`. + pub fn take_outbound(&mut self) -> Option> { + self.outbound.pop_front() + } +} + +impl FrameCarrier for LoopbackFrameCarrier { + fn send_frame(&mut self, bytes: &[u8]) -> Result<()> { + self.outbound.push_back(bytes.to_vec()); + Ok(()) + } + + fn recv_frame(&mut self) -> Result>> { + Ok(self.inbound.pop_front()) + } +} + +/// An in-memory `DatagramCarrier` — for tests and codec drives. +#[derive(Default)] +pub struct LoopbackDatagramCarrier { + pub outbound: std::collections::VecDeque>, + pub inbound: std::collections::VecDeque>, +} + +impl LoopbackDatagramCarrier { + pub fn new() -> Self { + Self::default() + } + + pub fn push_inbound(&mut self, bytes: Vec) { + self.inbound.push_back(bytes); + } + + pub fn take_outbound(&mut self) -> Option> { + self.outbound.pop_front() + } +} + +impl DatagramCarrier for LoopbackDatagramCarrier { + fn send_datagram(&mut self, bytes: &[u8]) -> Result<()> { + self.outbound.push_back(bytes.to_vec()); + Ok(()) + } + + fn recv_datagram(&mut self) -> Result>> { + Ok(self.inbound.pop_front()) + } +} diff --git a/crates/csilgen-transport/src/conventions.rs b/crates/csilgen-transport/src/conventions.rs new file mode 100644 index 0000000..0524b1a --- /dev/null +++ b/crates/csilgen-transport/src/conventions.rs @@ -0,0 +1,243 @@ +//! Conventions shared by every CSIL transport — see `csil-transport-conventions.md`. +//! +//! This module owns the parts the three transports agree on: the CBOR rules +//! (deterministic encoding, tag-24 payloads), the version constant, the transport +//! status registry, and the max-frame guard. The transport modules build their +//! envelopes from the canonical-CBOR helpers here so the bytes match the +//! conformance vectors regardless of struct layout. + +use ciborium::value::Value; + +/// Current transport version. A new value is minted only for a breaking change to +/// envelope layout or semantics. +pub const VERSION: u64 = 1; + +/// CBOR semantic tag wrapping an embedded, opaque CBOR data item (RFC 8949 §3.4.5.1). +pub const TAG_ENCODED_CBOR: u64 = 24; + +/// Reserved service ordinal for the transport control plane (Events lifecycle). +pub const CONTROL_SERVICE_ORD: u64 = 0; + +/// Default max encoded envelope size for stream/message carriers (16 MiB). A +/// carrier rejects anything larger before allocating for it. +pub const MAX_FRAME_DEFAULT: usize = 16 * 1024 * 1024; + +/// Transport-level status. Distinct from application errors, which ride inside the +/// payload as a declared `/ ErrorType` arm. See the conventions doc registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Ok, + MalformedEnvelope, + UnknownServiceOrOp, + Unauthenticated, + Forbidden, + VersionUnsupported, + Internal, + Unavailable, + DeadlineExceeded, + /// A host-defined transport-extension code (>= 64) or any unrecognized code. + Other(i64), +} + +impl Status { + pub fn code(self) -> i64 { + match self { + Status::Ok => 0, + Status::MalformedEnvelope => 1, + Status::UnknownServiceOrOp => 2, + Status::Unauthenticated => 3, + Status::Forbidden => 4, + Status::VersionUnsupported => 5, + Status::Internal => 6, + Status::Unavailable => 7, + Status::DeadlineExceeded => 8, + Status::Other(c) => c, + } + } + + pub fn from_code(code: i64) -> Status { + match code { + 0 => Status::Ok, + 1 => Status::MalformedEnvelope, + 2 => Status::UnknownServiceOrOp, + 3 => Status::Unauthenticated, + 4 => Status::Forbidden, + 5 => Status::VersionUnsupported, + 6 => Status::Internal, + 7 => Status::Unavailable, + 8 => Status::DeadlineExceeded, + other => Status::Other(other), + } + } + + pub fn is_ok(self) -> bool { + self.code() == 0 + } +} + +/// Errors surfaced by the transport layer (the wire), distinct from a host's +/// application errors. +#[derive(Debug, thiserror::Error)] +pub enum TransportError { + #[error("CBOR encode error: {0}")] + Encode(String), + #[error("CBOR decode error: {0}")] + Decode(String), + #[error("malformed envelope: {0}")] + Malformed(String), + #[error("frame of {got} bytes exceeds max-frame guard of {max} bytes")] + FrameTooLarge { got: usize, max: usize }, + #[error("unsupported transport version {0}")] + UnsupportedVersion(u64), + /// A non-zero transport status returned by the peer. + #[error("transport status {status} ({code}){}", .message.as_deref().map(|m| format!(": {m}")).unwrap_or_default())] + Status { + status: &'static str, + code: i64, + message: Option, + }, + #[error("carrier error: {0}")] + Carrier(String), +} + +pub type Result = std::result::Result; + +/// Wrap opaque payload bytes (themselves a CBOR item) in tag 24. +pub fn tag24(payload: Vec) -> Value { + Value::Tag(TAG_ENCODED_CBOR, Box::new(Value::Bytes(payload))) +} + +/// Extract the opaque payload bytes from a tag-24 value. +pub fn untag24(value: &Value) -> Result> { + match value { + Value::Tag(TAG_ENCODED_CBOR, inner) => match inner.as_ref() { + Value::Bytes(b) => Ok(b.clone()), + _ => Err(TransportError::Malformed( + "tag-24 payload is not a byte string".into(), + )), + }, + _ => Err(TransportError::Malformed( + "expected a tag-24 (encoded-cbor) payload".into(), + )), + } +} + +/// Encode a CBOR value to bytes. +pub fn encode_value(value: &Value) -> Result> { + let mut buf = Vec::new(); + ciborium::ser::into_writer(value, &mut buf) + .map_err(|e| TransportError::Encode(e.to_string()))?; + Ok(buf) +} + +/// Decode bytes into a CBOR value. An envelope is a single self-contained CBOR +/// item (conventions doc §1), so trailing bytes after the item are rejected — +/// matching the other reference libraries instead of silently ignoring them. +pub fn decode_value(bytes: &[u8]) -> Result { + let mut cursor = std::io::Cursor::new(bytes); + let value: Value = ciborium::de::from_reader(&mut cursor) + .map_err(|e| TransportError::Decode(e.to_string()))?; + if (cursor.position() as usize) != bytes.len() { + return Err(TransportError::Decode( + "trailing bytes after CBOR item".into(), + )); + } + Ok(value) +} + +/// Build a deterministically-keyed CBOR map. Entries are sorted by the bytewise +/// lexicographic order of their *encoded* keys (RFC 8949 core deterministic +/// encoding), so the same logical envelope always yields the same bytes. +pub fn canon_map(entries: Vec<(&'static str, Value)>) -> Result { + let mut keyed: Vec<(Vec, Value, Value)> = Vec::with_capacity(entries.len()); + for (k, v) in entries { + let key = Value::Text(k.to_string()); + let encoded_key = encode_value(&key)?; + keyed.push((encoded_key, key, v)); + } + keyed.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(Value::Map( + keyed.into_iter().map(|(_, k, v)| (k, v)).collect(), + )) +} + +/// Look up a text key in a CBOR map value. +pub fn map_get<'a>(map: &'a Value, key: &str) -> Option<&'a Value> { + if let Value::Map(entries) = map { + for (k, v) in entries { + if let Value::Text(t) = k + && t == key + { + return Some(v); + } + } + } + None +} + +/// Read a required unsigned integer field from a CBOR map. +pub fn get_uint(map: &Value, key: &str) -> Result { + match map_get(map, key) { + Some(Value::Integer(i)) => { + let n: i128 = (*i).into(); + u64::try_from(n).map_err(|_| { + TransportError::Malformed(format!("field '{key}' is not a non-negative integer")) + }) + } + _ => Err(TransportError::Malformed(format!( + "missing or non-integer field '{key}'" + ))), + } +} + +/// Read a required signed integer field from a CBOR map. +pub fn get_int(map: &Value, key: &str) -> Result { + match map_get(map, key) { + Some(Value::Integer(i)) => { + let n: i128 = (*i).into(); + i64::try_from(n) + .map_err(|_| TransportError::Malformed(format!("field '{key}' out of i64 range"))) + } + _ => Err(TransportError::Malformed(format!( + "missing or non-integer field '{key}'" + ))), + } +} + +/// Read a required text field from a CBOR map. +pub fn get_text(map: &Value, key: &str) -> Result { + match map_get(map, key) { + Some(Value::Text(t)) => Ok(t.clone()), + _ => Err(TransportError::Malformed(format!( + "missing or non-text field '{key}'" + ))), + } +} + +/// Read an optional text field from a CBOR map. +pub fn get_text_opt(map: &Value, key: &str) -> Option { + match map_get(map, key) { + Some(Value::Text(t)) => Some(t.clone()), + _ => None, + } +} + +/// Read an optional unsigned integer field from a CBOR map. +pub fn get_uint_opt(map: &Value, key: &str) -> Option { + match map_get(map, key) { + Some(Value::Integer(i)) => { + let n: i128 = (*i).into(); + u64::try_from(n).ok() + } + _ => None, + } +} + +/// Verify a decoded envelope's version field, returning a clear error otherwise. +pub fn check_version(v: u64) -> Result<()> { + if v == VERSION { + Ok(()) + } else { + Err(TransportError::UnsupportedVersion(v)) + } +} diff --git a/crates/csilgen-transport/src/datagrams.rs b/crates/csilgen-transport/src/datagrams.rs new file mode 100644 index 0000000..fdcf057 --- /dev/null +++ b/crates/csilgen-transport/src/datagrams.rs @@ -0,0 +1,200 @@ +//! CSIL-Datagrams transport — unreliable, unordered, message-oriented — see +//! `csil-datagrams-transport.md`. CBOR-array (default), compact fixed-header, and +//! payload-only profiles. A datagram channel is single-service: the service is +//! bound at channel setup, so datagrams carry no service ordinal. + +use crate::conventions::*; +use ciborium::value::Value; + +/// Conservative max datagram size (envelope + payload) safe across UDP/WebRTC/QUIC. +pub const MAX_DATAGRAM_DEFAULT: usize = 1200; + +/// A datagram in the CBOR-array (default) profile: `[v, op_ord, seq, payload]`. +#[derive(Debug, Clone, PartialEq)] +pub struct Datagram { + pub op_ord: u64, + /// Per-channel sequence; 0 means "unsequenced". + pub seq: u64, + /// Opaque CBOR(message type) bytes. + pub payload: Vec, +} + +impl Datagram { + pub fn new(op_ord: u64, seq: u64, payload: Vec) -> Self { + Self { + op_ord, + seq, + payload, + } + } + + pub fn encode(&self) -> Result> { + let arr = Value::Array(vec![ + Value::Integer(VERSION.into()), + Value::Integer(self.op_ord.into()), + Value::Integer(self.seq.into()), + tag24(self.payload.clone()), + ]); + encode_value(&arr) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + let arr = match &v { + Value::Array(a) => a, + _ => return Err(TransportError::Malformed("datagram is not an array".into())), + }; + if arr.len() != 4 { + return Err(TransportError::Malformed(format!( + "datagram array has {} elements, expected 4", + arr.len() + ))); + } + let as_u64 = |val: &Value| -> Result { + match val { + Value::Integer(i) => u64::try_from(Into::::into(*i)) + .map_err(|_| TransportError::Malformed("datagram field out of range".into())), + _ => Err(TransportError::Malformed( + "datagram field not an integer".into(), + )), + } + }; + check_version(as_u64(&arr[0])?)?; + Ok(Self { + op_ord: as_u64(&arr[1])?, + seq: as_u64(&arr[2])?, + payload: untag24(&arr[3])?, + }) + } +} + +/// A datagram in the compact fixed-header profile. Header layout: +/// `[ver|flags][op_ord:u8][seq:u16 BE]([epoch:u8])` then the opaque body. +#[derive(Debug, Clone, PartialEq)] +pub struct CompactDatagram { + pub op_ord: u8, + pub seq: u16, + /// Present when the sender tracks restarts (sets the flags epoch bit). + pub epoch: Option, + /// Opaque body bytes (tag-24 CBOR or a raw media frame, by channel agreement). + pub body: Vec, +} + +const COMPACT_VER: u8 = 1; +const FLAG_EPOCH: u8 = 0b0001; + +impl CompactDatagram { + pub fn new(op_ord: u8, seq: u16, body: Vec) -> Self { + Self { + op_ord, + seq, + epoch: None, + body, + } + } + + pub fn with_epoch(mut self, epoch: u8) -> Self { + self.epoch = Some(epoch); + self + } + + pub fn encode(&self) -> Vec { + let flags = if self.epoch.is_some() { FLAG_EPOCH } else { 0 }; + let mut out = Vec::with_capacity(5 + self.body.len()); + out.push((COMPACT_VER << 4) | (flags & 0x0f)); + out.push(self.op_ord); + out.extend_from_slice(&self.seq.to_be_bytes()); + if let Some(epoch) = self.epoch { + out.push(epoch); + } + out.extend_from_slice(&self.body); + out + } + + pub fn decode(bytes: &[u8]) -> Result { + if bytes.len() < 4 { + return Err(TransportError::Malformed( + "compact datagram shorter than the 4-byte header".into(), + )); + } + let ver = bytes[0] >> 4; + if ver != COMPACT_VER { + return Err(TransportError::UnsupportedVersion(ver as u64)); + } + let flags = bytes[0] & 0x0f; + let op_ord = bytes[1]; + let seq = u16::from_be_bytes([bytes[2], bytes[3]]); + let (epoch, body_start) = if flags & FLAG_EPOCH != 0 { + if bytes.len() < 5 { + return Err(TransportError::Malformed( + "compact datagram flags claim an epoch byte that is absent".into(), + )); + } + (Some(bytes[4]), 5) + } else { + (None, 4) + }; + Ok(Self { + op_ord, + seq, + epoch, + body: bytes[body_start..].to_vec(), + }) + } +} + +/// Classification of an incoming sequence number relative to what was last seen, +/// for loss/reorder/restart detection. The transport detects; the app decides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SeqEvent { + /// First datagram seen on the channel. + First, + /// Strictly newer than the last (possibly skipping some — a gap/loss). + Advanced { gap: u64 }, + /// Not newer (a late or duplicate datagram). + LateOrDuplicate, + /// The sender restarted (epoch changed); seq numbering reset. + Restart, +} + +/// Tracks the last sequence/epoch per channel to classify arrivals. Unsequenced +/// datagrams (seq 0) are always reported as `Advanced { gap: 0 }`. +#[derive(Debug, Default)] +pub struct SeqTracker { + last_seq: Option, + last_epoch: Option, +} + +impl SeqTracker { + pub fn new() -> Self { + Self::default() + } + + pub fn observe(&mut self, seq: u64, epoch: Option) -> SeqEvent { + if epoch != self.last_epoch && self.last_epoch.is_some() { + self.last_epoch = epoch; + self.last_seq = Some(seq); + return SeqEvent::Restart; + } + self.last_epoch = epoch; + // seq 0 marks an unsequenced datagram: it carries no ordering information, + // so it is never late or duplicate. Report a zero-gap advance and leave the + // running sequence untouched (a mix of sequenced and unsequenced still + // tracks the sequenced ones). + if seq == 0 { + return SeqEvent::Advanced { gap: 0 }; + } + match self.last_seq { + None => { + self.last_seq = Some(seq); + SeqEvent::First + } + Some(last) if seq > last => { + let gap = seq - last - 1; + self.last_seq = Some(seq); + SeqEvent::Advanced { gap } + } + Some(_) => SeqEvent::LateOrDuplicate, + } + } +} diff --git a/crates/csilgen-transport/src/events.rs b/crates/csilgen-transport/src/events.rs new file mode 100644 index 0000000..976b1ac --- /dev/null +++ b/crates/csilgen-transport/src/events.rs @@ -0,0 +1,376 @@ +//! CSIL-Events transport — typed bidirectional event streams — see +//! `csil-events-transport.md`. Verbose (text-keyed) and compact (positional) +//! profiles, plus the control plane (service ordinal 0) lifecycle events. + +use crate::conventions::*; +use ciborium::value::Value; + +/// Which wire profile a connection uses for its lifetime, fixed by `hello`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Profile { + Verbose, + Compact, +} + +impl Profile { + pub fn as_str(self) -> &'static str { + match self { + Profile::Verbose => "verbose", + Profile::Compact => "compact", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "verbose" => Some(Profile::Verbose), + "compact" => Some(Profile::Compact), + _ => None, + } + } +} + +/// One typed event flowing in either direction. Identified by service+operation +/// (verbose) or by their ordinals (compact); carries an optional correlation `id` +/// when it is a request expecting a reply, or that reply. +#[derive(Debug, Clone, PartialEq)] +pub struct Event { + /// CSIL service name (verbose). `None` on a single-service verbose connection. + pub service: Option, + /// Service ordinal (compact). Always present in compact frames. + pub service_ord: Option, + /// CSIL operation name (verbose). + pub event: Option, + /// Operation ordinal (compact). + pub op_ord: Option, + pub id: Option, + /// Opaque CBOR(event type) bytes. + pub payload: Vec, +} + +impl Event { + /// A verbose event by name. + pub fn verbose(service: Option, event: impl Into, payload: Vec) -> Self { + Self { + service, + service_ord: None, + event: Some(event.into()), + op_ord: None, + id: None, + payload, + } + } + + /// A compact event by ordinals. + pub fn compact(service_ord: u64, op_ord: u64, payload: Vec) -> Self { + Self { + service: None, + service_ord: Some(service_ord), + event: None, + op_ord: Some(op_ord), + id: None, + payload, + } + } + + pub fn with_id(mut self, id: u64) -> Self { + self.id = Some(id); + self + } + + /// Encode under the given profile. + pub fn encode(&self, profile: Profile) -> Result> { + match profile { + Profile::Verbose => self.encode_verbose(), + Profile::Compact => self.encode_compact(), + } + } + + fn encode_verbose(&self) -> Result> { + let event = self.event.clone().ok_or_else(|| { + TransportError::Malformed("verbose event missing 'event' name".into()) + })?; + let mut entries: Vec<(&'static str, Value)> = vec![ + ("event", Value::Text(event)), + ("payload", tag24(self.payload.clone())), + ]; + if let Some(service) = &self.service { + entries.push(("service", Value::Text(service.clone()))); + } + if let Some(id) = self.id { + entries.push(("id", Value::Integer(id.into()))); + } + encode_value(&canon_map(entries)?) + } + + fn encode_compact(&self) -> Result> { + let service_ord = self.service_ord.ok_or_else(|| { + TransportError::Malformed("compact event missing service ordinal".into()) + })?; + let op_ord = self + .op_ord + .ok_or_else(|| TransportError::Malformed("compact event missing op ordinal".into()))?; + let mut arr = vec![ + Value::Integer(service_ord.into()), + Value::Integer(op_ord.into()), + ]; + if let Some(id) = self.id { + arr.push(Value::Integer(id.into())); + } + arr.push(tag24(self.payload.clone())); + encode_value(&Value::Array(arr)) + } + + pub fn decode(bytes: &[u8], profile: Profile) -> Result { + match profile { + Profile::Verbose => Self::decode_verbose(bytes), + Profile::Compact => Self::decode_compact(bytes), + } + } + + fn decode_verbose(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + let payload = untag24( + map_get(&v, "payload") + .ok_or_else(|| TransportError::Malformed("missing 'payload'".into()))?, + )?; + Ok(Self { + service: get_text_opt(&v, "service"), + service_ord: None, + event: Some(get_text(&v, "event")?), + op_ord: None, + id: get_uint_opt(&v, "id"), + payload, + }) + } + + fn decode_compact(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + let arr = match &v { + Value::Array(a) => a, + _ => { + return Err(TransportError::Malformed( + "compact event is not an array".into(), + )); + } + }; + // 3 elements => [service_ord, op_ord, payload]; 4 => with correlation id. + let (service_ord, op_ord, id, payload_val) = match arr.len() { + 3 => (&arr[0], &arr[1], None, &arr[2]), + 4 => (&arr[0], &arr[1], Some(&arr[2]), &arr[3]), + n => { + return Err(TransportError::Malformed(format!( + "compact event array has {n} elements, expected 3 or 4" + ))); + } + }; + let as_u64 = |val: &Value| -> Result { + match val { + Value::Integer(i) => { + let n: i128 = (*i).into(); + u64::try_from(n) + .map_err(|_| TransportError::Malformed("ordinal out of range".into())) + } + _ => Err(TransportError::Malformed( + "ordinal is not an integer".into(), + )), + } + }; + Ok(Self { + service: None, + service_ord: Some(as_u64(service_ord)?), + event: None, + op_ord: Some(as_u64(op_ord)?), + id: match id { + Some(v) => Some(as_u64(v)?), + None => None, + }, + payload: untag24(payload_val)?, + }) + } +} + +/// Control-plane operation ordinals (under service ordinal 0). +pub mod control { + pub const HELLO: u64 = 0; + pub const HELLO_ACK: u64 = 1; + pub const PING: u64 = 2; + pub const PONG: u64 = 3; + pub const CLOSE: u64 = 4; + pub const ERROR: u64 = 5; + + /// Verbose control-event names (the `$`-sigil names). + pub const HELLO_NAME: &str = "$hello"; + pub const HELLO_ACK_NAME: &str = "$hello-ack"; + pub const PING_NAME: &str = "$ping"; + pub const PONG_NAME: &str = "$pong"; + pub const CLOSE_NAME: &str = "$close"; + pub const ERROR_NAME: &str = "$error"; +} + +/// The `$hello` payload offered by the connection initiator. +#[derive(Debug, Clone, PartialEq)] +pub struct Hello { + pub versions: Vec, + pub profiles: Vec, + pub service: Option, + pub auth: Option, +} + +impl Hello { + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = vec![ + ( + "versions", + Value::Array( + self.versions + .iter() + .map(|v| Value::Integer((*v).into())) + .collect(), + ), + ), + ( + "profiles", + Value::Array( + self.profiles + .iter() + .map(|p| Value::Text(p.clone())) + .collect(), + ), + ), + ]; + if let Some(service) = &self.service { + entries.push(("service", Value::Text(service.clone()))); + } + if let Some(auth) = &self.auth { + entries.push(("auth", Value::Text(auth.clone()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + let versions = match map_get(&v, "versions") { + Some(Value::Array(a)) => a + .iter() + .filter_map(|x| match x { + Value::Integer(i) => u64::try_from(Into::::into(*i)).ok(), + _ => None, + }) + .collect(), + _ => return Err(TransportError::Malformed("hello missing 'versions'".into())), + }; + let profiles = match map_get(&v, "profiles") { + Some(Value::Array(a)) => a + .iter() + .filter_map(|x| match x { + Value::Text(t) => Some(t.clone()), + _ => None, + }) + .collect(), + _ => return Err(TransportError::Malformed("hello missing 'profiles'".into())), + }; + Ok(Self { + versions, + profiles, + service: get_text_opt(&v, "service"), + auth: get_text_opt(&v, "auth"), + }) + } + + /// Select a profile from this hello's offers, honoring the peer's preference + /// order and what the server supports. Returns the chosen `(version, profile)`, + /// or `None` if nothing is mutually supported. + pub fn negotiate(&self, supported: &[Profile]) -> Option<(u64, Profile)> { + let version = self.versions.iter().copied().find(|v| *v == VERSION)?; + for offered in &self.profiles { + if let Some(p) = Profile::parse(offered) + && supported.contains(&p) + { + return Some((version, p)); + } + } + None + } +} + +/// The `$hello-ack` payload returned by the peer. +#[derive(Debug, Clone, PartialEq)] +pub struct HelloAck { + pub v: u64, + pub profile: String, + pub session: Option, +} + +impl HelloAck { + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = vec![ + ("v", Value::Integer(self.v.into())), + ("profile", Value::Text(self.profile.clone())), + ]; + if let Some(session) = &self.session { + entries.push(("session", Value::Text(session.clone()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + Ok(Self { + v: get_uint(&v, "v")?, + profile: get_text(&v, "profile")?, + session: get_text_opt(&v, "session"), + }) + } +} + +/// A `$ping`/`$pong` heartbeat payload. +#[derive(Debug, Clone, PartialEq)] +pub struct Heartbeat { + pub nonce: u64, + pub at: Option, +} + +impl Heartbeat { + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = + vec![("nonce", Value::Integer(self.nonce.into()))]; + if let Some(at) = self.at { + entries.push(("at", Value::Integer(at.into()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + Ok(Self { + nonce: get_uint(&v, "nonce")?, + at: get_uint_opt(&v, "at"), + }) + } +} + +/// A `$close` payload. +#[derive(Debug, Clone, PartialEq)] +pub struct Close { + pub status: Status, + pub reason: Option, +} + +impl Close { + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = + vec![("status", Value::Integer(self.status.code().into()))]; + if let Some(reason) = &self.reason { + entries.push(("reason", Value::Text(reason.clone()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + Ok(Self { + status: Status::from_code(get_int(&v, "status")?), + reason: get_text_opt(&v, "reason"), + }) + } +} diff --git a/crates/csilgen-transport/src/lib.rs b/crates/csilgen-transport/src/lib.rs new file mode 100644 index 0000000..1b4a4d0 --- /dev/null +++ b/crates/csilgen-transport/src/lib.rs @@ -0,0 +1,277 @@ +//! # csilgen-transport +//! +//! Reference implementation of the CSIL transport family — **CSIL-RPC**, +//! **CSIL-Events**, and **CSIL-Datagrams** — for Rust. It owns the envelope +//! codecs, framing, and connection lifecycle; the byte/datagram **carrier** is +//! injected (bring-your-own-carrier), so a host plugs HTTP, WebSocket, QUIC, +//! WebRTC, or a platform media stack without changing this library. +//! +//! See the spec documents at the repo root: `csil-transport-conventions.md`, +//! `csil-rpc-transport.md`, `csil-events-transport.md`, `csil-datagrams-transport.md`. +//! The byte layout is pinned by the conformance vectors in `transports/conformance/`. + +pub mod carrier; +pub mod conventions; +pub mod datagrams; +pub mod events; +pub mod rpc; + +pub use conventions::{Result, Status, TransportError, VERSION}; + +/// A UDP-backed `DatagramCarrier`. Built-in convenience for the native datagram +/// path; the browser path (WebRTC unreliable / WebTransport) implements the same +/// `DatagramCarrier` trait in the host. +pub mod udp { + use crate::carrier::DatagramCarrier; + use crate::conventions::{Result, TransportError}; + use crate::datagrams::MAX_DATAGRAM_DEFAULT; + use std::net::UdpSocket; + + pub struct UdpDatagramCarrier { + socket: UdpSocket, + recv_buf: Vec, + } + + impl UdpDatagramCarrier { + pub fn new(socket: UdpSocket) -> Self { + Self { + socket, + recv_buf: vec![0u8; MAX_DATAGRAM_DEFAULT], + } + } + } + + impl DatagramCarrier for UdpDatagramCarrier { + fn send_datagram(&mut self, bytes: &[u8]) -> Result<()> { + self.socket + .send(bytes) + .map_err(|e| TransportError::Carrier(e.to_string()))?; + Ok(()) + } + + fn recv_datagram(&mut self) -> Result>> { + match self.socket.recv(&mut self.recv_buf) { + Ok(n) => Ok(Some(self.recv_buf[..n].to_vec())), + Err(e) => Err(TransportError::Carrier(e.to_string())), + } + } + } +} + +#[cfg(test)] +mod tests { + use crate::carrier::{FrameCarrier, LoopbackFrameCarrier}; + use crate::conventions::Status; + use crate::datagrams::*; + use crate::events::*; + use crate::rpc::*; + + fn hex(s: &str) -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + /// Byte-exact match against the published CSIL-RPC v1 conformance vectors + /// (`transports/conformance/rpc.json`). Guards this vendored copy from + /// drifting off the standard until we swap to the git dependency. + #[test] + fn matches_published_rpc_vectors() { + let req = RpcRequest::new("Attestation", "deposit-claim", vec![0xa0]); + assert_eq!( + req.encode().unwrap(), + hex( + "a4617601626f706d6465706f7369742d636c61696d677061796c6f6164d81841a067736572766963656b4174746573746174696f6e" + ) + ); + let resp = RpcResponse::ok("DepositClaimResponse", vec![0xa0]).with_id(Some(7)); + assert_eq!( + resp.encode().unwrap(), + hex( + "a5617601626964076673746174757300677061796c6f6164d81841a06776617269616e74744465706f736974436c61696d526573706f6e7365" + ) + ); + } + + #[test] + fn rpc_request_round_trip() { + let req = RpcRequest::new("Attestation", "deposit-claim", vec![0xa0]).with_id(7); + let bytes = req.encode().unwrap(); + assert_eq!(RpcRequest::decode(&bytes).unwrap(), req); + } + + #[test] + fn rpc_response_success_and_error() { + let ok = RpcResponse::ok("DepositClaimResponse", vec![0xa0]).with_id(Some(7)); + let bytes = ok.encode().unwrap(); + let decoded = RpcResponse::decode(&bytes).unwrap(); + assert_eq!(decoded.status, Status::Ok); + assert_eq!(decoded.variant.as_deref(), Some("DepositClaimResponse")); + assert!(decoded.into_transport_error().is_ok()); + + let err = RpcResponse::transport_error(Status::UnknownServiceOrOp, "no such op"); + let bytes = err.encode().unwrap(); + let decoded = RpcResponse::decode(&bytes).unwrap(); + assert_eq!(decoded.status, Status::UnknownServiceOrOp); + assert!(decoded.into_transport_error().is_err()); + } + + #[test] + fn rpc_application_error_is_status_ok() { + // An application error rides as a typed variant with transport status 0. + let app_err = RpcResponse::ok("ServiceError", vec![0xa1, 0x00, 0x01]); + let decoded = RpcResponse::decode(&app_err.encode().unwrap()).unwrap(); + assert_eq!(decoded.status, Status::Ok); + assert_eq!(decoded.variant.as_deref(), Some("ServiceError")); + // into_transport_error leaves it Ok — the caller routes it by variant. + assert!(decoded.into_transport_error().is_ok()); + } + + #[test] + fn rpc_client_server_over_loopback() { + // Server replies to a request placed on the loopback's inbound queue. + let req = RpcRequest::new("Echo", "say", vec![0x63, b'h', b'i']).with_id(1); + let mut server_carrier = LoopbackFrameCarrier::new(); + server_carrier.push_inbound(req.encode().unwrap()); + let mut server = RpcServer::new(server_carrier); + let served = server + .serve_one(&mut |r: &RpcRequest| HandlerOutcome::Reply { + variant: "SayResponse".into(), + payload: r.payload.clone(), + }) + .unwrap(); + assert!(served); + } + + #[test] + fn rpc_version_mismatch_rejected() { + // Hand-craft an envelope with v=2; decode must reject, not misparse. + use ciborium::value::Value; + let bad = crate::conventions::canon_map(vec![ + ("v", Value::Integer(2.into())), + ("service", Value::Text("S".into())), + ("op", Value::Text("o".into())), + ("payload", crate::conventions::tag24(vec![0xa0])), + ]) + .unwrap(); + let bytes = crate::conventions::encode_value(&bad).unwrap(); + assert!(RpcRequest::decode(&bytes).is_err()); + } + + #[test] + fn events_verbose_round_trip() { + let ev = Event::verbose(Some("World".into()), "chat", vec![0x60]).with_id(3); + let bytes = ev.encode(Profile::Verbose).unwrap(); + assert_eq!(Event::decode(&bytes, Profile::Verbose).unwrap(), ev); + } + + #[test] + fn events_compact_round_trip_both_shapes() { + let fire = Event::compact(1, 0, vec![0x60]); + let bytes = fire.encode(Profile::Compact).unwrap(); + assert_eq!(Event::decode(&bytes, Profile::Compact).unwrap(), fire); + + let corr = Event::compact(1, 2, vec![0x60]).with_id(42); + let bytes = corr.encode(Profile::Compact).unwrap(); + let decoded = Event::decode(&bytes, Profile::Compact).unwrap(); + assert_eq!(decoded.id, Some(42)); + assert_eq!(decoded, corr); + } + + #[test] + fn events_hello_negotiation() { + let hello = Hello { + versions: vec![1], + profiles: vec!["compact".into(), "verbose".into()], + service: Some("World".into()), + auth: None, + }; + let round = Hello::decode(&hello.encode().unwrap()).unwrap(); + assert_eq!(round, hello); + let (v, p) = round + .negotiate(&[Profile::Verbose, Profile::Compact]) + .unwrap(); + assert_eq!(v, 1); + assert_eq!(p, Profile::Compact); // peer prefers compact, server supports it + } + + #[test] + fn events_hello_negotiation_fails_on_version() { + let hello = Hello { + versions: vec![99], + profiles: vec!["verbose".into()], + service: None, + auth: None, + }; + assert!(hello.negotiate(&[Profile::Verbose]).is_none()); + } + + #[test] + fn datagram_cbor_array_round_trip() { + let dg = Datagram::new(0, 5, vec![0x60]); + let bytes = dg.encode().unwrap(); + assert_eq!(Datagram::decode(&bytes).unwrap(), dg); + // Wrong arity rejected. + use ciborium::value::Value; + let three = crate::conventions::encode_value(&Value::Array(vec![ + Value::Integer(1.into()), + Value::Integer(0.into()), + crate::conventions::tag24(vec![0x60]), + ])) + .unwrap(); + assert!(Datagram::decode(&three).is_err()); + } + + #[test] + fn datagram_compact_header_round_trip() { + let dg = CompactDatagram::new(1, 0x1234, vec![1, 2, 3]); + let bytes = dg.encode(); + assert_eq!(bytes[0] >> 4, 1); // version nibble + assert_eq!(CompactDatagram::decode(&bytes).unwrap(), dg); + + let with_epoch = CompactDatagram::new(2, 7, vec![9]).with_epoch(4); + let decoded = CompactDatagram::decode(&with_epoch.encode()).unwrap(); + assert_eq!(decoded.epoch, Some(4)); + assert_eq!(decoded, with_epoch); + } + + #[test] + fn seq_tracker_detects_loss_reorder_restart() { + let mut t = SeqTracker::new(); + assert_eq!(t.observe(1, Some(0)), SeqEvent::First); + assert_eq!(t.observe(2, Some(0)), SeqEvent::Advanced { gap: 0 }); + assert_eq!(t.observe(5, Some(0)), SeqEvent::Advanced { gap: 2 }); // lost 3,4 + assert_eq!(t.observe(3, Some(0)), SeqEvent::LateOrDuplicate); + assert_eq!(t.observe(1, Some(1)), SeqEvent::Restart); // epoch bumped + } + + #[test] + fn seq_tracker_treats_unsequenced_as_advanced() { + // An all-unsequenced (seq 0) channel must never report LateOrDuplicate, + // or an app that drops late/dup datagrams would discard everything. + let mut t = SeqTracker::new(); + assert_eq!(t.observe(0, None), SeqEvent::Advanced { gap: 0 }); + assert_eq!(t.observe(0, None), SeqEvent::Advanced { gap: 0 }); + assert_eq!(t.observe(0, None), SeqEvent::Advanced { gap: 0 }); + } + + #[test] + fn decode_rejects_trailing_bytes() { + // A valid empty-map envelope (0xa0) followed by a stray byte must be rejected. + let mut bytes = crate::rpc::RpcPush::new("S", "e", vec![0xa0]) + .encode() + .unwrap(); + bytes.push(0x00); + assert!(crate::rpc::RpcPush::decode(&bytes).is_err()); + } + + #[test] + fn length_prefix_framing_round_trips_over_loopback() { + let mut c = LoopbackFrameCarrier::new(); + c.send_frame(&[1, 2, 3]).unwrap(); + let framed = c.take_outbound().unwrap(); + c.push_inbound(framed); + assert_eq!(c.recv_frame().unwrap().unwrap(), vec![1, 2, 3]); + } +} diff --git a/crates/csilgen-transport/src/rpc.rs b/crates/csilgen-transport/src/rpc.rs new file mode 100644 index 0000000..be06e3f --- /dev/null +++ b/crates/csilgen-transport/src/rpc.rs @@ -0,0 +1,312 @@ +//! CSIL-RPC transport — request/response/push envelopes — see `csil-rpc-transport.md`. + +use crate::carrier::FrameCarrier; +use crate::conventions::*; +use ciborium::value::Value; + +/// A CSIL-RPC request (client → server). +#[derive(Debug, Clone, PartialEq)] +pub struct RpcRequest { + pub service: String, + pub op: String, + pub id: Option, + /// Opaque CBOR(request type) bytes (wrapped in tag 24 on the wire). + pub payload: Vec, + pub auth: Option, +} + +/// A CSIL-RPC response (server → client). +#[derive(Debug, Clone, PartialEq)] +pub struct RpcResponse { + pub id: Option, + pub status: Status, + /// Which declared output-choice arm `payload` decodes to (the CSIL type name). + pub variant: Option, + pub error: Option, + /// Opaque CBOR(output type) bytes; empty when `status` is non-zero. + pub payload: Vec, +} + +/// A CSIL-RPC server push (server → client) for `<-` operations. +#[derive(Debug, Clone, PartialEq)] +pub struct RpcPush { + pub service: String, + pub event: String, + pub payload: Vec, +} + +impl RpcRequest { + pub fn new(service: impl Into, op: impl Into, payload: Vec) -> Self { + Self { + service: service.into(), + op: op.into(), + id: None, + payload, + auth: None, + } + } + + pub fn with_id(mut self, id: u64) -> Self { + self.id = Some(id); + self + } + + pub fn with_auth(mut self, auth: impl Into) -> Self { + self.auth = Some(auth.into()); + self + } + + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = vec![ + ("v", Value::Integer(VERSION.into())), + ("service", Value::Text(self.service.clone())), + ("op", Value::Text(self.op.clone())), + ("payload", tag24(self.payload.clone())), + ]; + if let Some(id) = self.id { + entries.push(("id", Value::Integer(id.into()))); + } + if let Some(auth) = &self.auth { + entries.push(("auth", Value::Text(auth.clone()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + check_version(get_uint(&v, "v")?)?; + let payload = untag24( + map_get(&v, "payload") + .ok_or_else(|| TransportError::Malformed("missing 'payload'".into()))?, + )?; + Ok(Self { + service: get_text(&v, "service")?, + op: get_text(&v, "op")?, + id: get_uint_opt(&v, "id"), + payload, + auth: get_text_opt(&v, "auth"), + }) + } +} + +impl RpcResponse { + /// A successful (`status: Ok`) typed reply. + pub fn ok(variant: impl Into, payload: Vec) -> Self { + Self { + id: None, + status: Status::Ok, + variant: Some(variant.into()), + error: None, + payload, + } + } + + /// A transport-level failure (no typed payload). + pub fn transport_error(status: Status, message: impl Into) -> Self { + Self { + id: None, + status, + variant: None, + error: Some(message.into()), + payload: Vec::new(), + } + } + + pub fn with_id(mut self, id: Option) -> Self { + self.id = id; + self + } + + pub fn encode(&self) -> Result> { + let mut entries: Vec<(&'static str, Value)> = vec![ + ("v", Value::Integer(VERSION.into())), + ("status", Value::Integer(self.status.code().into())), + ("payload", tag24(self.payload.clone())), + ]; + if let Some(id) = self.id { + entries.push(("id", Value::Integer(id.into()))); + } + if let Some(variant) = &self.variant { + entries.push(("variant", Value::Text(variant.clone()))); + } + if let Some(error) = &self.error { + entries.push(("error", Value::Text(error.clone()))); + } + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + check_version(get_uint(&v, "v")?)?; + // payload is present but may be an empty byte string on error. + let payload = match map_get(&v, "payload") { + Some(p) => untag24(p)?, + None => Vec::new(), + }; + Ok(Self { + id: get_uint_opt(&v, "id"), + status: Status::from_code(get_int(&v, "status")?), + variant: get_text_opt(&v, "variant"), + error: get_text_opt(&v, "error"), + payload, + }) + } + + /// Convert a non-ok response into a `TransportError::Status`. Callers use this + /// after `decode` to surface transport failures distinctly from app errors. + pub fn into_transport_error(self) -> Result { + if self.status.is_ok() { + Ok(self) + } else { + Err(TransportError::Status { + status: status_name(self.status), + code: self.status.code(), + message: self.error, + }) + } + } +} + +impl RpcPush { + pub fn new(service: impl Into, event: impl Into, payload: Vec) -> Self { + Self { + service: service.into(), + event: event.into(), + payload, + } + } + + pub fn encode(&self) -> Result> { + let entries: Vec<(&'static str, Value)> = vec![ + ("v", Value::Integer(VERSION.into())), + ("service", Value::Text(self.service.clone())), + ("event", Value::Text(self.event.clone())), + ("payload", tag24(self.payload.clone())), + ]; + encode_value(&canon_map(entries)?) + } + + pub fn decode(bytes: &[u8]) -> Result { + let v = decode_value(bytes)?; + check_version(get_uint(&v, "v")?)?; + let payload = untag24( + map_get(&v, "payload") + .ok_or_else(|| TransportError::Malformed("missing 'payload'".into()))?, + )?; + Ok(Self { + service: get_text(&v, "service")?, + event: get_text(&v, "event")?, + payload, + }) + } +} + +fn status_name(s: Status) -> &'static str { + match s { + Status::Ok => "ok", + Status::MalformedEnvelope => "malformed-envelope", + Status::UnknownServiceOrOp => "unknown-service-or-op", + Status::Unauthenticated => "unauthenticated", + Status::Forbidden => "forbidden", + Status::VersionUnsupported => "version-unsupported", + Status::Internal => "internal", + Status::Unavailable => "unavailable", + Status::DeadlineExceeded => "deadline-exceeded", + Status::Other(_) => "other", + } +} + +/// A CSIL-RPC client over a frame carrier. The carrier is injected (bring your own); +/// the client owns the envelope and a per-connection monotonic correlation id. +pub struct RpcClient { + carrier: C, + next_id: u64, + multiplexed: bool, +} + +impl RpcClient { + /// Create a client. `multiplexed` true assigns a correlation `id` to every + /// request (required on WS / pipelined streams); false omits it (one-in-flight). + pub fn new(carrier: C, multiplexed: bool) -> Self { + Self { + carrier, + next_id: 1, + multiplexed, + } + } + + /// Invoke `service/op` with an encoded request payload, returning the decoded + /// response. A non-zero transport status is surfaced as `TransportError::Status`. + pub fn call( + &mut self, + service: &str, + op: &str, + payload: Vec, + auth: Option, + ) -> Result { + let mut req = RpcRequest::new(service, op, payload); + req.auth = auth; + if self.multiplexed { + req.id = Some(self.next_id); + self.next_id += 1; + } + self.carrier.send_frame(&req.encode()?)?; + let frame = self + .carrier + .recv_frame()? + .ok_or_else(|| TransportError::Carrier("connection closed before response".into()))?; + RpcResponse::decode(&frame)?.into_transport_error() + } + + pub fn into_carrier(self) -> C { + self.carrier + } +} + +/// The outcome a server handler returns for one request: the variant name and the +/// encoded payload on success, or a transport status on failure. +pub enum HandlerOutcome { + Reply { variant: String, payload: Vec }, + Transport(Status, String), +} + +/// A CSIL-RPC server over a frame carrier. The host supplies a handler mapping +/// `(service, op, request-payload)` to an outcome; the generated router is the +/// natural implementation of that handler. +pub struct RpcServer { + carrier: C, +} + +impl RpcServer { + pub fn new(carrier: C) -> Self { + Self { carrier } + } + + /// Read one request, dispatch it through `handler`, and write the response. + /// Returns `Ok(false)` at a clean end of stream. + pub fn serve_one(&mut self, handler: &mut H) -> Result + where + H: FnMut(&RpcRequest) -> HandlerOutcome, + { + let frame = match self.carrier.recv_frame()? { + Some(f) => f, + None => return Ok(false), + }; + let resp = match RpcRequest::decode(&frame) { + Ok(req) => { + let id = req.id; + match handler(&req) { + HandlerOutcome::Reply { variant, payload } => { + RpcResponse::ok(variant, payload).with_id(id) + } + HandlerOutcome::Transport(status, msg) => { + RpcResponse::transport_error(status, msg).with_id(id) + } + } + } + Err(e) => RpcResponse::transport_error(Status::MalformedEnvelope, e.to_string()), + }; + self.carrier.send_frame(&resp.encode()?)?; + Ok(true) + } +} diff --git a/crates/liblinkkeys/src/claim_policy.rs b/crates/liblinkkeys/src/claim_policy.rs new file mode 100644 index 0000000..91f3b7a --- /dev/null +++ b/crates/liblinkkeys/src/claim_policy.rs @@ -0,0 +1,546 @@ +//! Pure claim-signing policy logic: value validation, the setting/signing lanes, +//! and the rejection taxonomy. This is the heart of "validate anything I can +//! sign" — the IDP only self-signs values it can validate, and the rules for who +//! may set a claim type and how it gets signed are evaluated here. +//! +//! No I/O. The server crate owns the registry storage and maps its DB rows onto +//! the [`ClaimPolicy`] struct, then calls [`evaluate_set`] to decide what to do +//! with a set attempt. Keeping this pure means the same decision is reproducible +//! from any transport (web, a future CLI, a native agent) and is unit-testable +//! without a database. + +use std::fmt; + +/// The type of a claim's value, which determines whether and how the IDP can +/// validate it. Primitives are validatable (so the IDP may self-sign); `Opaque` +/// is not (so the IDP can only custody an issuer-attested value). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValueType { + Text, + Url, + Email, + Bool, + Int, + Float, + Decimal, + /// A calendar date, `YYYY-MM-DD`. Always interpreted as UTC. + Date, + /// An RFC3339 timestamp. Always interpreted/stored as UTC. + Timestamp, + /// The IDP cannot validate the value; meaning is by convention only. + Opaque, +} + +impl ValueType { + /// Parse the wire/registry spelling (lowercase) of a value type. + pub fn parse(s: &str) -> Option { + Some(match s { + "text" => ValueType::Text, + "url" => ValueType::Url, + "email" => ValueType::Email, + "bool" => ValueType::Bool, + "int" => ValueType::Int, + "float" => ValueType::Float, + "decimal" => ValueType::Decimal, + "date" => ValueType::Date, + "timestamp" => ValueType::Timestamp, + "opaque" => ValueType::Opaque, + _ => return None, + }) + } + + /// The canonical lowercase spelling. + pub fn as_str(&self) -> &'static str { + match self { + ValueType::Text => "text", + ValueType::Url => "url", + ValueType::Email => "email", + ValueType::Bool => "bool", + ValueType::Int => "int", + ValueType::Float => "float", + ValueType::Decimal => "decimal", + ValueType::Date => "date", + ValueType::Timestamp => "timestamp", + ValueType::Opaque => "opaque", + } + } + + /// Validate a raw claim value against this type. `Opaque` accepts anything; + /// every other type requires valid UTF-8 plus a format check. The IDP only + /// ever self-signs a value that validates here. + pub fn validate(&self, value: &[u8]) -> Result<(), ValidationError> { + if *self == ValueType::Opaque { + return Ok(()); + } + let s = std::str::from_utf8(value).map_err(|_| ValidationError::NotUtf8)?; + let ok = match self { + ValueType::Text => !s.is_empty(), + ValueType::Url => is_http_url(s), + ValueType::Email => is_email(s), + ValueType::Bool => s == "true" || s == "false", + ValueType::Int => s.parse::().is_ok(), + // Reject non-finite (inf/nan) so a domain never self-signs a junk float. + ValueType::Float => s.parse::().map(|v| v.is_finite()).unwrap_or(false), + ValueType::Decimal => is_decimal(s), + ValueType::Date => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok(), + ValueType::Timestamp => chrono::DateTime::parse_from_rfc3339(s).is_ok(), + ValueType::Opaque => true, + }; + if ok { + Ok(()) + } else { + Err(ValidationError::BadFormat) + } + } +} + +fn is_http_url(s: &str) -> bool { + // Deliberately conservative — a real fetch/parse is the consumer's job. We + // only confirm it looks like an absolute http(s) URL with a host, so the + // signed value isn't obviously junk. Reject any whitespace/control chars + // (incl. embedded CR/LF) so a domain-signed URL can't carry an injection + // payload into a consumer's headers/requests. + if s.contains(|c: char| c.is_whitespace() || c.is_control()) { + return false; + } + let rest = s + .strip_prefix("https://") + .or_else(|| s.strip_prefix("http://")); + match rest { + Some(host_and_path) => { + let host = host_and_path.split(['/', '?', '#']).next().unwrap_or(""); + !host.is_empty() && host.contains('.') + } + None => false, + } +} + +fn is_email(s: &str) -> bool { + // One '@', non-empty local part, a dotted domain, no spaces. Not RFC 5322 — + // just enough that a self-signed value isn't nonsense. Ownership is proven by + // the verification flow, not by this check. + let mut parts = s.split('@'); + let (local, domain) = match (parts.next(), parts.next(), parts.next()) { + (Some(l), Some(d), None) => (l, d), + _ => return false, + }; + !local.is_empty() + && !domain.is_empty() + && domain.contains('.') + && !s.contains(|c: char| c.is_whitespace() || c.is_control()) + && !domain.starts_with('.') + && !domain.ends_with('.') +} + +fn is_decimal(s: &str) -> bool { + // Optional leading sign, digits, optional single fractional part. No + // exponent (a decimal is exact). Must contain at least one digit. + let body = s.strip_prefix(['+', '-']).unwrap_or(s); + if body.is_empty() { + return false; + } + let mut seen_digit = false; + let mut seen_dot = false; + for c in body.chars() { + match c { + '0'..='9' => seen_digit = true, + '.' if !seen_dot => seen_dot = true, + _ => return false, + } + } + seen_digit +} + +/// Why a value failed validation. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ValidationError { + NotUtf8, + BadFormat, +} + +/// How a claim type's value gets signed — the four lanes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SigningRule { + /// Lane A: a validatable primitive the IDP signs itself, on set. + SelfSigned, + /// Lane B: the IDP signs only after a built-in verification flow proves the + /// real-world fact (e.g. an email round-trip). + Verified, + /// Lane C: the IDP does not vouch for the value; it admits an external + /// signature from a trusted issuer. + Attested, + /// Lane D: never carries a domain signature. + Unsigned, +} + +impl SigningRule { + pub fn parse(s: &str) -> Option { + Some(match s { + "self_signed" => SigningRule::SelfSigned, + "verified" => SigningRule::Verified, + "attested" => SigningRule::Attested, + "unsigned" => SigningRule::Unsigned, + _ => return None, + }) + } + + pub fn as_str(&self) -> &'static str { + match self { + SigningRule::SelfSigned => "self_signed", + SigningRule::Verified => "verified", + SigningRule::Attested => "attested", + SigningRule::Unsigned => "unsigned", + } + } +} + +/// Who may set a value for a claim type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetRule { + /// The subject may set it themselves (self-asserted lane). + UserSelf, + /// The user may request the IDP set it, subject to a validator or approval. + IdpOnRequest, + /// Only an attestation from a trusted issuer may set it. + TrustedIssuerOnly, + /// Only a domain admin may set it. + AdminOnly, + /// No one may set it (effectively retired). + Deny, +} + +impl SetRule { + pub fn parse(s: &str) -> Option { + Some(match s { + "user_self" => SetRule::UserSelf, + "idp_on_request" => SetRule::IdpOnRequest, + "trusted_issuer_only" => SetRule::TrustedIssuerOnly, + "admin_only" => SetRule::AdminOnly, + "deny" => SetRule::Deny, + _ => return None, + }) + } + + pub fn as_str(&self) -> &'static str { + match self { + SetRule::UserSelf => "user_self", + SetRule::IdpOnRequest => "idp_on_request", + SetRule::TrustedIssuerOnly => "trusted_issuer_only", + SetRule::AdminOnly => "admin_only", + SetRule::Deny => "deny", + } + } +} + +/// The principal attempting to set a value. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Setter { + /// The subject acting on their own claim (self-service). + User, + /// A domain admin (break-glass; bypasses the approval queue). + Admin, + /// An external issuer presenting a signed attestation. + TrustedIssuer, +} + +/// A claim type's policy as the evaluator needs it. The server maps its registry +/// row onto this; fields it doesn't carry here (label, description, suggested) +/// don't affect the set/sign decision. +#[derive(Debug, Clone)] +pub struct ClaimPolicy { + pub claim_type: String, + pub value_type: ValueType, + pub max_bytes: u64, + pub set_rule: SetRule, + pub signing_rule: SigningRule, + pub requires_approval: bool, + pub user_settable: bool, +} + +/// What the server should do with an accepted set attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetAction { + /// Validate (already done) and sign now with the domain's active keys. + SelfSign, + /// Start a verification flow; sign on successful completion. + Verify, + /// Accept and store the issuer's external signature; the IDP adds none. + AcceptAttested, + /// Hold for admin approval before signing. + Queue, + /// Store without any domain signature. + StoreUnsigned, +} + +/// The machine-readable rejection taxonomy. Surfaced to callers (and, in +/// interpretable form, to users) so a set attempt fails with a reason, not a +/// generic error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RejectionReason { + /// The claim type is not in the registry. + UnknownClaimType, + /// The value isn't valid for the type's value rule. + ValueTypeMismatch, + /// The value exceeds the type's `max_bytes`. + ValueTooLarge { limit: u64 }, + /// This setter is not permitted to set this type. + SetterNotAuthorized, +} + +impl fmt::Display for RejectionReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + RejectionReason::UnknownClaimType => write!(f, "unknown claim type"), + RejectionReason::ValueTypeMismatch => { + write!(f, "value does not match the expected type") + } + RejectionReason::ValueTooLarge { limit } => { + write!(f, "value exceeds the {} byte limit", limit) + } + RejectionReason::SetterNotAuthorized => { + write!(f, "not authorized to set this claim type") + } + } + } +} + +/// Decide what to do with an attempt by `setter` to set `value` for a claim type +/// governed by `policy`. Returns the action the server should take, or a +/// machine-readable rejection. Pure: the trusted-issuer signature check and the +/// actual signing/queueing are the server's job; this only decides the lane. +pub fn evaluate_set( + policy: &ClaimPolicy, + setter: Setter, + value: &[u8], +) -> Result { + // 1. Authorize the setter against the set rule. Admins may set anything that + // isn't explicitly denied (break-glass), mirroring today's admin set-claim. + let authorized = match policy.set_rule { + SetRule::Deny => false, + SetRule::AdminOnly => setter == Setter::Admin, + SetRule::TrustedIssuerOnly => setter == Setter::TrustedIssuer || setter == Setter::Admin, + SetRule::UserSelf | SetRule::IdpOnRequest => { + matches!(setter, Setter::User | Setter::Admin) + } + }; + if !authorized { + return Err(RejectionReason::SetterNotAuthorized); + } + + // 2. Size bound, before any parsing work. + if value.len() as u64 > policy.max_bytes { + return Err(RejectionReason::ValueTooLarge { + limit: policy.max_bytes, + }); + } + + // 3. Validate the value where the type is validatable. Opaque is a no-op. + policy + .value_type + .validate(value) + .map_err(|_| RejectionReason::ValueTypeMismatch)?; + + // 4. A user-initiated set of an approval-gated type goes to the queue, + // whatever its signing lane. Admins bypass the queue. + if policy.requires_approval && setter == Setter::User { + return Ok(SetAction::Queue); + } + + // 5. Map the signing lane to an action. + let action = match policy.signing_rule { + SigningRule::SelfSigned => SetAction::SelfSign, + SigningRule::Verified => { + if setter == Setter::Admin { + // An admin may attest a verified-lane value directly. + SetAction::SelfSign + } else { + SetAction::Verify + } + } + SigningRule::Attested => { + if setter == Setter::Admin { + SetAction::SelfSign + } else { + SetAction::AcceptAttested + } + } + SigningRule::Unsigned => SetAction::StoreUnsigned, + }; + Ok(action) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn policy(value_type: ValueType, set_rule: SetRule, signing_rule: SigningRule) -> ClaimPolicy { + ClaimPolicy { + claim_type: "t".to_string(), + value_type, + max_bytes: 33792, + set_rule, + signing_rule, + requires_approval: false, + user_settable: true, + } + } + + #[test] + fn value_type_roundtrips() { + for vt in [ + ValueType::Text, + ValueType::Url, + ValueType::Email, + ValueType::Bool, + ValueType::Int, + ValueType::Float, + ValueType::Decimal, + ValueType::Date, + ValueType::Timestamp, + ValueType::Opaque, + ] { + assert_eq!(ValueType::parse(vt.as_str()), Some(vt)); + } + assert_eq!(ValueType::parse("nonsense"), None); + } + + #[test] + fn validates_primitives() { + assert!(ValueType::Text.validate(b"hi").is_ok()); + assert!(ValueType::Text.validate(b"").is_err()); + assert!(ValueType::Bool.validate(b"true").is_ok()); + assert!(ValueType::Bool.validate(b"True").is_err()); + assert!(ValueType::Int.validate(b"42").is_ok()); + assert!(ValueType::Int.validate(b"4.2").is_err()); + assert!(ValueType::Decimal.validate(b"-3.14").is_ok()); + assert!(ValueType::Decimal.validate(b"3.1.4").is_err()); + assert!(ValueType::Date.validate(b"2026-06-17").is_ok()); + assert!(ValueType::Date.validate(b"2026-13-40").is_err()); + assert!(ValueType::Timestamp + .validate(b"2026-06-17T00:00:00Z") + .is_ok()); + assert!(ValueType::Timestamp.validate(b"yesterday").is_err()); + } + + #[test] + fn validates_urls_and_emails() { + assert!(ValueType::Url.validate(b"https://example.com").is_ok()); + assert!(ValueType::Url + .validate(b"https://example.com/path?q=1") + .is_ok()); + assert!(ValueType::Url.validate(b"ftp://example.com").is_err()); + assert!(ValueType::Url.validate(b"https://nodot").is_err()); + // No embedded whitespace / control chars (CRLF-injection guard). + assert!(ValueType::Url + .validate(b"https://example.com\n/evil") + .is_err()); + assert!(ValueType::Url.validate(b"https://exa mple.com").is_err()); + assert!(ValueType::Email.validate(b"a@b.com").is_ok()); + assert!(ValueType::Email.validate(b"a@b@c.com").is_err()); + assert!(ValueType::Email.validate(b"a@nodot").is_err()); + assert!(ValueType::Email.validate(b"@b.com").is_err()); + // Control characters (e.g. NUL) are rejected, like the URL guard. + assert!(ValueType::Email.validate(b"a@b\0.com").is_err()); + // Non-finite floats must not be signable. + assert!(ValueType::Float.validate(b"3.14").is_ok()); + assert!(ValueType::Float.validate(b"nan").is_err()); + assert!(ValueType::Float.validate(b"inf").is_err()); + } + + #[test] + fn opaque_accepts_anything() { + assert!(ValueType::Opaque.validate(&[0xff, 0x00, 0x99]).is_ok()); + } + + #[test] + fn lane_a_self_signs_for_user() { + let p = policy(ValueType::Text, SetRule::UserSelf, SigningRule::SelfSigned); + assert_eq!( + evaluate_set(&p, Setter::User, b"Ada"), + Ok(SetAction::SelfSign) + ); + } + + #[test] + fn lane_b_user_verifies_admin_signs() { + let p = policy(ValueType::Email, SetRule::UserSelf, SigningRule::Verified); + assert_eq!( + evaluate_set(&p, Setter::User, b"a@b.com"), + Ok(SetAction::Verify) + ); + assert_eq!( + evaluate_set(&p, Setter::Admin, b"a@b.com"), + Ok(SetAction::SelfSign) + ); + } + + #[test] + fn lane_c_user_rejected_issuer_accepted() { + let p = policy( + ValueType::Bool, + SetRule::TrustedIssuerOnly, + SigningRule::Attested, + ); + assert_eq!( + evaluate_set(&p, Setter::User, b"true"), + Err(RejectionReason::SetterNotAuthorized) + ); + assert_eq!( + evaluate_set(&p, Setter::TrustedIssuer, b"true"), + Ok(SetAction::AcceptAttested) + ); + } + + #[test] + fn deny_blocks_everyone() { + let p = policy(ValueType::Text, SetRule::Deny, SigningRule::SelfSigned); + assert_eq!( + evaluate_set(&p, Setter::Admin, b"x"), + Err(RejectionReason::SetterNotAuthorized) + ); + } + + #[test] + fn admin_only_rejects_user() { + let p = policy(ValueType::Text, SetRule::AdminOnly, SigningRule::SelfSigned); + assert_eq!( + evaluate_set(&p, Setter::User, b"x"), + Err(RejectionReason::SetterNotAuthorized) + ); + assert_eq!( + evaluate_set(&p, Setter::Admin, b"x"), + Ok(SetAction::SelfSign) + ); + } + + #[test] + fn approval_queue_for_user_only() { + let mut p = policy( + ValueType::Text, + SetRule::IdpOnRequest, + SigningRule::SelfSigned, + ); + p.requires_approval = true; + assert_eq!(evaluate_set(&p, Setter::User, b"x"), Ok(SetAction::Queue)); + // Admin bypasses the queue. + assert_eq!( + evaluate_set(&p, Setter::Admin, b"x"), + Ok(SetAction::SelfSign) + ); + } + + #[test] + fn rejects_oversize_and_mismatch() { + let mut p = policy(ValueType::Text, SetRule::UserSelf, SigningRule::SelfSigned); + p.max_bytes = 4; + assert_eq!( + evaluate_set(&p, Setter::User, b"toolong"), + Err(RejectionReason::ValueTooLarge { limit: 4 }) + ); + let p = policy(ValueType::Int, SetRule::UserSelf, SigningRule::SelfSigned); + assert_eq!( + evaluate_set(&p, Setter::User, b"notanint"), + Err(RejectionReason::ValueTypeMismatch) + ); + } +} diff --git a/crates/liblinkkeys/src/generated/services.rs b/crates/liblinkkeys/src/generated/services.rs index 31f1240..c72466a 100644 --- a/crates/liblinkkeys/src/generated/services.rs +++ b/crates/liblinkkeys/src/generated/services.rs @@ -216,3 +216,14 @@ pub trait Account { input: EmptyRequest, ) -> Result; } + +/// Attestation service trait +pub trait Attestation { + type Context; + /// deposit-claim (request/response). + fn deposit_claim( + &self, + ctx: &Self::Context, + input: DepositClaimRequest, + ) -> Result; +} diff --git a/crates/liblinkkeys/src/generated/types.rs b/crates/liblinkkeys/src/generated/types.rs index 25d9236..dc4dca0 100644 --- a/crates/liblinkkeys/src/generated/types.rs +++ b/crates/liblinkkeys/src/generated/types.rs @@ -212,6 +212,37 @@ pub struct DomainClaim { pub expires_at: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SigningRequest { + pub request_id: String, + pub subject_user_id: String, + pub subject_domain: String, + pub issuer_domain: String, + pub requested_claim_types: Vec, + pub nonce: String, + pub issued_at: String, + pub expires_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub callback: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct SignedSigningRequest { + #[serde(with = "serde_bytes")] + pub request: Vec, + pub signatures: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DepositClaimRequest { + pub claim: Claim, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct DepositClaimResponse { + pub stored: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct IdentityAssertion { pub user_id: String, diff --git a/crates/liblinkkeys/src/lib.rs b/crates/liblinkkeys/src/lib.rs index 2360e43..cd73d6c 100644 --- a/crates/liblinkkeys/src/lib.rs +++ b/crates/liblinkkeys/src/lib.rs @@ -1,5 +1,6 @@ pub mod assertions; pub mod auth_request; +pub mod claim_policy; pub mod claims; pub mod consent; pub mod crypto; @@ -7,4 +8,5 @@ pub mod dns; pub mod domain_claims; pub mod encoding; pub mod generated; +pub mod signing_request; pub mod userinfo; diff --git a/crates/liblinkkeys/src/signing_request.rs b/crates/liblinkkeys/src/signing_request.rs new file mode 100644 index 0000000..a39a0ea --- /dev/null +++ b/crates/liblinkkeys/src/signing_request.rs @@ -0,0 +1,314 @@ +//! User-initiated signing requests: a home-domain-attested envelope asking a +//! third-party issuer to sign claim(s) about the user (see the CSIL +//! `SigningRequest`). Pure: signing/verification only, no I/O. The producing +//! side (the user's IDP) signs with its domain keys; the consuming side (the +//! issuer's server) verifies against the user's DNS-pinned keys before issuing. +//! +//! Mirrors `consent`: domain-separated CBOR payload, signature LIST, reusing the +//! shared `claims::verify_signature_quorum` so it can't drift from claim/consent +//! verification. + +use chrono::Utc; + +use crate::claims::{verify_signature_quorum, ClaimError, ClaimSigner, DomainKeySet}; +use crate::crypto::CryptoError; +use crate::generated::types::{ClaimSignature, SignedSigningRequest, SigningRequest}; + +/// Domain-separation tag + version for the signing-request signature payload. +const SIGNING_REQUEST_TAG: &str = "linkkeys-signing-request-v1"; + +/// What can go wrong verifying a [`SignedSigningRequest`]. +#[derive(Debug)] +pub enum SigningRequestError { + /// The request bytes are not a valid CBOR-encoded [`SigningRequest`]. + Malformed, + /// No signatures present. + Unsigned, + /// The request's subject_domain / issuer_domain don't match the verifier's + /// authoritative context. + ContextMismatch, + /// The per-domain signature quorum failed. + Signature(ClaimError), + /// `expires_at` is not a valid RFC3339 timestamp. + BadExpiry, + /// The request has expired. + Expired, +} + +impl std::fmt::Display for SigningRequestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SigningRequestError::Malformed => write!(f, "malformed signing request"), + SigningRequestError::Unsigned => write!(f, "signing request is unsigned"), + SigningRequestError::ContextMismatch => { + write!(f, "signing request subject/issuer does not match context") + } + SigningRequestError::Signature(e) => write!(f, "signature verification failed: {}", e), + SigningRequestError::BadExpiry => write!(f, "signing request has a bad expiry"), + SigningRequestError::Expired => write!(f, "signing request has expired"), + } + } +} + +impl std::error::Error for SigningRequestError {} + +/// Sorted/deduped claim types for a stable signed payload. +fn canonical_types(types: &[String]) -> Vec { + let mut v: Vec = types.to_vec(); + v.sort(); + v.dedup(); + v +} + +#[allow(clippy::too_many_arguments)] +fn signing_request_payload( + request_id: &str, + subject_user_id: &str, + subject_domain: &str, + issuer_domain: &str, + requested_claim_types: &[String], + nonce: &str, + issued_at: &str, + expires_at: &str, + signing_domain: &str, +) -> Vec { + let subject = format!("{}@{}", subject_user_id, subject_domain); + let payload = ( + SIGNING_REQUEST_TAG, + request_id, + subject.as_str(), + issuer_domain, + requested_claim_types, + nonce, + issued_at, + expires_at, + signing_domain, + ); + let mut out = Vec::new(); + ciborium::ser::into_writer(&payload, &mut out) + .expect("CBOR serialization of signing-request payload cannot fail"); + out +} + +/// The terms of a signing request, independent of who attests it. +/// `requested_claim_types` is canonicalized (sorted/deduped) before signing. +pub struct SigningRequestSpec<'a> { + pub request_id: &'a str, + pub subject_user_id: &'a str, + pub subject_domain: &'a str, + pub issuer_domain: &'a str, + pub requested_claim_types: &'a [String], + pub nonce: &'a str, + pub issued_at: &'a str, + pub expires_at: &'a str, + pub callback: Option<&'a str>, +} + +/// Sign a signing request with one or more domain keys (the user's home domain +/// attests it). An empty signer set yields an unsigned request, which +/// [`verify_signing_request`] rejects. +pub fn sign_signing_request( + spec: &SigningRequestSpec<'_>, + signers: &[ClaimSigner<'_>], +) -> Result { + let requested_claim_types = canonical_types(spec.requested_claim_types); + + let request = SigningRequest { + request_id: spec.request_id.to_string(), + subject_user_id: spec.subject_user_id.to_string(), + subject_domain: spec.subject_domain.to_string(), + issuer_domain: spec.issuer_domain.to_string(), + requested_claim_types: requested_claim_types.clone(), + nonce: spec.nonce.to_string(), + issued_at: spec.issued_at.to_string(), + expires_at: spec.expires_at.to_string(), + callback: spec.callback.map(str::to_string), + }; + + let mut request_bytes = Vec::new(); + ciborium::ser::into_writer(&request, &mut request_bytes) + .expect("CBOR serialization of signing request cannot fail"); + + let mut signatures = Vec::with_capacity(signers.len()); + for signer in signers { + let payload = signing_request_payload( + spec.request_id, + spec.subject_user_id, + spec.subject_domain, + spec.issuer_domain, + &requested_claim_types, + spec.nonce, + spec.issued_at, + spec.expires_at, + signer.domain, + ); + let signature = crate::crypto::sign_with_algorithm( + signer.algorithm, + &payload, + signer.private_key_bytes, + )?; + signatures.push(ClaimSignature { + domain: signer.domain.to_string(), + signed_by_key_id: signer.key_id.to_string(), + signature, + }); + } + + Ok(SignedSigningRequest { + request: request_bytes, + signatures, + }) +} + +/// Verify a signed signing request and return the decoded [`SigningRequest`]. +/// +/// `subject_domain` is where the verifier fetched the user's keys from (the +/// request's home domain) and `issuer_domain` is the verifier's own domain — both +/// authoritative context, never attacker input. The request's fields must match +/// them, the per-domain signature quorum must pass, and it must not be expired. +/// `domain_keys` supplies candidate keys per signing domain; performs no I/O. +pub fn verify_signing_request( + signed: &SignedSigningRequest, + subject_domain: &str, + issuer_domain: &str, + domain_keys: &[DomainKeySet], +) -> Result { + let request: SigningRequest = ciborium::de::from_reader(&signed.request[..]) + .map_err(|_| SigningRequestError::Malformed)?; + + if signed.signatures.is_empty() { + return Err(SigningRequestError::Unsigned); + } + + if request.subject_domain != subject_domain || request.issuer_domain != issuer_domain { + return Err(SigningRequestError::ContextMismatch); + } + + let requested_claim_types = canonical_types(&request.requested_claim_types); + verify_signature_quorum(&signed.signatures, domain_keys, |signing_domain| { + signing_request_payload( + &request.request_id, + &request.subject_user_id, + subject_domain, + issuer_domain, + &requested_claim_types, + &request.nonce, + &request.issued_at, + &request.expires_at, + signing_domain, + ) + }) + .map_err(SigningRequestError::Signature)?; + + let expires = chrono::DateTime::parse_from_rfc3339(&request.expires_at) + .map_err(|_| SigningRequestError::BadExpiry)?; + if Utc::now() > expires { + return Err(SigningRequestError::Expired); + } + + Ok(request) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::{fingerprint, generate_ed25519_keypair, SigningAlgorithm}; + use crate::generated::types::DomainPublicKey; + + fn keyset(domain: &str, key_id: &str, pk: &[u8]) -> DomainKeySet { + DomainKeySet { + domain: domain.to_string(), + keys: vec![DomainPublicKey { + key_id: key_id.to_string(), + public_key: pk.to_vec(), + fingerprint: fingerprint(pk), + algorithm: "ed25519".to_string(), + key_usage: "sign".to_string(), + created_at: String::new(), + expires_at: (Utc::now() + chrono::Duration::days(365)).to_rfc3339(), + revoked_at: None, + signed_by_key_id: None, + key_signature: None, + }], + } + } + + fn spec_signed(expires_at: &str) -> (SignedSigningRequest, Vec, String) { + let (vk, sk) = generate_ed25519_keypair(); + let pk = vk.as_bytes().to_vec(); + let types = vec!["age_over_21".to_string()]; + let signed = sign_signing_request( + &SigningRequestSpec { + request_id: "req-1", + subject_user_id: "user-1", + subject_domain: "home.test", + issuer_domain: "dmv.test", + requested_claim_types: &types, + nonce: "nonce-1", + issued_at: &Utc::now().to_rfc3339(), + expires_at, + callback: Some("https://home.test/deposit"), + }, + &[ClaimSigner { + domain: "home.test", + key_id: "k1", + algorithm: SigningAlgorithm::parse_str("ed25519").unwrap(), + private_key_bytes: &sk.to_bytes(), + }], + ) + .unwrap(); + (signed, pk, "k1".to_string()) + } + + #[test] + fn round_trips_and_verifies() { + let exp = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let (signed, pk, kid) = spec_signed(&exp); + let keys = vec![keyset("home.test", &kid, &pk)]; + let req = verify_signing_request(&signed, "home.test", "dmv.test", &keys).unwrap(); + assert_eq!(req.subject_user_id, "user-1"); + assert_eq!(req.requested_claim_types, vec!["age_over_21".to_string()]); + assert_eq!(req.callback.as_deref(), Some("https://home.test/deposit")); + } + + #[test] + fn wrong_issuer_is_rejected() { + let exp = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let (signed, pk, kid) = spec_signed(&exp); + let keys = vec![keyset("home.test", &kid, &pk)]; + // A request addressed to dmv.test must not verify at another issuer. + assert!(matches!( + verify_signing_request(&signed, "home.test", "evil.test", &keys), + Err(SigningRequestError::ContextMismatch) + )); + } + + #[test] + fn expired_is_rejected() { + let exp = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + let (signed, pk, kid) = spec_signed(&exp); + let keys = vec![keyset("home.test", &kid, &pk)]; + assert!(matches!( + verify_signing_request(&signed, "home.test", "dmv.test", &keys), + Err(SigningRequestError::Expired) + )); + } + + #[test] + fn tampered_payload_fails_quorum() { + let exp = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let (mut signed, pk, kid) = spec_signed(&exp); + // Flip a byte in the encoded request → decoded fields no longer match the + // signed payload. + let mut req: SigningRequest = ciborium::de::from_reader(&signed.request[..]).unwrap(); + req.subject_user_id = "user-2".to_string(); + let mut bytes = Vec::new(); + ciborium::ser::into_writer(&req, &mut bytes).unwrap(); + signed.request = bytes; + let keys = vec![keyset("home.test", &kid, &pk)]; + assert!(matches!( + verify_signing_request(&signed, "home.test", "dmv.test", &keys), + Err(SigningRequestError::Signature(_)) + )); + } +} diff --git a/crates/linkkeys/Cargo.toml b/crates/linkkeys/Cargo.toml index ba142d4..e2beb00 100644 --- a/crates/linkkeys/Cargo.toml +++ b/crates/linkkeys/Cargo.toml @@ -11,6 +11,10 @@ sqlite = ["diesel/sqlite"] [dependencies] liblinkkeys = { path = "../liblinkkeys" } +# CSIL transport family. Vendored copy for now while the crate is iterated; the +# git dep is verified working (public repo) — SWAP at the end: +# csilgen-transport = { git = "https://github.com/catalystcommunity/csilgen.git" } +csilgen-transport = { path = "../csilgen-transport" } # CLI argument parsing clap = { version = "4.5", features = ["derive"] } @@ -20,6 +24,7 @@ rocket = { version = "0.5", features = ["json", "tls", "secrets"] } # Database diesel = { version = "2.2", features = ["r2d2", "uuid", "chrono"] } +diesel_migrations = "2.2" r2d2 = "0.8" # UUID support @@ -60,6 +65,9 @@ tokio = { version = "1", features = ["rt-multi-thread"] } # Encoding base64ct = { version = "1", features = ["alloc"] } +# QR code rendering (SVG) for the verification-request UI convenience. +qrcode = { version = "0.14", default-features = false, features = ["svg"] } + # Utilities env_logger = "0.11" log = "0.4" diff --git a/crates/linkkeys/src/db/claim_policy.rs b/crates/linkkeys/src/db/claim_policy.rs new file mode 100644 index 0000000..9e7dd53 --- /dev/null +++ b/crates/linkkeys/src/db/claim_policy.rs @@ -0,0 +1,686 @@ +//! Persistence for the claim-signing policy registry and the per-user / +//! per-audience policy tables built on top of it: the claim-type registry, +//! trusted issuers, per-profile auto-sign preferences, release policies, and the +//! admin approval queue. The lane semantics these encode live in +//! `liblinkkeys::claim_policy`; this module is purely storage. + +use crate::db::models::ClaimTypePolicy; + +/// The starter registry shipped on first boot — sane, non-technical defaults so +/// a fresh domain works out of the box and the admin only edits to deviate. +/// Seeded idempotently (insert-if-absent), so an admin's later edits are never +/// overwritten on restart. +/// +/// Lanes (see `liblinkkeys::claim_policy`): +/// - `self_signed` (A): a CSIL primitive the IDP validates and signs on set. +/// - `verified` (B): the IDP signs only after a built-in verification flow. +/// - `attested` (C): the IDP does not self-sign; it admits an external signature +/// from a trusted issuer (configured per type in `trusted_issuers`). +pub fn default_registry() -> Vec { + let lane_a = |claim_type: &str, label: &str, value_type: &str| ClaimTypePolicy { + claim_type: claim_type.to_string(), + label: label.to_string(), + description: String::new(), + value_type: value_type.to_string(), + max_bytes: 33792, + set_rule: "user_self".to_string(), + signing_rule: "self_signed".to_string(), + requires_approval: false, + user_settable: true, + default_auto_sign: true, + suggested: true, + }; + let lane_c = |claim_type: &str, label: &str, value_type: &str| ClaimTypePolicy { + claim_type: claim_type.to_string(), + label: label.to_string(), + description: String::new(), + value_type: value_type.to_string(), + max_bytes: 33792, + set_rule: "trusted_issuer_only".to_string(), + signing_rule: "attested".to_string(), + requires_approval: false, + user_settable: false, + default_auto_sign: false, + suggested: true, + }; + vec![ + // Lane A — self-asserted, IDP validates the primitive and signs on set so + // the value is domain-attested, not merely in-payload. + lane_a("display_name", "Display name", "text"), + lane_a("handle", "Handle", "text"), + lane_a("website", "Website", "url"), + lane_a("avatar_url", "Avatar URL", "url"), + // Lane B — signed only after the built-in email round-trip. + ClaimTypePolicy { + claim_type: "email".to_string(), + label: "Email address".to_string(), + description: "Verified by a confirmation link sent to the address.".to_string(), + value_type: "email".to_string(), + max_bytes: 33792, + set_rule: "user_self".to_string(), + signing_rule: "verified".to_string(), + requires_approval: false, + user_settable: true, + default_auto_sign: true, + suggested: true, + }, + // The IDP sets this as a side effect of a successful email verification; + // the user cannot self-assert it. + ClaimTypePolicy { + claim_type: "email_verified".to_string(), + label: "Email verified".to_string(), + description: "Set automatically once an email address is verified.".to_string(), + value_type: "bool".to_string(), + max_bytes: 16, + set_rule: "idp_on_request".to_string(), + signing_rule: "verified".to_string(), + requires_approval: false, + user_settable: false, + default_auto_sign: true, + suggested: true, + }, + // Lane C — attested by a recognised third party (e.g. a government + // entity); the IDP signs nothing here, it admits a trusted issuer's + // signature. No issuers are trusted by default — an admin adds the + // domains they recognise in `trusted_issuers`. + lane_c("legal_name", "Legal name", "text"), + lane_c("date_of_birth", "Date of birth", "date"), + lane_c("age_over_21", "Age over 21", "bool"), + ] +} + +#[cfg(feature = "postgres")] +pub mod pg { + use diesel::prelude::*; + + use crate::db::models::pg::{ + ClaimApprovalRow, ClaimTypePolicyRow, NewClaimApprovalRow, ProfileClaimPrefRow, + ReleasePolicyRow, TrustedIssuerRow, + }; + use crate::db::models::{ + ClaimApproval, ClaimTypePolicy, ProfileClaimPref, ReleasePolicy, TrustedIssuer, + }; + use crate::schema::pg::{ + claim_approval_queue, claim_type_policies, profile_claim_prefs, release_policies, + trusted_issuers, + }; + + // -- Claim-type policy registry -- + + pub fn list_policies(conn: &mut diesel::PgConnection) -> QueryResult> { + claim_type_policies::table + .order(claim_type_policies::claim_type.asc()) + .select(ClaimTypePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn find_policy( + conn: &mut diesel::PgConnection, + claim_type: &str, + ) -> QueryResult> { + claim_type_policies::table + .find(claim_type) + .select(ClaimTypePolicyRow::as_select()) + .first::(conn) + .optional() + .map(|o| o.map(Into::into)) + } + + pub fn upsert_policy( + conn: &mut diesel::PgConnection, + policy: ClaimTypePolicy, + ) -> QueryResult { + let row: ClaimTypePolicyRow = policy.into(); + diesel::insert_into(claim_type_policies::table) + .values(&row) + .on_conflict(claim_type_policies::claim_type) + .do_update() + .set(&row) + .execute(conn) + } + + /// Insert a default policy only if the claim type is not already present. + /// Returns rows inserted (0 = already present). Used by idempotent seeding. + pub fn insert_policy_if_absent( + conn: &mut diesel::PgConnection, + policy: ClaimTypePolicy, + ) -> QueryResult { + let row: ClaimTypePolicyRow = policy.into(); + diesel::insert_into(claim_type_policies::table) + .values(&row) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn delete_policy(conn: &mut diesel::PgConnection, claim_type: &str) -> QueryResult { + diesel::delete(claim_type_policies::table.find(claim_type)).execute(conn) + } + + // -- Trusted issuers -- + + pub fn list_trusted_issuers_for( + conn: &mut diesel::PgConnection, + claim_type: &str, + ) -> QueryResult> { + trusted_issuers::table + .filter(trusted_issuers::claim_type.eq(claim_type)) + .select(trusted_issuers::issuer_domain) + .load::(conn) + } + + pub fn list_all_trusted_issuers( + conn: &mut diesel::PgConnection, + ) -> QueryResult> { + trusted_issuers::table + .order(( + trusted_issuers::claim_type.asc(), + trusted_issuers::issuer_domain.asc(), + )) + .select(TrustedIssuerRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn add_trusted_issuer( + conn: &mut diesel::PgConnection, + claim_type: &str, + issuer_domain: &str, + ) -> QueryResult { + diesel::insert_into(trusted_issuers::table) + .values(TrustedIssuerRow { + claim_type: claim_type.to_string(), + issuer_domain: issuer_domain.to_string(), + }) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn remove_trusted_issuer( + conn: &mut diesel::PgConnection, + claim_type: &str, + issuer_domain: &str, + ) -> QueryResult { + diesel::delete( + trusted_issuers::table + .filter(trusted_issuers::claim_type.eq(claim_type)) + .filter(trusted_issuers::issuer_domain.eq(issuer_domain)), + ) + .execute(conn) + } + + // -- Per-profile auto-sign preferences -- + + pub fn get_pref( + conn: &mut diesel::PgConnection, + profile_id: &str, + claim_type: &str, + ) -> QueryResult> { + profile_claim_prefs::table + .find((profile_id, claim_type)) + .select(profile_claim_prefs::auto_sign) + .first::(conn) + .optional() + } + + pub fn list_prefs_for_profile( + conn: &mut diesel::PgConnection, + profile_id: &str, + ) -> QueryResult> { + profile_claim_prefs::table + .filter(profile_claim_prefs::profile_id.eq(profile_id)) + .select(ProfileClaimPrefRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn upsert_pref( + conn: &mut diesel::PgConnection, + profile_id: &str, + claim_type: &str, + auto_sign: bool, + ) -> QueryResult { + let row = ProfileClaimPrefRow { + profile_id: profile_id.to_string(), + claim_type: claim_type.to_string(), + auto_sign, + }; + diesel::insert_into(profile_claim_prefs::table) + .values(&row) + .on_conflict(( + profile_claim_prefs::profile_id, + profile_claim_prefs::claim_type, + )) + .do_update() + .set(profile_claim_prefs::auto_sign.eq(auto_sign)) + .execute(conn) + } + + // -- Release policies -- + + pub fn list_release_policies( + conn: &mut diesel::PgConnection, + ) -> QueryResult> { + release_policies::table + .order(( + release_policies::audience.asc(), + release_policies::claim_type.asc(), + )) + .select(ReleasePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + /// Rows that apply to `audience`: that audience's explicit rows plus the + /// global `*` defaults. + pub fn list_release_policies_for_audience( + conn: &mut diesel::PgConnection, + audience: &str, + ) -> QueryResult> { + release_policies::table + .filter( + release_policies::audience + .eq(audience) + .or(release_policies::audience.eq("*")), + ) + .select(ReleasePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn count_release_policies(conn: &mut diesel::PgConnection) -> QueryResult { + release_policies::table.count().get_result(conn) + } + + pub fn upsert_release_policy( + conn: &mut diesel::PgConnection, + audience: &str, + claim_type: &str, + disposition: &str, + ) -> QueryResult { + let row = ReleasePolicyRow { + audience: audience.to_string(), + claim_type: claim_type.to_string(), + disposition: disposition.to_string(), + }; + diesel::insert_into(release_policies::table) + .values(&row) + .on_conflict((release_policies::audience, release_policies::claim_type)) + .do_update() + .set(release_policies::disposition.eq(disposition)) + .execute(conn) + } + + pub fn delete_release_policy( + conn: &mut diesel::PgConnection, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::delete( + release_policies::table + .filter(release_policies::audience.eq(audience)) + .filter(release_policies::claim_type.eq(claim_type)), + ) + .execute(conn) + } + + // -- Approval queue -- + + pub fn enqueue_approval( + conn: &mut diesel::PgConnection, + id: uuid::Uuid, + user_id: uuid::Uuid, + claim_type: &str, + claim_value: &[u8], + ) -> QueryResult { + diesel::insert_into(claim_approval_queue::table) + .values(NewClaimApprovalRow { + id, + user_id, + claim_type: claim_type.to_string(), + claim_value: claim_value.to_vec(), + }) + .execute(conn) + } + + pub fn list_pending_approvals( + conn: &mut diesel::PgConnection, + ) -> QueryResult> { + claim_approval_queue::table + .filter(claim_approval_queue::status.eq("pending")) + .order(claim_approval_queue::created_at.asc()) + .select(ClaimApprovalRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn find_approval( + conn: &mut diesel::PgConnection, + id: uuid::Uuid, + ) -> QueryResult { + claim_approval_queue::table + .find(id) + .select(ClaimApprovalRow::as_select()) + .first::(conn) + .map(Into::into) + } + + /// Resolve a still-pending approval. Guarded on `status = 'pending'`, so a + /// concurrent or repeat resolve affects 0 rows (caller treats 0 as + /// already-resolved) rather than overwriting the prior resolution. + pub fn resolve_approval( + conn: &mut diesel::PgConnection, + id: uuid::Uuid, + status: &str, + resolved_by: &str, + ) -> QueryResult { + diesel::update( + claim_approval_queue::table + .filter(claim_approval_queue::id.eq(id)) + .filter(claim_approval_queue::status.eq("pending")), + ) + .set(( + claim_approval_queue::status.eq(status), + claim_approval_queue::resolved_by.eq(resolved_by), + claim_approval_queue::resolved_at.eq(chrono::Utc::now()), + )) + .execute(conn) + } +} + +#[cfg(feature = "sqlite")] +pub mod sqlite { + use diesel::prelude::*; + + use crate::db::models::sqlite::{ + ClaimApprovalRow, ClaimTypePolicyRow, NewClaimApprovalRow, ProfileClaimPrefRow, + ReleasePolicyRow, TrustedIssuerRow, + }; + use crate::db::models::{ + ClaimApproval, ClaimTypePolicy, ProfileClaimPref, ReleasePolicy, TrustedIssuer, + }; + use crate::schema::sqlite::{ + claim_approval_queue, claim_type_policies, profile_claim_prefs, release_policies, + trusted_issuers, + }; + + // -- Claim-type policy registry -- + + pub fn list_policies(conn: &mut diesel::SqliteConnection) -> QueryResult> { + claim_type_policies::table + .order(claim_type_policies::claim_type.asc()) + .select(ClaimTypePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn find_policy( + conn: &mut diesel::SqliteConnection, + claim_type: &str, + ) -> QueryResult> { + claim_type_policies::table + .find(claim_type) + .select(ClaimTypePolicyRow::as_select()) + .first::(conn) + .optional() + .map(|o| o.map(Into::into)) + } + + pub fn upsert_policy( + conn: &mut diesel::SqliteConnection, + policy: ClaimTypePolicy, + ) -> QueryResult { + let row: ClaimTypePolicyRow = policy.into(); + diesel::insert_into(claim_type_policies::table) + .values(&row) + .on_conflict(claim_type_policies::claim_type) + .do_update() + .set(&row) + .execute(conn) + } + + pub fn insert_policy_if_absent( + conn: &mut diesel::SqliteConnection, + policy: ClaimTypePolicy, + ) -> QueryResult { + let row: ClaimTypePolicyRow = policy.into(); + diesel::insert_into(claim_type_policies::table) + .values(&row) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn delete_policy( + conn: &mut diesel::SqliteConnection, + claim_type: &str, + ) -> QueryResult { + diesel::delete(claim_type_policies::table.find(claim_type)).execute(conn) + } + + // -- Trusted issuers -- + + pub fn list_trusted_issuers_for( + conn: &mut diesel::SqliteConnection, + claim_type: &str, + ) -> QueryResult> { + trusted_issuers::table + .filter(trusted_issuers::claim_type.eq(claim_type)) + .select(trusted_issuers::issuer_domain) + .load::(conn) + } + + pub fn list_all_trusted_issuers( + conn: &mut diesel::SqliteConnection, + ) -> QueryResult> { + trusted_issuers::table + .order(( + trusted_issuers::claim_type.asc(), + trusted_issuers::issuer_domain.asc(), + )) + .select(TrustedIssuerRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn add_trusted_issuer( + conn: &mut diesel::SqliteConnection, + claim_type: &str, + issuer_domain: &str, + ) -> QueryResult { + diesel::insert_into(trusted_issuers::table) + .values(TrustedIssuerRow { + claim_type: claim_type.to_string(), + issuer_domain: issuer_domain.to_string(), + }) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn remove_trusted_issuer( + conn: &mut diesel::SqliteConnection, + claim_type: &str, + issuer_domain: &str, + ) -> QueryResult { + diesel::delete( + trusted_issuers::table + .filter(trusted_issuers::claim_type.eq(claim_type)) + .filter(trusted_issuers::issuer_domain.eq(issuer_domain)), + ) + .execute(conn) + } + + // -- Per-profile auto-sign preferences -- + + pub fn get_pref( + conn: &mut diesel::SqliteConnection, + profile_id: &str, + claim_type: &str, + ) -> QueryResult> { + profile_claim_prefs::table + .find((profile_id, claim_type)) + .select(profile_claim_prefs::auto_sign) + .first::(conn) + .optional() + .map(|o| o.map(|v| v != 0)) + } + + pub fn list_prefs_for_profile( + conn: &mut diesel::SqliteConnection, + profile_id: &str, + ) -> QueryResult> { + profile_claim_prefs::table + .filter(profile_claim_prefs::profile_id.eq(profile_id)) + .select(ProfileClaimPrefRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn upsert_pref( + conn: &mut diesel::SqliteConnection, + profile_id: &str, + claim_type: &str, + auto_sign: bool, + ) -> QueryResult { + let row = ProfileClaimPrefRow { + profile_id: profile_id.to_string(), + claim_type: claim_type.to_string(), + auto_sign: i32::from(auto_sign), + }; + diesel::insert_into(profile_claim_prefs::table) + .values(&row) + .on_conflict(( + profile_claim_prefs::profile_id, + profile_claim_prefs::claim_type, + )) + .do_update() + .set(profile_claim_prefs::auto_sign.eq(i32::from(auto_sign))) + .execute(conn) + } + + // -- Release policies -- + + pub fn list_release_policies( + conn: &mut diesel::SqliteConnection, + ) -> QueryResult> { + release_policies::table + .order(( + release_policies::audience.asc(), + release_policies::claim_type.asc(), + )) + .select(ReleasePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn list_release_policies_for_audience( + conn: &mut diesel::SqliteConnection, + audience: &str, + ) -> QueryResult> { + release_policies::table + .filter( + release_policies::audience + .eq(audience) + .or(release_policies::audience.eq("*")), + ) + .select(ReleasePolicyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn count_release_policies(conn: &mut diesel::SqliteConnection) -> QueryResult { + release_policies::table.count().get_result(conn) + } + + pub fn upsert_release_policy( + conn: &mut diesel::SqliteConnection, + audience: &str, + claim_type: &str, + disposition: &str, + ) -> QueryResult { + let row = ReleasePolicyRow { + audience: audience.to_string(), + claim_type: claim_type.to_string(), + disposition: disposition.to_string(), + }; + diesel::insert_into(release_policies::table) + .values(&row) + .on_conflict((release_policies::audience, release_policies::claim_type)) + .do_update() + .set(release_policies::disposition.eq(disposition)) + .execute(conn) + } + + pub fn delete_release_policy( + conn: &mut diesel::SqliteConnection, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::delete( + release_policies::table + .filter(release_policies::audience.eq(audience)) + .filter(release_policies::claim_type.eq(claim_type)), + ) + .execute(conn) + } + + // -- Approval queue -- + + pub fn enqueue_approval( + conn: &mut diesel::SqliteConnection, + id: &str, + user_id: &str, + claim_type: &str, + claim_value: &[u8], + ) -> QueryResult { + diesel::insert_into(claim_approval_queue::table) + .values(NewClaimApprovalRow { + id: id.to_string(), + user_id: user_id.to_string(), + claim_type: claim_type.to_string(), + claim_value: claim_value.to_vec(), + }) + .execute(conn) + } + + pub fn list_pending_approvals( + conn: &mut diesel::SqliteConnection, + ) -> QueryResult> { + claim_approval_queue::table + .filter(claim_approval_queue::status.eq("pending")) + .order(claim_approval_queue::created_at.asc()) + .select(ClaimApprovalRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } + + pub fn find_approval( + conn: &mut diesel::SqliteConnection, + id: &str, + ) -> QueryResult { + claim_approval_queue::table + .find(id) + .select(ClaimApprovalRow::as_select()) + .first::(conn) + .map(Into::into) + } + + /// See the postgres variant — guarded on `status = 'pending'`. + pub fn resolve_approval( + conn: &mut diesel::SqliteConnection, + id: &str, + status: &str, + resolved_by: &str, + ) -> QueryResult { + diesel::update( + claim_approval_queue::table + .filter(claim_approval_queue::id.eq(id)) + .filter(claim_approval_queue::status.eq("pending")), + ) + .set(( + claim_approval_queue::status.eq(status), + claim_approval_queue::resolved_by.eq(resolved_by), + claim_approval_queue::resolved_at.eq(chrono::Utc::now().to_rfc3339()), + )) + .execute(conn) + } +} diff --git a/crates/linkkeys/src/db/claims.rs b/crates/linkkeys/src/db/claims.rs index ef1f81e..0812d2c 100644 --- a/crates/linkkeys/src/db/claims.rs +++ b/crates/linkkeys/src/db/claims.rs @@ -57,15 +57,13 @@ pub mod pg { ) -> QueryResult<()> { let mut rows = Vec::with_capacity(signatures.len()); for s in signatures { - let key_id: uuid::Uuid = s - .signed_by_key_id - .parse() - .map_err(|_| diesel::result::Error::NotFound)?; + // signed_by_key_id is stored verbatim (VARCHAR): our own key ids are + // UUIDs, but an external issuer's key id can be any string. rows.push(NewClaimSignatureDbRow { id: uuid::Uuid::now_v7(), claim_id, domain: s.domain.clone(), - signed_by_key_id: key_id, + signed_by_key_id: s.signed_by_key_id.clone(), signature: s.signature.clone(), }); } @@ -172,6 +170,28 @@ pub mod pg { load_with_signatures(conn, id) } + /// Soft-revoke every currently-active claim of a given type for a user — used + /// by self-service set, which keeps one active claim per type by revoking the + /// prior value before writing the new one. Returns rows revoked. + pub fn revoke_active_of_type( + conn: &mut diesel::PgConnection, + user_id_str: &str, + claim_type: &str, + ) -> QueryResult { + let uid: uuid::Uuid = user_id_str + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + let now = chrono::Utc::now(); + diesel::update( + claims::table + .filter(claims::user_id.eq(uid)) + .filter(claims::claim_type.eq(claim_type)) + .filter(claims::revoked_at.is_null()), + ) + .set((claims::revoked_at.eq(Some(now)), claims::updated_at.eq(now))) + .execute(conn) + } + /// Claims with no signature rows — legacy claims left unsigned by the /// claim_signatures migration. Used by the pre-alpha re-sign backfill. The /// returned rows intentionally carry empty `signatures`. @@ -359,6 +379,27 @@ pub mod sqlite { load_with_signatures(conn, claim_id) } + /// Soft-revoke every currently-active claim of a given type for a user — see + /// the postgres variant. + pub fn revoke_active_of_type( + conn: &mut diesel::SqliteConnection, + user_id: &str, + claim_type: &str, + ) -> QueryResult { + let now = chrono::Utc::now().to_rfc3339(); + diesel::update( + claims::table + .filter(claims::user_id.eq(user_id)) + .filter(claims::claim_type.eq(claim_type)) + .filter(claims::revoked_at.is_null()), + ) + .set(( + claims::revoked_at.eq(Some(&now)), + claims::updated_at.eq(&now), + )) + .execute(conn) + } + /// Claims with no signature rows — legacy claims left unsigned by the /// claim_signatures migration. Used by the pre-alpha re-sign backfill. The /// returned rows intentionally carry empty `signatures`. diff --git a/crates/linkkeys/src/db/email_verification.rs b/crates/linkkeys/src/db/email_verification.rs new file mode 100644 index 0000000..d8943c3 --- /dev/null +++ b/crates/linkkeys/src/db/email_verification.rs @@ -0,0 +1,87 @@ +//! Persistence for pending email-verification challenges. Single-use tokens that +//! expire; on confirmation the server signs the `email` / `email_verified` +//! claims and deletes the row. + +#[cfg(feature = "postgres")] +pub mod pg { + use diesel::prelude::*; + + use crate::db::models::pg::EmailVerificationRow; + use crate::db::models::EmailVerification; + use crate::schema::pg::email_verifications; + + pub fn create( + conn: &mut diesel::PgConnection, + token: &str, + user_id: uuid::Uuid, + email: &str, + expires_at: chrono::DateTime, + ) -> QueryResult { + diesel::insert_into(email_verifications::table) + .values(EmailVerificationRow { + token: token.to_string(), + user_id, + email: email.to_string(), + expires_at, + }) + .execute(conn) + } + + pub fn find( + conn: &mut diesel::PgConnection, + token: &str, + ) -> QueryResult> { + email_verifications::table + .find(token) + .select(EmailVerificationRow::as_select()) + .first::(conn) + .optional() + .map(|o| o.map(Into::into)) + } + + pub fn delete(conn: &mut diesel::PgConnection, token: &str) -> QueryResult { + diesel::delete(email_verifications::table.find(token)).execute(conn) + } +} + +#[cfg(feature = "sqlite")] +pub mod sqlite { + use diesel::prelude::*; + + use crate::db::models::sqlite::EmailVerificationRow; + use crate::db::models::EmailVerification; + use crate::schema::sqlite::email_verifications; + + pub fn create( + conn: &mut diesel::SqliteConnection, + token: &str, + user_id: &str, + email: &str, + expires_at: &str, + ) -> QueryResult { + diesel::insert_into(email_verifications::table) + .values(EmailVerificationRow { + token: token.to_string(), + user_id: user_id.to_string(), + email: email.to_string(), + expires_at: expires_at.to_string(), + }) + .execute(conn) + } + + pub fn find( + conn: &mut diesel::SqliteConnection, + token: &str, + ) -> QueryResult> { + email_verifications::table + .find(token) + .select(EmailVerificationRow::as_select()) + .first::(conn) + .optional() + .map(|o| o.map(Into::into)) + } + + pub fn delete(conn: &mut diesel::SqliteConnection, token: &str) -> QueryResult { + diesel::delete(email_verifications::table.find(token)).execute(conn) + } +} diff --git a/crates/linkkeys/src/db/mod.rs b/crates/linkkeys/src/db/mod.rs index b87b9ca..2de8cb3 100644 --- a/crates/linkkeys/src/db/mod.rs +++ b/crates/linkkeys/src/db/mod.rs @@ -1,192 +1,134 @@ pub mod auth_credentials; +pub mod claim_policy; pub mod claims; pub mod consent_grants; pub mod domain_keys; +pub mod email_verification; pub mod guestbook; pub mod models; pub mod nonces; +pub mod peer_keys; pub mod profiles; pub mod relations; pub mod user_keys; +pub mod user_release_prefs; pub mod users; -use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel::r2d2::{self, ConnectionManager}; use std::env; -// Single-file, forward-only migrations: each entry is (version, SQL), applied in -// array order. A `__lk_migrations` table records which versions have run. No -// rollback by design (no down migrations). Adding a migration = drop one `.sql` -// file under migrations// and add a line here. -#[cfg(feature = "postgres")] -const PG_MIGRATIONS: &[(&str, &str)] = &[ - ( - "00000000000000_diesel_initial_setup", - include_str!("../../../../migrations/postgres/00000000000000_diesel_initial_setup.sql"), - ), - ( - "2026-03-15-000001_create_guestbook", - include_str!("../../../../migrations/postgres/2026-03-15-000001_create_guestbook.sql"), - ), - ( - "2026-04-02-000001_create_identity_tables", - include_str!( - "../../../../migrations/postgres/2026-04-02-000001_create_identity_tables.sql" - ), - ), - ( - "2026-04-09-000001_create_relations_and_extensions", - include_str!( - "../../../../migrations/postgres/2026-04-09-000001_create_relations_and_extensions.sql" - ), - ), - ( - "2026-04-09-000002_add_relations_unique_index", - include_str!( - "../../../../migrations/postgres/2026-04-09-000002_add_relations_unique_index.sql" - ), - ), - ( - "2026-05-30-000001_create_used_nonces", - include_str!("../../../../migrations/postgres/2026-05-30-000001_create_used_nonces.sql"), - ), - ( - "2026-06-01-000001_add_key_usage", - include_str!("../../../../migrations/postgres/2026-06-01-000001_add_key_usage.sql"), - ), - ( - "2026-06-14-000001_claim_signatures", - include_str!("../../../../migrations/postgres/2026-06-14-000001_claim_signatures.sql"), - ), - ( - "2026-06-14-000002_create_consent_grants", - include_str!("../../../../migrations/postgres/2026-06-14-000002_create_consent_grants.sql"), - ), - ( - "2026-06-15-000001_create_profiles", - include_str!("../../../../migrations/postgres/2026-06-15-000001_create_profiles.sql"), - ), - ( - "2026-06-16-000001_admin_accounts", - include_str!("../../../../migrations/postgres/2026-06-16-000001_admin_accounts.sql"), - ), -]; +use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness}; +// Schema migrations are tracked by diesel (the `__diesel_schema_migrations` +// table); diesel runs only the pending ones. Migrations are pure SCHEMA DDL, +// living under migrations//_/up.sql. Idempotent DATA +// backfills are NOT migrations — they are "transforms" run separately at +// startup (see main.rs). Paths are relative to this crate's manifest dir; the +// migrations tree is at the workspace root. +#[cfg(feature = "postgres")] +const PG_MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations/postgres"); #[cfg(feature = "sqlite")] -const SQLITE_MIGRATIONS: &[(&str, &str)] = &[ - ( - "00000000000000_diesel_initial_setup", - include_str!("../../../../migrations/sqlite/00000000000000_diesel_initial_setup.sql"), - ), - ( - "2026-03-15-000001_create_guestbook", - include_str!("../../../../migrations/sqlite/2026-03-15-000001_create_guestbook.sql"), - ), - ( - "2026-04-02-000001_create_identity_tables", - include_str!("../../../../migrations/sqlite/2026-04-02-000001_create_identity_tables.sql"), - ), - ( - "2026-04-09-000001_create_relations_and_extensions", - include_str!( - "../../../../migrations/sqlite/2026-04-09-000001_create_relations_and_extensions.sql" - ), - ), - ( - "2026-04-09-000002_add_relations_unique_index", - include_str!( - "../../../../migrations/sqlite/2026-04-09-000002_add_relations_unique_index.sql" - ), - ), - ( - "2026-05-30-000001_create_used_nonces", - include_str!("../../../../migrations/sqlite/2026-05-30-000001_create_used_nonces.sql"), - ), - ( - "2026-06-01-000001_add_key_usage", - include_str!("../../../../migrations/sqlite/2026-06-01-000001_add_key_usage.sql"), - ), - ( - "2026-06-14-000001_claim_signatures", - include_str!("../../../../migrations/sqlite/2026-06-14-000001_claim_signatures.sql"), - ), - ( - "2026-06-14-000002_create_consent_grants", - include_str!("../../../../migrations/sqlite/2026-06-14-000002_create_consent_grants.sql"), - ), - ( - "2026-06-15-000001_create_profiles", - include_str!("../../../../migrations/sqlite/2026-06-15-000001_create_profiles.sql"), - ), - ( - "2026-06-16-000001_admin_accounts", - include_str!("../../../../migrations/sqlite/2026-06-16-000001_admin_accounts.sql"), - ), -]; - -/// True for the benign "this object already exists" class of DB errors that -/// means a migration was already applied (so we skip it), as opposed to a real -/// failure (which propagates). Covers both Postgres ("... already exists") and -/// SQLite ("table ... already exists", "duplicate column name", ...). -fn is_already_applied(e: &diesel::result::Error) -> bool { - if let diesel::result::Error::DatabaseError(_, info) = e { - let msg = info.message().to_lowercase(); - msg.contains("already exists") || msg.contains("duplicate column") - } else { - false - } +const SQLITE_MIGRATIONS: EmbeddedMigrations = embed_migrations!("../../migrations/sqlite"); + +#[derive(diesel::QueryableByName)] +struct MigCount { + #[diesel(sql_type = diesel::sql_types::BigInt)] + n: i64, } -/// Apply forward-only, single-file migrations on a concrete connection. There is -/// NO migration-tracking table — the runner is idempotent: it runs every -/// migration on each boot and treats an "already exists / duplicate column" -/// failure as "already applied, skip". This works because each migration's first -/// statement is a uniquely-named CREATE/ALTER, so a previously-applied migration -/// fails on that first statement (rolling back its transaction untouched) and is -/// skipped wholesale, while a genuinely-new migration applies. Any OTHER error -/// is a real failure and propagates. Each migration runs in its own transaction, -/// so a partial/failed apply rolls back cleanly. +/// ONE-TIME diesel-tracking baseline (2026-06-18 migration-system transition). /// -/// SOUND ONLY FOR DDL migrations (the convention here): a data statement would -/// not error on re-run and would silently execute every boot. Data backfills -/// must be idempotent startup hooks instead (see `backfill_profiles` / -/// `split_admins`), never SQL migrations. -macro_rules! apply_migrations { - ($conn:expr, $migrations:expr) => {{ - // Migrations apply in array order, which must be strictly ascending so a - // fresh DB builds the schema in the right sequence. - debug_assert!( - $migrations.windows(2).all(|w| w[0].0 < w[1].0), - "migrations must be listed in strictly ascending version order" - ); - let mut count = 0usize; - for (name, sql) in $migrations.iter() { - match $conn.transaction::<(), diesel::result::Error, _>(|c| c.batch_execute(sql)) { - Ok(()) => { - log::info!("applied migration {}", name); - count += 1; - } - Err(e) if is_already_applied(&e) => { - log::debug!("migration {} already applied; skipping", name); - } - Err(e) => return Err(format!("migration {} failed: {}", name, e)), - } +/// Deployed DBs were originally diesel-migrated through `20260614000001` +/// (`claim_signatures`), then the interim custom runner applied +/// `create_consent_grants`, `create_profiles`, and `admin_accounts` WITHOUT +/// recording them in `__diesel_schema_migrations`. Now that diesel tracks +/// migrations again, those three would be seen as pending and re-run → "already +/// exists" crash. Record them as applied (they exist) so diesel skips them and +/// runs only genuinely-new migrations (e.g. `claim_policy`). +/// +/// Guarded + idempotent: acts only when diesel's table exists AND carries the +/// pre-transition cutoff version (so a fresh DB — table absent — is untouched and +/// diesel builds the whole schema normally). `ON CONFLICT DO NOTHING` makes +/// re-runs no-ops. The version strings are compile-time constants, not input. +/// Safe to delete once every deployment has booted on diesel tracking. +const LEGACY_APPLIED_UNTRACKED: &[&str] = &["20260614000002", "20260615000001", "20260616000001"]; +const LEGACY_TRACKING_CUTOFF: &str = "20260614000001"; + +/// Run pending schema migrations on a Postgres connection. Returns the number run. +#[cfg(feature = "postgres")] +pub fn migrate_pg(conn: &mut diesel::PgConnection) -> Result { + let applied = diesel::sql_query(format!( + "SELECT count(*) AS n FROM __diesel_schema_migrations WHERE version = '{}'", + LEGACY_TRACKING_CUTOFF + )) + .get_result::(conn) + .map(|c| c.n > 0) + .unwrap_or(false); // table absent (fresh DB) → nothing to baseline + if applied { + for v in LEGACY_APPLIED_UNTRACKED { + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('{}') ON CONFLICT (version) DO NOTHING", + v + )) + .execute(conn) + .map_err(|e| e.to_string())?; } - Ok::(count) - }}; + } + conn.run_pending_migrations(PG_MIGRATIONS) + .map(|v| v.len()) + .map_err(|e| e.to_string()) } -/// Apply pending migrations on a Postgres connection. Returns the number run. +/// Run pending schema migrations on a SQLite connection. Returns the number run. +#[cfg(feature = "sqlite")] +pub fn migrate_sqlite(conn: &mut diesel::SqliteConnection) -> Result { + let applied = diesel::sql_query(format!( + "SELECT count(*) AS n FROM __diesel_schema_migrations WHERE version = '{}'", + LEGACY_TRACKING_CUTOFF + )) + .get_result::(conn) + .map(|c| c.n > 0) + .unwrap_or(false); + if applied { + for v in LEGACY_APPLIED_UNTRACKED { + diesel::sql_query(format!( + "INSERT INTO __diesel_schema_migrations (version) VALUES ('{}') ON CONFLICT (version) DO NOTHING", + v + )) + .execute(conn) + .map_err(|e| e.to_string())?; + } + } + conn.run_pending_migrations(SQLITE_MIGRATIONS) + .map(|v| v.len()) + .map_err(|e| e.to_string()) +} + +/// Fetch a pooled connection, mapping the r2d2 checkout error into a diesel +/// error so call sites stay `QueryResult`-shaped. #[cfg(feature = "postgres")] -pub fn migrate_pg(conn: &mut diesel::PgConnection) -> Result { - apply_migrations!(conn, PG_MIGRATIONS) +fn pg_conn( + p: &r2d2::Pool>, +) -> QueryResult>> { + p.get().map_err(|e| { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::Unknown, + Box::new(e.to_string()), + ) + }) } -/// Apply pending migrations on a SQLite connection. Returns the number run. #[cfg(feature = "sqlite")] -pub fn migrate_sqlite(conn: &mut diesel::SqliteConnection) -> Result { - apply_migrations!(conn, SQLITE_MIGRATIONS) +fn sqlite_conn( + p: &r2d2::Pool>, +) -> QueryResult>> { + p.get().map_err(|e| { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::Unknown, + Box::new(e.to_string()), + ) + }) } pub enum DbPool { @@ -245,7 +187,7 @@ pub fn create_pool() -> DbPool { /// Per-account cap on presentable profiles (`MAX_PROFILES_PER_ACCOUNT`, default /// 1 — keeps the system single-identity and the multi-profile UI hidden until an /// operator opts in). The root anchor is separate and not counted. -fn max_profiles_per_account() -> i64 { +pub fn max_profiles_per_account() -> i64 { std::env::var("MAX_PROFILES_PER_ACCOUNT") .ok() .and_then(|s| s.parse::().ok()) @@ -454,33 +396,6 @@ impl DbPool { } } - /// All claims (any user, regardless of revocation/expiry), signatures - /// attached. Used by the pre-alpha re-sign backfill. - pub fn list_all_claims(&self) -> QueryResult> { - match self { - #[cfg(feature = "postgres")] - DbPool::Postgres(p) => { - let mut conn = p.get().map_err(|e| { - diesel::result::Error::DatabaseError( - diesel::result::DatabaseErrorKind::Unknown, - Box::new(e.to_string()), - ) - })?; - claims::pg::list_all(&mut conn) - } - #[cfg(feature = "sqlite")] - DbPool::Sqlite(p) => { - let mut conn = p.get().map_err(|e| { - diesel::result::Error::DatabaseError( - diesel::result::DatabaseErrorKind::Unknown, - Box::new(e.to_string()), - ) - })?; - claims::sqlite::list_all(&mut conn) - } - } - } - pub fn list_active_claims(&self, user_id: &str) -> QueryResult> { match self { #[cfg(feature = "postgres")] @@ -1159,11 +1074,27 @@ impl DbPool { } } - /// Provision root + default profiles for any pre-existing account that has - /// none (one-time data normalization after the profiles migration). Run at - /// startup. Returns how many accounts were backfilled. - pub fn backfill_profiles(&self) -> QueryResult { - let domain = crate::conversions::get_domain_name(); + /// Idempotently seed the claim-type policy registry with the starter + /// defaults (insert-if-absent, so admin edits are never overwritten) and, on + /// first boot only, seed the per-audience release policy from the deprecated + /// `CONSENT_FORCED_ALLOW` / `CONSENT_FORCED_DENY` env vars into the global + /// `*` audience. Run at startup after migrations. Returns how many registry + /// entries were newly inserted. + pub fn seed_default_policies(&self) -> QueryResult { + // TODO(later-session): remove the env-var seed once all deployments have + // migrated their release policy into the database. `release_policies` is + // the source of truth; the env vars are a one-time bootstrap. + let parse_env = |var: &str| { + std::env::var(var) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect::>() + }; + let forced_allow = parse_env("CONSENT_FORCED_ALLOW"); + let forced_deny = parse_env("CONSENT_FORCED_DENY"); + match self { #[cfg(feature = "postgres")] DbPool::Postgres(p) => { @@ -1173,7 +1104,24 @@ impl DbPool { Box::new(e.to_string()), ) })?; - profiles::pg::backfill(&mut conn, &domain) + let mut inserted = 0; + for policy in claim_policy::default_registry() { + inserted += claim_policy::pg::insert_policy_if_absent(&mut conn, policy)?; + } + if claim_policy::pg::count_release_policies(&mut conn)? == 0 { + for ct in &forced_allow { + claim_policy::pg::upsert_release_policy( + &mut conn, + "*", + ct, + "forced_allow", + )?; + } + for ct in &forced_deny { + claim_policy::pg::upsert_release_policy(&mut conn, "*", ct, "forced_deny")?; + } + } + Ok(inserted) } #[cfg(feature = "sqlite")] DbPool::Sqlite(p) => { @@ -1183,7 +1131,510 @@ impl DbPool { Box::new(e.to_string()), ) })?; - profiles::sqlite::backfill(&mut conn, &domain) + let mut inserted = 0; + for policy in claim_policy::default_registry() { + inserted += claim_policy::sqlite::insert_policy_if_absent(&mut conn, policy)?; + } + if claim_policy::sqlite::count_release_policies(&mut conn)? == 0 { + for ct in &forced_allow { + claim_policy::sqlite::upsert_release_policy( + &mut conn, + "*", + ct, + "forced_allow", + )?; + } + for ct in &forced_deny { + claim_policy::sqlite::upsert_release_policy( + &mut conn, + "*", + ct, + "forced_deny", + )?; + } + } + Ok(inserted) + } + } + } + + // ---- Claim-type policy registry & related policy tables ---- + // + // Thin DbPool wrappers over `claim_policy::{pg,sqlite}`, dispatching on the + // backend. The pure set/sign decision lives in `liblinkkeys::claim_policy`; + // these are storage only. + + pub fn list_claim_policies(&self) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::list_policies(&mut *pg_conn(p)?), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::list_policies(&mut *sqlite_conn(p)?), + } + } + + pub fn find_claim_policy( + &self, + claim_type: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::find_policy(&mut *pg_conn(p)?, claim_type), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::find_policy(&mut *sqlite_conn(p)?, claim_type) + } + } + } + + pub fn upsert_claim_policy(&self, policy: models::ClaimTypePolicy) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::upsert_policy(&mut *pg_conn(p)?, policy), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::upsert_policy(&mut *sqlite_conn(p)?, policy), + } + } + + pub fn delete_claim_policy(&self, claim_type: &str) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::delete_policy(&mut *pg_conn(p)?, claim_type), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::delete_policy(&mut *sqlite_conn(p)?, claim_type) + } + } + } + + pub fn get_profile_pref( + &self, + profile_id: &str, + claim_type: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::get_pref(&mut *pg_conn(p)?, profile_id, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::get_pref(&mut *sqlite_conn(p)?, profile_id, claim_type) + } + } + } + + pub fn list_profile_prefs( + &self, + profile_id: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::list_prefs_for_profile(&mut *pg_conn(p)?, profile_id) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::list_prefs_for_profile(&mut *sqlite_conn(p)?, profile_id) + } + } + } + + pub fn upsert_profile_pref( + &self, + profile_id: &str, + claim_type: &str, + auto_sign: bool, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::upsert_pref(&mut *pg_conn(p)?, profile_id, claim_type, auto_sign) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::upsert_pref( + &mut *sqlite_conn(p)?, + profile_id, + claim_type, + auto_sign, + ), + } + } + + pub fn list_trusted_issuers_for(&self, claim_type: &str) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::list_trusted_issuers_for(&mut *pg_conn(p)?, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::list_trusted_issuers_for(&mut *sqlite_conn(p)?, claim_type) + } + } + } + + pub fn list_all_trusted_issuers(&self) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::list_all_trusted_issuers(&mut *pg_conn(p)?), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::list_all_trusted_issuers(&mut *sqlite_conn(p)?) + } + } + } + + pub fn add_trusted_issuer(&self, claim_type: &str, issuer_domain: &str) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::add_trusted_issuer(&mut *pg_conn(p)?, claim_type, issuer_domain) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::add_trusted_issuer( + &mut *sqlite_conn(p)?, + claim_type, + issuer_domain, + ), + } + } + + pub fn remove_trusted_issuer( + &self, + claim_type: &str, + issuer_domain: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::remove_trusted_issuer( + &mut *pg_conn(p)?, + claim_type, + issuer_domain, + ), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::remove_trusted_issuer( + &mut *sqlite_conn(p)?, + claim_type, + issuer_domain, + ), + } + } + + pub fn list_release_policies(&self) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::list_release_policies(&mut *pg_conn(p)?), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::list_release_policies(&mut *sqlite_conn(p)?), + } + } + + pub fn list_release_policies_for_audience( + &self, + audience: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::list_release_policies_for_audience(&mut *pg_conn(p)?, audience) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::list_release_policies_for_audience( + &mut *sqlite_conn(p)?, + audience, + ), + } + } + + pub fn upsert_release_policy( + &self, + audience: &str, + claim_type: &str, + disposition: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::upsert_release_policy( + &mut *pg_conn(p)?, + audience, + claim_type, + disposition, + ), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::upsert_release_policy( + &mut *sqlite_conn(p)?, + audience, + claim_type, + disposition, + ), + } + } + + pub fn delete_release_policy(&self, audience: &str, claim_type: &str) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claim_policy::pg::delete_release_policy(&mut *pg_conn(p)?, audience, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::delete_release_policy( + &mut *sqlite_conn(p)?, + audience, + claim_type, + ), + } + } + + pub fn list_pending_approvals(&self) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => claim_policy::pg::list_pending_approvals(&mut *pg_conn(p)?), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claim_policy::sqlite::list_pending_approvals(&mut *sqlite_conn(p)?) + } + } + } + + pub fn find_approval(&self, id: &str) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = id.parse().map_err(|_| diesel::result::Error::NotFound)?; + claim_policy::pg::find_approval(&mut *pg_conn(p)?, uid) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::find_approval(&mut *sqlite_conn(p)?, id), + } + } + + pub fn enqueue_approval( + &self, + user_id: &str, + claim_type: &str, + claim_value: &[u8], + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let id = uuid::Uuid::now_v7(); + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + claim_policy::pg::enqueue_approval( + &mut *pg_conn(p)?, + id, + uid, + claim_type, + claim_value, + )?; + Ok(id.to_string()) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + let id = uuid::Uuid::now_v7().to_string(); + claim_policy::sqlite::enqueue_approval( + &mut *sqlite_conn(p)?, + &id, + user_id, + claim_type, + claim_value, + )?; + Ok(id) + } + } + } + + pub fn resolve_approval( + &self, + id: &str, + status: &str, + resolved_by: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = id.parse().map_err(|_| diesel::result::Error::NotFound)?; + claim_policy::pg::resolve_approval(&mut *pg_conn(p)?, uid, status, resolved_by) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => claim_policy::sqlite::resolve_approval( + &mut *sqlite_conn(p)?, + id, + status, + resolved_by, + ), + } + } + + pub fn revoke_active_claims_of_type( + &self, + user_id: &str, + claim_type: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + claims::pg::revoke_active_of_type(&mut *pg_conn(p)?, user_id, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + claims::sqlite::revoke_active_of_type(&mut *sqlite_conn(p)?, user_id, claim_type) + } + } + } + + pub fn create_email_verification( + &self, + token: &str, + user_id: &str, + email: &str, + expires_at: chrono::DateTime, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + email_verification::pg::create(&mut *pg_conn(p)?, token, uid, email, expires_at) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => email_verification::sqlite::create( + &mut *sqlite_conn(p)?, + token, + user_id, + email, + &expires_at.to_rfc3339(), + ), + } + } + + pub fn find_email_verification( + &self, + token: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => email_verification::pg::find(&mut *pg_conn(p)?, token), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => email_verification::sqlite::find(&mut *sqlite_conn(p)?, token), + } + } + + pub fn delete_email_verification(&self, token: &str) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => email_verification::pg::delete(&mut *pg_conn(p)?, token), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => email_verification::sqlite::delete(&mut *sqlite_conn(p)?, token), + } + } + + /// Append a peer domain's public key to the cache (no-op if already cached). + pub fn cache_peer_key(&self, key: &models::PeerKey) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => peer_keys::pg::cache(&mut *pg_conn(p)?, key), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => peer_keys::sqlite::cache(&mut *sqlite_conn(p)?, key), + } + } + + /// All cached public keys for a peer domain (for verifying stored external + /// signatures). + pub fn list_peer_keys_for_domain(&self, domain: &str) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => peer_keys::pg::list_for_domain(&mut *pg_conn(p)?, domain), + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => peer_keys::sqlite::list_for_domain(&mut *sqlite_conn(p)?, domain), + } + } + + pub fn add_user_release_pref( + &self, + user_id: &str, + audience: &str, + claim_type: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + user_release_prefs::pg::add(&mut *pg_conn(p)?, uid, audience, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => user_release_prefs::sqlite::add( + &mut *sqlite_conn(p)?, + user_id, + audience, + claim_type, + ), + } + } + + pub fn remove_user_release_pref( + &self, + user_id: &str, + audience: &str, + claim_type: &str, + ) -> QueryResult { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + user_release_prefs::pg::remove(&mut *pg_conn(p)?, uid, audience, claim_type) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => user_release_prefs::sqlite::remove( + &mut *sqlite_conn(p)?, + user_id, + audience, + claim_type, + ), + } + } + + /// Claim types the user pre-allows for `audience` (plus their global `*`). + pub fn list_user_release_allows( + &self, + user_id: &str, + audience: &str, + ) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + user_release_prefs::pg::list_allows(&mut *pg_conn(p)?, uid, audience) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + user_release_prefs::sqlite::list_allows(&mut *sqlite_conn(p)?, user_id, audience) + } + } + } + + /// All (audience, claim_type) standing prefs for the user. + pub fn list_user_release_prefs(&self, user_id: &str) -> QueryResult> { + match self { + #[cfg(feature = "postgres")] + DbPool::Postgres(p) => { + let uid: uuid::Uuid = user_id + .parse() + .map_err(|_| diesel::result::Error::NotFound)?; + user_release_prefs::pg::list_all(&mut *pg_conn(p)?, uid) + } + #[cfg(feature = "sqlite")] + DbPool::Sqlite(p) => { + user_release_prefs::sqlite::list_all(&mut *sqlite_conn(p)?, user_id) } } } @@ -1245,73 +1696,6 @@ impl DbPool { } } - /// Split existing administrators into a normal user + a separate - /// `_admin` admin account (with a copy of the user's password), and - /// demote the original to a normal user. Idempotent: an account that is - /// already an admin account, or whose `_admin` twin already exists, is - /// skipped; demoted originals no longer match. Best-effort per admin (logs + - /// skips on error) so one bad account can't abort the pass. Returns how many - /// admins were split. - pub fn split_admins(&self) -> QueryResult { - // Relations that confer administrative power on the domain. - const ADMIN_RELATIONS: &[&str] = &["admin", "manage_users", "manage_claims", "api_access"]; - let domain = crate::conversions::get_domain_name(); - let rels = self.list_relations_for_object("domain", &domain)?; - - let admin_ids: std::collections::BTreeSet = rels - .iter() - .filter(|r| { - r.subject_type == "user" && (r.relation == "admin" || r.relation == "manage_users") - }) - .map(|r| r.subject_id.clone()) - .collect(); - - let mut count = 0; - for uid in admin_ids { - let user = match self.find_user_by_id(&uid) { - Ok(u) => u, - Err(_) => continue, - }; - if user.is_admin_account { - continue; // already a separated admin account - } - let target = format!("{}_admin", user.username); - if self.find_user_by_username(&target).is_ok() { - continue; // already split - } - let hash = match self - .find_credentials_for_user(&uid, "password") - .ok() - .and_then(|c| c.into_iter().next()) - { - Some(cred) => cred.credential_hash, - None => { - log::warn!( - "admin split skipped {}: no password credential to copy", - user.username - ); - continue; - } - }; - if let Err(e) = self.create_admin_account(&target, &user.display_name, &hash) { - log::warn!("admin split: creating {} failed: {:?}", target, e); - continue; - } - // Demote the original: drop its administrative relations on the domain. - for r in rels.iter().filter(|r| { - r.subject_type == "user" - && r.subject_id == uid - && r.object_type == "domain" - && r.object_id == domain - && ADMIN_RELATIONS.contains(&r.relation.as_str()) - }) { - let _ = self.remove_relation(&r.id); - } - count += 1; - } - Ok(count) - } - pub fn update_display_name( &self, user_id: &str, diff --git a/crates/linkkeys/src/db/models.rs b/crates/linkkeys/src/db/models.rs index ac9faf7..d3afa5f 100644 --- a/crates/linkkeys/src/db/models.rs +++ b/crates/linkkeys/src/db/models.rs @@ -146,6 +146,85 @@ pub struct Profile { pub label: Option, } +/// One entry in the claim-type policy registry: the rules for setting and +/// signing a single claim type. See `liblinkkeys::claim_policy`. +#[derive(Debug, Clone)] +pub struct ClaimTypePolicy { + pub claim_type: String, + pub label: String, + pub description: String, + pub value_type: String, + pub max_bytes: i64, + pub set_rule: String, + pub signing_rule: String, + pub requires_approval: bool, + pub user_settable: bool, + pub default_auto_sign: bool, + pub suggested: bool, +} + +/// A domain whose signature is accepted as attestation for a claim type (lane C). +#[derive(Debug, Clone)] +pub struct TrustedIssuer { + pub claim_type: String, + pub issuer_domain: String, +} + +/// A user's per-profile auto-sign preference for one claim type. +#[derive(Debug, Clone)] +pub struct ProfileClaimPref { + pub profile_id: String, + pub claim_type: String, + pub auto_sign: bool, +} + +/// A per-audience release-policy row (forced_allow / forced_deny). Audience `*` +/// is the global default. +#[derive(Debug, Clone)] +pub struct ReleasePolicy { + pub audience: String, + pub claim_type: String, + pub disposition: String, +} + +/// A self-asserted claim awaiting admin approval before the IDP signs it. +#[derive(Debug, Clone)] +pub struct ClaimApproval { + pub id: String, + pub user_id: String, + pub claim_type: String, + pub claim_value: Vec, + pub status: String, + pub resolved_by: Option, + pub resolved_at: Option, + pub created_at: String, +} + +/// A pending email-verification challenge. `expires_at` is RFC3339 UTC. +#[derive(Debug, Clone)] +pub struct EmailVerification { + pub token: String, + pub user_id: String, + pub email: String, + pub expires_at: String, +} + +/// A cached public key of another domain (append-only). Lets us verify stored +/// external (attested) signatures even after the issuer rotates or disappears. +#[derive(Debug, Clone)] +pub struct PeerKey { + pub domain: String, + pub key_id: String, + pub public_key: Vec, + pub algorithm: String, + pub fingerprint: String, + pub key_usage: String, + /// RFC3339; honoured at verify time so an expired issuer key won't verify. + pub expires_at: String, + /// RFC3339 if the issuer revoked the key; honoured at verify time. + pub revoked_at: Option, +} + /// Parse a JSON `[String]` column. A parse failure (only reachable via DB /// corruption — the writer always emits valid JSON) yields an empty set, which /// is fail-safe (empty claim_types under-releases; empty requested_types @@ -420,7 +499,9 @@ pub mod pg { pub id: uuid::Uuid, pub claim_id: uuid::Uuid, pub domain: String, - pub signed_by_key_id: uuid::Uuid, + // VARCHAR, not UUID: a signature may come from an external issuer whose + // key id is not one of our domain_keys UUIDs. + pub signed_by_key_id: String, pub signature: Vec, pub created_at: chrono::DateTime, } @@ -431,7 +512,7 @@ pub mod pg { id: row.id.to_string(), claim_id: row.claim_id.to_string(), domain: row.domain, - signed_by_key_id: row.signed_by_key_id.to_string(), + signed_by_key_id: row.signed_by_key_id, signature: row.signature, created_at: row.created_at.to_rfc3339(), } @@ -444,7 +525,7 @@ pub mod pg { pub id: uuid::Uuid, pub claim_id: uuid::Uuid, pub domain: String, - pub signed_by_key_id: uuid::Uuid, + pub signed_by_key_id: String, pub signature: Vec, } @@ -575,6 +656,200 @@ pub mod pg { pub is_root: bool, pub label: Option, } + + // -- Claim-type policy registry -- + + #[derive(Queryable, Selectable, Insertable, AsChangeset)] + #[diesel(table_name = crate::schema::pg::claim_type_policies)] + #[diesel(primary_key(claim_type))] + pub struct ClaimTypePolicyRow { + pub claim_type: String, + pub label: String, + pub description: String, + pub value_type: String, + pub max_bytes: i64, + pub set_rule: String, + pub signing_rule: String, + pub requires_approval: bool, + pub user_settable: bool, + pub default_auto_sign: bool, + pub suggested: bool, + } + + impl From for super::ClaimTypePolicy { + fn from(r: ClaimTypePolicyRow) -> Self { + Self { + claim_type: r.claim_type, + label: r.label, + description: r.description, + value_type: r.value_type, + max_bytes: r.max_bytes, + set_rule: r.set_rule, + signing_rule: r.signing_rule, + requires_approval: r.requires_approval, + user_settable: r.user_settable, + default_auto_sign: r.default_auto_sign, + suggested: r.suggested, + } + } + } + + impl From for ClaimTypePolicyRow { + fn from(p: super::ClaimTypePolicy) -> Self { + Self { + claim_type: p.claim_type, + label: p.label, + description: p.description, + value_type: p.value_type, + max_bytes: p.max_bytes, + set_rule: p.set_rule, + signing_rule: p.signing_rule, + requires_approval: p.requires_approval, + user_settable: p.user_settable, + default_auto_sign: p.default_auto_sign, + suggested: p.suggested, + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::pg::trusted_issuers)] + pub struct TrustedIssuerRow { + pub claim_type: String, + pub issuer_domain: String, + } + + impl From for super::TrustedIssuer { + fn from(r: TrustedIssuerRow) -> Self { + Self { + claim_type: r.claim_type, + issuer_domain: r.issuer_domain, + } + } + } + + #[derive(Queryable, Selectable, Insertable, AsChangeset)] + #[diesel(table_name = crate::schema::pg::profile_claim_prefs)] + #[diesel(primary_key(profile_id, claim_type))] + pub struct ProfileClaimPrefRow { + pub profile_id: String, + pub claim_type: String, + pub auto_sign: bool, + } + + impl From for super::ProfileClaimPref { + fn from(r: ProfileClaimPrefRow) -> Self { + Self { + profile_id: r.profile_id, + claim_type: r.claim_type, + auto_sign: r.auto_sign, + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::pg::release_policies)] + pub struct ReleasePolicyRow { + pub audience: String, + pub claim_type: String, + pub disposition: String, + } + + impl From for super::ReleasePolicy { + fn from(r: ReleasePolicyRow) -> Self { + Self { + audience: r.audience, + claim_type: r.claim_type, + disposition: r.disposition, + } + } + } + + #[derive(Queryable, Selectable)] + #[diesel(table_name = crate::schema::pg::claim_approval_queue)] + pub struct ClaimApprovalRow { + pub id: uuid::Uuid, + pub user_id: uuid::Uuid, + pub claim_type: String, + pub claim_value: Vec, + pub status: String, + pub resolved_by: Option, + pub resolved_at: Option>, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + } + + impl From for super::ClaimApproval { + fn from(r: ClaimApprovalRow) -> Self { + Self { + id: r.id.to_string(), + user_id: r.user_id.to_string(), + claim_type: r.claim_type, + claim_value: r.claim_value, + status: r.status, + resolved_by: r.resolved_by, + resolved_at: r.resolved_at.map(|t| t.to_rfc3339()), + created_at: r.created_at.to_rfc3339(), + } + } + } + + #[derive(Insertable)] + #[diesel(table_name = crate::schema::pg::claim_approval_queue)] + pub struct NewClaimApprovalRow { + pub id: uuid::Uuid, + pub user_id: uuid::Uuid, + pub claim_type: String, + pub claim_value: Vec, + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::pg::email_verifications)] + pub struct EmailVerificationRow { + pub token: String, + pub user_id: uuid::Uuid, + pub email: String, + pub expires_at: chrono::DateTime, + } + + impl From for super::EmailVerification { + fn from(r: EmailVerificationRow) -> Self { + Self { + token: r.token, + user_id: r.user_id.to_string(), + email: r.email, + expires_at: r.expires_at.to_rfc3339(), + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::pg::peer_keys)] + pub struct PeerKeyRow { + pub domain: String, + pub key_id: String, + pub public_key: Vec, + pub algorithm: String, + pub fingerprint: String, + pub key_usage: String, + pub expires_at: String, + pub revoked_at: Option, + } + + impl From for super::PeerKey { + fn from(r: PeerKeyRow) -> Self { + Self { + domain: r.domain, + key_id: r.key_id, + public_key: r.public_key, + algorithm: r.algorithm, + fingerprint: r.fingerprint, + key_usage: r.key_usage, + expires_at: r.expires_at, + revoked_at: r.revoked_at, + } + } + } } #[cfg(feature = "sqlite")] @@ -991,4 +1266,198 @@ pub mod sqlite { pub is_root: i32, pub label: Option, } + + // -- Claim-type policy registry -- + + #[derive(Queryable, Selectable, Insertable, AsChangeset)] + #[diesel(table_name = crate::schema::sqlite::claim_type_policies)] + #[diesel(primary_key(claim_type))] + pub struct ClaimTypePolicyRow { + pub claim_type: String, + pub label: String, + pub description: String, + pub value_type: String, + pub max_bytes: i64, + pub set_rule: String, + pub signing_rule: String, + pub requires_approval: i32, + pub user_settable: i32, + pub default_auto_sign: i32, + pub suggested: i32, + } + + impl From for super::ClaimTypePolicy { + fn from(r: ClaimTypePolicyRow) -> Self { + Self { + claim_type: r.claim_type, + label: r.label, + description: r.description, + value_type: r.value_type, + max_bytes: r.max_bytes, + set_rule: r.set_rule, + signing_rule: r.signing_rule, + requires_approval: r.requires_approval != 0, + user_settable: r.user_settable != 0, + default_auto_sign: r.default_auto_sign != 0, + suggested: r.suggested != 0, + } + } + } + + impl From for ClaimTypePolicyRow { + fn from(p: super::ClaimTypePolicy) -> Self { + Self { + claim_type: p.claim_type, + label: p.label, + description: p.description, + value_type: p.value_type, + max_bytes: p.max_bytes, + set_rule: p.set_rule, + signing_rule: p.signing_rule, + requires_approval: i32::from(p.requires_approval), + user_settable: i32::from(p.user_settable), + default_auto_sign: i32::from(p.default_auto_sign), + suggested: i32::from(p.suggested), + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::sqlite::trusted_issuers)] + pub struct TrustedIssuerRow { + pub claim_type: String, + pub issuer_domain: String, + } + + impl From for super::TrustedIssuer { + fn from(r: TrustedIssuerRow) -> Self { + Self { + claim_type: r.claim_type, + issuer_domain: r.issuer_domain, + } + } + } + + #[derive(Queryable, Selectable, Insertable, AsChangeset)] + #[diesel(table_name = crate::schema::sqlite::profile_claim_prefs)] + #[diesel(primary_key(profile_id, claim_type))] + pub struct ProfileClaimPrefRow { + pub profile_id: String, + pub claim_type: String, + pub auto_sign: i32, + } + + impl From for super::ProfileClaimPref { + fn from(r: ProfileClaimPrefRow) -> Self { + Self { + profile_id: r.profile_id, + claim_type: r.claim_type, + auto_sign: r.auto_sign != 0, + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::sqlite::release_policies)] + pub struct ReleasePolicyRow { + pub audience: String, + pub claim_type: String, + pub disposition: String, + } + + impl From for super::ReleasePolicy { + fn from(r: ReleasePolicyRow) -> Self { + Self { + audience: r.audience, + claim_type: r.claim_type, + disposition: r.disposition, + } + } + } + + #[derive(Queryable, Selectable)] + #[diesel(table_name = crate::schema::sqlite::claim_approval_queue)] + pub struct ClaimApprovalRow { + pub id: String, + pub user_id: String, + pub claim_type: String, + pub claim_value: Vec, + pub status: String, + pub resolved_by: Option, + pub resolved_at: Option, + pub created_at: String, + pub updated_at: String, + } + + impl From for super::ClaimApproval { + fn from(r: ClaimApprovalRow) -> Self { + Self { + id: r.id, + user_id: r.user_id, + claim_type: r.claim_type, + claim_value: r.claim_value, + status: r.status, + resolved_by: r.resolved_by, + resolved_at: r.resolved_at, + created_at: r.created_at, + } + } + } + + #[derive(Insertable)] + #[diesel(table_name = crate::schema::sqlite::claim_approval_queue)] + pub struct NewClaimApprovalRow { + pub id: String, + pub user_id: String, + pub claim_type: String, + pub claim_value: Vec, + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::sqlite::email_verifications)] + pub struct EmailVerificationRow { + pub token: String, + pub user_id: String, + pub email: String, + pub expires_at: String, + } + + impl From for super::EmailVerification { + fn from(r: EmailVerificationRow) -> Self { + Self { + token: r.token, + user_id: r.user_id, + email: r.email, + expires_at: r.expires_at, + } + } + } + + #[derive(Queryable, Selectable, Insertable)] + #[diesel(table_name = crate::schema::sqlite::peer_keys)] + pub struct PeerKeyRow { + pub domain: String, + pub key_id: String, + pub public_key: Vec, + pub algorithm: String, + pub fingerprint: String, + pub key_usage: String, + pub expires_at: String, + pub revoked_at: Option, + } + + impl From for super::PeerKey { + fn from(r: PeerKeyRow) -> Self { + Self { + domain: r.domain, + key_id: r.key_id, + public_key: r.public_key, + algorithm: r.algorithm, + fingerprint: r.fingerprint, + key_usage: r.key_usage, + expires_at: r.expires_at, + revoked_at: r.revoked_at, + } + } + } } diff --git a/crates/linkkeys/src/db/peer_keys.rs b/crates/linkkeys/src/db/peer_keys.rs new file mode 100644 index 0000000..c643ce1 --- /dev/null +++ b/crates/linkkeys/src/db/peer_keys.rs @@ -0,0 +1,80 @@ +//! Append-only cache of other domains' public keys. We record keys we resolve +//! when accepting an externally-signed (attested) claim, so the claim's +//! signatures stay verifiable later even if the issuer rotates or disappears. +//! Never deleted; a rotated key is a new `(domain, key_id)` row. + +#[cfg(feature = "postgres")] +pub mod pg { + use diesel::prelude::*; + + use crate::db::models::pg::PeerKeyRow; + use crate::db::models::PeerKey; + use crate::schema::pg::peer_keys; + + /// Record a key if we haven't seen this `(domain, key_id)` before. Keeps the + /// first-seen copy (append-only); returns rows inserted (0 = already cached). + pub fn cache(conn: &mut diesel::PgConnection, key: &PeerKey) -> QueryResult { + diesel::insert_into(peer_keys::table) + .values(PeerKeyRow { + domain: key.domain.clone(), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + algorithm: key.algorithm.clone(), + fingerprint: key.fingerprint.clone(), + key_usage: key.key_usage.clone(), + expires_at: key.expires_at.clone(), + revoked_at: key.revoked_at.clone(), + }) + .on_conflict((peer_keys::domain, peer_keys::key_id)) + .do_nothing() + .execute(conn) + } + + pub fn list_for_domain( + conn: &mut diesel::PgConnection, + domain: &str, + ) -> QueryResult> { + peer_keys::table + .filter(peer_keys::domain.eq(domain)) + .select(PeerKeyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } +} + +#[cfg(feature = "sqlite")] +pub mod sqlite { + use diesel::prelude::*; + + use crate::db::models::sqlite::PeerKeyRow; + use crate::db::models::PeerKey; + use crate::schema::sqlite::peer_keys; + + pub fn cache(conn: &mut diesel::SqliteConnection, key: &PeerKey) -> QueryResult { + diesel::insert_into(peer_keys::table) + .values(PeerKeyRow { + domain: key.domain.clone(), + key_id: key.key_id.clone(), + public_key: key.public_key.clone(), + algorithm: key.algorithm.clone(), + fingerprint: key.fingerprint.clone(), + key_usage: key.key_usage.clone(), + expires_at: key.expires_at.clone(), + revoked_at: key.revoked_at.clone(), + }) + .on_conflict((peer_keys::domain, peer_keys::key_id)) + .do_nothing() + .execute(conn) + } + + pub fn list_for_domain( + conn: &mut diesel::SqliteConnection, + domain: &str, + ) -> QueryResult> { + peer_keys::table + .filter(peer_keys::domain.eq(domain)) + .select(PeerKeyRow::as_select()) + .load::(conn) + .map(|rows| rows.into_iter().map(Into::into).collect()) + } +} diff --git a/crates/linkkeys/src/db/profiles.rs b/crates/linkkeys/src/db/profiles.rs index 8ae4066..e8cc7b6 100644 --- a/crates/linkkeys/src/db/profiles.rs +++ b/crates/linkkeys/src/db/profiles.rs @@ -89,39 +89,6 @@ pub mod pg { .load::(conn) .map(|rows| rows.into_iter().map(Into::into).collect()) } - - /// Provision a default presentable profile (id reuses the account id) + a - /// fresh root anchor for every account that has no profiles yet. Idempotent; - /// returns how many accounts were backfilled. - pub fn backfill(conn: &mut diesel::PgConnection, domain: &str) -> QueryResult { - use crate::schema::pg::users; - // Admin accounts must never get a presentable profile. - let user_ids: Vec = users::table - .filter(users::is_admin_account.eq(false)) - .select(users::id) - .load(conn)?; - let existing: Vec = profiles::table.select(profiles::account_id).load(conn)?; - let have: std::collections::HashSet = existing.into_iter().collect(); - let mut count = 0; - for uid in user_ids { - if have.contains(&uid) { - continue; - } - // Per-account atomic provisioning. If a concurrent backfiller on - // another node won the race, our insert hits the PK and rolls back — - // skip and continue rather than aborting the whole pass. - if let Err(e) = conn.transaction::<(), diesel::result::Error, _>(|conn| { - create(conn, uid, uid, domain, false, None)?; - create(conn, uuid::Uuid::now_v7(), uid, domain, true, Some("root"))?; - Ok(()) - }) { - log::warn!("profile backfill skipped account {}: {}", uid, e); - continue; - } - count += 1; - } - Ok(count) - } } #[cfg(feature = "sqlite")] @@ -206,37 +173,4 @@ pub mod sqlite { .load::(conn) .map(|rows| rows.into_iter().map(Into::into).collect()) } - - pub fn backfill(conn: &mut diesel::SqliteConnection, domain: &str) -> QueryResult { - use crate::schema::sqlite::users; - let user_ids: Vec = users::table - .filter(users::is_admin_account.eq(0)) - .select(users::id) - .load(conn)?; - let existing: Vec = profiles::table.select(profiles::account_id).load(conn)?; - let have: std::collections::HashSet = existing.into_iter().collect(); - let mut count = 0; - for uid in user_ids { - if have.contains(&uid) { - continue; - } - if let Err(e) = conn.transaction::<(), diesel::result::Error, _>(|conn| { - create(conn, &uid, &uid, domain, false, None)?; - create( - conn, - &uuid::Uuid::now_v7().to_string(), - &uid, - domain, - true, - Some("root"), - )?; - Ok(()) - }) { - log::warn!("profile backfill skipped account {}: {}", uid, e); - continue; - } - count += 1; - } - Ok(count) - } } diff --git a/crates/linkkeys/src/db/user_release_prefs.rs b/crates/linkkeys/src/db/user_release_prefs.rs new file mode 100644 index 0000000..275a502 --- /dev/null +++ b/crates/linkkeys/src/db/user_release_prefs.rs @@ -0,0 +1,126 @@ +//! Persistence for a user's standing release preferences — claim types they +//! pre-authorize for an audience (or `*` = any domain), set from their own +//! profile editor. Surfaced as pre-checked rows at consent. + +#[cfg(feature = "postgres")] +pub mod pg { + use diesel::prelude::*; + + use crate::schema::pg::user_release_prefs as t; + + pub fn add( + conn: &mut diesel::PgConnection, + user_id: uuid::Uuid, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::insert_into(t::table) + .values(( + t::user_id.eq(user_id), + t::audience.eq(audience), + t::claim_type.eq(claim_type), + )) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn remove( + conn: &mut diesel::PgConnection, + user_id: uuid::Uuid, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::delete( + t::table + .filter(t::user_id.eq(user_id)) + .filter(t::audience.eq(audience)) + .filter(t::claim_type.eq(claim_type)), + ) + .execute(conn) + } + + /// Claim types this user pre-allows for `audience`, including their global + /// `*` (any domain) preferences. + pub fn list_allows( + conn: &mut diesel::PgConnection, + user_id: uuid::Uuid, + audience: &str, + ) -> QueryResult> { + t::table + .filter(t::user_id.eq(user_id)) + .filter(t::audience.eq(audience).or(t::audience.eq("*"))) + .select(t::claim_type) + .load::(conn) + } + + /// All (audience, claim_type) standing prefs for the user, for the editor. + pub fn list_all( + conn: &mut diesel::PgConnection, + user_id: uuid::Uuid, + ) -> QueryResult> { + t::table + .filter(t::user_id.eq(user_id)) + .select((t::audience, t::claim_type)) + .load::<(String, String)>(conn) + } +} + +#[cfg(feature = "sqlite")] +pub mod sqlite { + use diesel::prelude::*; + + use crate::schema::sqlite::user_release_prefs as t; + + pub fn add( + conn: &mut diesel::SqliteConnection, + user_id: &str, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::insert_into(t::table) + .values(( + t::user_id.eq(user_id), + t::audience.eq(audience), + t::claim_type.eq(claim_type), + )) + .on_conflict_do_nothing() + .execute(conn) + } + + pub fn remove( + conn: &mut diesel::SqliteConnection, + user_id: &str, + audience: &str, + claim_type: &str, + ) -> QueryResult { + diesel::delete( + t::table + .filter(t::user_id.eq(user_id)) + .filter(t::audience.eq(audience)) + .filter(t::claim_type.eq(claim_type)), + ) + .execute(conn) + } + + pub fn list_allows( + conn: &mut diesel::SqliteConnection, + user_id: &str, + audience: &str, + ) -> QueryResult> { + t::table + .filter(t::user_id.eq(user_id)) + .filter(t::audience.eq(audience).or(t::audience.eq("*"))) + .select(t::claim_type) + .load::(conn) + } + + pub fn list_all( + conn: &mut diesel::SqliteConnection, + user_id: &str, + ) -> QueryResult> { + t::table + .filter(t::user_id.eq(user_id)) + .select((t::audience, t::claim_type)) + .load::<(String, String)>(conn) + } +} diff --git a/crates/linkkeys/src/email.rs b/crates/linkkeys/src/email.rs new file mode 100644 index 0000000..f3897f5 --- /dev/null +++ b/crates/linkkeys/src/email.rs @@ -0,0 +1,24 @@ +//! Outbound email seam. There is no SMTP integration yet: by default we log the +//! message (including the verification link) at info level so dogfood deployments +//! can complete the flow by copying the link from the logs. A real provider +//! plugs in here behind the same `send` function. +//! +//! TODO(later-session): add an SMTP / provider backend gated by env config +//! (e.g. `SMTP_URL`); keep the log fallback for local/dev. + +/// Send an email. Returns `Ok` once handed off (here: logged). Never logs the +/// body at a level that would leak secrets in production — the verification link +/// is a single-use, short-lived token, logged at info deliberately for dogfood. +pub fn send(to: &str, subject: &str, body: &str) -> Result<(), String> { + log::info!("[email] to={} subject={:?}\n{}", to, subject, body); + Ok(()) +} + +/// Convenience for the verification message. +pub fn send_verification_email(to: &str, link: &str) -> Result<(), String> { + let body = format!( + "Confirm this email address to verify it with your LinkKeys domain:\n\n{}\n\nIf you didn't request this, ignore this message.", + link + ); + send(to, "Verify your email address", &body) +} diff --git a/crates/linkkeys/src/lib.rs b/crates/linkkeys/src/lib.rs index 970ea30..35ea3c1 100644 --- a/crates/linkkeys/src/lib.rs +++ b/crates/linkkeys/src/lib.rs @@ -5,6 +5,7 @@ pub mod claim_signing; pub mod conversions; pub mod db; pub mod dns; +pub mod email; pub mod net; pub mod rp_config; pub mod schema; diff --git a/crates/linkkeys/src/main.rs b/crates/linkkeys/src/main.rs index 68c666b..465d699 100644 --- a/crates/linkkeys/src/main.rs +++ b/crates/linkkeys/src/main.rs @@ -28,20 +28,13 @@ async fn main() { let pool = db_pool.clone(); let flag = ready_flag.clone(); thread::spawn(move || { + // Schema migrations (diesel-tracked), then startup TRANSFORMS: + // idempotent data ops that must run at least once, in order. + // Transforms that were applied to every deployment from main + // (legacy-claim re-sign, profile backfill, admin split) have + // been removed; new transforms join the ordered list below. linkkeys::db::run_migrations_with_locking(&pool); - resign_legacy_claims_on_startup(&pool); - match pool.backfill_profiles() { - Ok(n) if n > 0 => log::info!("Backfilled profiles for {} account(s)", n), - Ok(_) => {} - Err(e) => log::error!("Profile backfill failed: {}", e), - } - match pool.split_admins() { - Ok(n) if n > 0 => { - log::info!("Split {} admin(s) into separate admin accounts", n) - } - Ok(_) => {} - Err(e) => log::error!("Admin split failed: {}", e), - } + run_startup_transforms(&pool); // Signal readiness only after every startup DB write is done, // so the TCP server's domain-key read can't contend on the // SQLite lock (which previously failed its TLS setup). @@ -118,120 +111,19 @@ async fn main() { } } -/// TEMPORARY (added 2026-06-14, pre-alpha): re-sign claims whose signatures -/// don't verify under the current signed-payload construction. +/// Run startup TRANSFORMS in order. A transform is an idempotent data operation +/// that must run at least once; unlike a schema migration (diesel-tracked) it is +/// safe to re-run every boot. Each is best-effort: a failure is logged but never +/// aborts the boot. The list is the single source of order. /// -/// Pre-existing claims may carry signatures over an older payload (e.g. before -/// the subject's home domain was bound, or none at all after the -/// claim_signatures migration). This re-signs them with this domain's active -/// signing keys so they verify again. Idempotent: a claim that already verifies -/// is skipped, so this is a no-op on every boot after it converges. Failures are -/// logged but never fatal — a server with no claims, keys, or passphrase simply -/// skips. Remove once all deployments have moved past pre-alpha. -fn resign_legacy_claims_on_startup(pool: &linkkeys::db::DbPool) { - let claims = match pool.list_all_claims() { - Ok(c) => c, - Err(e) => { - log::error!("re-sign backfill: failed to list claims: {}", e); - return; - } - }; - if claims.is_empty() { - return; - } - - let domain_keys = match pool.list_active_domain_keys() { - Ok(k) => k, - Err(e) => { - log::error!("re-sign backfill: failed to list domain keys: {}", e); - return; - } - }; - - // The subject of every local claim is a local user, so the subject domain is - // our own. Verification uses our public keys (no passphrase needed). - let subject_domain = linkkeys::conversions::get_domain_name(); - let local_keys: Vec = - domain_keys.iter().map(Into::into).collect(); - let keysets = vec![liblinkkeys::claims::DomainKeySet { - domain: subject_domain.clone(), - keys: local_keys, - }]; - - // Claims whose signatures don't verify under the current payload. Skip any - // that carry a signature from another domain — we can't re-create a foreign - // domain's signature, so replacing all signatures would drop it. - let stale: Vec<&linkkeys::db::models::ClaimRow> = claims - .iter() - .filter(|c| { - let claim: liblinkkeys::generated::types::Claim = (*c).into(); - if liblinkkeys::claims::verify_claim_signatures(&claim, &subject_domain, &keysets) - .is_ok() - { - return false; - } - if claim.signatures.iter().any(|s| s.domain != subject_domain) { - log::warn!( - "re-sign backfill: claim {} has signatures from other domains; skipping", - c.id - ); - return false; - } - true - }) - .collect(); - if stale.is_empty() { - return; - } - - let passphrase = match std::env::var("DOMAIN_KEY_PASSPHRASE") { - Ok(p) => p, - Err(_) => { - log::warn!( - "re-sign backfill: {} claim(s) need re-signing but DOMAIN_KEY_PASSPHRASE \ - is not set; skipping", - stale.len() - ); - return; - } - }; - let signers = match linkkeys::claim_signing::active_signers(&domain_keys, passphrase.as_bytes()) - { - Ok(s) => s, - Err(e) => { - log::warn!("re-sign backfill: cannot sign ({}); skipping", e); - return; - } - }; - - let mut resigned = 0usize; - for c in stale { - let spec = liblinkkeys::claims::ClaimSpec { - claim_id: &c.id, - claim_type: &c.claim_type, - claim_value: &c.claim_value, - user_id: &c.user_id, - subject_domain: &subject_domain, - expires_at: c.expires_at.as_deref(), - }; - let signed = match linkkeys::claim_signing::sign_with_active(&spec, &signers) { - Ok(s) => s, - Err(e) => { - log::error!("re-sign backfill: failed to sign claim {}: {}", c.id, e); - continue; - } - }; - if let Err(e) = pool.replace_claim_signatures(&c.id, &signed.signatures) { - log::error!( - "re-sign backfill: failed to store signatures for {}: {}", - c.id, - e - ); - continue; - } - resigned += 1; +/// (Transforms that had been applied to every deployment from main — legacy +/// claim re-signing, profile backfill, admin split — were removed once universal.) +fn run_startup_transforms(pool: &linkkeys::db::DbPool) { + match pool.seed_default_policies() { + Ok(n) if n > 0 => log::info!("Seeded {} default claim-type polic(ies)", n), + Ok(_) => {} + Err(e) => log::error!("Policy seed transform failed: {}", e), } - log::info!("re-sign backfill: re-signed {} claim(s)", resigned); } fn get_passphrase() -> String { diff --git a/crates/linkkeys/src/schema.rs b/crates/linkkeys/src/schema.rs index 967651c..168d99f 100644 --- a/crates/linkkeys/src/schema.rs +++ b/crates/linkkeys/src/schema.rs @@ -104,12 +104,26 @@ pub mod pg { id -> Uuid, claim_id -> Uuid, domain -> Varchar, - signed_by_key_id -> Uuid, + signed_by_key_id -> Varchar, signature -> Binary, created_at -> Timestamptz, } } + diesel::table! { + peer_keys (domain, key_id) { + domain -> Varchar, + key_id -> Varchar, + public_key -> Binary, + algorithm -> Varchar, + fingerprint -> Varchar, + key_usage -> Varchar, + expires_at -> Varchar, + revoked_at -> Nullable, + first_seen -> Timestamptz, + } + } + diesel::table! { used_nonces (nonce) { nonce -> Varchar, @@ -147,13 +161,92 @@ pub mod pg { } } + diesel::table! { + claim_type_policies (claim_type) { + claim_type -> Varchar, + label -> Varchar, + description -> Varchar, + value_type -> Varchar, + max_bytes -> BigInt, + set_rule -> Varchar, + signing_rule -> Varchar, + requires_approval -> Bool, + user_settable -> Bool, + default_auto_sign -> Bool, + suggested -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + + diesel::table! { + trusted_issuers (claim_type, issuer_domain) { + claim_type -> Varchar, + issuer_domain -> Varchar, + created_at -> Timestamptz, + } + } + + diesel::table! { + profile_claim_prefs (profile_id, claim_type) { + profile_id -> Varchar, + claim_type -> Varchar, + auto_sign -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + + diesel::table! { + release_policies (audience, claim_type) { + audience -> Varchar, + claim_type -> Varchar, + disposition -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + + diesel::table! { + claim_approval_queue (id) { + id -> Uuid, + user_id -> Uuid, + claim_type -> Varchar, + claim_value -> Binary, + status -> Varchar, + resolved_by -> Nullable, + resolved_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } + } + + diesel::table! { + email_verifications (token) { + token -> Varchar, + user_id -> Uuid, + email -> Varchar, + expires_at -> Timestamptz, + created_at -> Timestamptz, + } + } + + diesel::table! { + user_release_prefs (user_id, audience, claim_type) { + user_id -> Uuid, + audience -> Varchar, + claim_type -> Varchar, + created_at -> Timestamptz, + } + } + diesel::joinable!(user_keys -> users (user_id)); diesel::joinable!(claims -> users (user_id)); diesel::joinable!(claim_signatures -> claims (claim_id)); - diesel::joinable!(claim_signatures -> domain_keys (signed_by_key_id)); diesel::joinable!(auth_credentials -> users (user_id)); diesel::joinable!(consent_grants -> users (user_id)); diesel::joinable!(profiles -> users (account_id)); + diesel::joinable!(claim_approval_queue -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( guestbook_entries, domain_keys, @@ -162,9 +255,17 @@ pub mod pg { user_keys, claims, claim_signatures, + peer_keys, relations, consent_grants, profiles, + claim_type_policies, + trusted_issuers, + profile_claim_prefs, + release_policies, + claim_approval_queue, + email_verifications, + user_release_prefs, ); } @@ -277,6 +378,20 @@ pub mod sqlite { } } + diesel::table! { + peer_keys (domain, key_id) { + domain -> Text, + key_id -> Text, + public_key -> Binary, + algorithm -> Text, + fingerprint -> Text, + key_usage -> Text, + expires_at -> Text, + revoked_at -> Nullable, + first_seen -> Text, + } + } + diesel::table! { used_nonces (nonce) { nonce -> Text, @@ -314,13 +429,92 @@ pub mod sqlite { } } + diesel::table! { + claim_type_policies (claim_type) { + claim_type -> Text, + label -> Text, + description -> Text, + value_type -> Text, + max_bytes -> BigInt, + set_rule -> Text, + signing_rule -> Text, + requires_approval -> Integer, + user_settable -> Integer, + default_auto_sign -> Integer, + suggested -> Integer, + created_at -> Text, + updated_at -> Text, + } + } + + diesel::table! { + trusted_issuers (claim_type, issuer_domain) { + claim_type -> Text, + issuer_domain -> Text, + created_at -> Text, + } + } + + diesel::table! { + profile_claim_prefs (profile_id, claim_type) { + profile_id -> Text, + claim_type -> Text, + auto_sign -> Integer, + created_at -> Text, + updated_at -> Text, + } + } + + diesel::table! { + release_policies (audience, claim_type) { + audience -> Text, + claim_type -> Text, + disposition -> Text, + created_at -> Text, + updated_at -> Text, + } + } + + diesel::table! { + claim_approval_queue (id) { + id -> Text, + user_id -> Text, + claim_type -> Text, + claim_value -> Binary, + status -> Text, + resolved_by -> Nullable, + resolved_at -> Nullable, + created_at -> Text, + updated_at -> Text, + } + } + + diesel::table! { + email_verifications (token) { + token -> Text, + user_id -> Text, + email -> Text, + expires_at -> Text, + created_at -> Text, + } + } + + diesel::table! { + user_release_prefs (user_id, audience, claim_type) { + user_id -> Text, + audience -> Text, + claim_type -> Text, + created_at -> Text, + } + } + diesel::joinable!(user_keys -> users (user_id)); diesel::joinable!(claims -> users (user_id)); diesel::joinable!(claim_signatures -> claims (claim_id)); - diesel::joinable!(claim_signatures -> domain_keys (signed_by_key_id)); diesel::joinable!(auth_credentials -> users (user_id)); diesel::joinable!(consent_grants -> users (user_id)); diesel::joinable!(profiles -> users (account_id)); + diesel::joinable!(claim_approval_queue -> users (user_id)); diesel::allow_tables_to_appear_in_same_query!( guestbook_entries, domain_keys, @@ -329,8 +523,16 @@ pub mod sqlite { user_keys, claims, claim_signatures, + peer_keys, relations, consent_grants, profiles, + claim_type_policies, + trusted_issuers, + profile_claim_prefs, + release_policies, + claim_approval_queue, + email_verifications, + user_release_prefs, ); } diff --git a/crates/linkkeys/src/services/admin.rs b/crates/linkkeys/src/services/admin.rs index f1ea216..47f6e94 100644 --- a/crates/linkkeys/src/services/admin.rs +++ b/crates/linkkeys/src/services/admin.rs @@ -253,6 +253,39 @@ pub fn set_claim(pool: &DbPool, req: SetClaimRequest) -> Result Result<(), ServiceError> { + let approval = pool.find_approval(approval_id).map_err(|e| match e { + diesel::result::Error::NotFound => ServiceError { + code: 404, + message: "Approval not found".to_string(), + }, + other => db_err(other), + })?; + if approval.status != "pending" { + return Err(svc_err("approval already resolved")); + } + crate::services::self_service::sign_and_store( + pool, + &approval.user_id, + &approval.claim_type, + &approval.claim_value, + )?; + pool.resolve_approval(approval_id, "approved", admin_id) + .map_err(db_err)?; + Ok(()) +} + +/// Reject a queued claim: mark it rejected without signing anything. +pub fn reject_claim(pool: &DbPool, approval_id: &str, admin_id: &str) -> Result<(), ServiceError> { + pool.resolve_approval(approval_id, "rejected", admin_id) + .map_err(db_err)?; + Ok(()) +} + pub fn remove_claim( pool: &DbPool, req: RemoveClaimRequest, diff --git a/crates/linkkeys/src/services/attestation.rs b/crates/linkkeys/src/services/attestation.rs new file mode 100644 index 0000000..896ba5b --- /dev/null +++ b/crates/linkkeys/src/services/attestation.rs @@ -0,0 +1,285 @@ +//! Attested (lane-C) claims: claims signed by a THIRD-PARTY issuer about one of +//! our accounts. Per docs/claim-trust-verification.md the issuer's signature is +//! kept and exposed — we never strip it and re-attest in our own name — so any +//! verifier can check it against the issuer's public keys (trust but verify). +//! +//! This module holds the synchronous primitives: build the key sets for a +//! claim's signer domains (our own keys for our domain, the append-only peer-key +//! cache for others), verify a stored claim, and gate-then-store an externally +//! signed claim. Resolving a brand-new issuer's keys over the network (and +//! caching them) is the deposit layer that sits on top of these. + +use std::collections::BTreeSet; + +use liblinkkeys::claims::{ClaimSigner, DomainKeySet}; +use liblinkkeys::generated::services::ServiceError; +use liblinkkeys::generated::types::{Claim, DomainPublicKey, SignedSigningRequest}; +use liblinkkeys::signing_request::{sign_signing_request, SigningRequestSpec}; + +use crate::conversions::get_domain_name; +use crate::db::{models, DbPool}; + +/// Default max validity for a minted signing request: 2 days. Generous on +/// purpose — a user without a portable device might print a QR at a library and +/// physically carry it to the issuer (e.g. a DMV), so a 1-hour window would be +/// hostile to people in low-device contexts. Override with +/// `SIGNING_REQUEST_TTL_SECONDS`. +const DEFAULT_SIGNING_REQUEST_TTL_SECONDS: i64 = 2 * 24 * 60 * 60; + +fn signing_request_ttl_seconds() -> i64 { + std::env::var("SIGNING_REQUEST_TTL_SECONDS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(DEFAULT_SIGNING_REQUEST_TTL_SECONDS) +} + +fn svc_err(code: i32, msg: &str) -> ServiceError { + ServiceError { + code, + message: msg.to_string(), + } +} + +fn db_err(e: diesel::result::Error) -> ServiceError { + log::error!("Database error: {}", e); + svc_err(500, "Internal database error") +} + +/// A claim's verification result for display — what it asserts, who signed it, +/// and whether every signer's signature checks out against that signer's keys. +#[derive(Debug, Clone)] +pub struct ClaimVerification { + pub claim_type: String, + pub value: String, + pub signed_by: Vec, + pub verified: bool, +} + +fn peer_to_public(p: &models::PeerKey) -> DomainPublicKey { + DomainPublicKey { + key_id: p.key_id.clone(), + public_key: p.public_key.clone(), + fingerprint: p.fingerprint.clone(), + algorithm: p.algorithm.clone(), + key_usage: p.key_usage.clone(), + created_at: String::new(), + expires_at: p.expires_at.clone(), + revoked_at: p.revoked_at.clone(), + signed_by_key_id: None, + key_signature: None, + } +} + +/// The distinct domains that signed a claim. +fn signer_domains(claim: &Claim) -> Vec { + let mut set: BTreeSet<&str> = BTreeSet::new(); + for s in &claim.signatures { + set.insert(s.domain.as_str()); + } + set.into_iter().map(str::to_string).collect() +} + +/// Resolve candidate keys for every domain that signed `claim`: our own active +/// domain keys for our domain, the peer-key cache for any other. A domain with +/// no resolvable keys yields an empty set, so its signatures read as unverified +/// (fail closed) rather than erroring. +fn build_keysets(pool: &DbPool, claim: &Claim) -> Vec { + let our = get_domain_name(); + signer_domains(claim) + .into_iter() + .map(|d| { + let keys: Vec = if d == our { + pool.list_active_domain_keys() + .unwrap_or_default() + .iter() + .map(DomainPublicKey::from) + .collect() + } else { + pool.list_peer_keys_for_domain(&d) + .unwrap_or_default() + .iter() + .map(peer_to_public) + .collect() + }; + DomainKeySet { domain: d, keys } + }) + .collect() +} + +/// Base URL used to tell an issuer where to deposit the resulting signed claim. +fn public_origin() -> String { + std::env::var("PUBLIC_ORIGIN").unwrap_or_else(|_| format!("https://{}", get_domain_name())) +} + +/// Mint a home-domain-signed [`SignedSigningRequest`] for `subject_user_id`, +/// addressed to `issuer_domain`, asking it to attest `requested_types`. The user +/// pulls this from the API and conveys it to the issuer however they like +/// (base64 in a QR, a file, an HTTP POST). Signed with the domain's active keys. +pub fn mint_signing_request( + pool: &DbPool, + subject_user_id: &str, + issuer_domain: &str, + requested_types: &[String], +) -> Result { + let passphrase = std::env::var("DOMAIN_KEY_PASSPHRASE") + .map_err(|_| svc_err(500, "DOMAIN_KEY_PASSPHRASE not set"))?; + let domain_keys = pool.list_active_domain_keys().map_err(db_err)?; + let signers = crate::claim_signing::active_signers(&domain_keys, passphrase.as_bytes()) + .map_err(|e| svc_err(500, &e.to_string()))?; + + let our = get_domain_name(); + let claim_signers: Vec = signers + .iter() + .map(|s| ClaimSigner { + domain: &our, + key_id: &s.key_id, + algorithm: s.algorithm, + private_key_bytes: &s.private_key, + }) + .collect(); + + let request_id = uuid::Uuid::now_v7().to_string(); + let nonce = uuid::Uuid::now_v7().to_string(); + let issued_at = chrono::Utc::now().to_rfc3339(); + let expires_at = (chrono::Utc::now() + + chrono::Duration::seconds(signing_request_ttl_seconds())) + .to_rfc3339(); + // Where the issuer deposits the resulting claim: this domain's CBOR-RPC + // carrier. The issuer normally resolves this from DNS, but we include it for + // convenience / out-of-band issuers. + let callback = format!("{}/csil/v1/rpc", public_origin()); + + sign_signing_request( + &SigningRequestSpec { + request_id: &request_id, + subject_user_id, + subject_domain: &our, + issuer_domain, + requested_claim_types: requested_types, + nonce: &nonce, + issued_at: &issued_at, + expires_at: &expires_at, + callback: Some(&callback), + }, + &claim_signers, + ) + .map_err(|e| svc_err(500, &e.to_string())) +} + +/// Issue (sign) a claim about a subject as THIS domain acting as the issuer. +/// Used by the receive/accept flow: after verifying a signing request, an +/// authorized operator signs the requested claim about `subject_user_id@subject_domain` +/// with our domain keys. The subject may be remote — `subject_domain` comes from +/// the (verified) request, bound into the signature. The result is handed back / +/// deposited at the subject's domain. +pub fn issue_attested_claim( + pool: &DbPool, + subject_user_id: &str, + subject_domain: &str, + claim_type: &str, + value: &[u8], +) -> Result { + let passphrase = std::env::var("DOMAIN_KEY_PASSPHRASE") + .map_err(|_| svc_err(500, "DOMAIN_KEY_PASSPHRASE not set"))?; + let domain_keys = pool.list_active_domain_keys().map_err(db_err)?; + let signers = crate::claim_signing::active_signers(&domain_keys, passphrase.as_bytes()) + .map_err(|e| svc_err(500, &e.to_string()))?; + let our = get_domain_name(); + let claim_signers: Vec = signers + .iter() + .map(|s| ClaimSigner { + domain: &our, + key_id: &s.key_id, + algorithm: s.algorithm, + private_key_bytes: &s.private_key, + }) + .collect(); + let claim_id = uuid::Uuid::now_v7().to_string(); + liblinkkeys::claims::sign_claim( + &liblinkkeys::claims::ClaimSpec { + claim_id: &claim_id, + claim_type, + claim_value: value, + user_id: subject_user_id, + subject_domain, + expires_at: None, + }, + &claim_signers, + ) + .map_err(|e| svc_err(500, &e.to_string())) +} + +/// Verify a claim we hold: every signing domain must contribute a valid +/// signature from a currently-valid key of that domain (resolved from our keys / +/// the peer cache), and the claim itself must not be revoked/expired. The +/// "one-click verify" a viewer's IDP performs. +pub fn verify_stored_claim(pool: &DbPool, claim: &Claim) -> ClaimVerification { + let our = get_domain_name(); + let sets = build_keysets(pool, claim); + let verified = liblinkkeys::claims::verify_claim(claim, &our, &sets).is_ok(); + ClaimVerification { + claim_type: claim.claim_type.clone(), + value: String::from_utf8_lossy(&claim.claim_value).to_string(), + signed_by: signer_domains(claim), + verified, + } +} + +/// Accept an externally-signed claim about `subject_id` (an account UUID) and +/// store it verbatim (issuer signatures intact). Gated: at least one signing +/// domain must be a trusted issuer for the claim type, the signatures must +/// verify against keys already cached (caller resolves/caches them first for a +/// new issuer), and the claim's subject must match `subject_id`. +pub fn verify_and_store_attested( + pool: &DbPool, + subject_id: &str, + claim: &Claim, +) -> Result<(), ServiceError> { + if claim.user_id != subject_id { + return Err(svc_err(400, "claim subject does not match the account")); + } + if claim.signatures.is_empty() { + return Err(svc_err(400, "an attested claim must carry a signature")); + } + + // Trust gate: we only admit an attestation from a domain the admin has + // marked as a trusted issuer for this claim type. + let trusted = pool + .list_trusted_issuers_for(&claim.claim_type) + .map_err(db_err)?; + let trusted_set: BTreeSet<&str> = trusted.iter().map(String::as_str).collect(); + let from_trusted = signer_domains(claim) + .iter() + .any(|d| trusted_set.contains(d.as_str())); + if !from_trusted { + return Err(svc_err( + 403, + &liblinkkeys::claim_policy::RejectionReason::SetterNotAuthorized.to_string(), + )); + } + + // Cryptographic verification against the resolved (cached) issuer keys. + let our = get_domain_name(); + let sets = build_keysets(pool, claim); + liblinkkeys::claims::verify_claim(claim, &our, &sets) + .map_err(|_| svc_err(400, "the issuer signature did not verify"))?; + + let expires = claim + .expires_at + .as_deref() + .map(|s| chrono::DateTime::parse_from_rfc3339(s).map(|dt| dt.with_timezone(&chrono::Utc))) + .transpose() + .map_err(|_| svc_err(400, "invalid expires_at"))?; + + pool.create_claim( + &claim.claim_id, + subject_id, + &claim.claim_type, + &claim.claim_value, + &claim.signatures, + expires, + ) + .map_err(db_err)?; + Ok(()) +} diff --git a/crates/linkkeys/src/services/mod.rs b/crates/linkkeys/src/services/mod.rs index 92613b0..70e99d8 100644 --- a/crates/linkkeys/src/services/mod.rs +++ b/crates/linkkeys/src/services/mod.rs @@ -1,8 +1,11 @@ pub mod account; pub mod admin; +pub mod attestation; pub mod auth; pub mod authorization; pub mod handshake; pub mod hello; pub mod ops; pub mod password; +pub mod self_service; +pub mod verification; diff --git a/crates/linkkeys/src/services/self_service.rs b/crates/linkkeys/src/services/self_service.rs new file mode 100644 index 0000000..6e769fc --- /dev/null +++ b/crates/linkkeys/src/services/self_service.rs @@ -0,0 +1,246 @@ +//! User self-service over the claim-type policy registry: a user setting their +//! own claims (auto-validated and -signed per the registry), toggling whether a +//! claim is kept signed, managing their own profiles, and discovering what they +//! may set. The set/sign decision is the pure `liblinkkeys::claim_policy` +//! evaluator; this module is the fallible adapter that loads policy, signs with +//! the domain keys, and persists. +//! +//! Every operation here takes a structured input and returns a structured +//! result, so the same logic backs the web editor today and a CLI / native agent +//! later — the transport only renders. + +use std::env; + +use liblinkkeys::claim_policy::{ + evaluate_set, ClaimPolicy, SetAction, SetRule, Setter, SigningRule, ValueType, +}; +use liblinkkeys::generated::services::ServiceError; + +use crate::db::models; +use crate::db::DbPool; + +fn svc_err(code: i32, msg: &str) -> ServiceError { + ServiceError { + code, + message: msg.to_string(), + } +} + +fn db_err(e: diesel::result::Error) -> ServiceError { + log::error!("Database error: {}", e); + svc_err(500, "Internal database error") +} + +/// What happened to a self-service set attempt — the caller renders an +/// appropriate message/badge. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetOutcome { + /// Validated and signed with the domain keys; the value is now verified. + Signed, + /// Stored without a domain signature (auto-sign off, or an unsigned lane). + StoredUnsigned, + /// A verification flow is required before this value can be signed (lane B). + VerificationRequired, + /// Held for admin approval before signing. + Queued, +} + +/// Map a registry row onto the pure evaluator's policy view, or fail if the row +/// holds an unparseable rule (a registry corruption / generator bug, not user +/// error). +fn to_policy(row: &models::ClaimTypePolicy) -> Result { + let value_type = ValueType::parse(&row.value_type) + .ok_or_else(|| svc_err(500, "registry: bad value_type"))?; + let set_rule = + SetRule::parse(&row.set_rule).ok_or_else(|| svc_err(500, "registry: bad set_rule"))?; + let signing_rule = SigningRule::parse(&row.signing_rule) + .ok_or_else(|| svc_err(500, "registry: bad signing_rule"))?; + Ok(ClaimPolicy { + claim_type: row.claim_type.clone(), + value_type, + max_bytes: row.max_bytes.max(0) as u64, + set_rule, + signing_rule, + requires_approval: row.requires_approval, + user_settable: row.user_settable, + }) +} + +/// Set a claim for a profile on behalf of the user. Looks up the registry, +/// evaluates the set, and either signs+stores, stores unsigned, defers to +/// verification, or queues for approval. `subject_id` is the profile the claim +/// is about (the default profile == the account id today). +pub fn set_my_claim( + pool: &DbPool, + subject_id: &str, + claim_type: &str, + value: &[u8], +) -> Result { + let row = pool + .find_claim_policy(claim_type) + .map_err(db_err)? + .ok_or_else(|| { + svc_err( + 400, + &liblinkkeys::claim_policy::RejectionReason::UnknownClaimType.to_string(), + ) + })?; + + // Server-side authorization: the registry's `user_settable` flag is the gate, + // not just a UI hint. `claim_type` arrives from a client-controlled form + // field, so a user must never be able to set a type the admin didn't open to + // self-service (e.g. `email_verified`, which the IDP sets as a side effect). + if !row.user_settable { + return Err(svc_err( + 403, + &liblinkkeys::claim_policy::RejectionReason::SetterNotAuthorized.to_string(), + )); + } + + let policy = to_policy(&row)?; + + let action = evaluate_set(&policy, Setter::User, value) + .map_err(|reason| svc_err(400, &reason.to_string()))?; + + // The user's auto-sign preference, defaulting to the registry default. + let auto_sign = pool + .get_profile_pref(subject_id, claim_type) + .map_err(db_err)? + .unwrap_or(row.default_auto_sign); + + match action { + SetAction::SelfSign => { + if auto_sign { + sign_and_store(pool, subject_id, claim_type, value)?; + Ok(SetOutcome::Signed) + } else { + store_unsigned(pool, subject_id, claim_type, value)?; + Ok(SetOutcome::StoredUnsigned) + } + } + SetAction::StoreUnsigned => { + store_unsigned(pool, subject_id, claim_type, value)?; + Ok(SetOutcome::StoredUnsigned) + } + // Lane B: the value isn't stored until a verification flow completes + // (see services::verification). Self-service set just signals that. + SetAction::Verify => Ok(SetOutcome::VerificationRequired), + SetAction::Queue => { + pool.enqueue_approval(subject_id, claim_type, value) + .map_err(db_err)?; + Ok(SetOutcome::Queued) + } + // A user can't reach the attested path — set_rule gates it to issuers — + // so this is unreachable in practice; fail closed if it ever isn't. + SetAction::AcceptAttested => Err(svc_err(400, "this claim must be attested by an issuer")), + } +} + +/// Decrypt the domain's active signing keys, sign a fresh claim binding +/// `subject_id@`, and store it — revoking any prior active claim of +/// the same type first so a profile holds one active value per type. Exposed to +/// the verification flow, which signs `email` / `email_verified` once a +/// challenge is confirmed. +/// +/// NOTE(follow-up): the revoke and create run on separate pooled connections, so +/// they are not atomic. For single-user self-service this is low-risk, but two +/// concurrent sets of the same type could leave two active values. Wrap both in +/// one transaction when a transaction-scoped DbPool accessor exists. +pub(crate) fn sign_and_store( + pool: &DbPool, + subject_id: &str, + claim_type: &str, + value: &[u8], +) -> Result<(), ServiceError> { + let passphrase = env::var("DOMAIN_KEY_PASSPHRASE") + .map_err(|_| svc_err(500, "DOMAIN_KEY_PASSPHRASE not set"))?; + let domain_keys = pool.list_active_domain_keys().map_err(db_err)?; + let signers = crate::claim_signing::active_signers(&domain_keys, passphrase.as_bytes()) + .map_err(|e| svc_err(500, &e.to_string()))?; + + let claim_id = uuid::Uuid::now_v7().to_string(); + let subject_domain = crate::conversions::get_domain_name(); + let signed = crate::claim_signing::sign_with_active( + &liblinkkeys::claims::ClaimSpec { + claim_id: &claim_id, + claim_type, + claim_value: value, + user_id: subject_id, + subject_domain: &subject_domain, + expires_at: None, + }, + &signers, + ) + .map_err(|e| svc_err(500, &e.to_string()))?; + + pool.revoke_active_claims_of_type(subject_id, claim_type) + .map_err(db_err)?; + pool.create_claim( + &claim_id, + subject_id, + claim_type, + value, + &signed.signatures, + None, + ) + .map_err(db_err)?; + Ok(()) +} + +/// Store a claim with no domain signature, revoking any prior active value. +fn store_unsigned( + pool: &DbPool, + subject_id: &str, + claim_type: &str, + value: &[u8], +) -> Result<(), ServiceError> { + let claim_id = uuid::Uuid::now_v7().to_string(); + pool.revoke_active_claims_of_type(subject_id, claim_type) + .map_err(db_err)?; + pool.create_claim(&claim_id, subject_id, claim_type, value, &[], None) + .map_err(db_err)?; + Ok(()) +} + +/// Record whether the user wants a claim type kept signed automatically. Only +/// meaningful for a `user_settable` type; the value is (re)applied on the next +/// set. +pub fn set_signing_pref( + pool: &DbPool, + profile_id: &str, + claim_type: &str, + auto_sign: bool, +) -> Result<(), ServiceError> { + pool.upsert_profile_pref(profile_id, claim_type, auto_sign) + .map_err(db_err)?; + Ok(()) +} + +/// The registry entries a user may set themselves, for the profile editor and +/// the discovery endpoint. +pub fn list_user_settable_policies( + pool: &DbPool, +) -> Result, ServiceError> { + let all = pool.list_claim_policies().map_err(db_err)?; + Ok(all.into_iter().filter(|p| p.user_settable).collect()) +} + +/// Create an additional presentable profile for the account (capped by +/// `MAX_PROFILES_PER_ACCOUNT`). Deletion is an admin action — users never delete. +pub fn create_profile( + pool: &DbPool, + account_id: &str, + label: Option<&str>, +) -> Result { + pool.create_presentable_profile(account_id, label) + .map_err(|e| svc_err(400, &e)) +} + +/// The account's presentable profiles (the root anchor is never listed). +pub fn list_profiles( + pool: &DbPool, + account_id: &str, +) -> Result, ServiceError> { + pool.list_presentable_profiles_for_account(account_id) + .map_err(db_err) +} diff --git a/crates/linkkeys/src/services/verification.rs b/crates/linkkeys/src/services/verification.rs new file mode 100644 index 0000000..fd39cbe --- /dev/null +++ b/crates/linkkeys/src/services/verification.rs @@ -0,0 +1,116 @@ +//! Email verification (the lane-B flow). A user asks to verify an address; we +//! store a single-use token and email a confirmation link. When the link is +//! visited, the IDP signs both the `email` value and an `email_verified` boolean +//! and deletes the token. No phone/SMS flow exists by design. + +use std::env; + +use liblinkkeys::claim_policy::ValueType; +use liblinkkeys::generated::services::ServiceError; + +use crate::conversions::get_domain_name; +use crate::db::DbPool; +use crate::services::self_service; + +/// How long a verification link is valid. +const VERIFICATION_TTL_HOURS: i64 = 24; + +fn svc_err(code: i32, msg: &str) -> ServiceError { + ServiceError { + code, + message: msg.to_string(), + } +} + +fn db_err(e: diesel::result::Error) -> ServiceError { + log::error!("Database error: {}", e); + svc_err(500, "Internal database error") +} + +/// Public base URL for building the confirmation link, from `PUBLIC_ORIGIN` or +/// `https://`. +fn public_origin() -> String { + env::var("PUBLIC_ORIGIN").unwrap_or_else(|_| format!("https://{}", get_domain_name())) +} + +/// Begin verifying `email` for `subject_id`: validate the address, store a token, +/// and send the confirmation link. The address is not signed until the link is +/// confirmed. +// TODO(follow-up): rate-limit per user and per target address. Today a +// logged-in user can trigger a confirmation email to any address on each submit +// (no false verification results — signing still needs the click — but it is an +// authenticated spam amplifier). Add a per-user/per-recipient throttle and/or a +// cap on outstanding tokens before wiring a real SMTP backend. +pub fn request_email_verification( + pool: &DbPool, + subject_id: &str, + email: &str, +) -> Result<(), ServiceError> { + // Honor the registry: the type must exist and bound the value size. + let row = pool + .find_claim_policy("email") + .map_err(db_err)? + .ok_or_else(|| svc_err(400, "email verification is not enabled on this domain"))?; + if email.len() as i64 > row.max_bytes { + return Err(svc_err(400, "email address is too long")); + } + if ValueType::Email.validate(email.as_bytes()).is_err() { + return Err(svc_err(400, "that doesn't look like an email address")); + } + + let token = uuid::Uuid::now_v7().to_string(); + let expires_at = chrono::Utc::now() + chrono::Duration::hours(VERIFICATION_TTL_HOURS); + pool.create_email_verification(&token, subject_id, email, expires_at) + .map_err(db_err)?; + + let link = format!( + "{}/account/identity/verify-email?token={}", + public_origin(), + token + ); + crate::email::send_verification_email(email, &link).map_err(|e| svc_err(500, &e))?; + Ok(()) +} + +/// Confirm a verification token: sign `email` and `email_verified` for the +/// subject and consume the token. `session_user_id` is the logged-in user +/// visiting the link; it must match the account that requested verification so a +/// leaked link can't be redeemed under a different session. Returns the verified +/// address. +pub fn confirm_email_verification( + pool: &DbPool, + token: &str, + session_user_id: &str, +) -> Result { + let v = pool + .find_email_verification(token) + .map_err(db_err)? + .ok_or_else(|| { + svc_err( + 400, + "this verification link is invalid or has already been used", + ) + })?; + + if v.user_id != session_user_id { + return Err(svc_err( + 403, + "this verification link belongs to a different account", + )); + } + + let expired = chrono::DateTime::parse_from_rfc3339(&v.expires_at) + .map(|e| chrono::Utc::now() > e.with_timezone(&chrono::Utc)) + .unwrap_or(true); + if expired { + let _ = pool.delete_email_verification(token); + return Err(svc_err(400, "this verification link has expired")); + } + + // Sign the address itself and the boolean flag, both bound to the subject. + self_service::sign_and_store(pool, &v.user_id, "email", v.email.as_bytes())?; + self_service::sign_and_store(pool, &v.user_id, "email_verified", b"true")?; + + pool.delete_email_verification(token).map_err(db_err)?; + Ok(v.email) +} diff --git a/crates/linkkeys/src/tcp/mod.rs b/crates/linkkeys/src/tcp/mod.rs index 6b322fd..cb225b2 100644 --- a/crates/linkkeys/src/tcp/mod.rs +++ b/crates/linkkeys/src/tcp/mod.rs @@ -16,29 +16,18 @@ use crate::services::handshake::HandshakeHandler; use crate::services::hello::HelloHandler; use liblinkkeys::generated::types::{ - DomainPublicKey, GetDomainKeysResponse, GetUserInfoRequest, GetUserKeysRequest, - GetUserKeysResponse, HandshakeRequest, UserInfo, + DepositClaimRequest, DepositClaimResponse, DomainPublicKey, GetDomainKeysResponse, + GetUserInfoRequest, GetUserKeysRequest, GetUserKeysResponse, HandshakeRequest, UserInfo, }; -#[derive(Serialize, Deserialize)] -struct RequestEnvelope { - v: u32, - service: String, - op: String, - #[serde(with = "serde_bytes")] - payload: Vec, - #[serde(default)] - auth: Option, -} - -#[derive(Serialize, Deserialize)] -struct ResponseEnvelope { - v: u32, - status: i32, - error: Option, - #[serde(with = "serde_bytes")] - payload: Vec, -} +// The RPC envelope is the canonical CSIL-RPC transport (`csilgen-transport`), +// not a bespoke struct — `RpcRequest` / `RpcResponse` carry the tag-24 payload, +// version, status registry, and `variant`. Framing on this TCP carrier is the +// length-prefixed frames in `read_frame`/`write_frame` (matches the spec's +// length-delimited stream carrier). `RpcRequest`'s fields (`service`, `op`, +// `payload`, `auth`) line up with what `dispatch` already reads. +use csilgen_transport::rpc::{RpcRequest, RpcResponse}; +use csilgen_transport::Status; #[derive(Serialize, Deserialize)] struct HelloRequest { @@ -318,9 +307,7 @@ fn handle_message_loop( continue; } - let envelope: RequestEnvelope = match std::panic::catch_unwind(|| { - ciborium::de::from_reader::(&frame[..]) - }) { + let envelope: RpcRequest = match std::panic::catch_unwind(|| RpcRequest::decode(&frame)) { Ok(Ok(env)) => env, Ok(Err(e)) => { let resp = error_response(1, &format!("Invalid request envelope: {}", e)); @@ -374,22 +361,50 @@ pub fn dispatch_for_test( db_pool: &DbPool, client_domain: Option<&str>, ) -> (i32, Vec) { - let envelope = RequestEnvelope { - v: 1, - service: service.to_string(), - op: op.to_string(), - payload, - auth: None, - }; + let request = RpcRequest::new(service, op, payload); let ready = Arc::new(AtomicBool::new(true)); - let bytes = dispatch(&envelope, &ready, db_pool, client_domain); - let resp: ResponseEnvelope = - ciborium::de::from_reader(&bytes[..]).expect("response envelope decodes"); - (resp.status, resp.payload) + let bytes = dispatch(&request, &ready, db_pool, client_domain); + let resp = RpcResponse::decode(&bytes).expect("response decodes"); + (resp.status.code() as i32, resp.payload) +} + +/// Encode a CSIL-RPC request (no auth) for an outbound call — e.g. one domain +/// depositing a signed claim to another via its `/csil/v1/rpc` carrier. +pub fn encode_request_envelope(service: &str, op: &str, payload: Vec) -> Vec { + RpcRequest::new(service, op, payload) + .encode() + .expect("encode CSIL-RPC request cannot fail") +} + +/// Decode a CSIL-RPC request envelope and dispatch it, returning the response +/// envelope bytes. This is the single entry the generic CBOR-RPC carrier (the +/// web `POST /csil/v1/rpc`) shares with the TCP server, so the web carries the +/// same RPC surface without per-op routes. `client_domain` is the mTLS-proven +/// peer over TCP, `None` over the web. +pub fn dispatch_envelope( + envelope_bytes: &[u8], + ready_flag: &Arc, + db_pool: &DbPool, + client_domain: Option<&str>, +) -> Vec { + let request = match RpcRequest::decode(envelope_bytes) { + Ok(r) => r, + Err(e) => return error_response(1, &format!("Invalid envelope: {}", e)), + }; + dispatch(&request, ready_flag, db_pool, client_domain) +} + +/// The CSIL-RPC status code of a response (0 = success), or a non-zero sentinel +/// if it can't be decoded. Lets an outbound caller tell whether the remote op +/// succeeded. +pub fn response_status(bytes: &[u8]) -> i32 { + RpcResponse::decode(bytes) + .map(|r| r.status.code() as i32) + .unwrap_or(i32::MAX) } fn dispatch( - envelope: &RequestEnvelope, + envelope: &RpcRequest, ready_flag: &Arc, db_pool: &DbPool, client_domain: Option<&str>, @@ -532,6 +547,29 @@ fn dispatch( }; dispatch_account(op, &envelope.payload, db_pool, &user) } + // Server-to-server: an issuer deposits a claim it signed about one of our + // accounts. The issuer's signature is the authority (verified against its + // cached keys + our trusted-issuer policy), so no caller auth is required + // beyond that — anyone may carry a valid trusted attestation to us. + ("Attestation", "deposit-claim") => { + let request: DepositClaimRequest = + match ciborium::de::from_reader(&envelope.payload[..]) { + Ok(r) => r, + Err(e) => return error_response(2, &format!("Invalid payload: {}", e)), + }; + let claim = request.claim; + if db_pool.find_user_by_id(&claim.user_id).is_err() { + return error_response(4, "Unknown subject"); + } + match crate::services::attestation::verify_and_store_attested( + db_pool, + &claim.user_id, + &claim, + ) { + Ok(()) => ok_response(&DepositClaimResponse { stored: true }), + Err(e) => error_response(5, &e.message), + } + } _ => error_response( 3, &format!( @@ -605,35 +643,38 @@ fn dispatch_account( } } +/// A successful CSIL-RPC reply carrying a typed payload. Our operations declare a +/// single output type (no `/ ErrorType` arms yet), so `variant` is omitted; if we +/// later add typed error arms, set it to the chosen arm's CSIL type name. fn ok_response(payload: &T) -> Vec { let mut payload_bytes = Vec::new(); ciborium::ser::into_writer(payload, &mut payload_bytes) .expect("CBOR serialization of response payload"); - - let envelope = ResponseEnvelope { - v: 1, - status: 0, + RpcResponse { + id: None, + status: Status::Ok, + variant: None, error: None, payload: payload_bytes, - }; - - let mut out = Vec::new(); - ciborium::ser::into_writer(&envelope, &mut out) - .expect("CBOR serialization of response envelope"); - out + } + .encode() + .expect("encode RPC response") } +/// Map our historical transport status ints onto the CSIL-RPC status registry and +/// build a transport-error response (no typed payload). These are *transport* +/// failures, never application errors (which would ride as a status-0 variant). fn error_response(status: i32, message: &str) -> Vec { - let envelope = ResponseEnvelope { - v: 1, - status, - error: Some(message.to_string()), - payload: Vec::new(), + let status = match status { + 1 | 2 => Status::MalformedEnvelope, + 3 => Status::UnknownServiceOrOp, + 4 => Status::Internal, + 5 => Status::Forbidden, + other => Status::Other(other as i64), }; - - let mut out = Vec::new(); - ciborium::ser::into_writer(&envelope, &mut out).expect("CBOR serialization of error envelope"); - out + RpcResponse::transport_error(status, message) + .encode() + .expect("encode RPC error response") } /// Authenticate a TCP request using the auth field from the envelope. @@ -705,45 +746,3 @@ mod depth_tests { assert!(!check_cbor_depth(&data)); } } - -mod serde_bytes { - use serde::{Deserializer, Serializer}; - - pub fn serialize(bytes: &[u8], serializer: S) -> Result { - serializer.serialize_bytes(bytes) - } - - pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - use serde::de::Visitor; - - struct BytesVisitor; - impl<'de> Visitor<'de> for BytesVisitor { - type Value = Vec; - - fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { - f.write_str("bytes or byte string") - } - - fn visit_bytes(self, v: &[u8]) -> Result, E> { - Ok(v.to_vec()) - } - - fn visit_byte_buf(self, v: Vec) -> Result, E> { - Ok(v) - } - - fn visit_seq>( - self, - mut seq: A, - ) -> Result, A::Error> { - let mut bytes = Vec::with_capacity(seq.size_hint().unwrap_or(0)); - while let Some(b) = seq.next_element()? { - bytes.push(b); - } - Ok(bytes) - } - } - - deserializer.deserialize_any(BytesVisitor) - } -} diff --git a/crates/linkkeys/src/web/account_ui.rs b/crates/linkkeys/src/web/account_ui.rs index 0b084f1..72d5c23 100644 --- a/crates/linkkeys/src/web/account_ui.rs +++ b/crates/linkkeys/src/web/account_ui.rs @@ -145,6 +145,15 @@ pub(super) fn build_nav(current: &str, is_admin: bool, is_logged_in: bool) -> St r#"Account"#, account_class )); + let identity_class = if current == "identity" { + " class=\"active\"" + } else { + "" + }; + nav.push_str(&format!( + r#"My Identity"#, + identity_class + )); if is_admin { let admin_class = if current == "admin" { " class=\"active\"" @@ -155,6 +164,15 @@ pub(super) fn build_nav(current: &str, is_admin: bool, is_logged_in: bool) -> St r#"User Admin"#, admin_class )); + let policy_class = if current == "policy" { + " class=\"active\"" + } else { + "" + }; + nav.push_str(&format!( + r#"Policy Admin"#, + policy_class + )); } nav.push_str(r#""#); nav.push_str(r#"
"#); @@ -170,7 +188,7 @@ pub(super) fn is_user_admin(pool: &DbPool, user_id: &str) -> bool { authorization::user_has_permission(pool, user_id, "manage_users", "domain", &domain) } -fn flash_html(msg: Option<&str>, error: Option<&str>) -> String { +pub(super) fn flash_html(msg: Option<&str>, error: Option<&str>) -> String { let mut html = String::new(); if let Some(m) = msg { html.push_str(&format!(r#"
{}
"#, html_escape(m))); diff --git a/crates/linkkeys/src/web/mod.rs b/crates/linkkeys/src/web/mod.rs index dd6bd64..92e1842 100644 --- a/crates/linkkeys/src/web/mod.rs +++ b/crates/linkkeys/src/web/mod.rs @@ -4,6 +4,8 @@ mod admin; mod admin_ui; mod guard; pub mod nonce_store; +mod policy_admin_ui; +mod profile_ui; pub mod rp; use rocket::form::FromForm; @@ -94,19 +96,14 @@ pub fn pick_active_signing_key( Some(signing[rand::thread_rng().gen_range(0..signing.len())]) } -/// Resolve the profile an assertion is issued FOR. The subject is always a -/// presentable profile, NEVER the root anchor. For a default (single-profile) -/// account the presentable profile's id equals the account id, so this is -/// behavior-preserving; it falls back to the account id only for an account not -/// yet backfilled with profiles. (Multi-profile *selection* — picking among -/// several personas — is a later step; for now the oldest presentable wins, -/// which is the account-id default profile.) -fn resolve_subject_profile(pool: &DbPool, account_id: &str) -> String { - pool.list_presentable_profiles_for_account(account_id) - .ok() - .and_then(|ps| ps.into_iter().next()) - .map(|p| p.id) - .unwrap_or_else(|| account_id.to_string()) +/// The subject an assertion/claim is issued FOR. Per the trust model +/// (docs/claim-trust-verification.md) this is ALWAYS the account UUID: profiles +/// are presentation override sets, not separate cryptographic subjects, so they +/// never become the subject. Unlinkable personas are separate accounts, not +/// profiles. Kept as a function so the single source of truth is documented and +/// any future per-presentation re-binding lives in one place. +fn resolve_subject_profile(_pool: &DbPool, account_id: &str) -> String { + account_id.to_string() } /// Sign an identity assertion for the given user with a randomly chosen active @@ -160,23 +157,31 @@ fn consent_ttl_seconds() -> i64 { .unwrap_or(DEFAULT_CONSENT_TTL_SECONDS) } -/// Home-domain claim-release policy, from comma-separated `CONSENT_FORCED_ALLOW` -/// / `CONSENT_FORCED_DENY` claim-type lists. Forced rows are shown to the user -/// but cannot be toggled; deny wins over allow. (Prototype: global, not yet -/// per-audience.) -fn domain_policy() -> DomainPolicy { - let parse = |var: &str| { - env::var(var) - .unwrap_or_default() - .split(',') - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect::>() - }; - DomainPolicy { - forced_allow: parse("CONSENT_FORCED_ALLOW"), - forced_deny: parse("CONSENT_FORCED_DENY"), +/// Home-domain claim-release policy for an audience, from the `release_policies` +/// table: rows for that audience plus the global `*` defaults. Forced rows are +/// shown to the user but cannot be toggled; deny wins over allow. The table is +/// seeded on first boot from the deprecated `CONSENT_FORCED_ALLOW` / +/// `CONSENT_FORCED_DENY` env vars (see `DbPool::seed_default_policies`). +fn domain_policy_for(pool: &DbPool, audience: &str) -> Result { + // Fail CLOSED: `forced_deny` is a security control. If the policy can't be + // loaded, do NOT fall back to an empty (allow-everything) policy — propagate + // the error so the caller aborts rather than releasing claims an admin denied. + let rows = pool + .list_release_policies_for_audience(audience) + .map_err(|e| log::error!("release policy load failed for {}: {}", audience, e))?; + let mut forced_allow = Vec::new(); + let mut forced_deny = Vec::new(); + for r in rows { + match r.disposition.as_str() { + "forced_allow" => forced_allow.push(r.claim_type), + "forced_deny" => forced_deny.push(r.claim_type), + other => log::warn!("release_policies: unknown disposition {:?}", other), + } } + Ok(DomainPolicy { + forced_allow, + forced_deny, + }) } /// The claim types an RP requested (required ∪ optional), as a flat list. @@ -256,7 +261,23 @@ fn build_consent_screen( .ok() .flatten(); let prior_obj = prior.as_ref().map(prior_grant_object); - resolve_consent_screen(req, &available, prior_obj.as_ref(), policy) + let mut screen = resolve_consent_screen(req, &available, prior_obj.as_ref(), policy); + + // Fold in the user's standing release preferences (claims they chose to + // pre-share with this audience or with any domain). These rows arrive + // pre-checked — the user still confirms, but with zero friction. Locked + // (policy-forced) rows are left untouched. + let standing = pool + .list_user_release_allows(user_id, audience) + .unwrap_or_default(); + let allow: std::collections::BTreeSet<&str> = standing.iter().map(String::as_str).collect(); + for row in &mut screen.rows { + if row.policy == consent::PolicyDisposition::User && allow.contains(row.claim_type.as_str()) + { + row.previously_granted = true; + } + } + screen } /// A relying-party self-claim with its verification status, for display and @@ -1090,7 +1111,11 @@ async fn handle_signed_request_post( // full callback URL. The login-request nonce is burned inside finalize_login // at token issuance — not here — so an abandoned consent screen doesn't // consume a legitimate request. - let policy = domain_policy(); + let Ok(policy) = domain_policy_for(pool, &request.relying_party) else { + return Err(render_form_error( + "Could not load the release policy. Please try again.", + )); + }; match request.requested_claims.clone() { // Authentication only: no claims requested, nothing to consent to. None => finalize_login(pool, net, nonces, &user, &request, vec![], vec![]) @@ -1201,7 +1226,11 @@ async fn auth_consent_post( Err(_) => return Err(render_error_page("Your account could not be found.")), }; - let policy = domain_policy(); + let Ok(policy) = domain_policy_for(pool, &request.relying_party) else { + return Err(render_error_page( + "Could not load the release policy. Please try again.", + )); + }; let authorized = compute_authorized_claims(&req, &form.grant, &policy); // A required claim the user declined (and policy didn't deny) blocks @@ -1523,6 +1552,28 @@ async fn userinfo_cbor( Ok(cbor_response(out)) } +/// Generic CBOR-RPC carrier: the web's second-class mirror of the TCP service +/// dispatch. The body is a CBOR `RequestEnvelope`; we run the same `dispatch()` +/// and return the CBOR `ResponseEnvelope`. This is how the web carries the whole +/// RPC surface (e.g. `Attestation/deposit-claim`) without per-op routes — a +/// browser is never the intended caller here, another server is. `dispatch` is +/// synchronous (diesel), so run it on a blocking thread. +#[rocket::post("/csil/v1/rpc", data = "")] +async fn rpc_cbor( + pool: &State, + ready: &State>, + body: Vec, +) -> (ContentType, Vec) { + let pool = pool.inner().clone(); + let ready = ready.inner().clone(); + let resp = rocket::tokio::task::spawn_blocking(move || { + crate::tcp::dispatch_envelope(&body, &ready, &pool, None) + }) + .await + .unwrap_or_default(); + cbor_response(resp) +} + // -- TLS + Launch -- fn generate_self_signed_cert() -> (String, String) { @@ -1620,6 +1671,7 @@ pub fn build_rocket( handshake_cbor, handshake_json, userinfo_cbor, + rpc_cbor, ]; // Mount password auth routes (login form) when enabled (default: true) @@ -1696,6 +1748,13 @@ pub fn build_rocket( account_ui::account_dashboard, account_ui::change_password_page, account_ui::change_password_submit, + profile_ui::identity_editor, + profile_ui::set_claim_submit, + profile_ui::create_profile_submit, + profile_ui::verify_email, + profile_ui::set_share_submit, + profile_ui::request_verification, + profile_ui::request_verification_download, ], ); @@ -1718,6 +1777,26 @@ pub fn build_rocket( ], ); + // Mount the admin policy editor (claim-type registry, trusted issuers, + // release defaults, approval queue). + rocket_instance = rocket_instance.mount( + "/", + rocket::routes![ + policy_admin_ui::policy_admin, + policy_admin_ui::upsert_policy, + policy_admin_ui::delete_policy, + policy_admin_ui::add_issuer, + policy_admin_ui::remove_issuer, + policy_admin_ui::upsert_release, + policy_admin_ui::delete_release, + policy_admin_ui::approve, + policy_admin_ui::reject, + policy_admin_ui::issue_page, + policy_admin_ui::issue_verify, + policy_admin_ui::issue_sign, + ], + ); + rocket_instance } diff --git a/crates/linkkeys/src/web/policy_admin_ui.rs b/crates/linkkeys/src/web/policy_admin_ui.rs new file mode 100644 index 0000000..5f89d2e --- /dev/null +++ b/crates/linkkeys/src/web/policy_admin_ui.rs @@ -0,0 +1,706 @@ +//! Admin policy editor: the one technical surface in the system, but a guided +//! one — dropdowns, not a policy language. It manages the claim-type registry +//! (what may be set, how it's signed), the trusted issuers for attested types, +//! the per-audience release defaults, and the approval queue. +//! +//! Registry / release / approvals are gated by `manage_claims` (it already means +//! "controls claims on this domain"). Each button is one `admin`/`DbPool` call, +//! so the same operations back a future CLI. + +use rocket::http::{CookieJar, Status}; +use rocket::response::content::RawHtml; +use rocket::response::Redirect; +use rocket::State; + +use crate::conversions::{get_domain_name, html_escape}; +use crate::db::models::ClaimTypePolicy; +use crate::db::DbPool; +use crate::net::Net; +use crate::services::{admin, attestation, authorization}; + +use super::account_ui::{build_nav, flash_html, get_session_user_id, is_user_admin, layout}; +use super::profile_ui::qr_svg; +use liblinkkeys::generated::types::{SignedSigningRequest, SigningRequest}; + +fn require_manage_claims(pool: &DbPool, cookies: &CookieJar<'_>) -> Result { + let user_id = get_session_user_id(cookies).ok_or(Status::Unauthorized)?; + let domain = get_domain_name(); + if !authorization::user_has_permission(pool, &user_id, "manage_claims", "domain", &domain) { + return Err(Status::Forbidden); + } + Ok(user_id) +} + +fn select(name: &str, options: &[&str], current: &str) -> String { + let mut s = format!(r#""); + s +} + +const VALUE_TYPES: &[&str] = &[ + "text", + "url", + "email", + "bool", + "int", + "float", + "decimal", + "date", + "timestamp", + "opaque", +]; +const SET_RULES: &[&str] = &[ + "user_self", + "idp_on_request", + "trusted_issuer_only", + "admin_only", + "deny", +]; +const SIGNING_RULES: &[&str] = &["self_signed", "verified", "attested", "unsigned"]; + +#[rocket::get("/policy-admin?&")] +pub fn policy_admin( + pool: &State, + cookies: &CookieJar<'_>, + msg: Option<&str>, + error: Option<&str>, +) -> Result, Status> { + let user_id = require_manage_claims(pool.inner(), cookies)?; + let admin_nav = is_user_admin(pool.inner(), &user_id); + let nav = build_nav("policy", admin_nav, true); + let flash = flash_html(msg, error); + + let policies = pool + .list_claim_policies() + .map_err(|_| Status::InternalServerError)?; + let issuers = pool + .list_all_trusted_issuers() + .map_err(|_| Status::InternalServerError)?; + let releases = pool + .list_release_policies() + .map_err(|_| Status::InternalServerError)?; + let approvals = pool + .list_pending_approvals() + .map_err(|_| Status::InternalServerError)?; + + // -- Registry table -- + let mut reg_rows = String::new(); + for p in &policies { + let flags = format!( + "{}{}{}{}", + if p.user_settable { + "user-settable " + } else { + "" + }, + if p.default_auto_sign { + "auto-sign " + } else { + "" + }, + if p.requires_approval { "approval " } else { "" }, + if p.suggested { "suggested" } else { "" }, + ); + reg_rows.push_str(&format!( + r#"{ct}{label}{vt}{set}{sign}{flags} +
"#, + ct = html_escape(&p.claim_type), + label = html_escape(&p.label), + vt = html_escape(&p.value_type), + set = html_escape(&p.set_rule), + sign = html_escape(&p.signing_rule), + flags = flags, + )); + } + + // -- Trusted issuers -- + let mut issuer_rows = String::new(); + for i in &issuers { + issuer_rows.push_str(&format!( + r#"{ct}{dom} +
"#, + ct = html_escape(&i.claim_type), + dom = html_escape(&i.issuer_domain), + )); + } + + // -- Release defaults -- + let mut release_rows = String::new(); + for r in &releases { + release_rows.push_str(&format!( + r#"{aud}{ct}{disp} +
"#, + aud = html_escape(&r.audience), + ct = html_escape(&r.claim_type), + disp = html_escape(&r.disposition), + )); + } + + // -- Approvals -- + let mut approval_rows = String::new(); + for a in &approvals { + let value = String::from_utf8(a.claim_value.clone()).unwrap_or_else(|_| "(binary)".into()); + approval_rows.push_str(&format!( + r#"{uid}{ct}{val} +
+
"#, + uid = html_escape(&a.user_id[..8.min(a.user_id.len())]), + ct = html_escape(&a.claim_type), + val = html_escape(&value), + id = html_escape(&a.id), + )); + } + + let content = format!( + r#"{flash} +

Policy Administration

+

Control which claims this domain recognises, how they get signed, who you trust to attest them, and what is released to relying parties by default.

+

Issue an attestation from a user's request →

+ +

Claim types

+{reg_rows}
TypeLabelValueSet ruleSigningFlags
+ +

Add / edit a claim type

+
+ + + + {value_type_sel} + + {set_rule_sel} + {signing_rule_sel} + + + + +
+
+ +

Trusted issuers

+

Domains whose signature you accept as attestation for a claim type (e.g. a government entity for age_over_21).

+{issuer_rows}
Claim typeIssuer domain
+
+ + + +
+ +

Release defaults

+

Per-audience policy applied at consent. Audience * is the global default. forced_allow always releases; forced_deny never does (deny wins).

+{release_rows}
AudienceClaim typeDisposition
+
+ + + {disposition_sel} + +
+ +

Pending approvals

+{approval_rows}
SubjectClaimValue
+"#, + flash = flash, + reg_rows = reg_rows, + value_type_sel = select("value_type", VALUE_TYPES, "text"), + set_rule_sel = select("set_rule", SET_RULES, "user_self"), + signing_rule_sel = select("signing_rule", SIGNING_RULES, "self_signed"), + issuer_rows = issuer_rows, + release_rows = release_rows, + disposition_sel = select( + "disposition", + &["forced_allow", "forced_deny"], + "forced_allow" + ), + approval_rows = approval_rows, + ); + + Ok(layout("Policy Admin", &nav, &content)) +} + +fn redirect_ok(msg: &str) -> Redirect { + Redirect::found(format!("/policy-admin?msg={}", urlencoding::encode(msg))) +} +fn redirect_err(msg: &str) -> Redirect { + Redirect::found(format!("/policy-admin?error={}", urlencoding::encode(msg))) +} + +#[derive(rocket::FromForm)] +pub struct PolicyForm { + claim_type: String, + label: String, + description: Option, + value_type: String, + max_bytes: String, + set_rule: String, + signing_rule: String, + user_settable: Option, + default_auto_sign: Option, + requires_approval: Option, + suggested: Option, +} + +#[rocket::post("/policy-admin/claim-types", data = "
")] +pub fn upsert_policy( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + let max_bytes = match form.max_bytes.trim().parse::() { + Ok(n) if n > 0 => n, + _ => return Ok(redirect_err("max bytes must be a positive number")), + }; + // Validate the enum strings server-side rather than trusting the dropdowns — + // an unparseable rule would otherwise be stored and 500 at set time. + use liblinkkeys::claim_policy::{SetRule, SigningRule, ValueType}; + if ValueType::parse(&form.value_type).is_none() { + return Ok(redirect_err("invalid value type")); + } + if SetRule::parse(&form.set_rule).is_none() { + return Ok(redirect_err("invalid set rule")); + } + if SigningRule::parse(&form.signing_rule).is_none() { + return Ok(redirect_err("invalid signing rule")); + } + let policy = ClaimTypePolicy { + claim_type: form.claim_type.trim().to_string(), + label: form.label.trim().to_string(), + description: form.description.clone().unwrap_or_default(), + value_type: form.value_type.clone(), + max_bytes, + set_rule: form.set_rule.clone(), + signing_rule: form.signing_rule.clone(), + requires_approval: form.requires_approval.is_some(), + user_settable: form.user_settable.is_some(), + default_auto_sign: form.default_auto_sign.is_some(), + suggested: form.suggested.is_some(), + }; + match pool.upsert_claim_policy(policy) { + Ok(_) => Ok(redirect_ok("Claim type saved")), + Err(_) => Ok(redirect_err("Could not save claim type")), + } +} + +#[derive(rocket::FromForm)] +pub struct ClaimTypeForm { + claim_type: String, +} + +#[rocket::post("/policy-admin/claim-types/delete", data = "")] +pub fn delete_policy( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + match pool.delete_claim_policy(form.claim_type.trim()) { + Ok(_) => Ok(redirect_ok("Claim type deleted")), + Err(_) => Ok(redirect_err("Could not delete claim type")), + } +} + +#[derive(rocket::FromForm)] +pub struct IssuerForm { + claim_type: String, + issuer_domain: String, +} + +#[rocket::post("/policy-admin/trusted-issuers", data = "")] +pub async fn add_issuer( + _csrf: super::guard::SameOriginPost, + pool: &State, + net: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + let issuer = form.issuer_domain.trim(); + if pool + .add_trusted_issuer(form.claim_type.trim(), issuer) + .is_err() + { + return Ok(redirect_err("Could not add issuer")); + } + // Cache the issuer's keys now (trust-establishment time) so the synchronous + // deposit op can verify attestations from it later. Best-effort: if the + // issuer is unreachable, the trust is still recorded and keys can be cached + // on a later deposit/refresh. + if issuer != get_domain_name() { + if let Ok(keys) = super::rp::fetch_domain_keys(pool.inner(), net.inner(), issuer).await { + for k in keys { + let pk = crate::db::models::PeerKey { + domain: issuer.to_string(), + key_id: k.key_id, + public_key: k.public_key, + algorithm: k.algorithm, + fingerprint: k.fingerprint, + key_usage: k.key_usage, + expires_at: k.expires_at, + revoked_at: k.revoked_at, + }; + let _ = pool.cache_peer_key(&pk); + } + } + } + Ok(redirect_ok("Trusted issuer added")) +} + +#[rocket::post("/policy-admin/trusted-issuers/delete", data = "")] +pub fn remove_issuer( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + match pool.remove_trusted_issuer(form.claim_type.trim(), form.issuer_domain.trim()) { + Ok(_) => Ok(redirect_ok("Trusted issuer removed")), + Err(_) => Ok(redirect_err("Could not remove issuer")), + } +} + +#[derive(rocket::FromForm)] +pub struct ReleaseForm { + audience: String, + claim_type: String, + disposition: String, +} + +#[rocket::post("/policy-admin/release", data = "")] +pub fn upsert_release( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + if form.disposition != "forced_allow" && form.disposition != "forced_deny" { + return Ok(redirect_err("invalid disposition")); + } + match pool.upsert_release_policy( + form.audience.trim(), + form.claim_type.trim(), + &form.disposition, + ) { + Ok(_) => Ok(redirect_ok("Release rule saved")), + Err(_) => Ok(redirect_err("Could not save release rule")), + } +} + +#[derive(rocket::FromForm)] +pub struct ReleaseDeleteForm { + audience: String, + claim_type: String, +} + +#[rocket::post("/policy-admin/release/delete", data = "")] +pub fn delete_release( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + require_manage_claims(pool.inner(), cookies)?; + match pool.delete_release_policy(form.audience.trim(), form.claim_type.trim()) { + Ok(_) => Ok(redirect_ok("Release rule deleted")), + Err(_) => Ok(redirect_err("Could not delete release rule")), + } +} + +#[derive(rocket::FromForm)] +pub struct ApprovalForm { + id: String, +} + +#[rocket::post("/policy-admin/approvals/approve", data = "")] +pub fn approve( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + let admin_id = require_manage_claims(pool.inner(), cookies)?; + match admin::approve_claim(pool.inner(), &form.id, &admin_id) { + Ok(()) => Ok(redirect_ok("Claim approved and signed")), + Err(e) => Ok(redirect_err(&e.message)), + } +} + +#[rocket::post("/policy-admin/approvals/reject", data = "")] +pub fn reject( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result { + let admin_id = require_manage_claims(pool.inner(), cookies)?; + match admin::reject_claim(pool.inner(), &form.id, &admin_id) { + Ok(()) => Ok(redirect_ok("Claim rejected")), + Err(e) => Ok(redirect_err(&e.message)), + } +} + +// -- Receive & issue: the issuer admin accepts a signing request and signs -- + +/// Decode a base64url(CBOR(SignedSigningRequest)) string into the envelope and +/// its inner request. +fn decode_request(b64: &str) -> Option<(SignedSigningRequest, SigningRequest)> { + use base64ct::{Base64UrlUnpadded, Encoding as _}; + let bytes = Base64UrlUnpadded::decode_vec(b64.trim()).ok()?; + let signed: SignedSigningRequest = ciborium::de::from_reader(&bytes[..]).ok()?; + let req: SigningRequest = ciborium::de::from_reader(&signed.request[..]).ok()?; + Some((signed, req)) +} + +/// Verify a pasted signing request: it must be addressed to us, and its +/// signature must check out against the subject domain's (fetched) keys. +async fn verify_pasted( + pool: &DbPool, + net: &Net, + signed: &SignedSigningRequest, + req: &SigningRequest, +) -> Result<(), String> { + let our = get_domain_name(); + if req.issuer_domain != our { + return Err(format!( + "This request is addressed to {}, not this domain.", + req.issuer_domain + )); + } + let keys = super::rp::fetch_domain_keys(pool, net, &req.subject_domain) + .await + .map_err(|e| format!("could not fetch {} keys: {}", req.subject_domain, e))?; + let keysets = vec![liblinkkeys::claims::DomainKeySet { + domain: req.subject_domain.clone(), + keys, + }]; + liblinkkeys::signing_request::verify_signing_request( + signed, + &req.subject_domain, + &our, + &keysets, + ) + .map(|_| ()) + .map_err(|e| e.to_string()) +} + +#[rocket::get("/policy-admin/issue?")] +pub fn issue_page( + pool: &State, + cookies: &CookieJar<'_>, + error: Option<&str>, +) -> Result, Status> { + let user_id = require_manage_claims(pool.inner(), cookies)?; + let nav = build_nav("policy", is_user_admin(pool.inner(), &user_id), true); + let content = format!( + r#"{flash} +

Issue an attestation

+

Paste a verification request a user brought to you (QR text or file contents). +We verify it was really signed by their domain, then you attest what you've +checked. Your signature travels with the claim so anyone can verify it.

+ + + +
+"#, + flash = flash_html(None, error), + ); + Ok(layout("Issue Attestation", &nav, &content)) +} + +#[derive(rocket::FromForm)] +pub struct IssueForm { + request: String, +} + +#[rocket::post("/policy-admin/issue", data = "
")] +pub async fn issue_verify( + _csrf: super::guard::SameOriginPost, + pool: &State, + net: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result, Status> { + let user_id = require_manage_claims(pool.inner(), cookies)?; + let nav = build_nav("policy", is_user_admin(pool.inner(), &user_id), true); + + let (signed, req) = match decode_request(&form.request) { + Some(v) => v, + None => { + return Ok(layout( + "Issue Attestation", + &nav, + &format!( + "{}

Back

", + flash_html(None, Some("That doesn't decode as a signing request.")) + ), + )) + } + }; + if let Err(e) = verify_pasted(pool.inner(), net.inner(), &signed, &req).await { + return Ok(layout( + "Issue Attestation", + &nav, + &format!( + "{}

Back

", + flash_html(None, Some(&e)) + ), + )); + } + + // Verified — show the subject + requested types and a values form. The admin + // fills `claim_type=value` lines for what they're willing to attest. + let prefill: String = req + .requested_claim_types + .iter() + .map(|t| format!("{}=", t)) + .collect::>() + .join("\n"); + let content = format!( + r#"

Issue an attestation

+
Verified — really signed by {subject_domain}.
+

Subject: {subject}@{subject_domain}
+Requested: {types}

+ + + + +
+"#, + subject = html_escape(&req.subject_user_id), + subject_domain = html_escape(&req.subject_domain), + types = html_escape(&req.requested_claim_types.join(", ")), + request = html_escape(form.request.trim()), + prefill = html_escape(&prefill), + ); + Ok(layout("Issue Attestation", &nav, &content)) +} + +#[derive(rocket::FromForm)] +pub struct IssueSignForm { + request: String, + values: String, +} + +#[rocket::post("/policy-admin/issue/sign", data = "
")] +pub async fn issue_sign( + _csrf: super::guard::SameOriginPost, + pool: &State, + net: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Result, Status> { + let user_id = require_manage_claims(pool.inner(), cookies)?; + let nav = build_nav("policy", is_user_admin(pool.inner(), &user_id), true); + + let (signed, req) = match decode_request(&form.request) { + Some(v) => v, + None => return Err(Status::BadRequest), + }; + // Re-verify before signing anything (the hidden field is client-supplied). + if let Err(e) = verify_pasted(pool.inner(), net.inner(), &signed, &req).await { + return Ok(layout( + "Issue Attestation", + &nav, + &flash_html(None, Some(&e)), + )); + } + + use base64ct::{Base64UrlUnpadded, Encoding as _}; + let requested: std::collections::BTreeSet<&str> = req + .requested_claim_types + .iter() + .map(String::as_str) + .collect(); + let mut out = String::new(); + for line in form.values.lines() { + let line = line.trim(); + let Some((ct, val)) = line.split_once('=') else { + continue; + }; + let (ct, val) = (ct.trim(), val.trim()); + if ct.is_empty() || val.is_empty() { + continue; + } + if !requested.contains(ct) { + out.push_str(&format!( + r#"
{} was not requested — skipped.
"#, + html_escape(ct) + )); + continue; + } + match attestation::issue_attested_claim( + pool.inner(), + &req.subject_user_id, + &req.subject_domain, + ct, + val.as_bytes(), + ) { + Ok(claim) => { + // Deposit it server-to-server to the subject's home domain (the + // normal path — no user round-trip). If that domain is + // unreachable, fall back to handing the user the signed claim. + match super::rp::deposit_claim_to_domain(net.inner(), &req.subject_domain, &claim) + .await + { + Ok(()) => out.push_str(&format!( + r#"
{ct} = {val} — signed and deposited to {dom}.
"#, + ct = html_escape(ct), + val = html_escape(val), + dom = html_escape(&req.subject_domain), + )), + Err(e) => { + let mut cbor = Vec::new(); + if ciborium::ser::into_writer(&claim, &mut cbor).is_err() { + continue; + } + let b64 = Base64UrlUnpadded::encode_string(&cbor); + let qr = qr_svg(&b64) + .unwrap_or_else(|| "

(too large for a QR code)

".to_string()); + out.push_str(&format!( + r#"
+{ct} = {val} — signed, but couldn't deposit ({err}). Hand this to the user: +
{qr}
+ +
"#, + ct = html_escape(ct), + val = html_escape(val), + err = html_escape(&e), + qr = qr, + b64 = html_escape(&b64), + )); + } + } + } + Err(e) => out.push_str(&format!( + r#"
{}: {}
"#, + html_escape(ct), + html_escape(&e.message) + )), + } + } + if out.is_empty() { + out = "

Nothing issued — add at least one claim_type=value line.

" + .to_string(); + } + + let content = format!( + r#"

Issued

+

Each attestation is signed and deposited to the subject's home domain, where +anyone can verify your signature. If a domain was unreachable, hand the user the +claim shown and they can add it themselves.

+{out} +

Issue another

"#, + out = out, + ); + Ok(layout("Issued", &nav, &content)) +} diff --git a/crates/linkkeys/src/web/profile_ui.rs b/crates/linkkeys/src/web/profile_ui.rs new file mode 100644 index 0000000..1abe287 --- /dev/null +++ b/crates/linkkeys/src/web/profile_ui.rs @@ -0,0 +1,503 @@ +//! User-facing profile & identity editor: the non-technical surface where a +//! person fills in the things they want to share (display name, handle, …), +//! sees a "Verified by " badge when the IDP could validate and sign the +//! value, and — when the operator allows more than one — creates additional +//! pseudonymous profiles. Deletion is intentionally absent: removing a profile +//! is an admin action. +//! +//! Every button maps to one `services::self_service` call; the page is a thin +//! renderer so the same operations back a future CLI / agent unchanged. + +use rocket::http::{ContentType, CookieJar, Status}; +use rocket::response::content::RawHtml; +use rocket::response::Redirect; +use rocket::State; + +use crate::conversions::{get_domain_name, html_escape}; +use crate::db::{max_profiles_per_account, DbPool}; +use crate::services::attestation; +use crate::services::self_service::{self, SetOutcome}; + +/// Render `data` as an inline SVG QR code, if it fits. Returns None for data too +/// large to encode (caller falls back to the base64 text). +pub(super) fn qr_svg(data: &str) -> Option { + use qrcode::render::svg; + use qrcode::QrCode; + QrCode::new(data.as_bytes()) + .ok() + .map(|code| code.render::().min_dimensions(240, 240).build()) +} + +fn parse_type_list(types: &str) -> Vec { + types + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +use super::account_ui::{build_nav, flash_html, get_session_user_id, is_user_admin, layout}; + +/// The verified-state badge for a claim the user holds (or lack of one). +fn value_badge(signed: bool) -> &'static str { + if signed { + r#"Verified ✓"# + } else { + r#"Not verified"# + } +} + +#[rocket::get("/account/identity?&")] +pub fn identity_editor( + pool: &State, + cookies: &CookieJar<'_>, + msg: Option<&str>, + error: Option<&str>, +) -> Result, Box> { + let account_id = match get_session_user_id(cookies) { + Some(id) => id, + None => return Err(Box::new(Redirect::found("/account/login"))), + }; + // The default profile shares the account id; per-profile claim keying for + // additional personas is still pending, so the editor operates on it. + let subject_id = account_id.clone(); + + let policies = self_service::list_user_settable_policies(pool.inner()) + .map_err(|_| Box::new(Redirect::found("/account?error=Could+not+load+policies")))?; + let claims = pool + .list_active_claims(&subject_id) + .map_err(|_| Box::new(Redirect::found("/account?error=Could+not+load+claims")))?; + let prefs = pool.list_profile_prefs(&subject_id).unwrap_or_default(); + // Claim types the user has chosen to share with any domain (audience "*"). + let any_domain: std::collections::HashSet = pool + .list_user_release_allows(&subject_id, "*") + .unwrap_or_default() + .into_iter() + .collect(); + + let current = |ct: &str| -> Option<(String, bool)> { + claims.iter().find(|c| c.claim_type == ct).map(|c| { + let v = String::from_utf8(c.claim_value.clone()).unwrap_or_default(); + (v, !c.signatures.is_empty()) + }) + }; + let pref_for = |ct: &str, default: bool| -> bool { + prefs + .iter() + .find(|p| p.claim_type == ct) + .map(|p| p.auto_sign) + .unwrap_or(default) + }; + + let admin = is_user_admin(pool.inner(), &account_id); + let nav = build_nav("identity", admin, true); + let flash = flash_html(msg, error); + + let mut rows = String::new(); + for p in &policies { + let (value, signed) = current(&p.claim_type).unwrap_or((String::new(), false)); + let signable = p.signing_rule == "self_signed" || p.signing_rule == "verified"; + let badge = if current(&p.claim_type).is_some() { + value_badge(signed) + } else { + "" + }; + // Auto-sign toggle only where signing is possible. + let auto_sign_field = if signable { + let checked = if pref_for(&p.claim_type, p.default_auto_sign) { + "checked" + } else { + "" + }; + format!( + r#""#, + checked = checked + ) + } else { + String::new() + }; + let hint = if p.description.is_empty() { + String::new() + } else { + format!( + r#"
{}
"#, + html_escape(&p.description) + ) + }; + let share_checked = if any_domain.contains(&p.claim_type) { + "checked" + } else { + "" + }; + rows.push_str(&format!( + r#"
+ + +
+ {label} {badge} +
+ {hint} + +
+ {auto_sign} + +
+ +
+ + + +
+
"#, + ct = html_escape(&p.claim_type), + label = html_escape(&p.label), + badge = badge, + hint = hint, + value = html_escape(&value), + auto_sign = auto_sign_field, + share_checked = share_checked, + )); + } + if policies.is_empty() { + rows.push_str("

Your domain hasn't enabled any self-service claims yet.

"); + } + + // Multi-profile section only when the operator has raised the cap. + let profiles_section = if max_profiles_per_account() > 1 { + let profiles = self_service::list_profiles(pool.inner(), &account_id).unwrap_or_default(); + let mut list = String::new(); + for pr in &profiles { + list.push_str(&format!( + "
  • {} {}
  • ", + html_escape(pr.label.as_deref().unwrap_or("(default)")), + html_escape(&pr.id[..8.min(pr.id.len())]), + )); + } + format!( + r#"

    Profiles

    +

    Separate personas you can present to different sites. Linkage between them is yours to reveal — never the system's.

    +
      {list}
    +
    + + +
    "#, + list = list, + ) + } else { + String::new() + }; + + // Verified credentials: every claim the user holds, checked live against the + // signer's keys, with WHO signed it. This is the one-click verify — an + // attested claim reads "signed by dmv.test ✓", a self-asserted one shows its + // own domain (or "self-asserted" when unsigned). + let mut creds = String::new(); + for c in &claims { + let claim: liblinkkeys::generated::types::Claim = c.into(); + let v = attestation::verify_stored_claim(pool.inner(), &claim); + let badge = if v.verified { + r#"Verified ✓"# + } else { + r#"Unverified"# + }; + let signers = if v.signed_by.is_empty() { + "self-asserted".to_string() + } else { + v.signed_by.join(", ") + }; + creds.push_str(&format!( + "{ct}{val}{badge}{signers}", + ct = html_escape(&v.claim_type), + val = html_escape(&v.value), + badge = badge, + signers = html_escape(&signers), + )); + } + let creds_section = if creds.is_empty() { + String::new() + } else { + format!( + r#"

    Verified credentials

    +

    What you hold and who vouches for each — checked live against the signer's keys.

    +{creds}
    ClaimValueStatusSigned by
    "#, + creds = creds, + ) + }; + + let content = format!( + r#"{flash} +

    My Identity

    +

    Fill in what you'd like to be able to share. A Verified ✓ badge means {domain} has checked and signed the value, so sites can trust it came from your domain.

    +{rows} +

    Request verification from a third party →

    +{creds_section} +{profiles} +

    Back to Account

    "#, + flash = flash, + domain = html_escape(&get_domain_name()), + rows = rows, + creds_section = creds_section, + profiles = profiles_section, + ); + + Ok(layout("My Identity", &nav, &content)) +} + +#[derive(rocket::FromForm)] +pub struct SetClaimForm { + claim_type: String, + value: String, + auto_sign: Option, +} + +#[rocket::post("/account/identity/claim", data = "
    ")] +pub fn set_claim_submit( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Redirect { + let account_id = match get_session_user_id(cookies) { + Some(id) => id, + None => return Redirect::found("/account/login"), + }; + let subject_id = account_id; + let auto_sign = form.auto_sign.is_some(); + + // Record the auto-sign preference first so the set respects it. + let _ = self_service::set_signing_pref(pool.inner(), &subject_id, &form.claim_type, auto_sign); + + match self_service::set_my_claim( + pool.inner(), + &subject_id, + &form.claim_type, + form.value.as_bytes(), + ) { + Ok(SetOutcome::VerificationRequired) => { + // Lane B: kick off the verification flow now (email is the only one). + match crate::services::verification::request_email_verification( + pool.inner(), + &subject_id, + form.value.trim(), + ) { + Ok(()) => Redirect::found(format!( + "/account/identity?msg={}", + urlencoding::encode("Check your inbox for a verification link") + )), + Err(e) => Redirect::found(format!( + "/account/identity?error={}", + urlencoding::encode(&e.message) + )), + } + } + Ok(outcome) => { + let msg = match outcome { + SetOutcome::Signed => "Saved and verified", + SetOutcome::StoredUnsigned => "Saved", + SetOutcome::Queued => "Submitted for review", + SetOutcome::VerificationRequired => unreachable!(), + }; + Redirect::found(format!( + "/account/identity?msg={}", + urlencoding::encode(msg) + )) + } + Err(e) => Redirect::found(format!( + "/account/identity?error={}", + urlencoding::encode(&e.message) + )), + } +} + +#[rocket::get("/account/identity/verify-email?")] +pub fn verify_email(pool: &State, cookies: &CookieJar<'_>, token: &str) -> Redirect { + // Require a session, and confirm the link under the SAME account that + // requested it — the service rechecks token.user_id == session user, so a + // leaked link can't be redeemed by someone else. + let session_user = match get_session_user_id(cookies) { + Some(id) => id, + None => return Redirect::found("/account/login"), + }; + match crate::services::verification::confirm_email_verification( + pool.inner(), + token, + &session_user, + ) { + Ok(_) => Redirect::found(format!( + "/account/identity?msg={}", + urlencoding::encode("Email verified") + )), + Err(e) => Redirect::found(format!( + "/account/identity?error={}", + urlencoding::encode(&e.message) + )), + } +} + +#[derive(rocket::FromForm)] +pub struct ShareForm { + claim_type: String, + share_any: Option, +} + +#[rocket::post("/account/identity/share", data = "")] +pub fn set_share_submit( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Redirect { + let account_id = match get_session_user_id(cookies) { + Some(id) => id, + None => return Redirect::found("/account/login"), + }; + // Only let a user pre-share a claim type that actually exists and is theirs + // to set — don't accumulate junk standing prefs from crafted POSTs. + let known = matches!( + pool.find_claim_policy(&form.claim_type), + Ok(Some(ref p)) if p.user_settable + ); + if !known { + return Redirect::found("/account/identity?error=Unknown+claim+type"); + } + let result = if form.share_any.is_some() { + pool.add_user_release_pref(&account_id, "*", &form.claim_type) + } else { + pool.remove_user_release_pref(&account_id, "*", &form.claim_type) + }; + match result { + Ok(_) => Redirect::found("/account/identity?msg=Sharing+updated"), + Err(_) => Redirect::found("/account/identity?error=Could+not+update+sharing"), + } +} + +/// "Request verification" — the user picks an issuer and claim types, and we +/// mint a home-domain-signed signing request rendered three ways (QR, base64 +/// text, downloadable file) so they can carry it to the issuer however they +/// like, including printing a QR with no portable device. +#[rocket::get("/account/request-verification?&&")] +pub fn request_verification( + pool: &State, + cookies: &CookieJar<'_>, + issuer: Option<&str>, + types: Option<&str>, + error: Option<&str>, +) -> Result, Box> { + let account_id = match get_session_user_id(cookies) { + Some(id) => id, + None => return Err(Box::new(Redirect::found("/account/login"))), + }; + let admin = is_user_admin(pool.inner(), &account_id); + let nav = build_nav("identity", admin, true); + let flash = flash_html(None, error); + + let issuer_val = issuer.unwrap_or("").trim().to_string(); + let types_val = types.unwrap_or("").trim().to_string(); + + let mut result = String::new(); + if !issuer_val.is_empty() && !types_val.is_empty() { + let type_list = parse_type_list(&types_val); + match attestation::mint_signing_request(pool.inner(), &account_id, &issuer_val, &type_list) + { + Ok(signed) => { + let mut cbor = Vec::new(); + if ciborium::ser::into_writer(&signed, &mut cbor).is_ok() { + use base64ct::{Base64UrlUnpadded, Encoding as _}; + let b64 = Base64UrlUnpadded::encode_string(&cbor); + let qr = qr_svg(&b64) + .unwrap_or_else(|| "

    (request too large for a QR code)

    ".to_string()); + let dl = format!( + "/account/request-verification.bin?issuer={}&types={}", + urlencoding::encode(&issuer_val), + urlencoding::encode(&types_val) + ); + result = format!( + r#"

    Your verification request

    +

    Show this to {issuer}, or save/print it. It asks them to attest: {types}. Valid for a couple of days.

    +
    {qr}
    +

    Download as a file

    + +"#, + issuer = html_escape(&issuer_val), + types = html_escape(&types_val), + qr = qr, + dl = dl, + b64 = html_escape(&b64), + ); + } + } + Err(e) => { + result = format!(r#"
    {}
    "#, html_escape(&e.message)); + } + } + } + + let content = format!( + r#"{flash} +

    Request verification

    +

    Ask a trusted third party (a government office, a company, even a neighbor) to +attest something about you. They sign it; anyone can later verify their signature.

    + + + + + +

    + +{result} +

    Back to My Identity

    "#, + flash = flash, + issuer = html_escape(&issuer_val), + types = html_escape(&types_val), + result = result, + ); + Ok(layout("Request Verification", &nav, &content)) +} + +/// Raw CBOR(SignedSigningRequest) download of the same request. +#[rocket::get("/account/request-verification.bin?&")] +pub fn request_verification_download( + pool: &State, + cookies: &CookieJar<'_>, + issuer: &str, + types: &str, +) -> Result<(ContentType, Vec), Status> { + let account_id = get_session_user_id(cookies).ok_or(Status::Unauthorized)?; + let type_list = parse_type_list(types); + if issuer.trim().is_empty() || type_list.is_empty() { + return Err(Status::BadRequest); + } + let signed = + attestation::mint_signing_request(pool.inner(), &account_id, issuer.trim(), &type_list) + .map_err(|_| Status::InternalServerError)?; + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&signed, &mut cbor).map_err(|_| Status::InternalServerError)?; + Ok((ContentType::Binary, cbor)) +} + +#[derive(rocket::FromForm)] +pub struct CreateProfileForm { + label: String, +} + +#[rocket::post("/account/profiles/create", data = "
    ")] +pub fn create_profile_submit( + _csrf: super::guard::SameOriginPost, + pool: &State, + cookies: &CookieJar<'_>, + form: rocket::form::Form, +) -> Redirect { + let account_id = match get_session_user_id(cookies) { + Some(id) => id, + None => return Redirect::found("/account/login"), + }; + let label = form.label.trim(); + let label = if label.is_empty() { None } else { Some(label) }; + match self_service::create_profile(pool.inner(), &account_id, label) { + Ok(_) => Redirect::found("/account/identity?msg=Profile+created"), + Err(e) => Redirect::found(format!( + "/account/identity?error={}", + urlencoding::encode(&e.message) + )), + } +} diff --git a/crates/linkkeys/src/web/rp.rs b/crates/linkkeys/src/web/rp.rs index fbceb5c..3171816 100644 --- a/crates/linkkeys/src/web/rp.rs +++ b/crates/linkkeys/src/web/rp.rs @@ -511,6 +511,46 @@ pub async fn fetch_domain_keys( Ok(trusted) } +/// Deposit a signed claim to `domain`'s IDP over its generic CBOR-RPC carrier +/// (`/csil/v1/rpc`, the `Attestation/deposit-claim` op). Server-to-server: an +/// issuer pushes an attestation it signed to the subject's home domain, which +/// verifies + stores it. Returns Err with a human message on any failure so the +/// caller can fall back (e.g. hand the claim to the user out-of-band). +pub(super) async fn deposit_claim_to_domain( + net: &crate::net::Net, + domain: &str, + claim: &liblinkkeys::generated::types::Claim, +) -> Result<(), String> { + let apis = lookup_linkkeys_apis(net, domain) + .await + .map_err(|e| e.to_string())?; + let base = apis + .https_base + .ok_or_else(|| format!("{} advertises no https endpoint", domain))?; + let mut payload = Vec::new(); + ciborium::ser::into_writer( + &liblinkkeys::generated::types::DepositClaimRequest { + claim: claim.clone(), + }, + &mut payload, + ) + .map_err(|e| e.to_string())?; + let envelope = crate::tcp::encode_request_envelope("Attestation", "deposit-claim", payload); + let url = format!("{}/csil/v1/rpc", base); + let resp = net + .http + .post_cbor(&url, envelope) + .await + .map_err(|e| e.to_string())?; + if !resp.success { + return Err(format!("{} rejected the deposit (transport)", domain)); + } + if crate::tcp::response_status(&resp.body) != 0 { + return Err(format!("{} rejected the claim", domain)); + } + Ok(()) +} + #[cfg(test)] mod tests { use super::api_base_host; diff --git a/crates/linkkeys/tests/admin_accounts_test.rs b/crates/linkkeys/tests/admin_accounts_test.rs index dc34eed..6c443f2 100644 --- a/crates/linkkeys/tests/admin_accounts_test.rs +++ b/crates/linkkeys/tests/admin_accounts_test.rs @@ -1,12 +1,10 @@ -//! Admin/user account separation: an administrator becomes a separate -//! `_admin` account (copied password, the admin relation, NO profile), -//! and the original is demoted to a normal user. Admin accounts are not real -//! users — they administer the domain and never present to a relying party. +//! Admin accounts: an administrator account carries the admin relation and NO +//! profile — admin accounts administer the domain and never present to a relying +//! party. (The one-time `split_admins` startup transform that migrated legacy +//! in-band admins was removed once applied everywhere.) mod common; -use common::data_factory::{create_user, DataMap}; - const DOMAIN: &str = "admin-split.test"; fn set_domain() { @@ -32,62 +30,3 @@ fn create_admin_account_has_flag_admin_relation_and_no_profiles() { .unwrap() .is_empty()); } - -#[test] -fn split_admins_separates_and_demotes_then_is_idempotent() { - set_domain(); - let pool = common::create_test_pool(); - - // A regular user who is currently an admin (relation + password). - let mut overrides = DataMap::new(); - overrides.insert("username".to_string(), serde_json::json!("alice")); - let user = create_user(&pool, &overrides); - pool.create_relation("user", &user.id, "admin", "domain", DOMAIN) - .expect("grant admin"); - let hash = linkkeys::services::password::hash_for_storage("pw").unwrap(); - pool.create_auth_credential(&user.id, "password", &hash) - .expect("password credential"); - - assert!( - pool.check_permission(&user.id, "manage_users", "domain", DOMAIN) - .unwrap(), - "precondition: alice is an admin" - ); - - let n = pool.split_admins().expect("split"); - assert_eq!(n, 1, "one admin split"); - - // A separate admin account now exists, flagged, admin, no profiles. - let admin = pool - .find_user_by_username("alice_admin") - .expect("alice_admin created"); - assert!(admin.is_admin_account); - assert!(pool - .check_permission(&admin.id, "manage_users", "domain", DOMAIN) - .unwrap()); - assert!(pool - .list_profiles_for_account(&admin.id) - .unwrap() - .is_empty()); - - // The original is demoted to a normal user: no longer admin, still has its - // presentable profile, still a regular (non-admin) account. - assert!( - !pool - .check_permission(&user.id, "manage_users", "domain", DOMAIN) - .unwrap(), - "alice is no longer an admin" - ); - let alice = pool.find_user_by_username("alice").unwrap(); - assert!(!alice.is_admin_account); - assert!( - !pool - .list_profiles_for_account(&alice.id) - .unwrap() - .is_empty(), - "alice keeps her presentable profile" - ); - - // Idempotent: re-running splits nothing (original demoted, twin exists). - assert_eq!(pool.split_admins().expect("re-split"), 0); -} diff --git a/crates/linkkeys/tests/attestation_test.rs b/crates/linkkeys/tests/attestation_test.rs new file mode 100644 index 0000000..5c871ba --- /dev/null +++ b/crates/linkkeys/tests/attestation_test.rs @@ -0,0 +1,180 @@ +// Attested (lane-C) claims: a third-party issuer signs a claim about one of our +// accounts; we keep & store the issuer's signature and can verify it against the +// issuer's (cached) public key. Exercises the FK-relaxed claim_signatures + +// peer-key cache + the attestation service, against a real DB. + +mod common; + +use common::data_factory::{create_user, DataMap}; +use linkkeys::db::models::PeerKey; +use linkkeys::services::attestation; + +const OUR_DOMAIN: &str = "test.com"; +const ISSUER: &str = "dmv.test"; + +/// Mint an issuer keypair + an issuer-signed claim about `subject_id`, and return +/// the claim plus the issuer's public key (to cache as a peer key). +fn issuer_signed_claim( + subject_id: &str, + claim_type: &str, + value: &[u8], +) -> (liblinkkeys::generated::types::Claim, PeerKey) { + let (vk, sk) = liblinkkeys::crypto::generate_ed25519_keypair(); + let pk_bytes = vk.as_bytes().to_vec(); + let key_id = format!("issuer-key-{}", &uuid::Uuid::now_v7().to_string()[..8]); + let claim_id = uuid::Uuid::now_v7().to_string(); + + let claim = liblinkkeys::claims::sign_claim( + &liblinkkeys::claims::ClaimSpec { + claim_id: &claim_id, + claim_type, + claim_value: value, + user_id: subject_id, + subject_domain: OUR_DOMAIN, + expires_at: None, + }, + &[liblinkkeys::claims::ClaimSigner { + domain: ISSUER, + key_id: &key_id, + algorithm: liblinkkeys::crypto::SigningAlgorithm::parse_str("ed25519").unwrap(), + private_key_bytes: &sk.to_bytes(), + }], + ) + .expect("sign issuer claim"); + + let peer = PeerKey { + domain: ISSUER.to_string(), + key_id, + public_key: pk_bytes.clone(), + algorithm: "ed25519".to_string(), + fingerprint: liblinkkeys::crypto::fingerprint(&pk_bytes), + key_usage: "sign".to_string(), + expires_at: (chrono::Utc::now() + chrono::Duration::days(365)).to_rfc3339(), + revoked_at: None, + }; + (claim, peer) +} + +fn setup() -> (linkkeys::db::DbPool, String) { + std::env::set_var("DOMAIN_NAME", OUR_DOMAIN); + let pool = common::create_test_pool(); + let user = create_user(&pool, &DataMap::new()); + (pool, user.id) +} + +#[test] +fn trusted_issuer_claim_is_stored_and_verifies() { + let (pool, uid) = setup(); + let (claim, peer) = issuer_signed_claim(&uid, "age_over_21", b"true"); + + pool.cache_peer_key(&peer).expect("cache issuer key"); + pool.add_trusted_issuer("age_over_21", ISSUER) + .expect("trust issuer"); + + attestation::verify_and_store_attested(&pool, &uid, &claim).expect("accept attested claim"); + + // Stored verbatim, with the EXTERNAL signature (issuer domain + key id) intact. + let stored = pool.list_active_claims(&uid).unwrap(); + let c = stored + .iter() + .find(|c| c.claim_type == "age_over_21") + .expect("claim stored"); + assert_eq!(c.claim_value, b"true"); + assert_eq!(c.signatures.len(), 1); + assert_eq!(c.signatures[0].domain, ISSUER); + + // One-click verify resolves the issuer's cached key and checks out. + let v = attestation::verify_stored_claim(&pool, &claim); + assert!(v.verified, "should verify against the cached issuer key"); + assert_eq!(v.signed_by, vec![ISSUER.to_string()]); +} + +#[test] +fn deposit_via_rpc_dispatch_stores_claim() { + let (pool, uid) = setup(); + let (claim, peer) = issuer_signed_claim(&uid, "age_over_21", b"true"); + pool.cache_peer_key(&peer).unwrap(); + pool.add_trusted_issuer("age_over_21", ISSUER).unwrap(); + + // Deposit over the CBOR-RPC dispatch (the server-to-server API), not a REST + // route — issuer's server -> subject's domain. + let req = liblinkkeys::generated::types::DepositClaimRequest { claim }; + let mut payload = Vec::new(); + ciborium::ser::into_writer(&req, &mut payload).unwrap(); + let (status, resp) = + linkkeys::tcp::dispatch_for_test("Attestation", "deposit-claim", payload, &pool, None); + assert_eq!(status, 0, "deposit should succeed"); + let r: liblinkkeys::generated::types::DepositClaimResponse = + ciborium::de::from_reader(&resp[..]).unwrap(); + assert!(r.stored); + assert!(pool + .list_active_claims(&uid) + .unwrap() + .iter() + .any(|c| c.claim_type == "age_over_21")); +} + +#[test] +fn untrusted_issuer_is_rejected() { + let (pool, uid) = setup(); + let (claim, peer) = issuer_signed_claim(&uid, "age_over_21", b"true"); + pool.cache_peer_key(&peer).unwrap(); + // No add_trusted_issuer → the signature is valid but the issuer isn't trusted. + let err = attestation::verify_and_store_attested(&pool, &uid, &claim).unwrap_err(); + assert_eq!(err.code, 403); +} + +#[test] +fn tampered_value_fails_verification() { + let (pool, uid) = setup(); + let (mut claim, peer) = issuer_signed_claim(&uid, "age_over_21", b"true"); + pool.cache_peer_key(&peer).unwrap(); + pool.add_trusted_issuer("age_over_21", ISSUER).unwrap(); + // Flip the value after signing — signature no longer matches. + claim.claim_value = b"false".to_vec(); + + let err = attestation::verify_and_store_attested(&pool, &uid, &claim).unwrap_err(); + assert_eq!(err.code, 400); + assert!(!attestation::verify_stored_claim(&pool, &claim).verified); +} + +#[test] +fn minted_signing_request_verifies_against_domain_keys() { + std::env::set_var("DOMAIN_NAME", OUR_DOMAIN); + std::env::set_var("DOMAIN_KEY_PASSPHRASE", "test-passphrase"); + let pool = common::create_test_pool(); + common::data_factory::create_domain_key(&pool); + let user = create_user(&pool, &DataMap::new()); + + let types = vec!["age_over_21".to_string()]; + let signed = + linkkeys::services::attestation::mint_signing_request(&pool, &user.id, ISSUER, &types) + .expect("mint signing request"); + + // An issuer would verify it against our DNS-pinned domain keys. + let keys: Vec = pool + .list_active_domain_keys() + .unwrap() + .iter() + .map(Into::into) + .collect(); + let keysets = vec![liblinkkeys::claims::DomainKeySet { + domain: OUR_DOMAIN.to_string(), + keys, + }]; + let req = + liblinkkeys::signing_request::verify_signing_request(&signed, OUR_DOMAIN, ISSUER, &keysets) + .expect("issuer verifies the request"); + assert_eq!(req.subject_user_id, user.id); + assert_eq!(req.issuer_domain, ISSUER); + assert_eq!(req.requested_claim_types, types); + assert!(req.callback.is_some()); +} + +#[test] +fn verify_without_cached_key_is_unverified() { + let (pool, uid) = setup(); + let (claim, _peer) = issuer_signed_claim(&uid, "age_over_21", b"true"); + // Issuer key never cached → cannot resolve → unverified (fail closed). + assert!(!attestation::verify_stored_claim(&pool, &claim).verified); +} diff --git a/crates/linkkeys/tests/claim_policy_flow_test.rs b/crates/linkkeys/tests/claim_policy_flow_test.rs new file mode 100644 index 0000000..c5aff8e --- /dev/null +++ b/crates/linkkeys/tests/claim_policy_flow_test.rs @@ -0,0 +1,161 @@ +// End-to-end-ish tests for the claim-signing policy registry and user +// self-service, against a real DB in a rolled-back transaction (DataUtils +// pattern). The pure set/sign decision is unit-tested in +// `liblinkkeys::claim_policy`; here we exercise the storage + signing adapter. + +mod common; + +use common::data_factory::DataMap; +use common::{create_test_pool, data_factory}; +use linkkeys::services::self_service::{self, SetOutcome}; + +fn setup() -> (linkkeys::db::DbPool, String) { + std::env::set_var("DOMAIN_KEY_PASSPHRASE", "test-passphrase"); + std::env::set_var("DOMAIN_NAME", "test.com"); + let pool = create_test_pool(); + data_factory::create_domain_key(&pool); + pool.seed_default_policies().expect("seed policies"); + let user = data_factory::create_user(&pool, &DataMap::new()); + (pool, user.id) +} + +#[test] +fn lane_a_self_set_is_signed() { + let (pool, uid) = setup(); + let outcome = self_service::set_my_claim(&pool, &uid, "display_name", b"Ada").unwrap(); + assert_eq!(outcome, SetOutcome::Signed); + + let claims = pool.list_active_claims(&uid).unwrap(); + let dn = claims + .iter() + .find(|c| c.claim_type == "display_name") + .expect("display_name stored"); + assert_eq!(dn.claim_value, b"Ada"); + assert!( + !dn.signatures.is_empty(), + "lane A value must carry a signature" + ); +} + +#[test] +fn auto_sign_off_stores_unsigned() { + let (pool, uid) = setup(); + self_service::set_signing_pref(&pool, &uid, "display_name", false).unwrap(); + let outcome = self_service::set_my_claim(&pool, &uid, "display_name", b"Ada").unwrap(); + assert_eq!(outcome, SetOutcome::StoredUnsigned); + + let claims = pool.list_active_claims(&uid).unwrap(); + let dn = claims + .iter() + .find(|c| c.claim_type == "display_name") + .unwrap(); + assert!( + dn.signatures.is_empty(), + "auto-sign off must store unsigned" + ); +} + +#[test] +fn set_replaces_prior_value() { + let (pool, uid) = setup(); + self_service::set_my_claim(&pool, &uid, "display_name", b"First").unwrap(); + self_service::set_my_claim(&pool, &uid, "display_name", b"Second").unwrap(); + let active: Vec<_> = pool + .list_active_claims(&uid) + .unwrap() + .into_iter() + .filter(|c| c.claim_type == "display_name") + .collect(); + assert_eq!(active.len(), 1, "only one active value per type"); + assert_eq!(active[0].claim_value, b"Second"); +} + +#[test] +fn unknown_claim_type_rejected() { + let (pool, uid) = setup(); + assert!(self_service::set_my_claim(&pool, &uid, "not_a_real_type", b"x").is_err()); +} + +#[test] +fn invalid_value_rejected() { + let (pool, uid) = setup(); + // `website` requires a URL value. + assert!(self_service::set_my_claim(&pool, &uid, "website", b"not a url").is_err()); + assert!(self_service::set_my_claim(&pool, &uid, "website", b"https://example.com").is_ok()); +} + +#[test] +fn email_set_requires_verification() { + let (pool, uid) = setup(); + let outcome = self_service::set_my_claim(&pool, &uid, "email", b"a@b.com").unwrap(); + assert_eq!(outcome, SetOutcome::VerificationRequired); + // Nothing signed yet — verification hasn't happened. + let claims = pool.list_active_claims(&uid).unwrap(); + assert!(claims.iter().all(|c| c.claim_type != "email")); +} + +#[test] +fn user_release_prefs_apply_to_any_audience() { + let (pool, uid) = setup(); + pool.add_user_release_pref(&uid, "*", "handle").unwrap(); + let allows = pool + .list_user_release_allows(&uid, "anything.example") + .unwrap(); + assert!(allows.contains(&"handle".to_string())); + + pool.remove_user_release_pref(&uid, "*", "handle").unwrap(); + let allows = pool + .list_user_release_allows(&uid, "anything.example") + .unwrap(); + assert!(!allows.contains(&"handle".to_string())); +} + +#[test] +fn release_policy_audience_plus_global() { + let (pool, uid) = setup(); + let _ = uid; + pool.upsert_release_policy("*", "email", "forced_deny") + .unwrap(); + pool.upsert_release_policy("app.example", "handle", "forced_allow") + .unwrap(); + let rows = pool + .list_release_policies_for_audience("app.example") + .unwrap(); + assert!(rows + .iter() + .any(|r| r.claim_type == "email" && r.disposition == "forced_deny")); + assert!(rows + .iter() + .any(|r| r.claim_type == "handle" && r.disposition == "forced_allow")); + // A different audience sees only the global rule. + let other = pool + .list_release_policies_for_audience("other.example") + .unwrap(); + assert!(other.iter().any(|r| r.claim_type == "email")); + assert!(!other.iter().any(|r| r.claim_type == "handle")); +} + +#[test] +fn registry_seed_is_idempotent() { + let (pool, _uid) = setup(); + let before = pool.list_claim_policies().unwrap().len(); + pool.seed_default_policies().unwrap(); + let after = pool.list_claim_policies().unwrap().len(); + assert_eq!(before, after, "re-seeding must not duplicate"); + assert!(before >= 8, "starter registry present"); +} + +#[test] +fn approval_queue_round_trip() { + let (pool, uid) = setup(); + // Make display_name require approval, then a user set should queue. + let mut p = pool.find_claim_policy("display_name").unwrap().unwrap(); + p.requires_approval = true; + pool.upsert_claim_policy(p).unwrap(); + + let outcome = self_service::set_my_claim(&pool, &uid, "display_name", b"Ada").unwrap(); + assert_eq!(outcome, SetOutcome::Queued); + let pending = pool.list_pending_approvals().unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].claim_type, "display_name"); +} diff --git a/crates/linkkeys/tests/profiles_test.rs b/crates/linkkeys/tests/profiles_test.rs index e6b46e1..128207c 100644 --- a/crates/linkkeys/tests/profiles_test.rs +++ b/crates/linkkeys/tests/profiles_test.rs @@ -60,11 +60,3 @@ fn presentable_profile_limit_default_one_is_enforced() { .unwrap(); assert_eq!(presentable.len(), 1); } - -#[test] -fn backfill_is_idempotent_when_all_accounts_have_profiles() { - let pool = common::create_test_pool(); - create_user(&pool, &DataMap::new()); - // create_user already provisions profiles, so a backfill finds nothing to do. - assert_eq!(pool.backfill_profiles().expect("backfill"), 0); -} diff --git a/csil/linkkeys.csil b/csil/linkkeys.csil index 8be5775..65909ac 100644 --- a/csil/linkkeys.csil +++ b/csil/linkkeys.csil @@ -250,6 +250,58 @@ DomainClaim = { ? expires_at: text } +;; ============================================================ +;; Signing Requests (user asks a third party to attest a claim) +;; ============================================================ + +;; A user-initiated, home-domain-attested request asking a third-party issuer +;; (issuer_domain) to sign claim(s) about the user. The user gets the signed +;; envelope from their IDP and conveys it to the issuer however they like +;; (base64 in a QR code, a file, an HTTP POST, …) — the issuer verifies it +;; against the user's DNS-pinned domain keys, does its own real-world checks, and +;; (if it agrees) issues a Claim about subject_user_id@subject_domain signed with +;; its own domain keys, which is then deposited back. subject_user_id@subject_domain +;; AND issuer_domain are bound into the signature so a request meant for one +;; issuer about one subject can't be replayed for another. +SigningRequest = { + request_id: text, + ;; The subject the issued claim will be ABOUT (account UUID + home domain). + subject_user_id: text, + subject_domain: text, + ;; The issuer this request is addressed to (the would-be signer). + issuer_domain: text, + ;; The claim types the user is asking to have attested (e.g. "age_over_21"). + ;; The issuer decides the values it will actually sign. + requested_claim_types: [* text], + nonce: text, + issued_at: text, + expires_at: text, + ;; Optional hint for where to deposit the resulting signed claim. Absent => + ;; out-of-band return (the user collects it and deposits it themselves). + ? callback: text +} + +;; Same envelope shape as SignedConsentGrant: CBOR(SigningRequest) plus a +;; signature LIST attesting it. Signed by the user's HOME DOMAIN keys (the IDP +;; vouches the request is genuinely from that user); a user key may co-sign later +;; without a schema change. +SignedSigningRequest = { + request: bytes, + signatures: [* ClaimSignature] +} + +;; Deposit of an externally-signed (attested) claim about a local account. The +;; claim carries the issuer's signature(s); the receiving domain verifies them +;; against the issuer's (cached) keys, gates on its trusted-issuer policy, and +;; stores the claim verbatim. Server-to-server (issuer -> subject's domain). +DepositClaimRequest = { + claim: Claim +} + +DepositClaimResponse = { + stored: bool +} + ;; ============================================================ ;; Identity Assertions (auth tokens) ;; ============================================================ @@ -598,3 +650,9 @@ service Account { change-password: ChangePasswordRequest -> ChangePasswordResponse, get-my-info: EmptyRequest -> GetMyInfoResponse } + +;; Attested claims (server-to-server). An issuer deposits a claim it signed about +;; a subject to the subject's home domain, which verifies + stores it. +service Attestation { + deposit-claim: DepositClaimRequest -> DepositClaimResponse +} diff --git a/docs/claim-policy-and-consent.md b/docs/claim-policy-and-consent.md new file mode 100644 index 0000000..cbcc6c7 --- /dev/null +++ b/docs/claim-policy-and-consent.md @@ -0,0 +1,211 @@ +# Claim signing, policy management, and privacy consent + +This describes how LinkKeys decides **what claims exist**, **how they get signed**, +**who may set them**, and **what gets released to which sites** — and the web +surfaces a person (technical or not) uses to control all of it. + +The design separates three layers, each owned by a different party and each a +strict bound on the next: + +| Layer | Owner | Question it answers | Where | +|---|---|---|---| +| Claim‑type registry | Domain admin | What may exist, who may set it, how it's signed | `/policy-admin` | +| Claims + signing prefs | The user | My values, and which I keep signed | `/account/identity` | +| Release / consent | The user (within admin bounds) | Who actually receives each claim | consent screen + `/account/identity` | + +A non‑technical user never sees the word "sign." They fill in values and see a +**Verified ✓** badge when the domain was able to validate and sign them. + +--- + +## 1. Signing lanes + +Every claim type has a `signing_rule` that puts it in one of four lanes. This is +the spine of the system (`liblinkkeys::claim_policy`). + +| Lane | `signing_rule` | Meaning | IDP signs? | +|---|---|---|---| +| A | `self_signed` | A CSIL primitive the IDP validates and signs **on set** | Yes, immediately | +| B | `verified` | The IDP signs only **after a built‑in verification flow** | Yes, after the flow | +| C | `attested` | The IDP doesn't vouch for the value; it admits an **external trusted‑issuer** signature | No (custodies an external sig) | +| D | `unsigned` | Never carries a domain signature | No | + +A claim type also carries: + +- `value_type` — the CSIL primitive the IDP can validate (`text`, `url`, `email`, + `bool`, `int`, `float`, `decimal`, `date`, `timestamp`) or `opaque` (not + validatable). The IDP only ever self‑signs a value it can validate. +- `set_rule` — who may set it: `user_self`, `idp_on_request`, + `trusted_issuer_only`, `admin_only`, `deny`. +- `max_bytes` — size bound (default 33792 ≈ 33 KiB). +- `requires_approval` — a user set is queued for an admin instead of signed now. +- `user_settable` — whether a user may set it at all (enforced server‑side, not + just hidden in the UI). +- `default_auto_sign` — the default of the user's per‑type auto‑sign toggle. + +### Seeded starter registry + +Shipped on first boot (idempotent insert‑if‑absent, so admin edits are never +overwritten): + +- **Lane A, auto‑sign on:** `display_name`, `handle`, `website` (url), + `avatar_url` (url). +- **Lane B:** `email` (verified via an email round‑trip) and `email_verified` + (a bool the IDP sets as a side effect — not user‑settable). +- **Lane C (suggested):** `legal_name`, `date_of_birth`, `age_over_21` — set as + `trusted_issuer_only` / `attested`. No issuers are trusted by default; an admin + adds the domains they recognize (e.g. a government entity for `age_over_21`). + **See the limitation in §6 — external‑signature storage is not wired yet.** + +### The set decision (pure) + +`liblinkkeys::claim_policy::evaluate_set(policy, setter, value)` returns a +`SetAction` (`SelfSign`, `Verify`, `AcceptAttested`, `Queue`, `StoreUnsigned`) or +a machine‑readable `RejectionReason` (`UnknownClaimType`, `ValueTypeMismatch`, +`ValueTooLarge{limit}`, `SetterNotAuthorized`). It is pure and unit‑tested, so the +same decision is reproducible from web, a future CLI, or a native agent. + +--- + +## 2. The profile / identity editor (`/account/identity`) + +What the user sees: + +- One card per claim type the admin marked `user_settable`: a value field, a + **Keep this verified** toggle (auto‑sign), a **Verified ✓ / Not verified** + badge, and a **Share with any domain automatically** checkbox. +- Saving a lane‑A value validates and (if auto‑sign is on) signs it immediately. + Turning auto‑sign off stores the value **unsigned** on the next save. +- Saving `email` starts the verification flow (an email with a confirmation + link; the stub logs the link — see §6). Confirming signs `email` + + `email_verified`. +- **Profiles:** when the operator raises `MAX_PROFILES_PER_ACCOUNT` above 1, a + section appears to create additional pseudonymous profiles. Users never delete + a profile — that's an admin action. + +One active claim is kept per (subject, type): a new value revokes the prior one. + +--- + +## 3. The admin policy editor (`/policy-admin`) + +Gated by the `manage_claims` relation. Four sections, all guided forms (dropdowns, +not a policy language): + +1. **Claim types** — view/add/edit/delete registry entries (value type, set rule, + signing lane, flags). +2. **Trusted issuers** — domains whose signature is accepted as attestation for a + claim type (lane C). +3. **Release defaults** — per‑audience `forced_allow` / `forced_deny`. Audience + `*` is the global default; `forced_deny` wins over `forced_allow`. +4. **Pending approvals** — queue of user‑set claims awaiting approval; **Approve** + signs with the domain keys and stores, **Reject** discards. + +--- + +## 4. Privacy consent — "even my mom" + +Two paths let a person control what sites receive, without understanding crypto: + +- **Per‑login consent screen** (existing): the RP asks for claims; the user + checks what to share. Rows the user has standing‑allowed arrive **pre‑checked**. +- **Standing release preferences** (new): from the identity editor, the user can + **Share with any domain automatically**. This records a preference for + audience `*` (any domain). At consent time those rows are pre‑checked, so the + user confirms with one click — affirmative consent is preserved, friction is + gone. + +Standing preferences are user‑owned and can never override an admin `forced_deny` +(deny always wins), and they only set the consent **default** — they do not +auto‑release without the user submitting the consent form. (Auto‑skip of the +prompt still keys off real prior consent grants, not standing prefs.) + +Release policy is loaded **per audience** from the `release_policies` table +(`domain_policy_for`). It fails **closed**: if the policy can't be loaded, the +login/consent aborts rather than releasing claims an admin denied. + +--- + +## 5. Data model & env + +New tables (Postgres + SQLite, single‑file idempotent migrations): + +- `claim_type_policies` — the registry. +- `trusted_issuers` — `(claim_type, issuer_domain)`. +- `profile_claim_prefs` — per‑profile auto‑sign toggle. +- `release_policies` — `(audience, claim_type, disposition)`; `*` = global. +- `claim_approval_queue` — pending approvals. +- `email_verifications` — single‑use verification tokens (24h TTL). +- `user_release_prefs` — `(user_id, audience, claim_type)`; `*` = any domain. + +Env: + +- `CONSENT_FORCED_ALLOW` / `CONSENT_FORCED_DENY` — **deprecated**. Seeded once into + `release_policies` (audience `*`) on first boot if the table is empty; the DB is + now the source of truth. A `TODO` marks removal in a later session. +- `PUBLIC_ORIGIN` — base URL for verification links (falls back to + `https://`). +- `MAX_PROFILES_PER_ACCOUNT` — default 1 (hides multi‑profile UI). + +--- + +## 6. Known limitations / follow‑ups + +1. **Lane‑C external‑signature storage — DONE (foundation).** `claim_signatures` + no longer FKs `signed_by_key_id` to `domain_keys` and the column is widened to + text, so an issuer's signature stores verbatim. An append‑only `peer_keys` + cache (with the issuer's `expires_at`/`revoked_at`) lets stored signatures be + re‑verified later. `services::attestation` provides `verify_and_store_attested` + (trusted‑issuer gate → signature verify → store) and `verify_stored_claim` + (the one‑click verify). The issuer's signature is **kept and exposed** per + `docs/claim-trust-verification.md`. Deposit is a **CBOR-RPC op** + (`Attestation/deposit-claim`, server-to-server, dispatched like the other + protocol services — not a REST route). The user-initiated **signing request** + is `liblinkkeys::signing_request` + the CSIL `SigningRequest` envelope; + `attestation::mint_signing_request` produces it (TTL `SIGNING_REQUEST_TTL_SECONDS`, + default 2 days for non-portable-device logistics). The full loop has UIs: + **Request** (`/account/request-verification` → QR + base64 + file download), + **Issue/receive** (`/policy-admin/issue` — admin pastes a request, it's + verified against the subject's keys, the admin signs the attested claim). The + issuer then **deposits it server-to-server** to the subject's home domain over + the generic CBOR-RPC carrier (`POST /csil/v1/rpc` → `Attestation/deposit-claim`, + resolved via DNS / the request's `callback`) — no user round-trip; if the + domain is unreachable it falls back to handing the user the claim. Trusting an + issuer caches its keys (so the sync deposit op verifies). The carrier speaks + the **canonical CSIL-RPC v1 transport** (`csilgen-transport` — tag-24 payloads, + `id`/`variant`, the status registry; vendored under `crates/csilgen-transport` + for now, to swap for the git/published crate later), and `/csil/v1/rpc` is the + single CBOR-RPC carrier over the web (shares `dispatch()` with TCP), so the web + carries the whole RPC surface without per-op routes. The identity page's + **Verified credentials** section runs `verify_stored_claim` live and shows + "signed by `` ✓" per held claim. Nothing left on the attestation track. +2. **Email sending is a stub** that logs the link (`crate::email`). Add an SMTP / + provider backend before production; gate link logging behind a dev flag. +3. **No rate limiting on verification email** — a `TODO` marks adding a + per‑user / per‑recipient throttle. +4. **Per‑profile claim keying is pending.** Claims are still keyed by account id, + so the identity editor operates on the default profile. Raising + `MAX_PROFILES_PER_ACCOUNT` and using extra personas needs per‑profile claim + keying first (it currently fails closed — no leak). +5. **revoke + create is not a single transaction** in `sign_and_store` + (low‑risk for single‑user self‑service; noted in code). +6. **CSIL not yet extended.** These operations are server (web/DB) only today; a + later pass should add CSIL request types + service verbs so a CLI/agent shares + the contract. Pure logic already lives in `liblinkkeys::claim_policy`. + +--- + +## 7. Suggested test flow (trust IDP → apps; users choose per‑domain / any‑domain) + +1. As admin, open `/policy-admin`. Confirm the seeded claim types; add any you + want (e.g. `pronouns`, lane A). Optionally add a `forced_deny` rule for a + sensitive type, or a `forced_allow` for a specific app audience. +2. As a user, open `/account/identity`. Fill in `display_name`, `handle`, + `website` → they show **Verified ✓**. Verify `email` (grab the link from the + server log). Tick **Share with any domain automatically** for `handle`. +3. From a relying‑party app, start a login that requests `handle`, `display_name`, + `email`. On the consent screen, `handle` is pre‑checked (standing "any domain" + pref); the user confirms. +4. Confirm the RP receives exactly the consented claims (scoped by + `compute_authorized_claims`), and that an admin `forced_deny` on a type + suppresses it even if the user checked it. diff --git a/docs/claim-trust-verification.md b/docs/claim-trust-verification.md new file mode 100644 index 0000000..1f3e3a8 --- /dev/null +++ b/docs/claim-trust-verification.md @@ -0,0 +1,105 @@ +# Claim trust & verification model (decided — do not revisit) + +This records the identity/trust decisions so we don't relitigate them. It is the +authority for how subjects, profiles, and signatures relate. + +## 1. One account = one UUID = the subject of everything + +A human's account has exactly **one UUID**. Every assertion and every claim binds +that account UUID as its subject. **Profiles are NOT their own UUID.** The subject +a relying party sees is the account UUID. + +A signature must bind a subject id, or the signed claim would be replayable to +anyone at the domain ("portable across the entire domain" — unacceptable). The +account UUID is that binding. + +## 2. Profiles are override sets, not identities + +A **profile** is a named **override set**: per-claim value overrides plus a +release policy, layered over the one account identity — "use *this* avatar_url +and stage name with gaming sites, but not with family." When a profile is +presented, the domain emits the overridden values **signed by the domain, still +bound to the account UUID**. Profiles let you *present differently*; they do not +make you a *different cryptographic subject*. + +Consequently profiles are **not unlinkable**. Two profiles share the account UUID, +so an RP (or a breach) can correlate them. + +## 3. Unlinkability is a separate account, not a profile + +If you need a persona that cannot be correlated with your main identity (a +political forum, an adult site), create **another account** — possibly at another +domain. The system supports this; a device can be enrolled in multiple +accounts/domains, so switching is smooth. Each account is its own UUID and is +cryptographically independent. + +This is honest about the limit: we do not pretend a profile gives you +unlinkability it can't deliver. Operational correlation (timing, IP, reused +values) remains the user's/domain's responsibility, not something tech fully +solves. + +## 4. Third-party signatures are KEPT and EXPOSED — trust but verify + +When a third party (a government, a trusted company, "Bob's Junkyard," a +neighbor) attests a claim, **their signature stays on the claim and is shown to +relying parties and verifiers.** We do **NOT** strip it and have the domain +re-attest in its place. + +Rejected alternative (domain re-attests, hides the issuer): it destroys the +credibility the issuer was supposed to add. If RPs only see "the domain says so," +then anyone can spin up a domain, build a little trust, and sign whatever they +want. **Trust but verify** is the whole point — the issuer's signature must be +independently checkable against the issuer's public keys. + +So a claim carries one or more signatures; each names its signing domain and is +verifiable against that domain's DNS-pinned keys (multi-signature is already in +the protocol). A forum profile might show "**age ≥ 21** — signed by `dmv.ca.gov`" +or "**over 18** — signed by `bobs-junkyard.example`." Whether that signer is +worth trusting is the **viewer's / RP's** decision, surfaced and verifiable at one +click via the viewer's own domain IDP. + +## 5. Issuers choose what and for how many UUIDs they sign + +An issuer signs a claim bound to a UUID. A government may choose to issue its +**official digital ID** (carrying the legitimizing "license number" it can look +up in its database) for only **one** UUID per person. But it is **encouraged** to +sign the *attribute* claims people actually need everywhere — `age_over_21`, +`is_real_person`, `sex`, etc. — for **multiple** UUIDs, *without* the legitimizing +official-ID field. That lets a person carry verified age onto a separate, +unlinkable account without dragging their official identity along. + +And no issuer is privileged: **any party can sign any claim**. A site that won't +accept government can accept whoever it wants; a person who distrusts a given +signer can ignore that claim. We do not designate official signers. + +## 6. We provide tools, not trust + +We do **not** solve trust. We make it easy for non-technical people to decide how +*they* want to solve it: + +- A claim shows **what** is asserted and **who** signed it. +- Verification is one click, performed by the viewer's own domain IDP against the + signer's DNS-pinned public keys. +- RPs configure which signers they display/accept; users configure which they + present. Both choices are theirs. + +The credibility of a claim = (the signers, verifiable) × (the verifier's trust in +those signers). The system guarantees the first and never dictates the second. + +## 7. Implications for the data model + +- **Claims bind the account UUID.** `claims.user_id` / `consent_grants.user_id` + stay keyed to the account (`users.id`). We are **not** repointing them at a + per-profile UUID. (The branch's `subject_profiles` migration is dropped.) +- **Profiles** remain rows under an account but are override sets, not subjects. + Per-profile **value overrides** (+ re-signing the overridden value with the + domain key, bound to the account UUID) are the future presentation feature. + The root/never-leaked-anchor distinction is moot — there is one account UUID. +- **Third-party claim storage** must keep the issuer's signature(s). The + `claim_signatures.signed_by_key_id → domain_keys(id)` FK can't hold external + signer keys, so this needs the **append-only peer-key cache** (rotate, never + delete, so old signatures stay verifiable) and a relaxed/repointed signer + reference. RPs verify a claim's signatures against the relevant domains' keys + (via the verifier's IDP), never needing the subject's domain to vouch. +- **Verification endpoint**: the viewer's IDP resolves each signer domain's keys + (cached) and checks every signature on a claim — the "one click" of §6. diff --git a/migrations/postgres/00000000000000_diesel_initial_setup.sql b/migrations/postgres/00000000000000_diesel_initial_setup/up.sql similarity index 100% rename from migrations/postgres/00000000000000_diesel_initial_setup.sql rename to migrations/postgres/00000000000000_diesel_initial_setup/up.sql diff --git a/migrations/postgres/2026-03-15-000001_create_guestbook.sql b/migrations/postgres/2026-03-15-000001_create_guestbook/up.sql similarity index 100% rename from migrations/postgres/2026-03-15-000001_create_guestbook.sql rename to migrations/postgres/2026-03-15-000001_create_guestbook/up.sql diff --git a/migrations/postgres/2026-04-02-000001_create_identity_tables.sql b/migrations/postgres/2026-04-02-000001_create_identity_tables/up.sql similarity index 100% rename from migrations/postgres/2026-04-02-000001_create_identity_tables.sql rename to migrations/postgres/2026-04-02-000001_create_identity_tables/up.sql diff --git a/migrations/postgres/2026-04-09-000001_create_relations_and_extensions.sql b/migrations/postgres/2026-04-09-000001_create_relations_and_extensions/up.sql similarity index 100% rename from migrations/postgres/2026-04-09-000001_create_relations_and_extensions.sql rename to migrations/postgres/2026-04-09-000001_create_relations_and_extensions/up.sql diff --git a/migrations/postgres/2026-04-09-000002_add_relations_unique_index.sql b/migrations/postgres/2026-04-09-000002_add_relations_unique_index/up.sql similarity index 100% rename from migrations/postgres/2026-04-09-000002_add_relations_unique_index.sql rename to migrations/postgres/2026-04-09-000002_add_relations_unique_index/up.sql diff --git a/migrations/postgres/2026-05-30-000001_create_used_nonces.sql b/migrations/postgres/2026-05-30-000001_create_used_nonces/up.sql similarity index 100% rename from migrations/postgres/2026-05-30-000001_create_used_nonces.sql rename to migrations/postgres/2026-05-30-000001_create_used_nonces/up.sql diff --git a/migrations/postgres/2026-06-01-000001_add_key_usage.sql b/migrations/postgres/2026-06-01-000001_add_key_usage/up.sql similarity index 100% rename from migrations/postgres/2026-06-01-000001_add_key_usage.sql rename to migrations/postgres/2026-06-01-000001_add_key_usage/up.sql diff --git a/migrations/postgres/2026-06-14-000001_claim_signatures.sql b/migrations/postgres/2026-06-14-000001_claim_signatures/up.sql similarity index 100% rename from migrations/postgres/2026-06-14-000001_claim_signatures.sql rename to migrations/postgres/2026-06-14-000001_claim_signatures/up.sql diff --git a/migrations/postgres/2026-06-14-000002_create_consent_grants.sql b/migrations/postgres/2026-06-14-000002_create_consent_grants/up.sql similarity index 100% rename from migrations/postgres/2026-06-14-000002_create_consent_grants.sql rename to migrations/postgres/2026-06-14-000002_create_consent_grants/up.sql diff --git a/migrations/postgres/2026-06-15-000001_create_profiles.sql b/migrations/postgres/2026-06-15-000001_create_profiles/up.sql similarity index 100% rename from migrations/postgres/2026-06-15-000001_create_profiles.sql rename to migrations/postgres/2026-06-15-000001_create_profiles/up.sql diff --git a/migrations/postgres/2026-06-16-000001_admin_accounts.sql b/migrations/postgres/2026-06-16-000001_admin_accounts/up.sql similarity index 100% rename from migrations/postgres/2026-06-16-000001_admin_accounts.sql rename to migrations/postgres/2026-06-16-000001_admin_accounts/up.sql diff --git a/migrations/postgres/2026-06-17-000001_claim_policy/up.sql b/migrations/postgres/2026-06-17-000001_claim_policy/up.sql new file mode 100644 index 0000000..604105e --- /dev/null +++ b/migrations/postgres/2026-06-17-000001_claim_policy/up.sql @@ -0,0 +1,110 @@ +-- Claim-signing policy registry and the per-user / per-audience policy tables +-- built on top of it. See liblinkkeys::claim_policy for the lane semantics +-- (self_signed / verified / attested / unsigned) referenced below. + +-- The catalogue of recognised claim types and the rules for setting and signing +-- each. A deployment serves a single domain, so claim_type is the natural key. +CREATE TABLE claim_type_policies ( + claim_type VARCHAR PRIMARY KEY, + label VARCHAR NOT NULL, + description VARCHAR NOT NULL DEFAULT '', + -- A CSIL primitive the IDP can validate (text/url/bool/date/email/int/ + -- decimal/timestamp) or 'opaque' (IDP cannot validate the value). + value_type VARCHAR NOT NULL, + max_bytes BIGINT NOT NULL DEFAULT 33792, + -- Who may set a value: user_self | idp_on_request | trusted_issuer_only | + -- admin_only | deny. + set_rule VARCHAR NOT NULL, + -- How a value gets signed (the lanes): self_signed (A) | verified (B) | + -- attested (C) | unsigned (D). + signing_rule VARCHAR NOT NULL, + requires_approval BOOLEAN NOT NULL DEFAULT FALSE, + user_settable BOOLEAN NOT NULL DEFAULT FALSE, + default_auto_sign BOOLEAN NOT NULL DEFAULT FALSE, + suggested BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +SELECT diesel_manage_updated_at('claim_type_policies'); + +-- Domains whose signature the IDP accepts as attestation for a claim type +-- (lane C) — e.g. a recognised government entity for age_over_21. +CREATE TABLE trusted_issuers ( + claim_type VARCHAR NOT NULL, + issuer_domain VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (claim_type, issuer_domain) +); + +-- A user's per-profile preference for whether the IDP keeps a claim signed +-- automatically. Only meaningful where the registry marks the type user_settable. +CREATE TABLE profile_claim_prefs ( + profile_id VARCHAR NOT NULL, + claim_type VARCHAR NOT NULL, + auto_sign BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (profile_id, claim_type) +); + +SELECT diesel_manage_updated_at('profile_claim_prefs'); + +-- Per-audience release policy (forced_allow / forced_deny). The audience '*' is +-- the global default applied to every audience; it seeds from the deprecated +-- CONSENT_FORCED_ALLOW / CONSENT_FORCED_DENY env vars on first boot. +CREATE TABLE release_policies ( + audience VARCHAR NOT NULL, + claim_type VARCHAR NOT NULL, + -- forced_allow | forced_deny + disposition VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (audience, claim_type) +); + +SELECT diesel_manage_updated_at('release_policies'); + +-- Self-asserted claims awaiting admin approval before the IDP signs them +-- (set_rule = idp_on_request with requires_approval). +CREATE TABLE claim_approval_queue ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + claim_type VARCHAR NOT NULL, + claim_value BYTEA NOT NULL, + status VARCHAR NOT NULL DEFAULT 'pending', + resolved_by VARCHAR, + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_claim_approval_queue_status ON claim_approval_queue(status); + +SELECT diesel_manage_updated_at('claim_approval_queue'); + +-- Pending email-verification challenges. A row is created when a user asks to +-- verify an email address; on confirmation the IDP signs `email` + +-- `email_verified` and the row is deleted. Tokens are single-use and expire. +CREATE TABLE email_verifications ( + token VARCHAR PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email VARCHAR NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_email_verifications_user ON email_verifications(user_id); + +-- A user's standing release preferences: claim types pre-authorized for an +-- audience (audience '*' = any domain), set from their own profile editor. +-- Surfaced as pre-checked rows at consent. +CREATE TABLE user_release_prefs ( + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + audience VARCHAR NOT NULL, + claim_type VARCHAR NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, audience, claim_type) +); + +CREATE INDEX idx_user_release_prefs_user ON user_release_prefs(user_id); diff --git a/migrations/postgres/2026-06-18-000001_external_attestations/up.sql b/migrations/postgres/2026-06-18-000001_external_attestations/up.sql new file mode 100644 index 0000000..2db240a --- /dev/null +++ b/migrations/postgres/2026-06-18-000001_external_attestations/up.sql @@ -0,0 +1,26 @@ +-- Support claims signed by EXTERNAL issuer domains (attested / lane-C claims). +-- An issuer's key id is not one of our domain_keys UUIDs, so drop the FK to +-- domain_keys and widen the column to text. The (domain, signed_by_key_id) pair +-- identifies the signing key, resolved against our domain_keys for our own +-- domain or the peer-key cache for others. The issuer's signature is KEPT and +-- exposed so anyone can verify it (trust but verify). +ALTER TABLE claim_signatures DROP CONSTRAINT IF EXISTS claim_signatures_signed_by_key_id_fkey; +ALTER TABLE claim_signatures ALTER COLUMN signed_by_key_id TYPE VARCHAR USING signed_by_key_id::text; + +-- Append-only cache of public keys we've seen for other domains, so stored +-- external signatures stay verifiable even after the issuer rotates or +-- disappears. Never deleted; a rotated key is simply a new (domain, key_id) row. +-- expires_at / revoked_at are captured from the issuer so verification still +-- honours the issuer's own key validity and revocation. +CREATE TABLE peer_keys ( + domain VARCHAR NOT NULL, + key_id VARCHAR NOT NULL, + public_key BYTEA NOT NULL, + algorithm VARCHAR NOT NULL, + fingerprint VARCHAR NOT NULL, + key_usage VARCHAR NOT NULL, + expires_at VARCHAR NOT NULL, + revoked_at VARCHAR, + first_seen TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (domain, key_id) +); diff --git a/migrations/sqlite/00000000000000_diesel_initial_setup.sql b/migrations/sqlite/00000000000000_diesel_initial_setup/up.sql similarity index 100% rename from migrations/sqlite/00000000000000_diesel_initial_setup.sql rename to migrations/sqlite/00000000000000_diesel_initial_setup/up.sql diff --git a/migrations/sqlite/2026-03-15-000001_create_guestbook.sql b/migrations/sqlite/2026-03-15-000001_create_guestbook/up.sql similarity index 100% rename from migrations/sqlite/2026-03-15-000001_create_guestbook.sql rename to migrations/sqlite/2026-03-15-000001_create_guestbook/up.sql diff --git a/migrations/sqlite/2026-04-02-000001_create_identity_tables.sql b/migrations/sqlite/2026-04-02-000001_create_identity_tables/up.sql similarity index 100% rename from migrations/sqlite/2026-04-02-000001_create_identity_tables.sql rename to migrations/sqlite/2026-04-02-000001_create_identity_tables/up.sql diff --git a/migrations/sqlite/2026-04-09-000001_create_relations_and_extensions.sql b/migrations/sqlite/2026-04-09-000001_create_relations_and_extensions/up.sql similarity index 100% rename from migrations/sqlite/2026-04-09-000001_create_relations_and_extensions.sql rename to migrations/sqlite/2026-04-09-000001_create_relations_and_extensions/up.sql diff --git a/migrations/sqlite/2026-04-09-000002_add_relations_unique_index.sql b/migrations/sqlite/2026-04-09-000002_add_relations_unique_index/up.sql similarity index 100% rename from migrations/sqlite/2026-04-09-000002_add_relations_unique_index.sql rename to migrations/sqlite/2026-04-09-000002_add_relations_unique_index/up.sql diff --git a/migrations/sqlite/2026-05-30-000001_create_used_nonces.sql b/migrations/sqlite/2026-05-30-000001_create_used_nonces/up.sql similarity index 100% rename from migrations/sqlite/2026-05-30-000001_create_used_nonces.sql rename to migrations/sqlite/2026-05-30-000001_create_used_nonces/up.sql diff --git a/migrations/sqlite/2026-06-01-000001_add_key_usage.sql b/migrations/sqlite/2026-06-01-000001_add_key_usage/up.sql similarity index 100% rename from migrations/sqlite/2026-06-01-000001_add_key_usage.sql rename to migrations/sqlite/2026-06-01-000001_add_key_usage/up.sql diff --git a/migrations/sqlite/2026-06-14-000001_claim_signatures.sql b/migrations/sqlite/2026-06-14-000001_claim_signatures/up.sql similarity index 100% rename from migrations/sqlite/2026-06-14-000001_claim_signatures.sql rename to migrations/sqlite/2026-06-14-000001_claim_signatures/up.sql diff --git a/migrations/sqlite/2026-06-14-000002_create_consent_grants.sql b/migrations/sqlite/2026-06-14-000002_create_consent_grants/up.sql similarity index 100% rename from migrations/sqlite/2026-06-14-000002_create_consent_grants.sql rename to migrations/sqlite/2026-06-14-000002_create_consent_grants/up.sql diff --git a/migrations/sqlite/2026-06-15-000001_create_profiles.sql b/migrations/sqlite/2026-06-15-000001_create_profiles/up.sql similarity index 100% rename from migrations/sqlite/2026-06-15-000001_create_profiles.sql rename to migrations/sqlite/2026-06-15-000001_create_profiles/up.sql diff --git a/migrations/sqlite/2026-06-16-000001_admin_accounts.sql b/migrations/sqlite/2026-06-16-000001_admin_accounts/up.sql similarity index 100% rename from migrations/sqlite/2026-06-16-000001_admin_accounts.sql rename to migrations/sqlite/2026-06-16-000001_admin_accounts/up.sql diff --git a/migrations/sqlite/2026-06-17-000001_claim_policy/up.sql b/migrations/sqlite/2026-06-17-000001_claim_policy/up.sql new file mode 100644 index 0000000..6d03b7a --- /dev/null +++ b/migrations/sqlite/2026-06-17-000001_claim_policy/up.sql @@ -0,0 +1,111 @@ +-- See the postgres migration for semantics. + +CREATE TABLE claim_type_policies ( + claim_type TEXT PRIMARY KEY, + label TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + value_type TEXT NOT NULL, + max_bytes BIGINT NOT NULL DEFAULT 33792, + set_rule TEXT NOT NULL, + signing_rule TEXT NOT NULL, + requires_approval INTEGER NOT NULL DEFAULT 0, + user_settable INTEGER NOT NULL DEFAULT 0, + default_auto_sign INTEGER NOT NULL DEFAULT 0, + suggested INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TRIGGER set_claim_type_policies_updated_at + AFTER UPDATE ON claim_type_policies + FOR EACH ROW + WHEN OLD.updated_at = NEW.updated_at +BEGIN + UPDATE claim_type_policies SET updated_at = datetime('now') WHERE claim_type = NEW.claim_type; +END; + +CREATE TABLE trusted_issuers ( + claim_type TEXT NOT NULL, + issuer_domain TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (claim_type, issuer_domain) +); + +CREATE TABLE profile_claim_prefs ( + profile_id TEXT NOT NULL, + claim_type TEXT NOT NULL, + auto_sign INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (profile_id, claim_type) +); + +CREATE TRIGGER set_profile_claim_prefs_updated_at + AFTER UPDATE ON profile_claim_prefs + FOR EACH ROW + WHEN OLD.updated_at = NEW.updated_at +BEGIN + UPDATE profile_claim_prefs SET updated_at = datetime('now') + WHERE profile_id = NEW.profile_id AND claim_type = NEW.claim_type; +END; + +CREATE TABLE release_policies ( + audience TEXT NOT NULL, + claim_type TEXT NOT NULL, + disposition TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (audience, claim_type) +); + +CREATE TRIGGER set_release_policies_updated_at + AFTER UPDATE ON release_policies + FOR EACH ROW + WHEN OLD.updated_at = NEW.updated_at +BEGIN + UPDATE release_policies SET updated_at = datetime('now') + WHERE audience = NEW.audience AND claim_type = NEW.claim_type; +END; + +CREATE TABLE claim_approval_queue ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + claim_type TEXT NOT NULL, + claim_value BLOB NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + resolved_by TEXT, + resolved_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_claim_approval_queue_status ON claim_approval_queue(status); + +CREATE TRIGGER set_claim_approval_queue_updated_at + AFTER UPDATE ON claim_approval_queue + FOR EACH ROW + WHEN OLD.updated_at = NEW.updated_at +BEGIN + UPDATE claim_approval_queue SET updated_at = datetime('now') WHERE id = NEW.id; +END; + +-- See the postgres migration for semantics. +CREATE TABLE email_verifications ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + email TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_email_verifications_user ON email_verifications(user_id); + +CREATE TABLE user_release_prefs ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + audience TEXT NOT NULL, + claim_type TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (user_id, audience, claim_type) +); + +CREATE INDEX idx_user_release_prefs_user ON user_release_prefs(user_id); diff --git a/migrations/sqlite/2026-06-18-000001_external_attestations/up.sql b/migrations/sqlite/2026-06-18-000001_external_attestations/up.sql new file mode 100644 index 0000000..76ff96a --- /dev/null +++ b/migrations/sqlite/2026-06-18-000001_external_attestations/up.sql @@ -0,0 +1,14 @@ +-- See the postgres migration. SQLite already stores signed_by_key_id as TEXT and +-- does not enforce the foreign key, so only the peer-key cache is created here. +CREATE TABLE peer_keys ( + domain TEXT NOT NULL, + key_id TEXT NOT NULL, + public_key BLOB NOT NULL, + algorithm TEXT NOT NULL, + fingerprint TEXT NOT NULL, + key_usage TEXT NOT NULL, + expires_at TEXT NOT NULL, + revoked_at TEXT, + first_seen TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (domain, key_id) +);