use cyberbrain_core::{Error, Result};
use ed25519_dalek::{Signature, SigningKey, Verifier, VerifyingKey};
use serde::{Deserialize, Serialize};
pub const WARN_DAYS: i64 = 30;
pub const ISSUER_PUBLIC_KEY: &str =
"afe47e2521a88be62cd385008ab2604c99b92ceaf641131ff8383ee743c19da3";
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Licence {
pub version: u32,
pub id: String,
pub customer: String,
pub seats: usize,
pub valid_from: String,
pub valid_until: String,
pub issued_at: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct SignedLicence {
pub licence: Licence,
canonical: String,
signature: String,
}
impl SignedLicence {
pub fn licence(&self) -> &Licence {
&self.licence
}
pub fn days_left(&self, now: jiff::Timestamp) -> i64 {
let Ok(end) = self.licence.valid_until.parse::<jiff::Timestamp>() else {
return -1;
};
(end.as_second() - now.as_second()) / 86_400
}
pub fn expired(&self, now: jiff::Timestamp) -> bool {
match self.licence.valid_until.parse::<jiff::Timestamp>() {
Ok(end) => now > end,
Err(_) => true,
}
}
pub fn not_yet_valid(&self, now: jiff::Timestamp) -> bool {
match self.licence.valid_from.parse::<jiff::Timestamp>() {
Ok(start) => now < start,
Err(_) => true,
}
}
pub fn warning(&self, now: jiff::Timestamp) -> Option<String> {
if self.expired(now) {
return Some(format!(
"the licence for {} ended on {}. New rows are no longer accepted; the record \
stays readable and exportable, and clients keep buffering. Renewing takes \
what they held.",
self.licence.customer, self.licence.valid_until
));
}
let days = self.days_left(now);
if days <= WARN_DAYS {
return Some(format!(
"the licence for {} ends on {} — {} day(s) left. After that the hub stops \
accepting rows; nothing is deleted.",
self.licence.customer, self.licence.valid_until, days
));
}
None
}
pub fn render(&self) -> String {
format!("{}\n{}\n", self.canonical, self.signature)
}
}
fn key_from_hex(hex: &str) -> Result<VerifyingKey> {
let bytes = decode_hex(hex).ok_or_else(|| Error::Config("issuer key is not hex".into()))?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Config("issuer key is not 32 bytes".into()))?;
VerifyingKey::from_bytes(&arr)
.map_err(|e| Error::Config(format!("issuer key is not a valid ed25519 key: {e}")))
}
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if !s.len().is_multiple_of(2) {
return None;
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok())
.collect()
}
fn to_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn parse(text: &str) -> Result<SignedLicence> {
parse_with_key(text, ISSUER_PUBLIC_KEY)
}
pub fn parse_with_key(text: &str, issuer_hex: &str) -> Result<SignedLicence> {
let bad = |m: String| Error::Config(format!("licence: {m}"));
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let mut lines = text.lines().filter(|l| !l.trim().is_empty());
let canonical = lines
.next()
.ok_or_else(|| bad("file is empty".into()))?
.to_string();
let signature = lines
.next()
.ok_or_else(|| bad("no signature line".into()))?
.trim()
.to_string();
let licence: Licence = serde_json::from_str(&canonical)
.map_err(|e| bad(format!("first line is not a licence: {e}")))?;
if licence.version != 1 {
return Err(bad(format!(
"format version {} is not 1; this hub cannot read it",
licence.version
)));
}
let key = key_from_hex(issuer_hex)?;
let sig_bytes = decode_hex(&signature).ok_or_else(|| bad("signature is not hex".into()))?;
let sig_arr: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| bad("signature is not 64 bytes".into()))?;
let sig = Signature::from_bytes(&sig_arr);
key.verify(canonical.as_bytes(), &sig).map_err(|_| {
bad("signature does not match; this licence was not issued for this product".into())
})?;
Ok(SignedLicence {
licence,
canonical,
signature,
})
}
pub fn issue(licence: &Licence, signing_key_hex: &str) -> Result<SignedLicence> {
let bytes = decode_hex(signing_key_hex)
.ok_or_else(|| Error::Config("signing key is not hex".into()))?;
let arr: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Config("signing key is not 32 bytes".into()))?;
let key = SigningKey::from_bytes(&arr);
let canonical = serde_json::to_string(licence)
.map_err(|e| Error::Config(format!("licence does not serialise: {e}")))?;
let signature = {
use ed25519_dalek::Signer;
to_hex(&key.sign(canonical.as_bytes()).to_bytes())
};
Ok(SignedLicence {
licence: licence.clone(),
canonical,
signature,
})
}
pub fn generate_key() -> Result<(String, String)> {
let mut seed = [0u8; 32];
getrandom::fill(&mut seed)
.map_err(|e| Error::Config(format!("no randomness available to make a key: {e}")))?;
let key = SigningKey::from_bytes(&seed);
Ok((
to_hex(key.as_bytes()),
to_hex(key.verifying_key().as_bytes()),
))
}
#[cfg(test)]
mod tests {
use super::*;
fn key_pair() -> (String, String) {
generate_key().expect("this machine has randomness")
}
fn licence(seats: usize, from: &str, until: &str) -> Licence {
Licence {
version: 1,
id: "lic_test".into(),
customer: "Beispiel GmbH".into(),
seats,
valid_from: from.into(),
valid_until: until.into(),
issued_at: "2026-01-01T00:00:00Z".into(),
}
}
#[test]
fn a_signed_licence_verifies_and_a_touched_one_does_not() {
let (private, public) = key_pair();
let signed = issue(
&licence(5, "2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"),
&private,
)
.unwrap();
let text = signed.render();
let back = parse_with_key(&text, &public).unwrap();
assert_eq!(back.licence().seats, 5);
let tampered = text.replace("\"seats\":5", "\"seats\":500");
let err = parse_with_key(&tampered, &public).unwrap_err().to_string();
assert!(err.contains("signature does not match"), "{err}");
}
#[test]
fn another_issuers_licence_is_not_accepted() {
let (private, _) = key_pair();
let (_, other_public) = key_pair();
let signed = issue(
&licence(5, "2026-01-01T00:00:00Z", "2027-01-01T00:00:00Z"),
&private,
)
.unwrap();
assert!(parse_with_key(&signed.render(), &other_public).is_err());
}
#[test]
fn expiry_is_a_date_not_a_grace_period() {
let (private, public) = key_pair();
let signed = issue(
&licence(5, "2026-01-01T00:00:00Z", "2026-06-30T23:59:59Z"),
&private,
)
.unwrap();
let l = parse_with_key(&signed.render(), &public).unwrap();
let before: jiff::Timestamp = "2026-06-30T12:00:00Z".parse().unwrap();
let after: jiff::Timestamp = "2026-07-01T00:00:01Z".parse().unwrap();
assert!(!l.expired(before));
assert!(l.expired(after));
}
#[test]
fn the_warning_starts_thirty_days_out_and_says_what_happens() {
let (private, public) = key_pair();
let signed = issue(
&licence(5, "2026-01-01T00:00:00Z", "2026-07-01T00:00:00Z"),
&private,
)
.unwrap();
let l = parse_with_key(&signed.render(), &public).unwrap();
let quiet: jiff::Timestamp = "2026-05-01T00:00:00Z".parse().unwrap();
assert_eq!(l.warning(quiet), None, "no nagging two months out");
let warned: jiff::Timestamp = "2026-06-10T00:00:00Z".parse().unwrap();
let w = l.warning(warned).expect("30 days out there is a warning");
assert!(w.contains("21 day(s) left"), "{w}");
assert!(w.contains("nothing is deleted"), "{w}");
let over: jiff::Timestamp = "2026-07-02T00:00:00Z".parse().unwrap();
let w = l.warning(over).expect("after the end there is a message");
assert!(w.contains("no longer accepted"), "{w}");
assert!(w.contains("stays readable and exportable"), "{w}");
}
#[test]
fn a_licence_that_has_not_started_yet_is_not_valid_either() {
let (private, public) = key_pair();
let signed = issue(
&licence(5, "2027-01-01T00:00:00Z", "2028-01-01T00:00:00Z"),
&private,
)
.unwrap();
let l = parse_with_key(&signed.render(), &public).unwrap();
let now: jiff::Timestamp = "2026-09-07T00:00:00Z".parse().unwrap();
assert!(l.not_yet_valid(now));
assert!(!l.expired(now));
}
#[test]
fn an_unreadable_end_date_counts_as_expired() {
let (private, public) = key_pair();
let mut l = licence(5, "2026-01-01T00:00:00Z", "whenever");
l.valid_until = "whenever".into();
let signed = issue(&l, &private).unwrap();
let parsed = parse_with_key(&signed.render(), &public).unwrap();
assert!(parsed.expired("2026-01-02T00:00:00Z".parse().unwrap()));
}
#[test]
fn a_file_that_is_not_a_licence_says_so_rather_than_crashing() {
let (_, public) = key_pair();
for text in ["", "not json\nsig\n", "{\"version\":1}\n", "{}\nzz\n"] {
assert!(parse_with_key(text, &public).is_err(), "{text:?} parsed");
}
}
}
#[cfg(test)]
mod file_shape_tests {
use super::*;
#[test]
fn a_licence_saved_by_notepad_still_verifies() {
let (private, public) = generate_key().unwrap();
let l = Licence {
version: 1,
id: "lic_shape".into(),
customer: "Beispiel GmbH".into(),
seats: 3,
valid_from: "2026-01-01T00:00:00Z".into(),
valid_until: "2027-01-01T00:00:00Z".into(),
issued_at: "2026-01-01T00:00:00Z".into(),
};
let clean = issue(&l, &private).unwrap().render();
let mangled = format!("\u{feff}{}", clean.replace('\n', "\r\n"));
let parsed = parse_with_key(&mangled, &public).expect("a saved copy is still a licence");
assert_eq!(parsed.licence().customer, "Beispiel GmbH");
let tampered = mangled.replace("Beispiel GmbH", "Jemand Anderes");
assert!(parse_with_key(&tampered, &public).is_err());
}
}