use async_trait::async_trait;
use base64::Engine as _;
use ciborium::value::Value as CborValue;
use coset::cwt::{ClaimsSet, ClaimsSetBuilder, Timestamp};
use coset::{
iana, Algorithm as CoseAlg, CborSerializable, CoseSign1, CoseSign1Builder, HeaderBuilder,
TaggedCborSerializable,
};
use boatramp_types::authz::GrantedRole;
const CLAIM_ROLES: &str = "br_roles";
const CLAIM_KIND: &str = "br_kind";
const CLAIM_NODE: &str = "br_node";
const CLAIM_PUBKEY: &str = "br_pubkey";
pub const KIND_ROLE: &str = "role";
pub const KIND_JOIN: &str = "join";
pub const KIND_CLUSTER_WRITE: &str = "cluster-write";
pub const KIND_DELEGATION: &str = "delegation";
pub const KIND_BOOTSTRAP_TLS: &str = "bootstrap-tls";
pub const KIND_POP: &str = "pop";
pub const KIND_MESH_MEMBER: &str = "mesh-member";
const CLAIM_HTM: &str = "htm";
const CLAIM_HTP: &str = "htp";
const CLAIM_ATH: &str = "ath";
const CLAIM_BH: &str = "bh";
pub const POP_WINDOW_SECS: u64 = 60;
pub const POP_SKEW_SECS: u64 = 30;
pub const POP_MAX_BODY_HASH_BYTES: usize = 1024 * 1024;
const CLAIM_CNF: &str = "br_cnf";
const CLAIM_CAVEATS: &str = "br_caveats";
pub const MAX_CHAIN_DEPTH: usize = 8;
pub const MAX_CHAIN_BYTES: usize = 8 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Caveats {
pub only_site: Option<String>,
pub read_only: bool,
pub not_after: Option<u64>,
impossible: bool,
}
impl Caveats {
pub fn restrict(only_site: Option<String>, read_only: bool, not_after: Option<u64>) -> Self {
Self {
only_site,
read_only,
not_after,
impossible: false,
}
}
pub fn is_empty(&self) -> bool {
self.only_site.is_none() && !self.read_only && self.not_after.is_none() && !self.impossible
}
fn tighten(&mut self, other: &Self) {
self.read_only |= other.read_only;
self.impossible |= other.impossible;
match (self.only_site.as_deref(), other.only_site.as_deref()) {
(None, Some(s)) => self.only_site = Some(s.to_string()),
(Some(a), Some(b)) if a != b => self.impossible = true,
_ => {}
}
self.not_after = match (self.not_after, other.not_after) {
(Some(a), Some(b)) => Some(a.min(b)),
(a, None) => a,
(None, b) => b,
};
}
pub fn allows(&self, required: &crate::authz::Right, _now_unix: u64) -> bool {
if self.impossible {
return false;
}
if self.read_only && required.action != crate::authz::Action::Read {
return false;
}
if let Some(site) = &self.only_site {
if required.target_term() != site {
return false;
}
}
true
}
fn to_cbor(&self) -> CborValue {
let mut map = Vec::new();
if let Some(site) = &self.only_site {
map.push((
CborValue::Text("only_site".into()),
CborValue::Text(site.clone()),
));
}
if self.read_only {
map.push((CborValue::Text("read_only".into()), CborValue::Bool(true)));
}
if let Some(na) = self.not_after {
map.push((
CborValue::Text("not_after".into()),
CborValue::Integer(na.into()),
));
}
CborValue::Map(map)
}
fn from_cbor(value: &CborValue) -> Self {
let mut caveats = Self::default();
let CborValue::Map(entries) = value else {
return caveats;
};
for (k, v) in entries {
let CborValue::Text(key) = k else { continue };
match (key.as_str(), v) {
("only_site", CborValue::Text(s)) => caveats.only_site = Some(s.clone()),
("read_only", CborValue::Bool(b)) => caveats.read_only = *b,
("not_after", CborValue::Integer(i)) => {
caveats.not_after = u64::try_from(*i).ok();
}
_ => {}
}
}
caveats
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TokenAlg {
Es256,
Ed25519,
}
impl TokenAlg {
fn iana(self) -> iana::Algorithm {
match self {
Self::Es256 => iana::Algorithm::ES256,
Self::Ed25519 => iana::Algorithm::EdDSA,
}
}
fn label(self) -> &'static str {
match self {
Self::Es256 => "es256",
Self::Ed25519 => "ed25519",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum TokenError {
#[error("token key: {0}")]
Key(String),
#[error("token build: {0}")]
Build(String),
#[error("token signer: {0}")]
Signer(String),
#[error("token invalid: {0}")]
Invalid(String),
#[error("token claims: {0}")]
Claims(String),
#[error("token expired")]
Expired,
}
#[async_trait]
pub trait Signer: Send + Sync {
fn alg(&self) -> TokenAlg;
fn public_key(&self) -> TokenPublicKey;
async fn sign(&self, tbs: &[u8]) -> Result<Vec<u8>, TokenError>;
}
pub enum LocalSigner {
Es256(p256::ecdsa::SigningKey),
Ed25519(ed25519_dalek::SigningKey),
}
impl LocalSigner {
pub fn generate(alg: TokenAlg) -> Self {
match alg {
TokenAlg::Es256 => Self::Es256(p256::ecdsa::SigningKey::random(&mut rand_core::OsRng)),
TokenAlg::Ed25519 => {
Self::Ed25519(ed25519_dalek::SigningKey::generate(&mut rand_core::OsRng))
}
}
}
pub fn from_private_hex(spec: &str) -> Result<Self, TokenError> {
let (alg, raw) = split_tagged(spec)?;
match alg {
TokenAlg::Es256 => Ok(Self::Es256(
p256::ecdsa::SigningKey::from_slice(&raw)
.map_err(|e| TokenError::Key(format!("es256 private key: {e}")))?,
)),
TokenAlg::Ed25519 => {
let bytes: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| TokenError::Key("ed25519 private key must be 32 bytes".into()))?;
Ok(Self::Ed25519(ed25519_dalek::SigningKey::from_bytes(&bytes)))
}
}
}
pub fn private_hex(&self) -> String {
match self {
Self::Es256(sk) => format!("es256:{}", hex::encode(sk.to_bytes())),
Self::Ed25519(sk) => format!("ed25519:{}", hex::encode(sk.to_bytes())),
}
}
}
#[async_trait]
impl Signer for LocalSigner {
fn alg(&self) -> TokenAlg {
match self {
Self::Es256(_) => TokenAlg::Es256,
Self::Ed25519(_) => TokenAlg::Ed25519,
}
}
fn public_key(&self) -> TokenPublicKey {
match self {
Self::Es256(sk) => TokenPublicKey::Es256(*sk.verifying_key()),
Self::Ed25519(sk) => TokenPublicKey::Ed25519(sk.verifying_key()),
}
}
async fn sign(&self, tbs: &[u8]) -> Result<Vec<u8>, TokenError> {
match self {
Self::Es256(sk) => {
use p256::ecdsa::signature::Signer as _;
let sig: p256::ecdsa::Signature = sk
.try_sign(tbs)
.map_err(|e| TokenError::Signer(e.to_string()))?;
Ok(sig.to_bytes().to_vec())
}
Self::Ed25519(sk) => {
use ed25519_dalek::Signer as _;
let sig = sk
.try_sign(tbs)
.map_err(|e| TokenError::Signer(e.to_string()))?;
Ok(sig.to_bytes().to_vec())
}
}
}
}
#[derive(Clone, Debug)]
pub enum TokenPublicKey {
Es256(p256::ecdsa::VerifyingKey),
Ed25519(ed25519_dalek::VerifyingKey),
}
impl TokenPublicKey {
pub fn alg(&self) -> TokenAlg {
match self {
Self::Es256(_) => TokenAlg::Es256,
Self::Ed25519(_) => TokenAlg::Ed25519,
}
}
pub fn to_hex(&self) -> String {
match self {
Self::Es256(vk) => {
format!(
"es256:{}",
hex::encode(vk.to_encoded_point(true).as_bytes())
)
}
Self::Ed25519(vk) => format!("ed25519:{}", hex::encode(vk.as_bytes())),
}
}
pub fn es256_from_spki_der(der: &[u8]) -> Result<Self, TokenError> {
use p256::pkcs8::DecodePublicKey as _;
p256::ecdsa::VerifyingKey::from_public_key_der(der)
.map(TokenPublicKey::Es256)
.map_err(|e| TokenError::Key(format!("es256 SPKI DER: {e}")))
}
pub fn es256_from_spki_pem(pem: &str) -> Result<Self, TokenError> {
use p256::pkcs8::DecodePublicKey as _;
p256::ecdsa::VerifyingKey::from_public_key_pem(pem)
.map(TokenPublicKey::Es256)
.map_err(|e| TokenError::Key(format!("es256 SPKI PEM: {e}")))
}
pub fn from_hex(spec: &str) -> Result<Self, TokenError> {
let (alg, raw) = split_tagged(spec)?;
match alg {
TokenAlg::Es256 => Ok(Self::Es256(
p256::ecdsa::VerifyingKey::from_sec1_bytes(&raw)
.map_err(|e| TokenError::Key(format!("es256 public key: {e}")))?,
)),
TokenAlg::Ed25519 => {
let bytes: [u8; 32] = raw
.as_slice()
.try_into()
.map_err(|_| TokenError::Key("ed25519 public key must be 32 bytes".into()))?;
Ok(Self::Ed25519(
ed25519_dalek::VerifyingKey::from_bytes(&bytes)
.map_err(|e| TokenError::Key(format!("ed25519 public key: {e}")))?,
))
}
}
}
pub fn verify(&self, tbs: &[u8], sig: &[u8]) -> Result<(), TokenError> {
match self {
Self::Es256(vk) => {
use p256::ecdsa::signature::Verifier as _;
let sig = p256::ecdsa::Signature::from_slice(sig)
.map_err(|e| TokenError::Invalid(format!("es256 signature: {e}")))?;
vk.verify(tbs, &sig)
.map_err(|_| TokenError::Invalid("signature verification failed".into()))
}
Self::Ed25519(vk) => {
let sig = ed25519_dalek::Signature::from_slice(sig)
.map_err(|e| TokenError::Invalid(format!("ed25519 signature: {e}")))?;
vk.verify_strict(tbs, &sig)
.map_err(|_| TokenError::Invalid("signature verification failed".into()))
}
}
}
}
pub struct Claims {
pub roles: Vec<GrantedRole>,
pub kind: String,
pub ttl_secs: Option<u64>,
pub now_unix: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedToken {
pub roles: Vec<GrantedRole>,
pub kind: String,
pub cti: String,
pub exp: Option<u64>,
pub cnf: Option<String>,
}
pub async fn mint(claims: &Claims, signer: &dyn Signer) -> Result<String, TokenError> {
mint_inner(claims, None, signer).await
}
pub async fn mint_delegatable(
claims: &Claims,
holder: &TokenPublicKey,
signer: &dyn Signer,
) -> Result<String, TokenError> {
mint_inner(claims, Some(holder), signer).await
}
async fn mint_inner(
claims: &Claims,
holder: Option<&TokenPublicKey>,
signer: &dyn Signer,
) -> Result<String, TokenError> {
let mut builder = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(claims.now_unix as i64))
.cwt_id(random_cti()?)
.text_claim(CLAIM_ROLES.to_string(), roles_to_cbor(&claims.roles))
.text_claim(CLAIM_KIND.to_string(), CborValue::Text(claims.kind.clone()));
if let Some(holder) = holder {
builder = builder.text_claim(CLAIM_CNF.to_string(), CborValue::Text(holder.to_hex()));
}
if let Some(ttl) = claims.ttl_secs {
builder = builder.expiration_time(Timestamp::WholeSeconds(
claims.now_unix.saturating_add(ttl) as i64,
));
}
sign_claims(builder.build(), signer).await
}
pub async fn mint_join(
ttl_secs: u64,
now_unix: u64,
signer: &dyn Signer,
) -> Result<String, TokenError> {
let claims = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(now_unix as i64))
.cwt_id(random_cti()?)
.expiration_time(Timestamp::WholeSeconds(
now_unix.saturating_add(ttl_secs) as i64
))
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_JOIN.to_string()),
)
.build();
sign_claims(claims, signer).await
}
pub async fn mint_attestation(
tls_pubkey_hex: &str,
ttl_secs: u64,
now_unix: u64,
signer: &dyn Signer,
) -> Result<String, TokenError> {
let claims = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(now_unix as i64))
.cwt_id(random_cti()?)
.expiration_time(Timestamp::WholeSeconds(
now_unix.saturating_add(ttl_secs) as i64
))
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_BOOTSTRAP_TLS.to_string()),
)
.text_claim(
CLAIM_PUBKEY.to_string(),
CborValue::Text(tls_pubkey_hex.to_string()),
)
.build();
sign_claims(claims, signer).await
}
pub fn verify_attestation(
token: &str,
public: &TokenPublicKey,
now_unix: u64,
) -> Result<String, TokenError> {
let claims = verify_envelope(token, public)?;
check_exp(&claims, now_unix)?;
let mut kind = String::new();
let mut pubkey_hex: Option<String> = None;
for (name, value) in &claims.rest {
if let coset::cwt::ClaimName::Text(t) = name {
match t.as_str() {
CLAIM_KIND => {
if let CborValue::Text(k) = value {
kind = k.clone();
}
}
CLAIM_PUBKEY => {
if let CborValue::Text(k) = value {
pubkey_hex = Some(k.clone());
}
}
_ => {}
}
}
}
if kind != KIND_BOOTSTRAP_TLS {
return Err(TokenError::Claims("not a bootstrap-tls attestation".into()));
}
pubkey_hex.ok_or_else(|| TokenError::Claims("attestation has no pubkey".into()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberAssertion {
pub node_id: u64,
pub pubkey_hex: String,
}
pub async fn mint_member_assertion(
node_id: u64,
mesh_pubkey_hex: &str,
ttl_secs: u64,
now_unix: u64,
signer: &dyn Signer,
) -> Result<String, TokenError> {
let claims = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(now_unix as i64))
.cwt_id(random_cti()?)
.expiration_time(Timestamp::WholeSeconds(
now_unix.saturating_add(ttl_secs) as i64
))
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_MESH_MEMBER.to_string()),
)
.text_claim(CLAIM_NODE.to_string(), CborValue::from(node_id))
.text_claim(
CLAIM_PUBKEY.to_string(),
CborValue::Text(mesh_pubkey_hex.to_string()),
)
.build();
sign_claims(claims, signer).await
}
pub fn verify_member_assertion(
token: &str,
public: &TokenPublicKey,
now_unix: u64,
) -> Result<MemberAssertion, TokenError> {
let claims = verify_envelope(token, public)?;
check_exp(&claims, now_unix)?;
let mut kind = String::new();
let mut node_id: Option<u64> = None;
let mut pubkey_hex: Option<String> = None;
for (name, value) in &claims.rest {
if let coset::cwt::ClaimName::Text(t) = name {
match t.as_str() {
CLAIM_KIND => {
if let CborValue::Text(k) = value {
kind = k.clone();
}
}
CLAIM_NODE => {
if let CborValue::Integer(i) = value {
node_id = u64::try_from(*i).ok();
}
}
CLAIM_PUBKEY => {
if let CborValue::Text(k) = value {
pubkey_hex = Some(k.clone());
}
}
_ => {}
}
}
}
if kind != KIND_MESH_MEMBER {
return Err(TokenError::Claims("not a mesh-member assertion".into()));
}
Ok(MemberAssertion {
node_id: node_id
.ok_or_else(|| TokenError::Claims("member assertion has no node".into()))?,
pubkey_hex: pubkey_hex
.ok_or_else(|| TokenError::Claims("member assertion has no pubkey".into()))?,
})
}
fn random_cti() -> Result<Vec<u8>, TokenError> {
let mut cti = [0u8; 16];
getrandom::getrandom(&mut cti).map_err(|e| TokenError::Build(format!("rng: {e}")))?;
Ok(cti.to_vec())
}
async fn sign_claims(claims: ClaimsSet, signer: &dyn Signer) -> Result<String, TokenError> {
let bytes = sign_claims_bytes(claims, signer).await?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
}
async fn sign_claims_bytes(claims: ClaimsSet, signer: &dyn Signer) -> Result<Vec<u8>, TokenError> {
let payload = claims
.to_vec()
.map_err(|e| TokenError::Build(e.to_string()))?;
let header = HeaderBuilder::new().algorithm(signer.alg().iana()).build();
let mut sign1 = CoseSign1Builder::new()
.protected(header)
.payload(payload)
.build();
let tbs = sign1.tbs_data(&[]);
sign1.signature = signer.sign(&tbs).await?;
sign1
.to_tagged_vec()
.map_err(|e| TokenError::Build(e.to_string()))
}
pub fn verify(
token: &str,
public: &TokenPublicKey,
now_unix: u64,
) -> Result<VerifiedToken, TokenError> {
let claims = verify_envelope(token, public)?;
let exp = check_exp(&claims, now_unix)?;
role_token(&claims, exp)
}
fn role_token(claims: &ClaimsSet, exp: Option<u64>) -> Result<VerifiedToken, TokenError> {
let cti = claim_cti(claims)?;
let mut roles = Vec::new();
let mut kind = String::new();
for (name, value) in &claims.rest {
if let coset::cwt::ClaimName::Text(t) = name {
match t.as_str() {
CLAIM_ROLES => roles = cbor_to_roles(value),
CLAIM_KIND => {
if let CborValue::Text(k) = value {
kind = k.clone();
}
}
_ => {}
}
}
}
Ok(VerifiedToken {
roles,
kind,
cti,
exp,
cnf: claim_cnf(claims),
})
}
pub fn verify_join(
token: &str,
public: &TokenPublicKey,
now_unix: u64,
) -> Result<String, TokenError> {
let claims = verify_envelope(token, public)?;
let jti = claim_cti(&claims)?;
check_exp(&claims, now_unix)?;
let kind = claims
.rest
.iter()
.find_map(|(name, value)| match (name, value) {
(coset::cwt::ClaimName::Text(t), CborValue::Text(k)) if t == CLAIM_KIND => {
Some(k.clone())
}
_ => None,
});
if kind.as_deref() != Some(KIND_JOIN) {
return Err(TokenError::Claims("not a mesh join token".into()));
}
Ok(jti)
}
pub fn join_challenge(jti: &str, mesh_pubkey_hex: &str, proof_iat: u64) -> Vec<u8> {
format!("boatramp-mesh-join/v1\n{jti}\n{mesh_pubkey_hex}\n{proof_iat}").into_bytes()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct VerifiedChain {
pub roles: Vec<GrantedRole>,
pub kind: String,
pub cti: String,
pub exp: Option<u64>,
pub caveats: Caveats,
pub leaf_cnf: Option<String>,
}
pub async fn attenuate(
credential: &str,
holder_signer: &dyn Signer,
caveats: &Caveats,
next_holder: Option<&TokenPublicKey>,
now_unix: u64,
) -> Result<String, TokenError> {
let mut blocks = decode_credential(credential)?;
let mut builder = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(now_unix as i64))
.cwt_id(random_cti()?)
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_DELEGATION.to_string()),
)
.text_claim(CLAIM_CAVEATS.to_string(), caveats.to_cbor());
if let Some(holder) = next_holder {
builder = builder.text_claim(CLAIM_CNF.to_string(), CborValue::Text(holder.to_hex()));
}
if let Some(na) = caveats.not_after {
builder = builder.expiration_time(Timestamp::WholeSeconds(na as i64));
}
blocks.push(sign_claims_bytes(builder.build(), holder_signer).await?);
encode_chain(blocks)
}
pub fn verify_credential(
credential: &str,
root_public: &TokenPublicKey,
now_unix: u64,
) -> Result<VerifiedChain, TokenError> {
let blocks = decode_credential(credential)?;
let (root_bytes, children) = blocks
.split_first()
.ok_or_else(|| TokenError::Invalid("empty credential".into()))?;
let root_claims = verify_envelope_bytes(root_bytes, root_public)?;
let root = role_token(&root_claims, check_exp(&root_claims, now_unix)?)?;
let mut effective_exp = root.exp;
let mut caveats = Caveats::default();
let mut parent_cnf = root.cnf.clone();
for block in children {
let holder_hex = parent_cnf
.take()
.ok_or_else(|| TokenError::Claims("delegation past a non-delegatable block".into()))?;
let holder = TokenPublicKey::from_hex(&holder_hex)
.map_err(|e| TokenError::Claims(format!("holder key: {e}")))?;
let claims = verify_envelope_bytes(block, &holder)?;
let kind = text_claim(&claims, CLAIM_KIND).and_then(|v| match v {
CborValue::Text(t) => Some(t.as_str()),
_ => None,
});
if kind != Some(KIND_DELEGATION) {
return Err(TokenError::Claims("not a delegation block".into()));
}
let block_caveats = text_claim(&claims, CLAIM_CAVEATS)
.map(Caveats::from_cbor)
.unwrap_or_default();
caveats.tighten(&block_caveats);
effective_exp = min_opt(effective_exp, check_exp(&claims, now_unix)?);
parent_cnf = claim_cnf(&claims);
}
Ok(VerifiedChain {
roles: root.roles,
kind: root.kind,
cti: root.cti,
exp: effective_exp,
caveats,
leaf_cnf: parent_cnf,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PopClaims {
pub htm: String,
pub htp: String,
pub aud: String,
pub ath: String,
pub bh: Option<String>,
}
pub fn canon_pop_path(path: &str) -> String {
let trimmed = path.trim().trim_start_matches('/').trim_end_matches('/');
format!("/{trimmed}")
}
pub fn pop_sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
hex::encode(Sha256::digest(bytes))
}
pub async fn mint_pop(
claims: &PopClaims,
holder: &dyn Signer,
now_unix: u64,
) -> Result<String, TokenError> {
let mut builder = ClaimsSetBuilder::new()
.issued_at(Timestamp::WholeSeconds(now_unix as i64))
.cwt_id(random_cti()?)
.audience(claims.aud.clone())
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_POP.to_string()),
)
.text_claim(CLAIM_HTM.to_string(), CborValue::Text(claims.htm.clone()))
.text_claim(CLAIM_HTP.to_string(), CborValue::Text(claims.htp.clone()))
.text_claim(CLAIM_ATH.to_string(), CborValue::Text(claims.ath.clone()));
if let Some(bh) = &claims.bh {
builder = builder.text_claim(CLAIM_BH.to_string(), CborValue::Text(bh.clone()));
}
sign_claims(builder.build(), holder).await
}
pub fn verify_pop(
proof: &str,
holder_public: &TokenPublicKey,
now_unix: u64,
expected: &PopClaims,
) -> Result<String, TokenError> {
let claims = verify_envelope(proof, holder_public)?;
let kind = text_claim(&claims, CLAIM_KIND).and_then(|v| match v {
CborValue::Text(t) => Some(t.as_str()),
_ => None,
});
if kind != Some(KIND_POP) {
return Err(TokenError::Claims("not a PoP proof".into()));
}
let iat = match claims.issued_at {
Some(Timestamp::WholeSeconds(s)) => s.max(0) as u64,
_ => return Err(TokenError::Claims("PoP proof has no iat".into())),
};
if iat > now_unix.saturating_add(POP_SKEW_SECS)
|| now_unix.saturating_sub(iat) > POP_WINDOW_SECS
{
return Err(TokenError::Expired);
}
let text = |name| {
text_claim(&claims, name).and_then(|v| match v {
CborValue::Text(t) => Some(t.clone()),
_ => None,
})
};
if text(CLAIM_HTM).as_deref() != Some(expected.htm.as_str())
|| text(CLAIM_HTP).as_deref() != Some(expected.htp.as_str())
|| claims.audience.as_deref() != Some(expected.aud.as_str())
|| text(CLAIM_ATH).as_deref() != Some(expected.ath.as_str())
{
return Err(TokenError::Claims(
"PoP proof does not match the request".into(),
));
}
if text(CLAIM_BH) != expected.bh {
return Err(TokenError::Claims("PoP proof body-hash mismatch".into()));
}
claim_cti(&claims)
}
fn decode_credential(credential: &str) -> Result<Vec<Vec<u8>>, TokenError> {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(credential.trim())
.map_err(|e| TokenError::Invalid(format!("base64: {e}")))?;
if bytes.len() > MAX_CHAIN_BYTES {
return Err(TokenError::Invalid("credential exceeds size bound".into()));
}
let is_array = matches!(bytes.first(), Some(b) if (b >> 5) == 4);
if !is_array {
return Ok(vec![bytes]);
}
let value: CborValue =
ciborium::from_reader(&bytes[..]).map_err(|e| TokenError::Invalid(e.to_string()))?;
let CborValue::Array(items) = value else {
return Err(TokenError::Invalid("malformed credential".into()));
};
if items.is_empty() || items.len() > MAX_CHAIN_DEPTH {
return Err(TokenError::Invalid(
"credential chain length out of bounds".into(),
));
}
items
.into_iter()
.map(|item| match item {
CborValue::Bytes(b) => Ok(b),
_ => Err(TokenError::Invalid("malformed chain block".into())),
})
.collect()
}
fn encode_chain(blocks: Vec<Vec<u8>>) -> Result<String, TokenError> {
let array = CborValue::Array(blocks.into_iter().map(CborValue::Bytes).collect());
let mut buf = Vec::new();
ciborium::into_writer(&array, &mut buf).map_err(|e| TokenError::Build(e.to_string()))?;
if buf.len() > MAX_CHAIN_BYTES {
return Err(TokenError::Build(
"delegation chain exceeds size bound".into(),
));
}
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf))
}
fn min_opt(a: Option<u64>, b: Option<u64>) -> Option<u64> {
match (a, b) {
(Some(x), Some(y)) => Some(x.min(y)),
(Some(x), None) => Some(x),
(None, b) => b,
}
}
fn verify_envelope(token: &str, public: &TokenPublicKey) -> Result<ClaimsSet, TokenError> {
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token.trim())
.map_err(|e| TokenError::Invalid(format!("base64: {e}")))?;
verify_envelope_bytes(&bytes, public)
}
fn verify_envelope_bytes(bytes: &[u8], public: &TokenPublicKey) -> Result<ClaimsSet, TokenError> {
let sign1 =
CoseSign1::from_tagged_slice(bytes).map_err(|e| TokenError::Invalid(e.to_string()))?;
let want = CoseAlg::Assigned(public.alg().iana());
if sign1.protected.header.alg.as_ref() != Some(&want) {
return Err(TokenError::Invalid("algorithm mismatch".into()));
}
sign1.verify_signature(&[], |sig, tbs| public.verify(tbs, sig))?;
let payload = sign1
.payload
.as_ref()
.ok_or_else(|| TokenError::Invalid("no payload".into()))?;
ClaimsSet::from_slice(payload).map_err(|e| TokenError::Invalid(e.to_string()))
}
fn claim_cnf(claims: &ClaimsSet) -> Option<String> {
text_claim(claims, CLAIM_CNF).and_then(|v| match v {
CborValue::Text(t) => Some(t.clone()),
_ => None,
})
}
fn text_claim<'a>(claims: &'a ClaimsSet, name: &str) -> Option<&'a CborValue> {
claims.rest.iter().find_map(|(k, v)| match k {
coset::cwt::ClaimName::Text(t) if t == name => Some(v),
_ => None,
})
}
fn claim_cti(claims: &ClaimsSet) -> Result<String, TokenError> {
claims
.cwt_id
.as_ref()
.map(hex::encode)
.ok_or_else(|| TokenError::Invalid("no cti".into()))
}
fn check_exp(claims: &ClaimsSet, now_unix: u64) -> Result<Option<u64>, TokenError> {
let exp = match claims.expiration_time {
Some(Timestamp::WholeSeconds(s)) => Some(s.max(0) as u64),
_ => None,
};
if let Some(exp) = exp {
if now_unix > exp {
return Err(TokenError::Expired);
}
}
Ok(exp)
}
fn roles_to_cbor(roles: &[GrantedRole]) -> CborValue {
CborValue::Array(
roles
.iter()
.map(|r| match &r.target {
Some(t) => CborValue::Array(vec![
CborValue::Text(r.name.clone()),
CborValue::Text(t.clone()),
]),
None => CborValue::Array(vec![CborValue::Text(r.name.clone())]),
})
.collect(),
)
}
fn cbor_to_roles(value: &CborValue) -> Vec<GrantedRole> {
let CborValue::Array(items) = value else {
return Vec::new();
};
let mut out = Vec::new();
for item in items {
let CborValue::Array(parts) = item else {
continue;
};
match parts.as_slice() {
[CborValue::Text(name)] => out.push(GrantedRole::global(name)),
[CborValue::Text(name), CborValue::Text(target)] => {
out.push(GrantedRole::scoped(name, target));
}
_ => {}
}
}
out
}
pub fn p256_der_sig_to_raw(der: &[u8]) -> Result<Vec<u8>, TokenError> {
p256::ecdsa::Signature::from_der(der)
.map(|sig| sig.to_bytes().to_vec())
.map_err(|e| TokenError::Invalid(format!("es256 DER signature: {e}")))
}
fn split_tagged(spec: &str) -> Result<(TokenAlg, Vec<u8>), TokenError> {
let (tag, hex_str) = spec
.trim()
.split_once(':')
.ok_or_else(|| TokenError::Key("expected \"<alg>:<hex>\"".into()))?;
let alg = match tag {
t if t == TokenAlg::Es256.label() => TokenAlg::Es256,
t if t == TokenAlg::Ed25519.label() => TokenAlg::Ed25519,
other => {
return Err(TokenError::Key(format!(
"unknown token algorithm {other:?}"
)))
}
};
let bytes = hex::decode(hex_str).map_err(|e| TokenError::Key(format!("hex: {e}")))?;
Ok((alg, bytes))
}
#[cfg(test)]
mod tests {
use super::*;
const NOW: u64 = 1_700_000_000;
fn claims() -> Claims {
Claims {
roles: vec![
GrantedRole::global("admin"),
GrantedRole::scoped("publisher", "blog"),
],
kind: "role".into(),
ttl_secs: Some(3600),
now_unix: NOW,
}
}
async fn round_trip(alg: TokenAlg) {
let signer = LocalSigner::generate(alg);
let public = signer.public_key();
assert_eq!(public.alg(), alg);
let token = mint(&claims(), &signer).await.unwrap();
let v = verify(&token, &public, NOW + 60).unwrap();
assert_eq!(v.kind, "role");
assert_eq!(v.roles.len(), 2);
assert_eq!(v.roles[0], GrantedRole::global("admin"));
assert_eq!(v.roles[1], GrantedRole::scoped("publisher", "blog"));
assert!(!v.cti.is_empty());
assert_eq!(v.exp, Some(NOW + 3600));
}
#[tokio::test]
async fn es256_round_trips() {
round_trip(TokenAlg::Es256).await;
}
#[tokio::test]
async fn ed25519_round_trips() {
round_trip(TokenAlg::Ed25519).await;
}
#[tokio::test]
async fn expiry_is_enforced() {
let signer = LocalSigner::generate(TokenAlg::Es256);
let token = mint(&claims(), &signer).await.unwrap();
assert!(verify(&token, &signer.public_key(), NOW + 50).is_ok());
assert!(matches!(
verify(&token, &signer.public_key(), NOW + 4000),
Err(TokenError::Expired)
));
}
#[tokio::test]
async fn wrong_key_and_tamper_are_rejected() {
let signer = LocalSigner::generate(TokenAlg::Es256);
let token = mint(&claims(), &signer).await.unwrap();
let other = LocalSigner::generate(TokenAlg::Es256);
assert!(verify(&token, &other.public_key(), NOW).is_err());
let mut raw = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(&token)
.unwrap();
*raw.last_mut().unwrap() ^= 0x01;
let tampered = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
assert!(verify(&tampered, &signer.public_key(), NOW).is_err());
}
#[tokio::test]
async fn algorithm_confusion_is_rejected() {
let es = LocalSigner::generate(TokenAlg::Es256);
let token = mint(&claims(), &es).await.unwrap();
let ed_key = LocalSigner::generate(TokenAlg::Ed25519).public_key();
assert!(matches!(
verify(&token, &ed_key, NOW),
Err(TokenError::Invalid(_))
));
}
#[tokio::test]
async fn bearer_join_token_round_trips_and_yields_a_single_use_jti() {
let signer = LocalSigner::generate(TokenAlg::Es256);
let public = signer.public_key();
let token = mint_join(3600, NOW, &signer).await.unwrap();
let jti = verify_join(&token, &public, NOW + 60).unwrap();
assert!(!jti.is_empty(), "the jti is the single-use handle");
assert_eq!(verify_join(&token, &public, NOW + 60).unwrap(), jti);
}
#[tokio::test]
async fn join_challenge_binds_the_token_key_and_time() {
let a = join_challenge("jti-1", "aa01", 100);
assert_eq!(a, join_challenge("jti-1", "aa01", 100));
assert_ne!(a, join_challenge("jti-2", "aa01", 100));
assert_ne!(a, join_challenge("jti-1", "bb02", 100));
assert_ne!(a, join_challenge("jti-1", "aa01", 101));
}
#[tokio::test]
async fn join_token_expires_and_rejects_foreign_key() {
let signer = LocalSigner::generate(TokenAlg::Es256);
let token = mint_join(100, NOW, &signer).await.unwrap();
assert!(verify_join(&token, &signer.public_key(), NOW + 50).is_ok());
assert!(matches!(
verify_join(&token, &signer.public_key(), NOW + 200),
Err(TokenError::Expired)
));
let stranger = LocalSigner::generate(TokenAlg::Es256);
assert!(matches!(
verify_join(&token, &stranger.public_key(), NOW),
Err(TokenError::Invalid(_))
));
}
#[tokio::test]
async fn attestation_round_trips_and_rejects_tamper_expiry_and_kind() {
let root = LocalSigner::generate(TokenAlg::Es256);
let public = root.public_key();
let tls_spki = "302a300506032b6570032100deadbeefdeadbeefdeadbeefdeadbeef";
let att = mint_attestation(tls_spki, 3600, NOW, &root).await.unwrap();
assert_eq!(
verify_attestation(&att, &public, NOW + 60).unwrap(),
tls_spki
);
assert!(matches!(
verify_attestation(&att, &public, NOW + 4000),
Err(TokenError::Expired)
));
let stranger = LocalSigner::generate(TokenAlg::Es256);
assert!(verify_attestation(&att, &stranger.public_key(), NOW).is_err());
let join = mint_join(3600, NOW, &root).await.unwrap();
assert!(matches!(
verify_attestation(&join, &public, NOW + 60),
Err(TokenError::Claims(_))
));
}
#[tokio::test]
async fn member_assertion_round_trips_and_rejects_tamper_expiry_and_kind() {
let root = LocalSigner::generate(TokenAlg::Es256);
let public = root.public_key();
let mesh_spki = "302a300506032b6570032100feedface00000000feedface00000000";
let m = mint_member_assertion(42, mesh_spki, 3600, NOW, &root)
.await
.unwrap();
let got = verify_member_assertion(&m, &public, NOW + 60).unwrap();
assert_eq!(got.node_id, 42);
assert_eq!(got.pubkey_hex, mesh_spki);
assert!(matches!(
verify_member_assertion(&m, &public, NOW + 4000),
Err(TokenError::Expired)
));
let stranger = LocalSigner::generate(TokenAlg::Es256);
assert!(verify_member_assertion(&m, &stranger.public_key(), NOW).is_err());
let att = mint_attestation(mesh_spki, 3600, NOW, &root).await.unwrap();
assert!(matches!(
verify_member_assertion(&att, &public, NOW + 60),
Err(TokenError::Claims(_))
));
}
#[tokio::test]
async fn kind_domain_separation_between_role_and_join_tokens() {
let signer = LocalSigner::generate(TokenAlg::Es256);
let public = signer.public_key();
let role = mint(&claims(), &signer).await.unwrap();
assert!(matches!(
verify_join(&role, &public, NOW),
Err(TokenError::Claims(_))
));
let join = mint_join(3600, NOW, &signer).await.unwrap();
let v = verify(&join, &public, NOW).unwrap();
assert_eq!(v.kind, KIND_JOIN);
assert!(v.roles.is_empty());
}
#[test]
fn p256_der_signature_converts_to_raw() {
use p256::ecdsa::signature::Signer as _;
let sk = p256::ecdsa::SigningKey::random(&mut rand_core::OsRng);
let sig: p256::ecdsa::Signature = sk.sign(b"boatramp cose kms");
let der = sig.to_der();
let raw = super::p256_der_sig_to_raw(der.as_bytes()).unwrap();
assert_eq!(raw.len(), 64, "raw r||s is fixed 64 bytes");
assert_eq!(
raw,
sig.to_bytes().to_vec(),
"DER→raw matches the native raw sig"
);
let public = TokenPublicKey::Es256(*sk.verifying_key());
public.verify(b"boatramp cose kms", &raw).unwrap();
}
#[test]
fn es256_public_key_parses_from_spki_der_and_pem() {
use p256::pkcs8::{EncodePublicKey as _, LineEnding};
let signer = LocalSigner::generate(TokenAlg::Es256);
let TokenPublicKey::Es256(vk) = signer.public_key() else {
unreachable!("generated ES256")
};
let der = vk.to_public_key_der().unwrap();
let pem = vk.to_public_key_pem(LineEnding::LF).unwrap();
let want = signer.public_key().to_hex();
assert_eq!(
TokenPublicKey::es256_from_spki_der(der.as_bytes())
.unwrap()
.to_hex(),
want
);
assert_eq!(
TokenPublicKey::es256_from_spki_pem(&pem).unwrap().to_hex(),
want
);
}
async fn delegatable_root(
signer: &LocalSigner,
holder: &TokenPublicKey,
roles: Vec<GrantedRole>,
) -> String {
let claims = Claims {
roles,
kind: KIND_ROLE.into(),
ttl_secs: Some(3600),
now_unix: NOW,
};
mint_delegatable(&claims, holder, signer).await.unwrap()
}
#[tokio::test]
async fn delegation_narrows_via_caveats() {
use crate::authz::{Action, Resource, Right};
let root = LocalSigner::generate(TokenAlg::Es256);
let root_pub = root.public_key();
let holder = LocalSigner::generate(TokenAlg::Es256);
let token = delegatable_root(
&root,
&holder.public_key(),
vec![GrantedRole::global("admin")],
)
.await;
let caveats = Caveats {
read_only: true,
only_site: Some("blog".into()),
..Default::default()
};
let chain = attenuate(&token, &holder, &caveats, None, NOW)
.await
.unwrap();
let v = verify_credential(&chain, &root_pub, NOW + 60).unwrap();
assert_eq!(v.roles, vec![GrantedRole::global("admin")]);
assert!(v.caveats.read_only);
assert_eq!(v.caveats.only_site.as_deref(), Some("blog"));
let read_blog = Right::new(Resource::Site, Some("blog".into()), Action::Read);
let write_blog = Right::new(Resource::Site, Some("blog".into()), Action::Write);
let read_shop = Right::new(Resource::Site, Some("shop".into()), Action::Read);
assert!(v.caveats.allows(&read_blog, NOW + 60));
assert!(
!v.caveats.allows(&write_blog, NOW + 60),
"read_only blocks writes"
);
assert!(
!v.caveats.allows(&read_shop, NOW + 60),
"only_site blocks other sites"
);
let plain = verify_credential(&token, &root_pub, NOW + 60).unwrap();
assert!(plain.caveats.is_empty());
assert_eq!(plain.roles, vec![GrantedRole::global("admin")]);
}
#[tokio::test]
async fn verified_chain_exposes_the_leaf_cnf() {
let root = LocalSigner::generate(TokenAlg::Es256);
let root_pub = root.public_key();
let holder = LocalSigner::generate(TokenAlg::Es256);
let token = delegatable_root(
&root,
&holder.public_key(),
vec![GrantedRole::global("admin")],
)
.await;
assert_eq!(
verify_credential(&token, &root_pub, NOW).unwrap().leaf_cnf,
Some(holder.public_key().to_hex())
);
let signer = LocalSigner::generate(TokenAlg::Es256);
let plain = mint(&claims(), &signer).await.unwrap();
assert_eq!(
verify_credential(&plain, &signer.public_key(), NOW)
.unwrap()
.leaf_cnf,
None
);
let d2 = LocalSigner::generate(TokenAlg::Es256);
let chain = attenuate(
&token,
&holder,
&Caveats::default(),
Some(&d2.public_key()),
NOW,
)
.await
.unwrap();
assert_eq!(
verify_credential(&chain, &root_pub, NOW).unwrap().leaf_cnf,
Some(d2.public_key().to_hex())
);
}
#[tokio::test]
async fn pop_proof_binds_the_request_and_rejects_mismatch() {
let holder = LocalSigner::generate(TokenAlg::Es256);
let public = holder.public_key();
let ath = pop_sha256_hex(b"the-access-token-string");
let want = PopClaims {
htm: "PUT".into(),
htp: canon_pop_path("/api/sites/x/config/"),
aud: "https://cp.example.com".into(),
ath: ath.clone(),
bh: Some(pop_sha256_hex(b"{\"body\":1}")),
};
assert_eq!(want.htp, "/api/sites/x/config");
let proof = mint_pop(&want, &holder, NOW).await.unwrap();
assert!(verify_pop(&proof, &public, NOW + 5, &want).is_ok());
let other = LocalSigner::generate(TokenAlg::Es256);
assert!(verify_pop(&proof, &other.public_key(), NOW + 5, &want).is_err());
assert!(matches!(
verify_pop(&proof, &public, NOW + POP_WINDOW_SECS + 5, &want),
Err(TokenError::Expired)
));
assert!(matches!(
verify_pop(&proof, &public, NOW - POP_SKEW_SECS - 5, &want),
Err(TokenError::Expired)
));
for bad in [
PopClaims {
htm: "GET".into(),
..want.clone()
},
PopClaims {
htp: "/api/sites/y/config".into(),
..want.clone()
},
PopClaims {
aud: "https://evil.example.com".into(),
..want.clone()
},
PopClaims {
ath: pop_sha256_hex(b"other-token"),
..want.clone()
},
PopClaims {
bh: Some(pop_sha256_hex(b"swapped")),
..want.clone()
},
PopClaims {
bh: None,
..want.clone()
},
] {
assert!(
verify_pop(&proof, &public, NOW + 5, &bad).is_err(),
"must reject {bad:?}"
);
}
let role = mint(&claims(), &holder).await.unwrap();
assert!(matches!(
verify_pop(&role, &public, NOW, &want),
Err(TokenError::Claims(_))
));
}
#[tokio::test]
async fn delegation_block_cannot_add_roles() {
let root = LocalSigner::generate(TokenAlg::Es256);
let holder = LocalSigner::generate(TokenAlg::Es256);
let token = delegatable_root(
&root,
&holder.public_key(),
vec![GrantedRole::scoped("viewer", "blog")],
)
.await;
let evil = ClaimsSetBuilder::new()
.cwt_id(random_cti().unwrap())
.text_claim(
CLAIM_KIND.to_string(),
CborValue::Text(KIND_DELEGATION.to_string()),
)
.text_claim(
CLAIM_ROLES.to_string(),
roles_to_cbor(&[GrantedRole::global("admin")]),
)
.build();
let evil_block = sign_claims_bytes(evil, &holder).await.unwrap();
let mut blocks = decode_credential(&token).unwrap();
blocks.push(evil_block);
let chain = encode_chain(blocks).unwrap();
let v = verify_credential(&chain, &root.public_key(), NOW).unwrap();
assert_eq!(
v.roles,
vec![GrantedRole::scoped("viewer", "blog")],
"an injected role in a delegation block must be ignored"
);
}
#[tokio::test]
async fn delegation_shortens_expiry() {
let root = LocalSigner::generate(TokenAlg::Es256);
let holder = LocalSigner::generate(TokenAlg::Es256);
let token = delegatable_root(
&root,
&holder.public_key(),
vec![GrantedRole::global("admin")],
)
.await;
let caveats = Caveats {
not_after: Some(NOW + 100),
..Default::default()
};
let chain = attenuate(&token, &holder, &caveats, None, NOW)
.await
.unwrap();
assert!(verify_credential(&chain, &root.public_key(), NOW + 50).is_ok());
assert!(matches!(
verify_credential(&chain, &root.public_key(), NOW + 200),
Err(TokenError::Expired)
));
}
#[tokio::test]
async fn delegation_requires_the_declared_holder_key() {
let root = LocalSigner::generate(TokenAlg::Es256);
let holder = LocalSigner::generate(TokenAlg::Es256);
let token = delegatable_root(
&root,
&holder.public_key(),
vec![GrantedRole::global("admin")],
)
.await;
let impostor = LocalSigner::generate(TokenAlg::Es256);
let forged = attenuate(
&token,
&impostor,
&Caveats {
read_only: true,
..Default::default()
},
None,
NOW,
)
.await
.unwrap();
assert!(matches!(
verify_credential(&forged, &root.public_key(), NOW),
Err(TokenError::Invalid(_))
));
let plain = mint(&claims(), &root).await.unwrap();
let bad = attenuate(
&plain,
&holder,
&Caveats {
read_only: true,
..Default::default()
},
None,
NOW,
)
.await
.unwrap();
assert!(matches!(
verify_credential(&bad, &root.public_key(), NOW),
Err(TokenError::Claims(_))
));
}
#[tokio::test]
async fn disjoint_site_caveats_authorize_nothing() {
use crate::authz::{Action, Resource, Right};
let root = LocalSigner::generate(TokenAlg::Es256);
let h1 = LocalSigner::generate(TokenAlg::Es256);
let h2 = LocalSigner::generate(TokenAlg::Es256);
let token =
delegatable_root(&root, &h1.public_key(), vec![GrantedRole::global("admin")]).await;
let c1 = attenuate(
&token,
&h1,
&Caveats {
only_site: Some("blog".into()),
..Default::default()
},
Some(&h2.public_key()),
NOW,
)
.await
.unwrap();
let c2 = attenuate(
&c1,
&h2,
&Caveats {
only_site: Some("shop".into()),
..Default::default()
},
None,
NOW,
)
.await
.unwrap();
let v = verify_credential(&c2, &root.public_key(), NOW).unwrap();
assert!(!v.caveats.allows(
&Right::new(Resource::Site, Some("blog".into()), Action::Read),
NOW
));
assert!(!v.caveats.allows(
&Right::new(Resource::Site, Some("shop".into()), Action::Read),
NOW
));
}
#[tokio::test]
async fn chain_depth_and_size_bounds_are_enforced() {
let root = LocalSigner::generate(TokenAlg::Es256);
let pk = root.public_key();
let one = decode_credential(&mint(&claims(), &root).await.unwrap())
.unwrap()
.pop()
.unwrap();
let deep = encode_chain(vec![one; MAX_CHAIN_DEPTH + 1]).unwrap();
assert!(matches!(
verify_credential(&deep, &pk, NOW),
Err(TokenError::Invalid(_))
));
let big = base64::engine::general_purpose::URL_SAFE_NO_PAD
.encode(vec![0x80u8; MAX_CHAIN_BYTES + 1]);
assert!(matches!(
verify_credential(&big, &pk, NOW),
Err(TokenError::Invalid(_))
));
}
#[test]
fn keys_round_trip_through_hex() {
for alg in [TokenAlg::Es256, TokenAlg::Ed25519] {
let signer = LocalSigner::generate(alg);
let priv_hex = signer.private_hex();
let pub_hex = signer.public_key().to_hex();
let restored = LocalSigner::from_private_hex(&priv_hex).unwrap();
assert_eq!(restored.public_key().to_hex(), pub_hex);
assert_eq!(
TokenPublicKey::from_hex(&pub_hex).unwrap().to_hex(),
pub_hex
);
}
}
}