use crate::c2pa_cbor::{decode, encode, Profile, Value};
use crate::c2pa_crypto::error::CryptoError;
const COSE_SIGN1_TAG: u64 = 18;
const COSE_HDR_IAT: i128 = 6;
const HEADER_SIG_TST: &str = "sigTst";
const HEADER_SIG_TST2: &str = "sigTst2";
const TOKENS_KEY: &str = "tstTokens";
const TOKEN_VALUE_KEY: &str = "val";
const PROFILE: Profile = Profile::LegacyPipelineBDefinite;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimestampHeaderVersion {
SigTst,
SigTst2,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimestampHeader {
pub version: TimestampHeaderVersion,
pub token_der: Vec<u8>,
pub message_imprint_input: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimestampHeaderDefect {
ConflictingHeaders,
MalformedShape,
InvalidTokenCardinality,
InvalidTimeStampResponse,
}
impl TimestampHeaderDefect {
pub fn explanation(self) -> &'static str {
match self {
Self::ConflictingHeaders => {
"sigTst and sigTst2 are mutually exclusive; both were present"
}
Self::MalformedShape => {
"timestamp header does not have the closed sigTst/sigTst2 shape"
}
Self::InvalidTokenCardinality => "tstTokens does not contain exactly one token",
Self::InvalidTimeStampResponse => {
"legacy sigTst response was not a granted RFC 3161 TimeStampResp"
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TimestampHeaderEvidence {
Absent,
Present(TimestampHeader),
Ignored(TimestampHeaderDefect),
}
pub fn parse_timestamp_header(
cose_sign1: &[u8],
claim_cbor: &[u8],
) -> Result<TimestampHeaderEvidence, CryptoError> {
let decoded = decode(cose_sign1)?;
let array = cose_array(&decoded)?;
let protected_bytes = array[0]
.as_bytes()
.ok_or_else(|| CryptoError::Malformed("protected header is not a byte string".into()))?;
let signature = array[3]
.as_bytes()
.ok_or_else(|| CryptoError::Malformed("signature is not a byte string".into()))?;
let protected = decode(protected_bytes)?;
let buckets: [&[(Value, Value)]; 2] = [
protected.as_map().unwrap_or(&[]),
array[1].as_map().unwrap_or(&[]),
];
let sig_tst = lookup_across_buckets(&buckets, HEADER_SIG_TST);
let sig_tst2 = lookup_across_buckets(&buckets, HEADER_SIG_TST2);
let (version, header) = match (sig_tst, sig_tst2) {
(HeaderLookup::Absent, HeaderLookup::Absent) => return Ok(TimestampHeaderEvidence::Absent),
(HeaderLookup::Duplicate, _) | (_, HeaderLookup::Duplicate) => {
return Ok(TimestampHeaderEvidence::Ignored(
TimestampHeaderDefect::MalformedShape,
))
}
(HeaderLookup::One(_), HeaderLookup::One(_)) => {
return Ok(TimestampHeaderEvidence::Ignored(
TimestampHeaderDefect::ConflictingHeaders,
))
}
(HeaderLookup::One(value), HeaderLookup::Absent) => (TimestampHeaderVersion::SigTst, value),
(HeaderLookup::Absent, HeaderLookup::One(value)) => {
(TimestampHeaderVersion::SigTst2, value)
}
};
let stored = match parse_single_token(header) {
Ok(token) => token,
Err(defect) => return Ok(TimestampHeaderEvidence::Ignored(defect)),
};
let token_der = match version {
TimestampHeaderVersion::SigTst => match granted_timestamp_token(stored) {
Some(token) => token.to_vec(),
None => {
return Ok(TimestampHeaderEvidence::Ignored(
TimestampHeaderDefect::InvalidTimeStampResponse,
))
}
},
TimestampHeaderVersion::SigTst2 => stored.to_vec(),
};
let message_imprint_input = match version {
TimestampHeaderVersion::SigTst => counter_signature_input(protected_bytes, claim_cbor)?,
TimestampHeaderVersion::SigTst2 => {
let serialized_signature = encode(&Value::Bytes(signature.to_vec()), PROFILE)?;
counter_signature_input(protected_bytes, &serialized_signature)?
}
};
Ok(TimestampHeaderEvidence::Present(TimestampHeader {
version,
token_der,
message_imprint_input,
}))
}
pub fn timestamp_assertion_input(cose_sign1: &[u8]) -> Result<Vec<u8>, CryptoError> {
let decoded = decode(cose_sign1)?;
let array = cose_array(&decoded)?;
array[3]
.as_bytes()
.map(<[u8]>::to_vec)
.ok_or_else(|| CryptoError::Malformed("signature is not a byte string".into()))
}
pub fn protected_iat(cose_sign1: &[u8]) -> Result<Option<f64>, CryptoError> {
let decoded = decode(cose_sign1)?;
let array = cose_array(&decoded)?;
let protected_bytes = array[0]
.as_bytes()
.ok_or_else(|| CryptoError::Malformed("protected header is not a byte string".into()))?;
let protected = decode(protected_bytes)?;
let entries = protected
.as_map()
.ok_or_else(|| CryptoError::Malformed("protected header is not a map".into()))?;
let mut found = None;
for (key, value) in entries {
if !matches!(key, Value::Integer(COSE_HDR_IAT)) {
continue;
}
if found.is_some() {
return Err(CryptoError::Malformed("duplicate protected iat".into()));
}
found = Some(match value {
Value::Integer(seconds) if *seconds >= 0 => *seconds as f64,
Value::Float(seconds) if seconds.is_finite() && *seconds >= 0.0 => *seconds,
_ => return Err(CryptoError::Malformed("invalid protected iat".into())),
});
}
Ok(found)
}
enum HeaderLookup<'a> {
Absent,
One(&'a Value),
Duplicate,
}
fn lookup_across_buckets<'a>(buckets: &[&'a [(Value, Value)]; 2], name: &str) -> HeaderLookup<'a> {
let mut found = None;
for bucket in buckets {
for (key, candidate) in bucket.iter() {
if key.as_text() != Some(name) {
continue;
}
if found.is_some() {
return HeaderLookup::Duplicate;
}
found = Some(candidate);
}
}
found.map_or(HeaderLookup::Absent, HeaderLookup::One)
}
fn parse_single_token(header: &Value) -> Result<&[u8], TimestampHeaderDefect> {
let entries = header
.as_map()
.ok_or(TimestampHeaderDefect::MalformedShape)?;
if entries.len() != 1 || entries[0].0.as_text() != Some(TOKENS_KEY) {
return Err(TimestampHeaderDefect::MalformedShape);
}
let Value::Array(tokens) = &entries[0].1 else {
return Err(TimestampHeaderDefect::MalformedShape);
};
if tokens.len() != 1 {
return Err(TimestampHeaderDefect::InvalidTokenCardinality);
}
let token_map = tokens[0]
.as_map()
.ok_or(TimestampHeaderDefect::MalformedShape)?;
if token_map.len() != 1 || token_map[0].0.as_text() != Some(TOKEN_VALUE_KEY) {
return Err(TimestampHeaderDefect::MalformedShape);
}
match &token_map[0].1 {
Value::Bytes(token) if !token.is_empty() => Ok(token),
_ => Err(TimestampHeaderDefect::MalformedShape),
}
}
fn counter_signature_input(body_protected: &[u8], payload: &[u8]) -> Result<Vec<u8>, CryptoError> {
encode(
&Value::Array(vec![
Value::Text("CounterSignature".to_string()),
Value::Bytes(body_protected.to_vec()),
Value::Bytes(Vec::new()),
Value::Bytes(payload.to_vec()),
]),
PROFILE,
)
.map_err(Into::into)
}
fn cose_array(value: &Value) -> Result<&[Value], CryptoError> {
match value {
Value::Tag(COSE_SIGN1_TAG, inner) => match inner.as_ref() {
Value::Array(items) if items.len() == 4 => Ok(items),
_ => Err(CryptoError::Malformed(
"tag 18 content is not a 4-element array".into(),
)),
},
_ => Err(CryptoError::Malformed(
"not a COSE_Sign1_Tagged (tag 18) structure".into(),
)),
}
}
fn granted_timestamp_token(response: &[u8]) -> Option<&[u8]> {
let mut outer = DerReader::new(read_tlv(response, 0x30)?);
let status_info = outer.next_tlv(0x30)?;
let status = DerReader::new(status_info).next_tlv(0x02)?;
if !matches!(status, [0] | [1]) {
return None;
}
let token = outer.remaining();
if token.is_empty() {
return None;
}
read_tlv(token, 0x30)?;
Some(token)
}
fn read_tlv(input: &[u8], tag: u8) -> Option<&[u8]> {
let mut reader = DerReader::new(input);
let content = reader.next_tlv(tag)?;
reader.remaining().is_empty().then_some(content)
}
struct DerReader<'a> {
input: &'a [u8],
offset: usize,
}
impl<'a> DerReader<'a> {
fn new(input: &'a [u8]) -> Self {
Self { input, offset: 0 }
}
fn next_tlv(&mut self, tag: u8) -> Option<&'a [u8]> {
let bytes = self.input.get(self.offset..)?;
if *bytes.first()? != tag {
return None;
}
let first_length = *bytes.get(1)?;
let (length, header) = if first_length < 0x80 {
(usize::from(first_length), 2)
} else {
let count = usize::from(first_length & 0x7f);
if count == 0 || count > core::mem::size_of::<usize>() {
return None;
}
let mut length = 0usize;
for byte in bytes.get(2..2 + count)? {
length = length.checked_mul(256)?.checked_add(usize::from(*byte))?;
}
(length, 2 + count)
};
let end = header.checked_add(length)?;
let content = bytes.get(header..end)?;
self.offset += end;
Some(content)
}
fn remaining(&self) -> &'a [u8] {
self.input.get(self.offset..).unwrap_or(&[])
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cose(protected: Vec<(Value, Value)>, unprotected: Vec<(Value, Value)>) -> Vec<u8> {
let protected = encode(&Value::Map(protected), PROFILE).expect("encode protected");
encode(
&Value::Tag(
COSE_SIGN1_TAG,
Box::new(Value::Array(vec![
Value::Bytes(protected),
Value::Map(unprotected),
Value::Null,
Value::Bytes(b"signature-bytes".to_vec()),
])),
),
PROFILE,
)
.expect("encode cose")
}
fn header(token: Vec<u8>) -> Value {
Value::Map(vec![(
Value::Text(TOKENS_KEY.into()),
Value::Array(vec![Value::Map(vec![(
Value::Text(TOKEN_VALUE_KEY.into()),
Value::Bytes(token),
)])]),
)])
}
fn timestamp_response(status: u8, token_body: &[u8]) -> Vec<u8> {
let status_info = vec![0x30, 0x03, 0x02, 0x01, status];
let mut token = vec![0x30, token_body.len() as u8];
token.extend_from_slice(token_body);
let mut content = status_info;
content.extend_from_slice(&token);
let mut response = vec![0x30, content.len() as u8];
response.extend(content);
response
}
#[test]
fn sig_tst_and_sig_tst2_derive_different_message_imprint_inputs() {
let claim = b"claim-cbor".to_vec();
let token = timestamp_response(0, b"\x04\x02ok");
let v1 = parse_timestamp_header(
&cose(
Vec::new(),
vec![(Value::Text(HEADER_SIG_TST.into()), header(token.clone()))],
),
&claim,
)
.expect("parse v1");
let v2 = parse_timestamp_header(
&cose(
Vec::new(),
vec![(
Value::Text(HEADER_SIG_TST2.into()),
header(b"\x30\x04\x04\x02ok".to_vec()),
)],
),
&claim,
)
.expect("parse v2");
let TimestampHeaderEvidence::Present(v1) = v1 else {
panic!("sigTst must parse as present evidence");
};
let TimestampHeaderEvidence::Present(v2) = v2 else {
panic!("sigTst2 must parse as present evidence");
};
assert_eq!(v1.version, TimestampHeaderVersion::SigTst);
assert_eq!(v2.version, TimestampHeaderVersion::SigTst2);
assert_eq!(v1.token_der, b"\x30\x04\x04\x02ok".to_vec());
assert_eq!(v2.token_der, b"\x30\x04\x04\x02ok".to_vec());
assert_ne!(v1.message_imprint_input, v2.message_imprint_input);
assert!(v1
.message_imprint_input
.windows(claim.len())
.any(|window| window == claim));
assert!(v2
.message_imprint_input
.windows(b"signature-bytes".len())
.any(|window| window == b"signature-bytes"));
}
#[test]
fn header_defects_are_reported_instead_of_yielding_a_token() {
let claim = b"claim-cbor".to_vec();
let good = header(b"\x30\x04\x04\x02ok".to_vec());
let both = cose(
Vec::new(),
vec![
(
Value::Text(HEADER_SIG_TST.into()),
header(timestamp_response(0, b"\x04\x02ok")),
),
(Value::Text(HEADER_SIG_TST2.into()), good.clone()),
],
);
assert_eq!(
parse_timestamp_header(&both, &claim).expect("parse"),
TimestampHeaderEvidence::Ignored(TimestampHeaderDefect::ConflictingHeaders)
);
let duplicated = cose(
vec![(Value::Text(HEADER_SIG_TST2.into()), good.clone())],
vec![(Value::Text(HEADER_SIG_TST2.into()), good.clone())],
);
assert_eq!(
parse_timestamp_header(&duplicated, &claim).expect("parse"),
TimestampHeaderEvidence::Ignored(TimestampHeaderDefect::MalformedShape)
);
let two_tokens = Value::Map(vec![(
Value::Text(TOKENS_KEY.into()),
Value::Array(vec![
Value::Map(vec![(
Value::Text(TOKEN_VALUE_KEY.into()),
Value::Bytes(b"\x30\x00".to_vec()),
)]),
Value::Map(vec![(
Value::Text(TOKEN_VALUE_KEY.into()),
Value::Bytes(b"\x30\x00".to_vec()),
)]),
]),
)]);
assert_eq!(
parse_timestamp_header(
&cose(
Vec::new(),
vec![(Value::Text(HEADER_SIG_TST2.into()), two_tokens)]
),
&claim
)
.expect("parse"),
TimestampHeaderEvidence::Ignored(TimestampHeaderDefect::InvalidTokenCardinality)
);
let extra_member = Value::Map(vec![
(
Value::Text(TOKENS_KEY.into()),
Value::Array(vec![Value::Map(vec![(
Value::Text(TOKEN_VALUE_KEY.into()),
Value::Bytes(b"\x30\x00".to_vec()),
)])]),
),
(Value::Text("extra".into()), Value::Integer(1)),
]);
assert_eq!(
parse_timestamp_header(
&cose(
Vec::new(),
vec![(Value::Text(HEADER_SIG_TST2.into()), extra_member)]
),
&claim
)
.expect("parse"),
TimestampHeaderEvidence::Ignored(TimestampHeaderDefect::MalformedShape)
);
}
#[test]
fn legacy_response_must_carry_a_granted_pki_status() {
let claim = b"claim-cbor".to_vec();
for status in [0u8, 1] {
let response = timestamp_response(status, b"\x04\x02ok");
let evidence = parse_timestamp_header(
&cose(
Vec::new(),
vec![(Value::Text(HEADER_SIG_TST.into()), header(response))],
),
&claim,
)
.expect("parse");
assert!(
matches!(evidence, TimestampHeaderEvidence::Present(_)),
"PKIStatusInfo status {status} is granted"
);
}
for status in [2u8, 3, 4, 5] {
let response = timestamp_response(status, b"\x04\x02ok");
assert_eq!(
parse_timestamp_header(
&cose(
Vec::new(),
vec![(Value::Text(HEADER_SIG_TST.into()), header(response))],
),
&claim
)
.expect("parse"),
TimestampHeaderEvidence::Ignored(TimestampHeaderDefect::InvalidTimeStampResponse)
);
}
}
#[test]
fn protected_iat_accepts_numeric_dates_and_rejects_unusable_ones() {
let iat = |value: Value| {
protected_iat(&cose(
vec![(Value::Integer(COSE_HDR_IAT), value)],
Vec::new(),
))
};
assert_eq!(
iat(Value::Integer(1_700_000_000)).expect("int"),
Some(1_700_000_000.0)
);
assert_eq!(iat(Value::Float(1.5)).expect("float"), Some(1.5));
assert!(iat(Value::Integer(-1)).is_err());
assert!(iat(Value::Float(f64::NAN)).is_err());
assert!(iat(Value::Text("now".into())).is_err());
assert_eq!(
protected_iat(&cose(Vec::new(), Vec::new())).expect("absent"),
None
);
assert_eq!(
protected_iat(&cose(
Vec::new(),
vec![(Value::Integer(COSE_HDR_IAT), Value::Integer(1))]
))
.expect("unprotected"),
None
);
}
}