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 | evidence-seal timestamp verify pkg.manifest.json | 94 | evidence-seal timestamp verify pkg.manifest.json |
| 95 | # -> timestamp OK: timestamped at 2026-08-06T09:00:00Z | 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 | `apply` refuses any token whose imprint is not this manifest's `id`. Because the | 103 | `apply` refuses any token whose imprint is not this manifest's `id`. Because the |
| 99 | `id` moves if a single byte changes, a token can never be transplanted onto | 104 | `id` moves if a single byte changes, a token can never be transplanted onto |
| 100 | tampered evidence — re-sealing after a change orphans the timestamp. A present | 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 | ## The manifest | 115 | ## The manifest |
| 104 | 116 | ||
| @@ -150,10 +162,12 @@ guarantee, **sign** the manifest (retain the public key out of band) and | |||
| 150 | 162 | ||
| 151 | Two limits to be honest about: | 163 | Two limits to be honest about: |
| 152 | 164 | ||
| 153 | - **`timestamp verify` checks the binding, not the TSA's signature.** It proves | 165 | - **`timestamp verify` checks the binding; `--tsa-cert` adds signature |
| 154 | the stored token timestamps this manifest's `id`; it does not by itself verify | 166 | verification but not chain-of-trust.** Without a cert, verification proves the |
| 155 | the TSA's own signature and certificate chain. Validate the token against the | 167 | stored token timestamps this manifest's `id`. With `--tsa-cert`, it also |
| 156 | TSA's certificate out of band (e.g. `openssl ts -verify`) for full assurance. | 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 | - Private keys are written **unencrypted** — store them accordingly. | 171 | - Private keys are written **unencrypted** — store them accordingly. |
| 158 | 172 | ||
| 159 | ## Development | 173 | ## Development |
evidence_seal/cli.py +10 −2
| @@ -118,7 +118,8 @@ def _cmd_verify(args) -> int: | |||
| 118 | if manifest.get("timestamp"): | 118 | if manifest.get("timestamp"): |
| 119 | from .timestamp import verify_timestamp | 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 | print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}") | 123 | print(f" timestamp {'OK' if ts_ok else 'FAIL'}: {ts_message}") |
| 123 | if not ts_ok: | 124 | if not ts_ok: |
| 124 | status = FAILED | 125 | status = FAILED |
| @@ -240,10 +241,11 @@ def _cmd_ts_verify(args) -> int: | |||
| 240 | 241 | ||
| 241 | try: | 242 | try: |
| 242 | manifest = load_manifest(args.manifest) | 243 | manifest = load_manifest(args.manifest) |
| 244 | cert = Path(args.tsa_cert).read_bytes() if args.tsa_cert else None | ||
| 243 | except (FileNotFoundError, ValueError) as exc: | 245 | except (FileNotFoundError, ValueError) as exc: |
| 244 | print(f"error: cannot read manifest: {exc}", file=sys.stderr) | 246 | print(f"error: cannot read manifest: {exc}", file=sys.stderr) |
| 245 | return USAGE | 247 | return USAGE |
| 246 | ok, message = verify_timestamp(manifest) | 248 | ok, message = verify_timestamp(manifest, tsa_cert=cert) |
| 247 | print(f"timestamp {'OK' if ok else 'FAIL'}: {message}") | 249 | print(f"timestamp {'OK' if ok else 'FAIL'}: {message}") |
| 248 | return OK if ok else FAILED | 250 | return OK if ok else FAILED |
| 249 | 251 | ||
| @@ -269,6 +271,9 @@ def _build_parser() -> argparse.ArgumentParser: | |||
| 269 | p_verify.add_argument("directory") | 271 | p_verify.add_argument("directory") |
| 270 | p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)") | 272 | p_verify.add_argument("--manifest", help="manifest path (default: <dir>.manifest.json)") |
| 271 | p_verify.add_argument("--pubkey", metavar="PEM", help="require a signature by this public key") | 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 | p_verify.set_defaults(func=_cmd_verify) | 277 | p_verify.set_defaults(func=_cmd_verify) |
| 273 | 278 | ||
| 274 | p_chain = sub.add_parser("chain", help="verify manifests link oldest -> newest") | 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 | p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") | 320 | p_tsv = ts.add_parser("verify", help="verify the manifest's embedded timestamp") |
| 316 | p_tsv.add_argument("manifest") | 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 | p_tsv.set_defaults(func=_cmd_ts_verify) | 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 | return algos, cms, core, tsp | 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 | def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes: | 51 | def build_request(manifest_id_hex: str, cert_req: bool = True) -> bytes: |
| 42 | """Return a DER-encoded RFC 3161 TimeStampReq over a manifest id. | 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 | return stamped | 150 | return stamped |
| 141 | 151 | ||
| 142 | 152 | ||
| 143 | def verify_timestamp(manifest: dict) -> tuple[bool, str]: | 153 | def verify_timestamp(manifest: dict, tsa_cert: bytes | None = None) -> tuple[bool, str]: |
| 144 | """Verify a manifest's embedded timestamp binds to its id. | 154 | """Verify a manifest's embedded timestamp. |
| 145 | 155 | ||
| 146 | Returns ``(ok, message)``. This checks that the stored token timestamps the | 156 | Returns ``(ok, message)``. Always checks that the stored token timestamps the |
| 147 | current manifest id; verifying the TSA's own signature and certificate chain | 157 | current manifest id. When *tsa_cert* (PEM or DER bytes) is given, the token's |
| 148 | is a separate, out-of-band step (see the README threat model). | 158 | RFC 3161 CMS signature is also verified against that certificate — proving |
| 159 | the timestamp really was issued by that authority. | ||
| 149 | """ | 160 | """ |
| 150 | block = manifest.get("timestamp") | 161 | block = manifest.get("timestamp") |
| 151 | if not block: | 162 | if not block: |
| @@ -163,7 +174,169 @@ def verify_timestamp(manifest: dict) -> tuple[bool, str]: | |||
| 163 | return False, "timestamp does not match the manifest id" | 174 | return False, "timestamp does not match the manifest id" |
| 164 | 175 | ||
| 165 | tsa = f" by {fields['tsa']}" if fields["tsa"] else "" | 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 | def load_der(path: str | Path) -> bytes: | 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 | ).dump() | 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 | @pytest.fixture | 161 | @pytest.fixture |
| 64 | def manifest(tmp_path): | 162 | def manifest(tmp_path): |
| 65 | d = tmp_path / "pkg" | 163 | d = tmp_path / "pkg" |
| @@ -122,3 +220,82 @@ def test_verify_unstamped_manifest(manifest): | |||
| 122 | ok, message = verify_timestamp(manifest) | 220 | ok, message = verify_timestamp(manifest) |
| 123 | assert not ok | 221 | assert not ok |
| 124 | assert "not timestamped" in message | 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) | ||