use std::collections::HashSet;
use bh_jws_utils::{jwt, HasJwkKid, HasX5Chain, JwkPublic, JwtSigner};
use bh_status_list::StatusClaim;
use bherror::{
traits::{ErrorContext as _, ForeignBoxed, PropagateError},
Error,
};
use bhx5chain::JwtX5Chain;
use rand_core::CryptoRngCore;
use serde::{Deserialize, Serialize};
use crate::{
encoder, error::FormatError, iref::UriBuf, utils::check_claim_names_object,
verifier::VerifierError, CnfClaim, Error::Format, Hasher, HashingAlgorithm, IssuedSdJwt,
JsonNodePath, JsonNodePathSegment, JsonObject, ParsedSdJwtIssuance, SecondsSinceEpoch,
};
pub struct Issuer<H: Hasher> {
hasher: H,
}
#[derive(strum_macros::Display, Debug, PartialEq, Eq)]
pub enum IssuerError {
#[strum(to_string = "Use of reserved or registered claim name {0}")]
ReservedOrRegisteredClaimName(&'static str),
#[strum(to_string = "Invalid path {0}")]
InvalidPath(String),
#[strum(to_string = "Non existent path {0}")]
NonExistentPath(String),
#[strum(to_string = "Signing failed")]
SigningFailed,
#[strum(to_string = "Duplicate path {0}")]
DuplicatePath(String),
}
impl bherror::BhError for IssuerError {}
pub type Result<T> = bherror::Result<T, IssuerError>;
impl<H: Hasher> Issuer<H> {
pub fn new(hasher: H) -> Self {
Self { hasher }
}
pub fn issue<S: JwtSigner + HasJwkKid + HasX5Chain, R: CryptoRngCore + ?Sized>(
&self,
mut jwt_payload: IssuerJwt,
disclosure_paths: &[&JsonNodePath],
signer: &S,
rng: &mut R,
) -> Result<IssuedSdJwt> {
check_registered_path_in_paths(disclosure_paths)?;
jwt_payload.sd_alg = Some(self.hasher.algorithm());
let disclosures =
encoder::encode_claims(&mut jwt_payload.claims, disclosure_paths, &self.hasher, rng)?;
let x5c = signer
.x5chain()
.try_into()
.with_err(|| IssuerError::SigningFailed)
.ctx(|| "invalid Issuer X.509 certificate chain")?;
let header = IssuerJwtHeader {
typ: TYP_VC_SD_JWT.into(),
kid: None,
alg: signer.algorithm(),
x5c: Some(x5c),
};
let unsigned_token = jwt::Token::new(header, jwt_payload);
let signed_token = signer
.sign_jwt(unsigned_token)
.foreign_boxed_err(|| IssuerError::SigningFailed)?;
Ok(IssuedSdJwt(ParsedSdJwtIssuance {
jwt: signed_token,
disclosures,
}))
}
}
fn check_registered_path_in_paths(paths: &[&[crate::JsonNodePathSegment]]) -> Result<()> {
for path in paths {
if let [JsonNodePathSegment::Key(key)] = path {
if let Some(name) = REGISTERED_CLAIM_NAMES.iter().find(|&name| name.eq(key)) {
return Err(Error::root(IssuerError::ReservedOrRegisteredClaimName(
name,
)));
}
}
}
Ok(())
}
pub const TYP_VC_SD_JWT: &str = "dc+sd-jwt";
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct IssuerJwtHeader {
pub typ: String,
pub alg: bh_jws_utils::SigningAlgorithm,
#[serde(skip_serializing_if = "Option::is_none")]
pub kid: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub x5c: Option<JwtX5Chain>,
}
impl jwt::JoseHeader for IssuerJwtHeader {
fn algorithm_type(&self) -> jwt::AlgorithmType {
self.alg.into()
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[non_exhaustive]
pub struct IssuerJwt {
pub iss: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub nbf: Option<SecondsSinceEpoch>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exp: Option<SecondsSinceEpoch>,
pub cnf: CnfClaim,
pub vct: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<StatusClaim>,
#[serde(rename = "_sd_alg", skip_serializing_if = "Option::is_none")]
pub(crate) sd_alg: Option<HashingAlgorithm>,
#[serde(flatten)]
pub claims: JsonObject,
}
lazy_static::lazy_static! {
pub(crate) static ref REGISTERED_CLAIM_NAMES: HashSet<&'static str> = {
["iss", "nbf", "exp", "cnf", "vct", "status"].into_iter().collect()
};
}
impl IssuerJwt {
pub fn new(
vct: String,
iss: UriBuf,
holder_binding_public_jwk: JwkPublic,
claims: JsonObject,
) -> Result<Self> {
if let Some(name) = check_claim_names_object(
&claims,
&|claim| REGISTERED_CLAIM_NAMES.get(claim).copied(),
false,
) {
return Err(bherror::Error::root(
IssuerError::ReservedOrRegisteredClaimName(name),
));
}
Ok(Self {
iss: iss.to_string(),
nbf: None,
exp: None,
cnf: CnfClaim {
jwk: holder_binding_public_jwk,
},
vct,
status: None,
sd_alg: None,
claims,
})
}
pub fn add_sub_claim(&mut self, sub: String) {
self.claims.insert("sub".to_owned(), sub.into());
}
pub fn sub(&self) -> Option<&str> {
self.claims.get("sub").and_then(serde_json::Value::as_str)
}
pub fn add_iat_claim(&mut self, iat: SecondsSinceEpoch) {
self.claims.insert("iat".to_owned(), iat.into());
}
pub fn iat(&self) -> Option<SecondsSinceEpoch> {
self.claims.get("iat").and_then(serde_json::Value::as_u64)
}
pub fn to_object(&self) -> JsonObject {
crate::into_object(
serde_json::to_value(self).expect("Implementation error: cannot serialize as JSON"),
)
}
pub(crate) fn validate_claims_holder(
&self,
current_time: SecondsSinceEpoch,
) -> crate::Result<(), crate::Error> {
if let Some(exp) = self.exp {
if current_time >= exp {
return Err(Error::root(crate::Error::JwtExpired(current_time, exp)));
};
};
if let Some(iat) = self.claims.get("iat") {
if !iat.is_number() {
return Err(Error::root(Format(FormatError::InvalidIatFormat)));
};
};
Ok(())
}
pub(crate) fn validate_claims_verifier(
&self,
current_time: SecondsSinceEpoch,
) -> crate::Result<(), VerifierError> {
self.validate_claims_holder(current_time)
.match_err(|crate_error| crate_error.to_verifier_error())?;
if let Some(nbf) = self.nbf {
if current_time < nbf {
return Err(Error::root(VerifierError::JwtNotYetValid(
current_time,
nbf,
)));
};
};
Ok(())
}
}
#[cfg(test)]
pub(crate) mod tests {
use std::str::FromStr;
use bhx5chain::X5Chain;
use iref::IriBuf;
use jwt::VerifyWithKey;
use super::*;
use crate::{
decoder::decode_disclosed_claims,
json_object,
test_utils::{dummy_hasher_factory, symbolic_crypto::*},
traits::SHA_256_ALG_NAME,
utils::SD_ALG_FIELD_NAME,
DisplayWrapper, JsonNodePathSegment, SdJwt, Sha256, Value, RESERVED_CLAIM_NAMES,
};
impl<State> std::fmt::Debug for ParsedSdJwtIssuance<State> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[derive(Debug)]
#[allow(dead_code)]
struct DebugWrapper<'a> {
header: &'a IssuerJwtHeader,
claims: &'a IssuerJwt,
}
f.debug_struct("IssuedSdJwt")
.field(
"jwt",
&DebugWrapper {
header: self.jwt.header(),
claims: self.jwt.claims(),
},
)
.field("disclosures", &self.disclosures)
.finish()
}
}
pub(crate) fn dummy_https_iss() -> UriBuf {
IriBuf::new("https://example.com/.well-known/jwt-issuer".into())
.unwrap()
.try_into_uri()
.unwrap()
}
pub(crate) fn dummy_claims() -> JsonObject {
json_object!({
"foo": "bar",
"baz": 42,
"parent": {
"child1": [
"elem 0",
"elem 1",
"elem 2",
{
"nested": false,
}
],
"child2": {
"leaf": Value::Null,
"foo": "bar",
},
"child3": "bar",
},
})
}
pub(crate) fn test_issuer_jwt() -> IssuerJwt {
IssuerJwt::new(
"TestCredential".into(),
dummy_https_iss(),
dummy_public_jwk(),
dummy_claims(),
)
.unwrap()
}
use JsonNodePathSegment::*;
pub(crate) const TEST_DISCLOSURE_PATHS: &[&JsonNodePath] = &[
&[Key("foo")],
&[Key("parent")],
&[Key("parent"), Key("child1"), Index(1)],
&[Key("parent"), Key("child1"), Index(3), Key("nested")],
&[Key("parent"), Key("child2"), Key("leaf")],
&[Key("parent"), Key("child2"), Key("foo")],
&[Key("parent"), Key("child3")],
];
pub(crate) fn test_sd_jwt(
issuer_jwt: IssuerJwt,
disclosure_paths: &[&JsonNodePath],
) -> IssuedSdJwt {
let public_jwk = issuer_jwt.cnf.jwk.clone();
Issuer::new(Sha256)
.issue(
issuer_jwt,
disclosure_paths,
&StubSigner::new(public_jwk, X5Chain::dummy()),
&mut rand::thread_rng(),
)
.expect("Issuing failed")
}
#[test]
fn happy_path() {
let hasher = Sha256;
let issuer = Issuer::new(&hasher);
let issuer_jwt = test_issuer_jwt();
let issued_sd_jwt = issuer
.issue(
issuer_jwt,
TEST_DISCLOSURE_PATHS,
&StubSigner::default(),
&mut rand::thread_rng(),
)
.expect("Issuing failed");
let claims = &issued_sd_jwt.0.jwt.claims().claims;
let disclosures = &issued_sd_jwt.0.disclosures[..];
assert_eq!(
disclosures.len(),
TEST_DISCLOSURE_PATHS.len(),
"Wrong number of disclosures"
);
let (decoded_claims, hasher, _) =
decode_disclosed_claims(claims, disclosures, dummy_hasher_factory).unwrap();
assert_eq!(decoded_claims, dummy_claims());
assert_eq!(hasher.algorithm(), HashingAlgorithm::Sha256);
let serialized_compact = issued_sd_jwt.into_string_compact();
println!("{}", serialized_compact);
let parsed = SdJwt::from_str(&serialized_compact).unwrap();
parsed
.parse()
.expect("Invalid compact serialization of an issued SD-JWT")
.0
.jwt
.verify_with_key(&StubVerifier::default())
.expect("Invalid signature");
}
#[test]
fn invalid_paths() {
let issuer = Issuer::new(Sha256);
let non_existent_paths: &[&JsonNodePath] = &[
&[Key("parent"), Key("nonexistent_key")],
&[Key("parent"), Key("child1"), Index(42)],
];
for path in non_existent_paths {
let disclosure_paths = &[*path];
let non_existent_path = issuer.issue(
test_issuer_jwt(),
disclosure_paths,
&StubSigner::default(),
&mut rand::thread_rng(),
);
assert_eq!(
non_existent_path.unwrap_err().error,
IssuerError::NonExistentPath(DisplayWrapper(*path).to_string())
);
}
let invalid_path: &JsonNodePath = &[Index(42)];
let error = issuer.issue(
test_issuer_jwt(),
&[invalid_path],
&StubSigner::default(),
&mut rand::thread_rng(),
);
assert_eq!(
error.unwrap_err().error,
IssuerError::InvalidPath(DisplayWrapper(invalid_path).to_string())
);
let empty_path: &JsonNodePath = &[];
let disclosure_paths = &[empty_path];
let error = issuer.issue(
test_issuer_jwt(),
disclosure_paths,
&StubSigner::default(),
&mut rand::thread_rng(),
);
assert_eq!(
error.unwrap_err().error,
IssuerError::InvalidPath(DisplayWrapper(empty_path).to_string())
);
for claim in RESERVED_CLAIM_NAMES
.iter()
.chain(REGISTERED_CLAIM_NAMES.iter())
{
let path = &[Key(claim)];
let error = issuer.issue(
test_issuer_jwt(),
&[path],
&StubSigner::default(),
&mut rand::thread_rng(),
);
assert_eq!(
error.unwrap_err().error,
IssuerError::ReservedOrRegisteredClaimName(claim)
);
}
let registered_path = &[Key("iss")];
let valid_path = &[Key("parent"), Key("child1")];
let error = issuer.issue(
test_issuer_jwt(),
&[valid_path, registered_path],
&StubSigner::default(),
&mut rand::thread_rng(),
);
assert_eq!(
error.unwrap_err().error,
IssuerError::ReservedOrRegisteredClaimName("iss")
);
}
#[test]
fn path_through_array_test() {
let issuer = Issuer::new(Sha256);
let path = &[Key("parent"), Key("child1"), Index(3), Key("nested")];
let _ = issuer
.issue(
test_issuer_jwt(),
&[path],
&StubSigner::default(),
&mut rand::thread_rng(),
)
.unwrap();
}
#[test]
fn model_errors() {
let invalid_models = [
(json_object!({ "iss": "definitely not a URI" }), "iss"),
(json_object!({ "nbf": "definitely not a URI" }), "nbf"),
(json_object!({ "exp": "definitely not a URI" }), "exp"),
(json_object!({ "cnf": "definitely not a URI" }), "cnf"),
(json_object!({ "vct": "definitely not a URI" }), "vct"),
(json_object!({ "status": "definitely not a URI" }), "status"),
];
for (model, reserved_name) in invalid_models {
let result = IssuerJwt::new(
"TestCredential".into(),
dummy_https_iss(),
dummy_public_jwk(),
model,
);
assert_eq!(
result.unwrap_err().error,
IssuerError::ReservedOrRegisteredClaimName(reserved_name)
);
}
let sub = "subject identifier";
let issuer_jwt = IssuerJwt::new(
"TestCredential".into(),
dummy_https_iss(),
dummy_public_jwk(),
json_object!({
"sub": sub,
}),
)
.unwrap();
let issuer = Issuer::new(Sha256);
let sd_jwt = issuer
.issue(
issuer_jwt,
&[&[Key("sub")]],
&StubSigner::default(),
&mut rand::thread_rng(),
)
.unwrap();
assert_eq!(sd_jwt.0.disclosures[0].claim_name().unwrap(), "sub");
assert_eq!(sd_jwt.0.disclosures[0].value(), sub);
}
#[test]
fn sd_alg_field_name_serializes_correctly() {
let alg = HashingAlgorithm::Sha256;
let alg_name = SHA_256_ALG_NAME;
let mut jwt = test_issuer_jwt();
jwt.sd_alg = Some(alg);
assert!(!jwt.claims.contains_key(SD_ALG_FIELD_NAME));
let serialized = serde_json::to_value(&jwt).unwrap();
let ser_object = serialized.as_object().unwrap();
assert!(ser_object.contains_key(SD_ALG_FIELD_NAME));
let ser_sd_alg = ser_object.get(SD_ALG_FIELD_NAME).unwrap();
let ser_sd_alg = ser_sd_alg.as_str().unwrap();
assert_eq!(ser_sd_alg, alg_name);
let deserialized: IssuerJwt = serde_json::from_value(serialized).unwrap();
assert_eq!(deserialized.sd_alg, jwt.sd_alg);
}
}