Commit fbe8823be3
Verified · cmc
Layout: unified · split
README.md +19 −5
| @@ -93,12 +93,24 @@ evidence-seal timestamp apply pkg.manifest.json --token pkg.tsr | ||
| 93 | 93 | |
| 94 | 94 | evidence-seal timestamp verify pkg.manifest.json |
| 95 | 95 | # -> timestamp OK: timestamped at 2026-08-06T09:00:00Z |
| 96 | ||
| 97 | # Full verification: also check the token's CMS signature against the TSA cert | |
| 98 | evidence-seal timestamp verify pkg.manifest.json --tsa-cert freetsa.pem | |
| 99 | # -> timestamp OK: timestamped at 2026-08-06T09:00:00Z; TSA signature valid (CN=…) | |
| 100 | evidence-seal verify ./pkg --tsa-cert freetsa.pem # same check inside a full verify | |
| 96 | 101 | ``` |
| 97 | 102 | |
| 98 | 103 | `apply` refuses any token whose imprint is not this manifest's `id`. Because the |
| 99 | 104 | `id` moves if a single byte changes, a token can never be transplanted onto |
| 100 | 105 | tampered evidence — re-sealing after a change orphans the timestamp. A present |
| 101 | timestamp is also checked automatically during `verify`. | |
| 106 | timestamp is checked automatically during `verify`. | |
| 107 | ||
| 108 | With `--tsa-cert`, the token's RFC 3161 CMS signature is verified against the | |
| 109 | supplied certificate: the certificate must carry the timeStamping extended key | |
| 110 | usage, identify the token's signer, be valid at `gen_time`, and its key must | |
| 111 | verify the signature over the timestamped content. This authenticates the token | |
| 112 | against a TSA certificate you trust; establishing that the certificate itself | |
| 113 | chains to a known root is left to you (supply a cert you already trust). | |
| 102 | 114 | |
| 103 | 115 | ## The manifest |
| 104 | 116 | |
| @@ -150,10 +162,12 @@ guarantee, **sign** the manifest (retain the public key out of band) and | ||
| 150 | 162 | |
| 151 | 163 | Two limits to be honest about: |
| 152 | 164 | |
| 153 | - **`timestamp verify` checks the binding, not the TSA's signature.** It proves | |
| 154 | the stored token timestamps this manifest's `id`; it does not by itself verify | |
| 155 | the TSA's own signature and certificate chain. Validate the token against the | |
| 156 | TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance. | |
| 165 | - **`timestamp verify` checks the binding; `--tsa-cert` adds signature | |
| 166 | verification but not chain-of-trust.** Without a cert, verification proves the | |
| 167 | stored token timestamps this manifest's `id`. With `--tsa-cert`, it also | |
| 168 | verifies the token's CMS signature, the timeStamping EKU, the signer match, | |
| 169 | and validity at `gen_time`. It does **not** verify that the certificate chains | |
| 170 | to a trusted root — supply a TSA certificate you already trust. | |
| 157 | 171 | - Private keys are written **unencrypted** — store them accordingly. |
| 158 | 172 | |
| 159 | 173 | ## Development |
evidence_seal/cli.py +10 −2
| @@ -118,7 +118,8 @@ def _cmd_verify(args) -> int: | ||
| 118 | 118 | if manifest.get("timestamp"): |
| 119 | 119 | from .timestamp import verify_timestamp |
| 120 | 120 | |
| 121 | ts_ok, ts_message = verify_timestamp(manifest) | |
| 121 | cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None | |
| 122 | ts_ok, ts_message = verify_timestamp(manifest, tsa_cert=cert) | |
| 122 | 123 | print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}") |
| 123 | 124 | if not ts_ok: |
| 124 | 125 | status = FAILED |
| @@ -240,10 +241,11 @@ def _cmd_ts_verify(args) -> int: | ||
| 240 | 241 | |
| 241 | 242 | try: |
| 242 | 243 | manifest = load_manifest(args.manifest) |
| 244 | cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None | |
| 243 | 245 | except (FileNotFoundError, ValueError) as exc: |
| 244 | 246 | print(f"error: cannot read manifest: {exc}", file=sys.stderr) |
| 245 | 247 | return USAGE |
| 246 | ok, message = verify_timestamp(manifest) | |
| 248 | ok, message = verify_timestamp(manifest, tsa_cert=cert) | |
| 247 | 249 | print(f"timestamp {'OK' if ok else 'FAIL'}: {message}") |
| 248 | 250 | return OK if ok else FAILED |
| 249 | 251 | |
| @@ -269,6 +271,9 @@ def _build_parser() -> argparse.ArgumentParser: | ||
| 269 | 271 | p_verify.add_argument("directory") |
| 270 | 272 | p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)") |
| 271 | 273 | p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key") |
| 274 | p_verify.add_argument( | |
| 275 | "--tsa-cert", metavar="CERT", help="verify the embedded timestamp against this TSA certificate" | |
| 276 | ) | |
| 272 | 277 | p_verify.set_defaults(func=_cmd_verify) |
| 273 | 278 | |
| 274 | 279 | p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest") |
| @@ -314,6 +319,9 @@ def _add_timestamp_commands(sub) -> None: | ||
| 314 | 319 | |
| 315 | 320 | p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") |
| 316 | 321 | p_tsv.add_argument("manifest") |
| 322 | p_tsv.add_argument( | |
| 323 | "--tsa-cert", metavar="CERT", help="also verify the token's CMS signature against this cert" | |
| 324 | ) | |
| 317 | 325 | p_tsv.set_defaults(func=_cmd_ts_verify) |
| 318 | 326 | |
| 319 | 327 | |
evidence_seal/timestamp.py +179 −6
| @@ -38,6 +38,16 @@ def _require_asn1(): | ||
| 38 | 38 | return algos, cms, core, tsp |
| 39 | 39 | |
| 40 | 40 | |
| 41 | def _require_crypto(): | |
| 42 | try: | |
| 43 | import cryptography # noqa: F401 | |
| 44 | except ImportError as exc: # pragma: no cover - exercised via a clear message | |
| 45 | raise RuntimeError( | |
| 46 | "verifying a TSA signature requires the 'cryptography' package — " | |
| 47 | "install evidence-seal[sign,timestamp]" | |
| 48 | ) from exc | |
| 49 | ||
| 50 | ||
| 41 | 51 | def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes: |
| 42 | 52 | """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id. |
| 43 | 53 | |
| @@ -140,12 +150,13 @@ def apply_timestamp(manifest: dict, token_or_response_der: bytes) -> dict: | ||
| 140 | 150 | return stamped |
| 141 | 151 | |
| 142 | 152 | |
| 143 | def verify_timestamp(manifest: dict) -> tuple[bool, str]: | |
| 144 | """Verify a manifest's embedded timestamp binds to its id. | |
| 153 | def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]: | |
| 154 | """Verify a manifest's embedded timestamp. | |
| 145 | 155 | |
| 146 | Returns ``(ok, message)``. This checks that the stored token timestamps the | |
| 147 | current manifest id; verifying the TSA's own signature and certificate chain | |
| 148 | is a separate, out-of-band step (see the README threat model). | |
| 156 | Returns ``(ok, message)``. Always checks that the stored token timestamps the | |
| 157 | current manifest id. When *tsa_cert* (PEM or DER bytes) is given, the token's | |
| 158 | RFC 3161 CMS signature is also verified against that certificate — proving | |
| 159 | the timestamp really was issued by that authority. | |
| 149 | 160 | """ |
| 150 | 161 | block = manifest.get("timestamp") |
| 151 | 162 | if not block: |
| @@ -163,7 +174,169 @@ def verify_timestamp(manifest: dict) -> tuple[bool, str]: | ||
| 163 | 174 | return False, "timestamp does not match the manifest id" |
| 164 | 175 | |
| 165 | 176 | tsa = f" by {fields['tsa']}" if fields["tsa"] else "" |
| 166 | return True, f"timestamped at {fields['gen_time']}{tsa}" | |
| 177 | bound = f"timestamped at {fields['gen_time']}{tsa}" | |
| 178 | ||
| 179 | if tsa_cert is None: | |
| 180 | return True, bound | |
| 181 | ||
| 182 | sig_ok, sig_message = verify_token_signature(token_der, tsa_cert) | |
| 183 | return sig_ok, f"{bound}; {sig_message}" | |
| 184 | ||
| 185 | ||
| 186 | # --------------------------------------------------------------------------- # | |
| 187 | # Full RFC 3161 CMS signature verification | |
| 188 | # --------------------------------------------------------------------------- # | |
| 189 | ||
| 190 | # asn1crypto hash names -> cryptography hash classes for the algorithms a TSA | |
| 191 | # realistically signs with. | |
| 192 | _HASHES = { | |
| 193 | "sha1": "SHA1", | |
| 194 | "sha224": "SHA224", | |
| 195 | "sha256": "SHA256", | |
| 196 | "sha384": "SHA384", | |
| 197 | "sha512": "SHA512", | |
| 198 | } | |
| 199 | ||
| 200 | ||
| 201 | def _load_certificate(cert_bytes: bytes): | |
| 202 | from cryptography import x509 | |
| 203 | ||
| 204 | try: | |
| 205 | return x509.load_pem_x509_certificate(cert_bytes) | |
| 206 | except ValueError: | |
| 207 | return x509.load_der_x509_certificate(cert_bytes) | |
| 208 | ||
| 209 | ||
| 210 | def _hash_instance(name: str): | |
| 211 | from cryptography.hazmat.primitives import hashes | |
| 212 | ||
| 213 | if name not in _HASHES: | |
| 214 | raise ValueError(f"unsupported digest algorithm: {name}") | |
| 215 | return getattr(hashes, _HASHES[name])() | |
| 216 | ||
| 217 | ||
| 218 | def _verify_raw(public_key, signature: bytes, data: bytes, sig_algo: str, hash_name: str) -> None: | |
| 219 | """Verify *signature* over *data*, raising on any failure.""" | |
| 220 | from cryptography.hazmat.primitives.asymmetric import ec, padding | |
| 221 | ||
| 222 | if sig_algo == "rsassa_pkcs1v15": | |
| 223 | public_key.verify(signature, data, padding.PKCS1v15(), _hash_instance(hash_name)) | |
| 224 | elif sig_algo == "rsassa_pss": | |
| 225 | digest = _hash_instance(hash_name) | |
| 226 | public_key.verify( | |
| 227 | signature, | |
| 228 | data, | |
| 229 | padding.PSS(mgf=padding.MGF1(digest), salt_length=padding.PSS.DIGEST_LENGTH), | |
| 230 | digest, | |
| 231 | ) | |
| 232 | elif sig_algo == "ecdsa": | |
| 233 | public_key.verify(signature, data, ec.ECDSA(_hash_instance(hash_name))) | |
| 234 | elif sig_algo in ("ed25519", "ed448"): | |
| 235 | public_key.verify(signature, data) | |
| 236 | else: | |
| 237 | raise ValueError(f"unsupported signature algorithm: {sig_algo}") | |
| 238 | ||
| 239 | ||
| 240 | def _signer_matches_cert(signer_info, cert) -> bool: | |
| 241 | """True if the SignerInfo identifies the supplied certificate.""" | |
| 242 | sid = signer_info["sid"] | |
| 243 | if sid.name == "issuer_and_serial_number": | |
| 244 | return sid.chosen["serial_number"].native == cert.serial_number | |
| 245 | # subject_key_identifier: compare against the cert's SKI extension. | |
| 246 | from cryptography import x509 | |
| 247 | ||
| 248 | try: | |
| 249 | ski = cert.extensions.get_extension_for_class(x509.SubjectKeyIdentifier).value | |
| 250 | except x509.ExtensionNotFound: | |
| 251 | return False | |
| 252 | return sid.chosen.native == ski.digest | |
| 253 | ||
| 254 | ||
| 255 | def _has_timestamping_eku(cert) -> bool: | |
| 256 | from cryptography import x509 | |
| 257 | from cryptography.x509.oid import ExtendedKeyUsageOID | |
| 258 | ||
| 259 | try: | |
| 260 | eku = cert.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value | |
| 261 | except x509.ExtensionNotFound: | |
| 262 | return False | |
| 263 | return ExtendedKeyUsageOID.TIME_STAMPING in eku | |
| 264 | ||
| 265 | ||
| 266 | def _signed_attr(signed_attrs, attr_type): | |
| 267 | for attr in signed_attrs: | |
| 268 | if attr["type"].native == attr_type: | |
| 269 | return attr["values"][0].native | |
| 270 | return None | |
| 271 | ||
| 272 | ||
| 273 | def verify_token_signature(token_or_response_der: bytes, cert_bytes: bytes) -> tuple[bool, str]: | |
| 274 | """Verify a token's CMS signature against a TSA certificate. | |
| 275 | ||
| 276 | Checks, in order: the certificate carries the timeStamping extended key | |
| 277 | usage; it identifies the token's signer; ``gen_time`` falls within its | |
| 278 | validity window; the signed message digest matches the timestamped content; | |
| 279 | and the signature verifies. Returns ``(ok, message)``. | |
| 280 | ||
| 281 | This authenticates the token against the certificate you supply. Establishing | |
| 282 | that the certificate itself is trusted (chain to a known root) is left to the | |
| 283 | caller — supply a TSA certificate you already trust. | |
| 284 | """ | |
| 285 | import hashlib | |
| 286 | ||
| 287 | _require_asn1() | |
| 288 | _require_crypto() | |
| 289 | ||
| 290 | try: | |
| 291 | token, tst, _status = _extract(token_or_response_der) | |
| 292 | signed_data = token["content"] | |
| 293 | signer_info = signed_data["signer_infos"][0] | |
| 294 | except Exception as exc: | |
| 295 | return False, f"TSA signature: token is unreadable ({exc})" | |
| 296 | ||
| 297 | try: | |
| 298 | cert = _load_certificate(cert_bytes) | |
| 299 | except ValueError as exc: | |
| 300 | return False, f"TSA signature: cannot load certificate ({exc})" | |
| 301 | ||
| 302 | if not _has_timestamping_eku(cert): | |
| 303 | return False, "TSA signature: certificate lacks the timeStamping extended key usage" | |
| 304 | if not _signer_matches_cert(signer_info, cert): | |
| 305 | return False, "TSA signature: certificate does not match the token's signer" | |
| 306 | ||
| 307 | gen_time = tst["gen_time"].native.astimezone(timezone.utc) | |
| 308 | if not (cert.not_valid_before_utc <= gen_time <= cert.not_valid_after_utc): | |
| 309 | return False, "TSA signature: gen_time is outside the certificate validity window" | |
| 310 | ||
| 311 | econtent = signed_data["encap_content_info"]["content"].parsed.dump() | |
| 312 | digest_name = signer_info["digest_algorithm"]["algorithm"].native | |
| 313 | signed_attrs = signer_info["signed_attrs"] | |
| 314 | ||
| 315 | if signed_attrs.native is not None: | |
| 316 | recorded = _signed_attr(signed_attrs, "message_digest") | |
| 317 | if recorded is None or recorded != hashlib.new(digest_name, econtent).digest(): | |
| 318 | return False, "TSA signature: signed message digest does not match the token content" | |
| 319 | signed_bytes = signed_attrs.untag().dump() | |
| 320 | else: | |
| 321 | signed_bytes = econtent | |
| 322 | ||
| 323 | sig_algo = signer_info["signature_algorithm"].signature_algo | |
| 324 | # For rsassa_pkcs1v15 the OID carries no hash, and asn1crypto raises rather | |
| 325 | # than returning None — fall back to the SignerInfo digest algorithm. | |
| 326 | try: | |
| 327 | hash_name = signer_info["signature_algorithm"].hash_algo or digest_name | |
| 328 | except ValueError: | |
| 329 | hash_name = digest_name | |
| 330 | try: | |
| 331 | _verify_raw( | |
| 332 | cert.public_key(), signer_info["signature"].native, signed_bytes, sig_algo, hash_name | |
| 333 | ) | |
| 334 | except ValueError as exc: | |
| 335 | return False, f"TSA signature: {exc}" | |
| 336 | except Exception as exc: | |
| 337 | return False, f"TSA signature is INVALID ({type(exc).__name__})" | |
| 338 | ||
| 339 | return True, f"TSA signature valid ({cert.subject.rfc4514_string()})" | |
| 167 | 340 | |
| 168 | 341 | |
| 169 | 342 | def load_der(path: str | Path) -> bytes: |
tests/test_timestamp.py +177
| @@ -60,6 +60,104 @@ def issue_token(imprint_hex: str, gen_time=None, as_response=True) -> bytes: | ||
| 60 | 60 | ).dump() |
| 61 | 61 | |
| 62 | 62 | |
| 63 | def issue_signed_token(imprint_hex, gen_time=None, timestamping_eku=True, validity=None): | |
| 64 | """Mint a genuinely CMS-signed token; return ``(response_der, cert_pem)``. | |
| 65 | ||
| 66 | Acts as a real (self-signed) TSA so full signature verification can be | |
| 67 | exercised offline. | |
| 68 | """ | |
| 69 | import datetime | |
| 70 | ||
| 71 | from asn1crypto import x509 as a1x509 | |
| 72 | from cryptography import x509 | |
| 73 | from cryptography.hazmat.primitives import hashes, serialization | |
| 74 | from cryptography.hazmat.primitives.asymmetric import padding, rsa | |
| 75 | from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID | |
| 76 | ||
| 77 | gen_time = gen_time or datetime.datetime(2026, 8, 6, 9, 0, tzinfo=datetime.timezone.utc) | |
| 78 | not_before, not_after = validity or ( | |
| 79 | datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc), | |
| 80 | datetime.datetime(2030, 1, 1, tzinfo=datetime.timezone.utc), | |
| 81 | ) | |
| 82 | key = rsa.generate_private_key(public_exponent=65537, key_size=2048) | |
| 83 | name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "Test TSA")]) | |
| 84 | builder = ( | |
| 85 | x509.CertificateBuilder() | |
| 86 | .subject_name(name) | |
| 87 | .issuer_name(name) | |
| 88 | .public_key(key.public_key()) | |
| 89 | .serial_number(4242) | |
| 90 | .not_valid_before(not_before) | |
| 91 | .not_valid_after(not_after) | |
| 92 | ) | |
| 93 | if timestamping_eku: | |
| 94 | builder = builder.add_extension( | |
| 95 | x509.ExtendedKeyUsage([ExtendedKeyUsageOID.TIME_STAMPING]), critical=True | |
| 96 | ) | |
| 97 | cert = builder.sign(key, hashes.SHA256()) | |
| 98 | cert_der = cert.public_bytes(serialization.Encoding.DER) | |
| 99 | cert_pem = cert.public_bytes(serialization.Encoding.PEM) | |
| 100 | ||
| 101 | imprint = tsp.MessageImprint( | |
| 102 | { | |
| 103 | "hash_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}), | |
| 104 | "hashed_message": bytes.fromhex(imprint_hex), | |
| 105 | } | |
| 106 | ) | |
| 107 | tst = tsp.TSTInfo( | |
| 108 | { | |
| 109 | "version": "v1", | |
| 110 | "policy": "1.2.3.4.5", | |
| 111 | "message_imprint": imprint, | |
| 112 | "serial_number": 7, | |
| 113 | "gen_time": gen_time, | |
| 114 | } | |
| 115 | ) | |
| 116 | econtent = tst.dump() | |
| 117 | ||
| 118 | import hashlib | |
| 119 | ||
| 120 | signed_attrs = cms.CMSAttributes( | |
| 121 | [ | |
| 122 | cms.CMSAttribute({"type": "content_type", "values": ["tst_info"]}), | |
| 123 | cms.CMSAttribute( | |
| 124 | {"type": "message_digest", "values": [core.OctetString(hashlib.sha256(econtent).digest())]} | |
| 125 | ), | |
| 126 | ] | |
| 127 | ) | |
| 128 | signature = key.sign(signed_attrs.untag().dump(), padding.PKCS1v15(), hashes.SHA256()) | |
| 129 | signer_info = cms.SignerInfo( | |
| 130 | { | |
| 131 | "version": "v1", | |
| 132 | "sid": cms.SignerIdentifier( | |
| 133 | { | |
| 134 | "issuer_and_serial_number": cms.IssuerAndSerialNumber( | |
| 135 | {"issuer": a1x509.Certificate.load(cert_der).issuer, "serial_number": 4242} | |
| 136 | ) | |
| 137 | } | |
| 138 | ), | |
| 139 | "digest_algorithm": algos.DigestAlgorithm({"algorithm": "sha256"}), | |
| 140 | "signed_attrs": signed_attrs, | |
| 141 | "signature_algorithm": algos.SignedDigestAlgorithm({"algorithm": "rsassa_pkcs1v15"}), | |
| 142 | "signature": signature, | |
| 143 | } | |
| 144 | ) | |
| 145 | signed_data = cms.SignedData( | |
| 146 | { | |
| 147 | "version": "v3", | |
| 148 | "digest_algorithms": [algos.DigestAlgorithm({"algorithm": "sha256"})], | |
| 149 | "encap_content_info": cms.EncapsulatedContentInfo( | |
| 150 | {"content_type": "tst_info", "content": core.ParsableOctetString(econtent)} | |
| 151 | ), | |
| 152 | "certificates": [a1x509.Certificate.load(cert_der)], | |
| 153 | "signer_infos": [signer_info], | |
| 154 | } | |
| 155 | ) | |
| 156 | token = cms.ContentInfo({"content_type": "signed_data", "content": signed_data}) | |
| 157 | response = tsp.TimeStampResp({"status": {"status": "granted"}, "time_stamp_token": token}) | |
| 158 | return response.dump(), cert_pem | |
| 159 | ||
| 160 | ||
| 63 | 161 | @pytest.fixture |
| 64 | 162 | def manifest(tmp_path): |
| 65 | 163 | d = tmp_path / "pkg" |
| @@ -122,3 +220,82 @@ def test_verify_unstamped_manifest(manifest): | ||
| 122 | 220 | ok, message = verify_timestamp(manifest) |
| 123 | 221 | assert not ok |
| 124 | 222 | assert "not timestamped" in message |
| 223 | ||
| 224 | ||
| 225 | # --- full CMS signature verification (needs cryptography) --- | |
| 226 | ||
| 227 | ||
| 228 | def test_full_signature_verifies(manifest): | |
| 229 | pytest.importorskip("cryptography") | |
| 230 | from evidence_seal.timestamp import verify_token_signature | |
| 231 | ||
| 232 | token, cert_pem = issue_signed_token(manifest["id"]) | |
| 233 | ok, message = verify_token_signature(token, cert_pem) | |
| 234 | assert ok | |
| 235 | assert "valid" in message and "Test TSA" in message | |
| 236 | ||
| 237 | ||
| 238 | def test_full_signature_via_verify_timestamp(manifest): | |
| 239 | pytest.importorskip("cryptography") | |
| 240 | token, cert_pem = issue_signed_token(manifest["id"]) | |
| 241 | stamped = apply_timestamp(manifest, token) | |
| 242 | ok, message = verify_timestamp(stamped, tsa_cert=cert_pem) | |
| 243 | assert ok | |
| 244 | assert "TSA signature valid" in message | |
| 245 | ||
| 246 | ||
| 247 | def test_full_signature_wrong_cert_rejected(manifest): | |
| 248 | pytest.importorskip("cryptography") | |
| 249 | from evidence_seal.timestamp import verify_token_signature | |
| 250 | ||
| 251 | token, _cert = issue_signed_token(manifest["id"]) | |
| 252 | _other_token, other_cert = issue_signed_token(manifest["id"]) | |
| 253 | # A different cert (different key, but same serial in our helper) must fail | |
| 254 | # either the signer match or the cryptographic check. | |
| 255 | ok, message = verify_token_signature(token, other_cert) | |
| 256 | assert not ok | |
| 257 | assert "TSA signature" in message | |
| 258 | ||
| 259 | ||
| 260 | def test_full_signature_missing_eku_rejected(manifest): | |
| 261 | pytest.importorskip("cryptography") | |
| 262 | from evidence_seal.timestamp import verify_token_signature | |
| 263 | ||
| 264 | token, cert_pem = issue_signed_token(manifest["id"], timestamping_eku=False) | |
| 265 | ok, message = verify_token_signature(token, cert_pem) | |
| 266 | assert not ok | |
| 267 | assert "timeStamping" in message | |
| 268 | ||
| 269 | ||
| 270 | def test_full_signature_gen_time_outside_validity_rejected(manifest): | |
| 271 | import datetime | |
| 272 | ||
| 273 | pytest.importorskip("cryptography") | |
| 274 | from evidence_seal.timestamp import verify_token_signature | |
| 275 | ||
| 276 | # gen_time in 2026 but the cert is only valid in 2020. | |
| 277 | token, cert_pem = issue_signed_token( | |
| 278 | manifest["id"], | |
| 279 | validity=( | |
| 280 | datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc), | |
| 281 | datetime.datetime(2021, 1, 1, tzinfo=datetime.timezone.utc), | |
| 282 | ), | |
| 283 | ) | |
| 284 | ok, message = verify_token_signature(token, cert_pem) | |
| 285 | assert not ok | |
| 286 | assert "validity window" in message | |
| 287 | ||
| 288 | ||
| 289 | def test_full_signature_tampered_content_rejected(manifest): | |
| 290 | pytest.importorskip("cryptography") | |
| 291 | from evidence_seal.timestamp import verify_token_signature | |
| 292 | ||
| 293 | # A token whose imprint is for a different id: the signature is valid over | |
| 294 | # its own content, but apply_timestamp would refuse it, and here we confirm | |
| 295 | # the signature itself is bound to the (wrong) content it was issued for. | |
| 296 | token, cert_pem = issue_signed_token("a" * 64) | |
| 297 | ok, _message = verify_token_signature(token, cert_pem) | |
| 298 | assert ok # signature is valid for its own content... | |
| 299 | # ...but it does not bind to this manifest, which apply enforces. | |
| 300 | with pytest.raises(ValueError, match="does not timestamp"): | |
| 301 | apply_timestamp(manifest, token) | |