fix(sign): a time-stamp response must carry the requested nonce and policy

parse_response compared only the digest algorithm and imprint with the
query, and the token parser dropped the TSTInfo nonce. A genuine token for
the same imprint from another request or under another policy was
accepted. The token keeps its nonce, and a requested nonce or policy must
match.
This commit is contained in:
aiongg
2026-10-22 17:57:53 +00:00
parent b1196c5cbe
commit ae791d667b
2 changed files with 34 additions and 1 deletions
+12 -1
View File
@@ -62,6 +62,8 @@ pub struct TimestampToken {
pub policy: String,
pub serial: Vec<u8>,
pub gen_time: Time,
/// The TSTInfo nonce, when it has one that fits a `u64` (the size requests use).
pub nonce: Option<u64>,
}
impl TimestampToken {
@@ -140,6 +142,9 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
.to_vec();
let serial = fields.get(3).ok_or_else(|| bad("TSTInfo has no serial number"))?.uint_bytes().to_vec();
let gen_time = fields.get(4).ok_or_else(|| bad("TSTInfo has no generation time"))?.time()?;
// The nonce is the only INTEGER after genTime (RFC 3161 §2.4.2): accuracy is a SEQUENCE and
// ordering a BOOLEAN.
let nonce = fields.iter().skip(5).find(|f| f.tag == tag::INTEGER).and_then(|f| f.u64().ok());
// The signed messageDigest uses the SignerInfo's digest algorithm, which need not be the
// imprint's (RFC 5652 §5.4).
let content_digest = token.signer.digest.digest(&[info]);
@@ -151,7 +156,7 @@ pub fn parse_token(raw: &[u8]) -> Result<TimestampToken, SignError> {
if !token.verify_signature(cert, &content_digest)? {
return Err(bad("TSA signature is invalid"));
}
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time })
Ok(TimestampToken { raw: raw.to_vec(), digest, imprint, policy, serial, gen_time, nonce })
}
/// Parse and validate a `TimeStampResp`, including the CMS signature and message imprint.
@@ -170,6 +175,12 @@ pub fn parse_response(bytes: &[u8], query: &TimestampQuery) -> Result<TimestampT
if token.digest != query.digest || token.imprint != query.imprint {
return Err(bad("message imprint does not match the requested digest"));
}
if query.nonce.is_some() && token.nonce != query.nonce {
return Err(bad("time-stamp token does not carry the requested nonce"));
}
if query.policy.as_ref().is_some_and(|p| *p != token.policy) {
return Err(bad("time-stamp token was issued under another policy than the one requested"));
}
Ok(token)
}
+22
View File
@@ -1580,6 +1580,28 @@ fn a_timestamp_token_may_sign_with_a_different_digest_than_its_imprint() {
assert_eq!((token.digest, token.imprint, token.gen_time), (pdfcraft_sign::DigestAlg::Sha1, imprint, time));
}
/// A token answers the request that asked for it: a requested nonce or policy must come back.
#[test]
fn a_timestamp_response_must_carry_the_requested_nonce_and_policy() {
use pdfcraft_sign::timestamp::{TimestampQuery, parse_response, respond};
let tsa = pkcs12::open(&data("rsa-aes.p12"), "test").unwrap();
let alg = pdfcraft_sign::DigestAlg::Sha256;
let time = Time { year: 2026, month: 10, day: 6, hour: 12, minute: 0, second: 0 };
let query = |nonce: Option<u64>, policy: Option<&str>| {
let mut q = TimestampQuery::new(alg, alg.digest(&[b"document"])).unwrap();
(q.nonce, q.policy) = (nonce, policy.map(str::to_string));
q
};
let answer = |q: &TimestampQuery| respond(&tsa.key, &tsa.certificate, &tsa.chain, alg, q, "1.2.3.4", time, 9).unwrap();
let with_nonce = answer(&query(Some(42), None));
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.4"))).is_ok());
assert!(parse_response(&with_nonce, &query(None, None)).is_ok());
assert!(parse_response(&with_nonce, &query(Some(43), None)).is_err(), "another request's nonce");
assert!(parse_response(&with_nonce, &query(Some(42), Some("1.2.3.5"))).is_err(), "another policy");
let without_nonce = answer(&query(None, None));
assert!(parse_response(&without_nonce, &query(Some(42), None)).is_err(), "no nonce in the token");
}
#[cfg(windows)]
#[test]
fn windows_store_enumeration_and_missing_identity() {