use coz::base64ct::{Base64UrlUnpadded, Encoding};
use coz::{Czd, Pay, Thumbprint};
use crate::error::{Error, Result};
use crate::key::Key;
use crate::state::{AuthRoot, PrincipalRoot};
pub mod typ {
pub const KEY_CREATE: &str = "cyphr/key/create";
pub const KEY_DELETE: &str = "cyphr/key/delete";
pub const KEY_REPLACE: &str = "cyphr/key/replace";
pub const KEY_REVOKE: &str = "cyphr/key/revoke";
pub const PRINCIPAL_CREATE: &str = "cyphr/principal/create";
pub const COMMIT_CREATE: &str = "cyphr/commit/create";
}
#[derive(Debug, Clone)]
pub enum CozKind {
KeyCreate {
pre: PrincipalRoot,
id: Thumbprint,
},
KeyDelete {
pre: PrincipalRoot,
id: Thumbprint,
},
KeyReplace {
pre: PrincipalRoot,
id: Thumbprint,
},
SelfRevoke {
pre: PrincipalRoot,
rvk: i64,
},
PrincipalCreate {
pre: PrincipalRoot,
id: AuthRoot,
},
CommitCreate {
arrow: crate::multihash::MultihashDigest,
},
}
impl std::fmt::Display for CozKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CozKind::KeyCreate { .. } => write!(f, "{}", typ::KEY_CREATE),
CozKind::KeyDelete { .. } => write!(f, "{}", typ::KEY_DELETE),
CozKind::KeyReplace { .. } => write!(f, "{}", typ::KEY_REPLACE),
CozKind::SelfRevoke { .. } => write!(f, "{}", typ::KEY_REVOKE),
CozKind::PrincipalCreate { .. } => write!(f, "{}", typ::PRINCIPAL_CREATE),
CozKind::CommitCreate { .. } => write!(f, "{}", typ::COMMIT_CREATE),
}
}
}
#[derive(Debug, Clone)]
pub struct ParsedCoz {
pub(crate) kind: CozKind,
pub(crate) signer: Thumbprint,
pub(crate) now: i64,
pub(crate) czd: Czd,
pub(crate) hash_alg: crate::state::HashAlg,
pub(crate) arrow: Option<crate::multihash::MultihashDigest>,
pub(crate) raw: coz::CozJson,
}
impl ParsedCoz {
pub fn from_pay(
pay: &Pay,
czd: Czd,
hash_alg: crate::state::HashAlg,
raw: coz::CozJson,
) -> Result<Self> {
let signer = pay.tmb.clone().ok_or(Error::MalformedPayload)?;
let now = pay.now.ok_or(Error::MalformedPayload)?;
let typ = pay.typ.as_ref().ok_or(Error::MalformedPayload)?;
let kind = Self::parse_kind(pay, typ, &signer)?;
let arrow = Self::extract_arrow(pay)?;
Ok(Self {
kind,
signer,
now,
czd,
hash_alg,
arrow,
raw,
})
}
pub fn kind(&self) -> &CozKind {
&self.kind
}
pub fn signer(&self) -> &Thumbprint {
&self.signer
}
pub fn now(&self) -> i64 {
self.now
}
pub fn czd(&self) -> &Czd {
&self.czd
}
pub fn raw(&self) -> &coz::CozJson {
&self.raw
}
pub fn hash_alg(&self) -> crate::state::HashAlg {
self.hash_alg
}
pub fn arrow(&self) -> Option<&crate::multihash::MultihashDigest> {
self.arrow.as_ref()
}
fn parse_kind(pay: &Pay, typ: &str, _signer: &Thumbprint) -> Result<CozKind> {
if typ.ends_with(typ::KEY_CREATE) {
let pre = Self::extract_pre(pay)?;
let id = Self::extract_id(pay)?;
Ok(CozKind::KeyCreate { pre, id })
} else if typ.ends_with(typ::KEY_DELETE) {
let pre = Self::extract_pre(pay)?;
let id = Self::extract_id(pay)?;
Ok(CozKind::KeyDelete { pre, id })
} else if typ.ends_with(typ::KEY_REPLACE) {
let pre = Self::extract_pre(pay)?;
let id = Self::extract_id(pay)?;
Ok(CozKind::KeyReplace { pre, id })
} else if typ.ends_with(typ::KEY_REVOKE) {
let pre = Self::extract_pre(pay)?;
let rvk = pay.rvk.ok_or(Error::MalformedPayload)?;
if Self::try_extract_id(pay).is_some() {
return Err(Error::MalformedPayload);
}
Ok(CozKind::SelfRevoke { pre, rvk })
} else if typ.ends_with(typ::PRINCIPAL_CREATE) {
let pre = Self::extract_pre(pay)?;
let id = Self::extract_as(pay)?;
Ok(CozKind::PrincipalCreate { pre, id })
} else if typ.ends_with(typ::COMMIT_CREATE) {
let arrow = Self::extract_arrow(pay)?.ok_or(Error::MalformedPayload)?;
Ok(CozKind::CommitCreate { arrow })
} else {
Err(Error::MalformedPayload)
}
}
fn extract_pre(pay: &Pay) -> Result<PrincipalRoot> {
use crate::multihash::MultihashDigest;
use crate::state::TaggedDigest;
let pre_value = pay.extra.get("pre").ok_or(Error::MalformedPayload)?;
let pre_str = pre_value.as_str().ok_or(Error::MalformedPayload)?;
let tagged: TaggedDigest = pre_str.parse().map_err(|_| Error::MalformedPayload)?;
Ok(PrincipalRoot(MultihashDigest::from_single(
tagged.alg(),
tagged.as_bytes().to_vec(),
)))
}
fn extract_id(pay: &Pay) -> Result<Thumbprint> {
Self::try_extract_id(pay).ok_or(Error::MalformedPayload)
}
fn try_extract_id(pay: &Pay) -> Option<Thumbprint> {
let id_value = pay.extra.get("id")?;
let id_str = id_value.as_str()?;
let id_bytes = Base64UrlUnpadded::decode_vec(id_str).ok()?;
Some(Thumbprint::from_bytes(id_bytes))
}
fn extract_as(pay: &Pay) -> Result<AuthRoot> {
use crate::multihash::MultihashDigest;
use crate::state::TaggedDigest;
let id_value = pay.extra.get("id").ok_or(Error::MalformedPayload)?;
let id_str = id_value.as_str().ok_or(Error::MalformedPayload)?;
let tagged: TaggedDigest = id_str.parse().map_err(|_| Error::MalformedPayload)?;
Ok(AuthRoot(MultihashDigest::from_single(
tagged.alg(),
tagged.as_bytes().to_vec(),
)))
}
fn extract_arrow(pay: &Pay) -> Result<Option<crate::multihash::MultihashDigest>> {
use crate::multihash::MultihashDigest;
use crate::state::TaggedDigest;
let Some(arrow_value) = pay.extra.get("arrow") else {
return Ok(None);
};
if arrow_value.is_boolean() {
return Ok(None);
}
let arrow_str = arrow_value.as_str().ok_or(Error::MalformedPayload)?;
let tagged: TaggedDigest = arrow_str.parse().map_err(|_| Error::MalformedPayload)?;
Ok(Some(MultihashDigest::from_single(
tagged.alg(),
tagged.as_bytes().to_vec(),
)))
}
}
#[derive(Debug, Clone)]
pub struct VerifiedCoz {
cz: ParsedCoz,
new_key: Option<Key>,
}
impl VerifiedCoz {
pub fn coz(&self) -> &ParsedCoz {
&self.cz
}
pub fn new_key(&self) -> Option<&Key> {
self.new_key.as_ref()
}
pub(crate) fn from_parts(cz: ParsedCoz, new_key: Option<Key>) -> Self {
Self { cz, new_key }
}
#[cfg(test)]
pub(crate) fn from_transaction_unsafe(cz: ParsedCoz, new_key: Option<Key>) -> Self {
Self::from_parts(cz, new_key)
}
}
impl std::ops::Deref for VerifiedCoz {
type Target = ParsedCoz;
fn deref(&self) -> &Self::Target {
&self.cz
}
}
pub fn verify_coz(
pay_json: &[u8],
sig: &[u8],
key: &Key,
czd: Czd,
new_key: Option<Key>,
) -> Result<VerifiedCoz> {
let valid = coz::verify_json(pay_json, sig, &key.alg, &key.pub_key).unwrap_or(false);
if !valid {
return Err(Error::InvalidSignature);
}
let pay: Pay = serde_json::from_slice(pay_json).map_err(|_| Error::MalformedPayload)?;
let pay_value: serde_json::Value =
serde_json::from_slice(pay_json).map_err(|_| Error::MalformedPayload)?;
let raw = coz::CozJson {
pay: pay_value,
sig: sig.to_vec(),
};
let hash_alg = crate::state::hash_alg_from_str(&key.alg)?;
let cz = ParsedCoz::from_pay(&pay, czd, hash_alg, raw)?;
Ok(VerifiedCoz { cz, new_key })
}
#[cfg(test)]
mod tests {
use coz::{PayBuilder, Thumbprint};
use serde_json::json;
use super::*;
use crate::state::HashAlg;
const TEST_PRE: &str = "SHA-256:U5XUZots-WmQYcQWmsO751Xk0yeVi9XUKWQ2mGz6Aqg";
const TEST_ID: &str = "xrYMu87EXes58PnEACcDW1t0jF2ez4FCN-njTF0MHNo";
fn to_raw(pay: &Pay) -> coz::CozJson {
coz::CozJson {
pay: serde_json::to_value(pay).unwrap(),
sig: vec![0; 64],
}
}
#[test]
fn parse_key_add() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/create")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
pay.extra.insert("id".into(), json!(TEST_ID));
let czd = Czd::from_bytes(vec![0; 32]);
let cz = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay)).unwrap();
assert!(matches!(cz.kind, CozKind::KeyCreate { .. }));
assert_eq!(cz.now, 1000);
}
#[test]
fn parse_key_delete() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/delete")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
pay.extra.insert("id".into(), json!(TEST_ID));
let czd = Czd::from_bytes(vec![0; 32]);
let cz = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay)).unwrap();
assert!(matches!(cz.kind, CozKind::KeyDelete { .. }));
}
#[test]
fn parse_key_replace() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/replace")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
pay.extra.insert("id".into(), json!(TEST_ID));
let czd = Czd::from_bytes(vec![0; 32]);
let cz = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay)).unwrap();
assert!(matches!(cz.kind, CozKind::KeyReplace { .. }));
}
#[test]
fn parse_self_revoke() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/revoke")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.rvk(1000)
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
let czd = Czd::from_bytes(vec![0; 32]);
let cz = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay)).unwrap();
assert!(matches!(cz.kind, CozKind::SelfRevoke { rvk: 1000, .. }));
}
#[test]
fn parse_self_revoke_with_id_fails() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/revoke")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.rvk(1000)
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
pay.extra.insert("id".into(), json!(TEST_ID));
let czd = Czd::from_bytes(vec![0; 32]);
let result = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay));
assert!(
matches!(result, Err(Error::MalformedPayload)),
"expected MalformedPayload when id present on self-revoke, got {:?}",
result
);
}
#[test]
fn parse_principal_create() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/principal/create")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
pay.extra.insert("pre".into(), json!(TEST_PRE));
pay.extra.insert("id".into(), json!(TEST_PRE));
pay.extra.insert("commit".into(), json!(true));
let czd = Czd::from_bytes(vec![0; 32]);
let cz = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay)).unwrap();
assert!(matches!(cz.kind, CozKind::PrincipalCreate { .. }));
}
#[test]
fn parse_missing_typ_fails() {
let pay = PayBuilder::new()
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
let czd = Czd::from_bytes(vec![0; 32]);
let result = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay));
assert!(matches!(result, Err(Error::MalformedPayload)));
}
#[test]
fn parse_missing_pre_fails() {
let mut pay = PayBuilder::new()
.typ("cyphr.me/cyphr/key/create")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
pay.extra.insert("id".into(), json!(TEST_ID));
let czd = Czd::from_bytes(vec![0; 32]);
let result = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay));
assert!(matches!(result, Err(Error::MalformedPayload)));
}
#[test]
fn parse_unknown_typ_fails() {
let pay = PayBuilder::new()
.typ("cyphr.me/unknown/action")
.alg("ES256")
.now(1000)
.tmb(Thumbprint::from_bytes(vec![0xAA; 32]))
.build();
let czd = Czd::from_bytes(vec![0; 32]);
let result = ParsedCoz::from_pay(&pay, czd, HashAlg::Sha256, to_raw(&pay));
assert!(matches!(result, Err(Error::MalformedPayload)));
}
}