use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use serde_json::{Map, Value};
use vta_sdk::jws::JwsKey;
use vta_sdk::protocols::key_management::sign_csr::{
SignCsrRdnAttribute, SignCsrRdnType, SignCsrSubjectAltNames,
};
use vta_sdk::protocols::key_management::sign_jws::FORBIDDEN_HEADER_MEMBERS;
use yasna::Tag;
use yasna::models::ObjectIdentifier;
#[derive(Debug, PartialEq, Eq)]
pub enum FormatRefusal {
HeaderMemberForbidden(String),
InvalidArgument(String),
PayloadInvalid(String),
SubjectInvalid {
member: &'static str,
index: Option<usize>,
reason: String,
},
}
#[derive(Debug, Clone, Copy)]
pub enum JwsPayload<'a> {
Json(&'a Map<String, Value>),
Base64Url(&'a str),
}
pub fn check_jws_header(header: &Map<String, Value>, key: &JwsKey) -> Result<(), FormatRefusal> {
if let Some(member) = FORBIDDEN_HEADER_MEMBERS
.iter()
.find(|m| header.contains_key(**m))
{
return Err(FormatRefusal::HeaderMemberForbidden((*member).to_string()));
}
let invalid = |m: String| Err(FormatRefusal::InvalidArgument(m));
for name in ["typ", "cty", "kid"] {
if let Some(v) = header.get(name)
&& !v.is_string()
{
return invalid(format!("header `{name}` must be a string"));
}
}
for name in ["x5t", "x5t#S256"] {
if let Some(v) = header.get(name)
&& !v
.as_str()
.is_some_and(|s| URL_SAFE_NO_PAD.decode(s).is_ok())
{
return invalid(format!("header `{name}` must be a base64url string"));
}
}
if let Some(chain) = header.get("x5c") {
let Some(chain) = chain.as_array().filter(|c| !c.is_empty()) else {
return invalid("header `x5c` must be a non-empty array of certificates".into());
};
let mut ders = Vec::with_capacity(chain.len());
for (i, cert) in chain.iter().enumerate() {
let Some(der) = cert.as_str().and_then(|s| STANDARD.decode(s).ok()) else {
return invalid(format!("header `x5c`[{i}] is not a base64 certificate"));
};
ders.push(der);
}
match vta_sdk::x509_issuer::certificate_key(&ders[0]) {
Ok(leaf) if &leaf == key => {}
Ok(_) => {
return invalid(
"the first `x5c` certificate does not carry the named key's public key".into(),
);
}
Err(e) => return invalid(format!("header `x5c`[0]: {e}")),
}
}
Ok(())
}
pub fn jws_signing_input(
alg: &str,
header: &Map<String, Value>,
payload: JwsPayload<'_>,
) -> Result<String, FormatRefusal> {
let mut protected = header.clone();
protected.insert("alg".into(), Value::String(alg.to_string()));
let header_json = jcs(&Value::Object(protected))
.map_err(|e| FormatRefusal::InvalidArgument(format!("header: {e}")))?;
let payload_segment = match payload {
JwsPayload::Json(object) => {
let object = Value::Object(object.clone());
check_i_json(&object)?;
URL_SAFE_NO_PAD.encode(jcs(&object).map_err(FormatRefusal::PayloadInvalid)?)
}
JwsPayload::Base64Url(segment) => {
let canonical = URL_SAFE_NO_PAD
.decode(segment)
.ok()
.is_some_and(|bytes| URL_SAFE_NO_PAD.encode(bytes) == segment);
if !canonical {
return Err(FormatRefusal::PayloadInvalid(
"payloadBase64Url is not the canonical unpadded base64url of any byte string"
.into(),
));
}
segment.to_string()
}
};
Ok(format!(
"{}.{payload_segment}",
URL_SAFE_NO_PAD.encode(header_json)
))
}
fn jcs(value: &Value) -> Result<String, String> {
serde_json_canonicalizer::to_string(value).map_err(|e| e.to_string())
}
fn check_i_json(value: &Value) -> Result<(), FormatRefusal> {
const MAX_EXACT: u64 = 1 << 53;
match value {
Value::Number(n) => {
let exact = n
.as_u64()
.map(|u| u <= MAX_EXACT)
.or_else(|| n.as_i64().map(|i| i.unsigned_abs() <= MAX_EXACT))
.unwrap_or(true);
if exact {
Ok(())
} else {
Err(FormatRefusal::PayloadInvalid(format!(
"the number {n} cannot be serialised exactly (RFC 8785); send it as a string"
)))
}
}
Value::Array(items) => items.iter().try_for_each(check_i_json),
Value::Object(members) => members.values().try_for_each(check_i_json),
_ => Ok(()),
}
}
#[derive(Debug, Clone)]
pub enum CsrKey {
P256(Vec<u8>),
Ed25519([u8; 32]),
}
impl CsrKey {
pub fn from_jws_key(key: &JwsKey) -> Option<Self> {
match key {
JwsKey::Ed25519(k) => Some(Self::Ed25519(*k)),
JwsKey::P256(compressed) => {
use p256::elliptic_curve::sec1::ToSec1Point;
let point = p256::PublicKey::from_sec1_bytes(compressed).ok()?;
Some(Self::P256(point.to_sec1_point(false).as_bytes().to_vec()))
}
_ => None,
}
}
}
const OID_EC_PUBLIC_KEY: &[u64] = &[1, 2, 840, 10045, 2, 1];
const OID_PRIME256V1: &[u64] = &[1, 2, 840, 10045, 3, 1, 7];
const OID_ED25519: &[u64] = &[1, 3, 101, 112];
const OID_ECDSA_WITH_SHA256: &[u64] = &[1, 2, 840, 10045, 4, 3, 2];
const OID_EXTENSION_REQUEST: &[u64] = &[1, 2, 840, 113549, 1, 9, 14];
const OID_SUBJECT_ALT_NAME: &[u64] = &[2, 5, 29, 17];
struct AttributeRule {
oid: &'static [u64],
string: DnString,
max_chars: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum DnString {
Utf8,
Printable,
Ia5,
}
fn attribute_rule(kind: &SignCsrRdnType) -> Option<AttributeRule> {
let (oid, string, max_chars): (&'static [u64], _, _) = match kind {
SignCsrRdnType::C => (&[2, 5, 4, 6], DnString::Printable, 2),
SignCsrRdnType::St => (&[2, 5, 4, 8], DnString::Utf8, 128),
SignCsrRdnType::L => (&[2, 5, 4, 7], DnString::Utf8, 128),
SignCsrRdnType::O => (&[2, 5, 4, 10], DnString::Utf8, 64),
SignCsrRdnType::Ou => (&[2, 5, 4, 11], DnString::Utf8, 64),
SignCsrRdnType::OrganizationIdentifier => (&[2, 5, 4, 97], DnString::Utf8, 128),
SignCsrRdnType::Cn => (&[2, 5, 4, 3], DnString::Utf8, 64),
SignCsrRdnType::SerialNumber => (&[2, 5, 4, 5], DnString::Printable, 64),
SignCsrRdnType::EmailAddress => (&[1, 2, 840, 113549, 1, 9, 1], DnString::Ia5, 255),
_ => return None,
};
Some(AttributeRule {
oid,
string,
max_chars,
})
}
fn is_control(c: char) -> bool {
matches!(c, '\u{0}'..='\u{1f}' | '\u{7f}'..='\u{9f}')
}
fn is_printable_string_char(c: char) -> bool {
c.is_ascii_alphanumeric() || " '()+,-./:=?".contains(c)
}
fn is_mailbox(v: &str) -> bool {
let Some((local, domain)) = v.split_once('@') else {
return false;
};
!local.is_empty()
&& !domain.is_empty()
&& local.chars().all(|c| matches!(c, '!'..='?' | 'A'..='~'))
&& domain
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
}
pub fn check_csr_subject(subject: &[SignCsrRdnAttribute]) -> Result<(), FormatRefusal> {
for (index, attr) in subject.iter().enumerate() {
let refuse = |reason: String| FormatRefusal::SubjectInvalid {
member: "subject",
index: Some(index),
reason,
};
let value = attr.value.as_str();
let Some(rule) = attribute_rule(&attr.type_) else {
return Err(refuse(format!(
"attribute type {} is not supported",
attr.type_
)));
};
let chars = value.chars().count();
if chars == 0 || chars > rule.max_chars {
return Err(refuse(format!(
"{} must be 1 to {} characters",
attr.type_, rule.max_chars
)));
}
if value.chars().any(is_control) {
return Err(refuse(format!(
"{} contains a control character",
attr.type_
)));
}
let ok = match attr.type_ {
SignCsrRdnType::C => value.chars().all(|c| c.is_ascii_uppercase()) && chars == 2,
SignCsrRdnType::SerialNumber => value.chars().all(is_printable_string_char),
SignCsrRdnType::EmailAddress => is_mailbox(value),
_ => true,
};
if !ok {
return Err(refuse(match attr.type_ {
SignCsrRdnType::C => "C must be an ISO 3166-1 alpha-2 code, e.g. DE".into(),
SignCsrRdnType::SerialNumber => {
"serialNumber must use the PrintableString characters only".into()
}
_ => "emailAddress must be an ASCII mailbox".into(),
}));
}
}
Ok(())
}
pub fn subject_public_key_info(key: &CsrKey) -> Vec<u8> {
yasna::construct_der(|w| {
w.write_sequence(|w| match key {
CsrKey::P256(point) => {
w.next().write_sequence(|w| {
w.next()
.write_oid(&ObjectIdentifier::from_slice(OID_EC_PUBLIC_KEY));
w.next()
.write_oid(&ObjectIdentifier::from_slice(OID_PRIME256V1));
});
w.next().write_bitvec_bytes(point, point.len() * 8);
}
CsrKey::Ed25519(k) => {
w.next().write_sequence(|w| {
w.next()
.write_oid(&ObjectIdentifier::from_slice(OID_ED25519));
});
w.next().write_bitvec_bytes(k, k.len() * 8);
}
});
})
}
pub fn certification_request_info(
subject: &[SignCsrRdnAttribute],
alt_names: Option<&SignCsrSubjectAltNames>,
key: &CsrKey,
) -> Vec<u8> {
yasna::construct_der(|w| {
w.write_sequence(|w| {
w.next().write_u8(0);
w.next().write_sequence(|w| {
for attr in subject {
let Some(rule) = attribute_rule(&attr.type_) else {
continue;
};
w.next().write_set(|w| {
w.next().write_sequence(|w| {
w.next().write_oid(&ObjectIdentifier::from_slice(rule.oid));
let value = attr.value.as_str();
match rule.string {
DnString::Utf8 => w.next().write_utf8_string(value),
DnString::Printable => w.next().write_printable_string(value),
DnString::Ia5 => w.next().write_ia5_string(value),
}
});
});
}
});
w.next().write_der(&subject_public_key_info(key));
w.next().write_tagged_implicit(Tag::context(0), |w| {
w.write_set(|w| {
let Some(names) = alt_names else {
return;
};
let general_names = yasna::construct_der(|w| {
w.write_sequence(|w| {
for (tag, list) in [
(2, names.dns.iter().map(|n| n.as_str()).collect::<Vec<_>>()),
(6, names.uri.iter().map(|n| n.as_str()).collect()),
(1, names.email.iter().map(|n| n.as_str()).collect()),
] {
for name in list {
w.next().write_tagged_implicit(Tag::context(tag), |w| {
w.write_ia5_string(name)
});
}
}
});
});
w.next().write_sequence(|w| {
w.next()
.write_oid(&ObjectIdentifier::from_slice(OID_EXTENSION_REQUEST));
w.next().write_set(|w| {
w.next().write_sequence(|w| {
w.next().write_sequence(|w| {
w.next().write_oid(&ObjectIdentifier::from_slice(
OID_SUBJECT_ALT_NAME,
));
w.next().write_bytes(&general_names);
});
});
});
});
});
});
});
})
}
pub fn certification_request_pem(
info: &[u8],
key: &CsrKey,
signature: &[u8],
) -> Result<String, String> {
let (algorithm, signature): (&[u64], Vec<u8>) = match key {
CsrKey::P256(_) => {
let sig = p256::ecdsa::Signature::from_slice(signature)
.map_err(|e| format!("P-256 signature: {e}"))?;
(OID_ECDSA_WITH_SHA256, sig.to_der().as_bytes().to_vec())
}
CsrKey::Ed25519(_) => (OID_ED25519, signature.to_vec()),
};
let der = yasna::construct_der(|w| {
w.write_sequence(|w| {
w.next().write_der(info);
w.next().write_sequence(|w| {
w.next().write_oid(&ObjectIdentifier::from_slice(algorithm));
});
w.next().write_bitvec_bytes(&signature, signature.len() * 8);
});
});
let body = STANDARD.encode(der);
let mut pem = String::from("-----BEGIN CERTIFICATE REQUEST-----\n");
for line in body.as_bytes().chunks(64) {
pem.push_str(std::str::from_utf8(line).map_err(|e| e.to_string())?);
pem.push('\n');
}
pem.push_str("-----END CERTIFICATE REQUEST-----\n");
Ok(pem)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use sha2::{Digest, Sha256};
fn object(v: Value) -> Map<String, Value> {
v.as_object().unwrap().clone()
}
#[test]
fn the_spec_example_signing_input_is_reproduced() {
let header = object(json!({
"typ": "oauth-authz-req+jwt",
"kid": "did:example:community-verifier#key-1"
}));
let payload = object(json!({
"aud": "https://self-issued.me/v2",
"client_id": "decentralized_identifier:did:example:community-verifier",
"response_type": "vp_token",
"response_mode": "direct_post.jwt",
"response_uri": "https://community.example/v1/oid4vp/response/7d1e4c2a",
"nonce": "q8vTt3yN5mQ0aW2f",
"state": "7d1e4c2a",
"dcql_query": { "credentials": [ {
"id": "membership",
"format": "dc+sd-jwt",
"meta": { "vct_values": ["https://credentials.example/membership"] }
} ] },
"iat": 1791709200,
"exp": 1791709500
}));
let input = jws_signing_input("ES256", &header, JwsPayload::Json(&payload)).unwrap();
assert!(input.starts_with(
"eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpleGFtcGxlOmNvbW11bml0eS12ZXJpZmllciNrZXktMSIsInR5cCI6Im9hdXRoLWF1dGh6LXJlcStqd3QifQ."
));
assert_eq!(
hex::encode(Sha256::digest(input.as_bytes())),
"e0c236e3a90999a1ab54699f308c0ad0468405da185731762396cd733e031257"
);
}
#[test]
fn forbidden_and_mistyped_header_members_are_refused() {
let key = JwsKey::Ed25519([7; 32]);
for member in FORBIDDEN_HEADER_MEMBERS {
let header = object(json!({ *member: "x" }));
assert_eq!(
check_jws_header(&header, &key),
Err(FormatRefusal::HeaderMemberForbidden((*member).to_string()))
);
}
for header in [
json!({ "typ": 1 }),
json!({ "kid": ["a"] }),
json!({ "x5t": "not base64url!" }),
json!({ "x5c": [] }),
json!({ "x5c": ["***"] }),
json!({ "x5c": [STANDARD.encode(b"not a certificate")] }),
] {
assert!(
matches!(
check_jws_header(&object(header.clone()), &key),
Err(FormatRefusal::InvalidArgument(_))
),
"{header}"
);
}
assert_eq!(
check_jws_header(
&object(json!({ "typ": "jwt", "profile_version": "x" })),
&key
),
Ok(())
);
}
#[test]
fn a_payload_that_cannot_be_signed_exactly_is_refused() {
let header = Map::new();
let big = object(json!({ "n": 9_007_199_254_740_993u64 }));
assert!(matches!(
jws_signing_input("EdDSA", &header, JwsPayload::Json(&big)),
Err(FormatRefusal::PayloadInvalid(_))
));
assert!(matches!(
jws_signing_input("EdDSA", &header, JwsPayload::Base64Url("QR")),
Err(FormatRefusal::PayloadInvalid(_))
));
let ok = jws_signing_input("EdDSA", &header, JwsPayload::Base64Url("QQ")).unwrap();
assert!(ok.ends_with(".QQ"));
}
fn attr(type_: &str, value: &str) -> SignCsrRdnAttribute {
serde_json::from_value(json!({ "type": type_, "value": value })).unwrap()
}
#[test]
fn subject_rules_refuse_without_repairing() {
for (i, bad) in [
attr("C", "de"),
attr("C", "DEU"),
attr("CN", &"x".repeat(65)),
attr("O", "Acme\u{7}"),
attr("serialNumber", "a_b"),
attr("emailAddress", "no-at-sign"),
]
.into_iter()
.enumerate()
{
let subject = vec![attr("C", "DE"), bad];
match check_csr_subject(&subject) {
Err(FormatRefusal::SubjectInvalid { member, index, .. }) => {
assert_eq!(member, "subject");
assert_eq!(index, Some(1), "case {i}");
}
other => panic!("case {i}: {other:?}"),
}
}
check_csr_subject(&[
attr("C", "DE"),
attr("O", "Example Community e.V."),
attr("organizationIdentifier", "LEIXG-5299000EXAMPLE000001"),
attr("CN", "Example Community Verifier"),
])
.unwrap();
}
}