use chrono::{DateTime, SecondsFormat, Utc};
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use super::canon::{canonical_json, hex_decode, hex_encode, is_lower_hex};
use super::ids::validate_id;
pub const CERT_DOMAIN: &str = "weftos-project-cert-v1\n";
pub const ANCHOR_DOMAIN: &str = "weftos-project-anchor-v1\n";
pub const POP_DOMAIN: &str = "weftos-mesh-local-pop-v2\n";
pub const CERT_TYPE: &str = "project-cert";
pub const CERT_VERSION: u32 = 1;
pub const MAX_ISSUE_SKEW_SECS: i64 = 60;
pub const POP_NONCE_HEX_LEN: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum CertError {
#[error("unsupported certificate version {0}")]
BadVersion(u32),
#[error("unexpected certificate type {0:?}")]
BadType(String),
#[error("malformed hex field `{0}`")]
BadHex(&'static str),
#[error("malformed timestamp field `{0}`")]
BadTimestamp(&'static str),
#[error("`{0}` does not match its public key")]
KeyIdMismatch(&'static str),
#[error("certificate not issued by the trusted user key")]
UntrustedUser,
#[error("signature does not verify")]
BadSignature,
#[error("certificate expired")]
Expired,
#[error("certificate issued in the future")]
NotYetValid,
#[error("project_id is not a canonical ULID")]
BadProjectId,
#[error("field `{0}` is not 64 lowercase hex characters")]
BadDigest(&'static str),
#[error("nonce is not {POP_NONCE_HEX_LEN} lowercase hex characters")]
BadNonce,
}
pub fn key_id(pubkey: &[u8]) -> String {
hex_encode(&Sha256::digest(pubkey)[..16])
}
pub fn ts(t: DateTime<Utc>) -> String {
t.to_rfc3339_opts(SecondsFormat::Secs, true)
}
fn parse_ts(s: &str, field: &'static str) -> Result<DateTime<Utc>, CertError> {
let t = DateTime::parse_from_rfc3339(s)
.map(|t| t.with_timezone(&Utc))
.map_err(|_| CertError::BadTimestamp(field))?;
if ts(t) != s {
return Err(CertError::BadTimestamp(field));
}
Ok(t)
}
fn verifying_key(pk: &[u8; 32]) -> Result<VerifyingKey, CertError> {
VerifyingKey::from_bytes(pk).map_err(|_| CertError::BadHex("pubkey"))
}
fn check_sig(
pk: &[u8; 32],
sig_hex: &str,
domain: &str,
body: &Value,
) -> Result<(), CertError> {
let sig: [u8; 64] = hex_decode(sig_hex).ok_or(CertError::BadHex("sig"))?;
let bytes = domain_bytes(domain, body);
verifying_key(pk)?
.verify_strict(&bytes, &Signature::from_bytes(&sig))
.map_err(|_| CertError::BadSignature)
}
fn domain_bytes(domain: &str, body: &Value) -> Vec<u8> {
let mut out = domain.as_bytes().to_vec();
out.extend_from_slice(canonical_json(body).as_bytes());
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CertRequest {
pub project_id: String,
pub project_pubkey: [u8; 32],
pub serial: u64,
pub issued_at: DateTime<Utc>,
pub expires_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectCert {
pub v: u32,
#[serde(rename = "type")]
pub kind: String,
pub project_id: String,
pub project_pubkey: String,
pub project_key_id: String,
pub user_key_id: String,
pub user_pubkey: String,
pub serial: u64,
pub issued_at: String,
pub expires_at: Option<String>,
#[serde(default)]
pub sig: String,
}
impl ProjectCert {
fn body(&self) -> Value {
json!({
"v": self.v,
"type": self.kind,
"project_id": self.project_id,
"project_pubkey": self.project_pubkey,
"project_key_id": self.project_key_id,
"user_key_id": self.user_key_id,
"user_pubkey": self.user_pubkey,
"serial": self.serial,
"issued_at": self.issued_at,
"expires_at": self.expires_at,
})
}
pub fn canonical_bytes(&self) -> Vec<u8> {
domain_bytes(CERT_DOMAIN, &self.body())
}
pub fn sign(user_key: &SigningKey, req: &CertRequest) -> Self {
let user_pubkey = user_key.verifying_key().to_bytes();
let mut cert = Self {
v: CERT_VERSION,
kind: CERT_TYPE.to_owned(),
project_id: req.project_id.clone(),
project_pubkey: hex_encode(&req.project_pubkey),
project_key_id: key_id(&req.project_pubkey),
user_key_id: key_id(&user_pubkey),
user_pubkey: hex_encode(&user_pubkey),
serial: req.serial,
issued_at: ts(req.issued_at),
expires_at: req.expires_at.map(ts),
sig: String::new(),
};
cert.sig = hex_encode(&user_key.sign(&cert.canonical_bytes()).to_bytes());
cert
}
pub fn computed_key_id(&self) -> Result<String, CertError> {
let pk: [u8; 32] =
hex_decode(&self.project_pubkey).ok_or(CertError::BadHex("project_pubkey"))?;
Ok(key_id(&pk))
}
pub fn verify(
&self,
trusted_user_pubkey: &[u8; 32],
now: DateTime<Utc>,
) -> Result<(), CertError> {
if self.v != CERT_VERSION {
return Err(CertError::BadVersion(self.v));
}
if self.kind != CERT_TYPE {
return Err(CertError::BadType(self.kind.clone()));
}
validate_id(&self.project_id).map_err(|_| CertError::BadProjectId)?;
let project_pk: [u8; 32] =
hex_decode(&self.project_pubkey).ok_or(CertError::BadHex("project_pubkey"))?;
let user_pk: [u8; 32] =
hex_decode(&self.user_pubkey).ok_or(CertError::BadHex("user_pubkey"))?;
if self.project_key_id != key_id(&project_pk) {
return Err(CertError::KeyIdMismatch("project_key_id"));
}
if self.user_key_id != key_id(&user_pk) {
return Err(CertError::KeyIdMismatch("user_key_id"));
}
if &user_pk != trusted_user_pubkey {
return Err(CertError::UntrustedUser);
}
let issued = parse_ts(&self.issued_at, "issued_at")?;
if issued > now + chrono::Duration::seconds(MAX_ISSUE_SKEW_SECS) {
return Err(CertError::NotYetValid);
}
let expiry = self
.expires_at
.as_deref()
.map(|s| parse_ts(s, "expires_at"))
.transpose()?;
check_sig(&user_pk, &self.sig, CERT_DOMAIN, &self.body())?;
match expiry {
Some(t) if t <= now => Err(CertError::Expired),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectAnchorStmt {
pub project_id: String,
pub project_key_id: String,
pub cert_serial: u64,
pub seq: u64,
pub chain_id: u64,
pub head_hash: String,
pub head_seq: u64,
pub rule_hash: String,
pub at: String,
pub prev_anchor: Option<String>,
#[serde(default)]
pub sig: String,
}
impl ProjectAnchorStmt {
fn body(&self) -> Value {
json!({
"project_id": self.project_id,
"project_key_id": self.project_key_id,
"cert_serial": self.cert_serial,
"seq": self.seq,
"chain_id": self.chain_id,
"head_hash": self.head_hash,
"head_seq": self.head_seq,
"rule_hash": self.rule_hash,
"at": self.at,
"prev_anchor": self.prev_anchor,
})
}
pub fn canonical_bytes(&self) -> Vec<u8> {
domain_bytes(ANCHOR_DOMAIN, &self.body())
}
pub fn sign(mut self, project_key: &SigningKey) -> Self {
self.project_key_id = key_id(&project_key.verifying_key().to_bytes());
self.sig = hex_encode(&project_key.sign(&self.canonical_bytes()).to_bytes());
self
}
pub fn verify(&self, project_pubkey: &[u8; 32]) -> Result<(), CertError> {
if self.project_key_id != key_id(project_pubkey) {
return Err(CertError::KeyIdMismatch("project_key_id"));
}
validate_id(&self.project_id).map_err(|_| CertError::BadProjectId)?;
for (name, v) in [("head_hash", &self.head_hash), ("rule_hash", &self.rule_hash)] {
if !is_lower_hex(v, 64) {
return Err(CertError::BadDigest(name));
}
}
if let Some(p) = &self.prev_anchor
&& !is_lower_hex(p, 64)
{
return Err(CertError::BadDigest("prev_anchor"));
}
parse_ts(&self.at, "at")?;
check_sig(project_pubkey, &self.sig, ANCHOR_DOMAIN, &self.body())
}
pub fn hash(&self) -> String {
let mut body = self.body();
body["sig"] = Value::String(self.sig.clone());
hex_encode(&Sha256::digest(domain_bytes(ANCHOR_DOMAIN, &body)))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PopOp {
Register,
Rekey,
}
impl PopOp {
pub fn as_str(self) -> &'static str {
match self {
Self::Register => "register",
Self::Rekey => "rekey",
}
}
}
pub fn pop_signed_bytes(
op: PopOp,
user_key_id: &str,
nonce: &str,
project_id: &str,
) -> Result<Vec<u8>, CertError> {
if !is_lower_hex(user_key_id, 32) {
return Err(CertError::BadHex("user_key_id"));
}
if !is_lower_hex(nonce, POP_NONCE_HEX_LEN) {
return Err(CertError::BadNonce);
}
validate_id(project_id).map_err(|_| CertError::BadProjectId)?;
Ok(format!("{POP_DOMAIN}{}\n{user_key_id}\n{nonce}\n{project_id}", op.as_str()).into_bytes())
}