A proxy in front of any licence server: it decides each licence request from signed inputs, appends its signed receipt to a durable single-writer log before it answers, refuses without forwarding, forwards a granted content protection system's request and returns the licence unchanged, and answers a clear key with a 307 and a release ticket instead of relaying it. A replay sent while the first request is still being forwarded waits and is refused after it, so the log stays in decision order. The key server, run by the key holder, redeems each ticket once. CLI: serve, keyserver, keygen, pubkey, list (a commitment list from a CPIX document), entitle, sign-request, verify. tests/end_to_end.rs runs everything on loopback against a fixture licence server; smoke.sh runs the release binary. Co-Authored-By: Claude Opus 5.5 <[email protected]>
191 lines
7.4 KiB
Rust
191 lines
7.4 KiB
Rust
//! The proxy's and the key server's configuration, as JSON.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
use std::net::SocketAddr;
|
|
use std::path::PathBuf;
|
|
|
|
use serde::Deserialize;
|
|
use wai::key_release::{cpix, unhex, CommitmentList, Delivery, DeliveryKind};
|
|
|
|
/// The proxy's configuration file.
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct ProxyFile {
|
|
/// Where the proxy listens for licence requests.
|
|
pub listen: SocketAddr,
|
|
/// Where it serves its whole log; not public (§9.6). None for no such listener.
|
|
#[serde(default)]
|
|
pub admin_listen: Option<SocketAddr>,
|
|
/// The file holding the proxy's Ed25519 seed (64 hexadecimal digits, mode 0600).
|
|
pub signer_key: PathBuf,
|
|
pub signer_id: String,
|
|
/// The entitlement authority keys it trusts, hexadecimal.
|
|
pub authorities: Vec<String>,
|
|
/// The commitment lists (`WKL1` files) of the content it serves.
|
|
pub lists: Vec<PathBuf>,
|
|
pub routes: Vec<RouteFile>,
|
|
/// The log: one receipt a line (§9.5).
|
|
pub log: PathBuf,
|
|
#[serde(default = "ticket_ttl")]
|
|
pub ticket_ttl_s: u64,
|
|
#[serde(default = "upstream_timeout")]
|
|
pub upstream_timeout_ms: u64,
|
|
#[serde(default = "max_body")]
|
|
pub max_body: usize,
|
|
}
|
|
|
|
fn ticket_ttl() -> u64 {
|
|
60
|
|
}
|
|
fn upstream_timeout() -> u64 {
|
|
10_000
|
|
}
|
|
fn max_body() -> usize {
|
|
1 << 16
|
|
}
|
|
|
|
/// One route (§9.1).
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct RouteFile {
|
|
pub name: String,
|
|
pub content_id: String,
|
|
/// `drm`, `hls-key-uri` or `clear-key`.
|
|
pub delivery: String,
|
|
/// For `drm`: the system ID, a UUID.
|
|
#[serde(default)]
|
|
pub system_id: Option<String>,
|
|
/// For `drm`: the licence server, `http://host:port/path`.
|
|
#[serde(default)]
|
|
pub upstream: Option<String>,
|
|
/// For `drm`: header fields sent to the licence server with each forwarded request.
|
|
#[serde(default)]
|
|
pub upstream_headers: BTreeMap<String, String>,
|
|
/// For a clear delivery: the key server's URI the ticket is appended to.
|
|
#[serde(default)]
|
|
pub key_url: Option<String>,
|
|
}
|
|
|
|
/// A route, checked.
|
|
#[derive(Clone, Debug)]
|
|
pub struct Route {
|
|
pub name: String,
|
|
pub content_id: String,
|
|
pub delivery: Delivery,
|
|
pub upstream: Option<crate::http::Target>,
|
|
pub upstream_headers: Vec<(String, String)>,
|
|
pub key_url: Option<String>,
|
|
}
|
|
|
|
/// The proxy's configuration, checked.
|
|
pub struct Proxy {
|
|
pub listen: SocketAddr,
|
|
pub admin_listen: Option<SocketAddr>,
|
|
pub signer: ed25519_dalek::SigningKey,
|
|
pub signer_id: String,
|
|
pub authorities: Vec<[u8; 32]>,
|
|
pub lists: HashMap<String, CommitmentList>,
|
|
pub routes: HashMap<String, Route>,
|
|
pub log: PathBuf,
|
|
pub ticket_ttl_s: u64,
|
|
pub upstream_timeout_ms: u64,
|
|
pub max_body: usize,
|
|
}
|
|
|
|
fn route_name_ok(n: &str) -> bool {
|
|
!n.is_empty() && n.len() <= 64 && n.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'.' | b'-' | b'_'))
|
|
}
|
|
|
|
impl Proxy {
|
|
pub fn load(path: &std::path::Path) -> Result<Proxy, String> {
|
|
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
|
let f: ProxyFile = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
|
|
Proxy::from_file(f)
|
|
}
|
|
|
|
pub fn from_file(f: ProxyFile) -> Result<Proxy, String> {
|
|
let signer = crate::read_key(&f.signer_key)?;
|
|
let authorities = f
|
|
.authorities
|
|
.iter()
|
|
.map(|a| unhex::<32>(a).ok_or_else(|| format!("authority {a}: not 64 lowercase hexadecimal digits")))
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
let mut lists = HashMap::new();
|
|
for p in &f.lists {
|
|
let b = std::fs::read(p).map_err(|e| format!("{}: {e}", p.display()))?;
|
|
let l = CommitmentList::parse(&b).map_err(|e| format!("{}: the list is refused ({e:?})", p.display()))?;
|
|
if lists.insert(l.content_id.clone(), l).is_some() {
|
|
return Err(format!("{}: a second list for one content", p.display()));
|
|
}
|
|
}
|
|
if !(1..=wai::key_release::MAX_TICKET_S).contains(&f.ticket_ttl_s) {
|
|
return Err("ticket_ttl_s must be from 1 to 300".into());
|
|
}
|
|
let mut routes = HashMap::new();
|
|
for r in f.routes {
|
|
if !route_name_ok(&r.name) {
|
|
return Err(format!("route {:?}: a name is 1 to 64 of A-Z a-z 0-9 . - _", r.name));
|
|
}
|
|
let kind = DeliveryKind::from_label(&r.delivery).ok_or_else(|| format!("route {}: delivery {:?}", r.name, r.delivery))?;
|
|
let system_id = match (kind, &r.system_id) {
|
|
(DeliveryKind::Drm, Some(s)) => Some(cpix::uuid(s).filter(|u| *u != [0; 16]).ok_or_else(|| format!("route {}: system_id {s}", r.name))?),
|
|
(DeliveryKind::Drm, None) => return Err(format!("route {}: a drm route names its system_id", r.name)),
|
|
(_, Some(_)) => return Err(format!("route {}: only a drm route has a system_id", r.name)),
|
|
(_, None) => None,
|
|
};
|
|
let upstream = match (kind, &r.upstream) {
|
|
(DeliveryKind::Drm, Some(u)) => Some(crate::http::Target::parse(u).ok_or_else(|| format!("route {}: upstream {u} is not http://host:port/path", r.name))?),
|
|
(DeliveryKind::Drm, None) => return Err(format!("route {}: a drm route names its licence server", r.name)),
|
|
(_, Some(_)) => return Err(format!("route {}: a clear delivery is never forwarded (§9.3)", r.name)),
|
|
(_, None) => None,
|
|
};
|
|
if kind.is_clear() != r.key_url.is_some() {
|
|
return Err(format!("route {}: a clear delivery, and only one, names its key server", r.name));
|
|
}
|
|
if let Some(l) = lists.get(&r.content_id) {
|
|
if let Some(k) = l.keys.iter().find(|k| !kind.carries(k.scheme)) {
|
|
return Err(format!("route {}: its delivery does not carry the {} key {} (§3.3)", r.name, k.scheme.label(), wai::key_release::hex(&k.kid)));
|
|
}
|
|
} else {
|
|
eprintln!("wai-key-proxy: route {} names content {} with no list: every request to it is refused", r.name, r.content_id);
|
|
}
|
|
let route = Route {
|
|
name: r.name.clone(),
|
|
content_id: r.content_id,
|
|
delivery: Delivery { kind, system_id },
|
|
upstream,
|
|
upstream_headers: r.upstream_headers.into_iter().collect(),
|
|
key_url: r.key_url,
|
|
};
|
|
if routes.insert(r.name.clone(), route).is_some() {
|
|
return Err(format!("route {}: named twice", r.name));
|
|
}
|
|
}
|
|
Ok(Proxy {
|
|
listen: f.listen,
|
|
admin_listen: f.admin_listen,
|
|
signer,
|
|
signer_id: f.signer_id,
|
|
authorities,
|
|
lists,
|
|
routes,
|
|
log: f.log,
|
|
ticket_ttl_s: f.ticket_ttl_s,
|
|
upstream_timeout_ms: f.upstream_timeout_ms,
|
|
max_body: f.max_body,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// The key server's configuration file.
|
|
#[derive(Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct KeyServerFile {
|
|
pub listen: SocketAddr,
|
|
/// The CPIX documents holding the keys it serves, in the clear.
|
|
pub cpix: Vec<PathBuf>,
|
|
/// The proxy keys whose tickets it redeems, hexadecimal.
|
|
pub proxies: Vec<String>,
|
|
/// The identities of the tickets redeemed, one a line.
|
|
pub redeemed: PathBuf,
|
|
}
|