laidline 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (205) hide show
  1. laidline/__init__.py +32 -0
  2. laidline/c2pa/__init__.py +4 -0
  3. laidline/c2pa/certs.py +185 -0
  4. laidline/c2pa/csr.py +183 -0
  5. laidline/c2pa/keyring.py +388 -0
  6. laidline/c2pa/manifest.py +99 -0
  7. laidline/c2pa/sign.py +307 -0
  8. laidline/c2pa/signer.py +319 -0
  9. laidline/c2pa/validators.py +169 -0
  10. laidline/cli/__init__.py +1 -0
  11. laidline/cli/main.py +2794 -0
  12. laidline/cli/options.py +101 -0
  13. laidline/compliance/__init__.py +21 -0
  14. laidline/compliance/_packaged.py +48 -0
  15. laidline/compliance/audio_robustness.py +215 -0
  16. laidline/compliance/data/audio_robustness.json +10876 -0
  17. laidline/compliance/data/nulls_audio.json +617 -0
  18. laidline/compliance/data/nulls_image.json +144 -0
  19. laidline/compliance/data/nulls_text.json +239 -0
  20. laidline/compliance/data/nulls_video.json +181 -0
  21. laidline/compliance/data/nulls_voice.json +466 -0
  22. laidline/compliance/data/phantom.json +738 -0
  23. laidline/compliance/data/robustness.json +13970 -0
  24. laidline/compliance/data/text_robustness.json +6320 -0
  25. laidline/compliance/data/video_robustness.json +8541 -0
  26. laidline/compliance/nulls.py +162 -0
  27. laidline/compliance/phantom.py +85 -0
  28. laidline/compliance/rendering.py +33 -0
  29. laidline/compliance/robustness.py +117 -0
  30. laidline/compliance/stats.py +101 -0
  31. laidline/compliance/templates/licence-clause.md.j2 +5 -0
  32. laidline/compliance/templates/measure-4.1-marking-method.md.j2 +29 -0
  33. laidline/compliance/templates/measure-4.2-detection-access.md.j2 +18 -0
  34. laidline/compliance/templates/unlicensed-notice.md.j2 +4 -0
  35. laidline/compliance/text_robustness.py +90 -0
  36. laidline/compliance/video_robustness.py +166 -0
  37. laidline/core/__init__.py +1 -0
  38. laidline/core/api.py +2196 -0
  39. laidline/core/audio.py +808 -0
  40. laidline/core/config.py +1404 -0
  41. laidline/core/cuda_precision.py +98 -0
  42. laidline/core/errors.py +34 -0
  43. laidline/core/ids.py +49 -0
  44. laidline/core/issuer_ranges.py +9 -0
  45. laidline/core/logs.py +13 -0
  46. laidline/core/process_marker.py +48 -0
  47. laidline/core/types.py +140 -0
  48. laidline/core/video.py +1283 -0
  49. laidline/detect/__init__.py +3 -0
  50. laidline/detect/arbiter.py +664 -0
  51. laidline/detect/attest.py +431 -0
  52. laidline/detect/audio_check.py +272 -0
  53. laidline/detect/audio_confirm.py +116 -0
  54. laidline/detect/audio_payload.py +810 -0
  55. laidline/detect/c2pa_check.py +171 -0
  56. laidline/detect/grading.py +46 -0
  57. laidline/detect/issuer_check.py +326 -0
  58. laidline/detect/registry.py +768 -0
  59. laidline/detect/state.py +608 -0
  60. laidline/detect/text_check.py +184 -0
  61. laidline/detect/tiers.py +249 -0
  62. laidline/detect/video_check.py +414 -0
  63. laidline/detect/watermark_check.py +844 -0
  64. laidline/evidence/__init__.py +3 -0
  65. laidline/evidence/chain.py +254 -0
  66. laidline/evidence/checkpoint.py +786 -0
  67. laidline/evidence/data/tsa-roots-public.pem +81 -0
  68. laidline/evidence/entry.py +275 -0
  69. laidline/evidence/export.py +1320 -0
  70. laidline/evidence/metrics.py +53 -0
  71. laidline/evidence/sinks/__init__.py +48 -0
  72. laidline/evidence/sinks/async_replica.py +258 -0
  73. laidline/evidence/sinks/base.py +36 -0
  74. laidline/evidence/sinks/file.py +214 -0
  75. laidline/evidence/sinks/postgres.py +550 -0
  76. laidline/evidence/sinks/s3.py +892 -0
  77. laidline/evidence/standalone_verify.py +1472 -0
  78. laidline/evidence/tsa.py +279 -0
  79. laidline/evidence/verify.py +16 -0
  80. laidline/ingest/__init__.py +3 -0
  81. laidline/ingest/cloudrun.py +240 -0
  82. laidline/ingest/lambda_handler.py +181 -0
  83. laidline/ingest/serverless.py +28 -0
  84. laidline/ingest/sources/__init__.py +1 -0
  85. laidline/ingest/sources/base.py +30 -0
  86. laidline/ingest/sources/lambda_sqs.py +82 -0
  87. laidline/ingest/sources/local.py +67 -0
  88. laidline/ingest/sources/r2_queues.py +130 -0
  89. laidline/ingest/sources/s3_sqs.py +124 -0
  90. laidline/ingest/store.py +175 -0
  91. laidline/ingest/worker.py +229 -0
  92. laidline/issuer/__init__.py +3 -0
  93. laidline/issuer/certificate.py +233 -0
  94. laidline/issuer/client.py +135 -0
  95. laidline/issuer/data/directory.json +1 -0
  96. laidline/issuer/directory.py +297 -0
  97. laidline/issuer/proof.py +164 -0
  98. laidline/issuer/ranges.py +57 -0
  99. laidline/issuer/request.py +136 -0
  100. laidline/licence/__init__.py +4 -0
  101. laidline/licence/config.py +77 -0
  102. laidline/licence/data/weights-0.0.0-test.json +62 -0
  103. laidline/licence/data/weights-1.0.1.json +61 -0
  104. laidline/licence/document.py +305 -0
  105. laidline/licence/meter.py +283 -0
  106. laidline/licence/refresh.py +387 -0
  107. laidline/licence/state.py +401 -0
  108. laidline/licence/statement.py +206 -0
  109. laidline/licence/terms.py +9 -0
  110. laidline/licence/usage_report.py +217 -0
  111. laidline/licence/weights.py +235 -0
  112. laidline/media/__init__.py +1 -0
  113. laidline/media/audio.py +510 -0
  114. laidline/media/audio_fingerprint.py +423 -0
  115. laidline/media/audio_fp_fixed.py +181 -0
  116. laidline/media/fingerprint.py +72 -0
  117. laidline/media/image.py +72 -0
  118. laidline/media/sniff.py +127 -0
  119. laidline/media/video.py +474 -0
  120. laidline/models/__init__.py +1 -0
  121. laidline/models/pins.json +40 -0
  122. laidline/models/tokenizers.py +65 -0
  123. laidline/models/weights.py +181 -0
  124. laidline/py.typed +0 -0
  125. laidline/server/__init__.py +3 -0
  126. laidline/server/app.py +303 -0
  127. laidline/server/assets.py +87 -0
  128. laidline/server/inbound.py +216 -0
  129. laidline/server/jobs/__init__.py +1 -0
  130. laidline/server/jobs/runner.py +435 -0
  131. laidline/server/jobs/store.py +190 -0
  132. laidline/server/netguard.py +166 -0
  133. laidline/server/proxy/__init__.py +1 -0
  134. laidline/server/proxy/audio.py +340 -0
  135. laidline/server/proxy/generic.py +466 -0
  136. laidline/server/proxy/images.py +430 -0
  137. laidline/server/proxy/openai.py +198 -0
  138. laidline/server/proxy/text.py +424 -0
  139. laidline/server/proxy/upstream.py +114 -0
  140. laidline/server/routes/__init__.py +2 -0
  141. laidline/server/routes/admin.py +227 -0
  142. laidline/server/routes/assets.py +22 -0
  143. laidline/server/routes/detect.py +306 -0
  144. laidline/server/routes/health.py +54 -0
  145. laidline/server/routes/jobs.py +26 -0
  146. laidline/server/routes/mark.py +223 -0
  147. laidline/trust/__init__.py +2 -0
  148. laidline/trust/roots.py +80 -0
  149. laidline/trust/verify.py +292 -0
  150. laidline/watermark/__init__.py +4 -0
  151. laidline/watermark/audio/__init__.py +1 -0
  152. laidline/watermark/audio/audioseal.py +1235 -0
  153. laidline/watermark/audio/p70.py +644 -0
  154. laidline/watermark/bch.py +381 -0
  155. laidline/watermark/image/__init__.py +2 -0
  156. laidline/watermark/image/localise.py +254 -0
  157. laidline/watermark/image/pixelseal.py +561 -0
  158. laidline/watermark/image/trustmark.py +511 -0
  159. laidline/watermark/image/trustmark_onnx.py +265 -0
  160. laidline/watermark/image_code.py +427 -0
  161. laidline/watermark/key_select.py +275 -0
  162. laidline/watermark/keyed_payload.py +193 -0
  163. laidline/watermark/payload.py +135 -0
  164. laidline/watermark/payload_keys.py +517 -0
  165. laidline/watermark/softdecode.py +206 -0
  166. laidline/watermark/text/__init__.py +1 -0
  167. laidline/watermark/text/detect.py +175 -0
  168. laidline/watermark/text/keys.py +51 -0
  169. laidline/watermark/text/kgw.py +314 -0
  170. laidline/watermark/text/synthid.py +111 -0
  171. laidline/watermark/video/__init__.py +1 -0
  172. laidline/watermark/video/_videoseal/LICENSE +21 -0
  173. laidline/watermark/video/_videoseal/VENDOR.md +83 -0
  174. laidline/watermark/video/_videoseal/__init__.py +8 -0
  175. laidline/watermark/video/_videoseal/cards/pixelseal.yaml +40 -0
  176. laidline/watermark/video/_videoseal/cards/videoseal_1.0.yaml +40 -0
  177. laidline/watermark/video/_videoseal/configs/attenuation.yaml +15 -0
  178. laidline/watermark/video/_videoseal/models/__init__.py +8 -0
  179. laidline/watermark/video/_videoseal/models/blender.py +105 -0
  180. laidline/watermark/video/_videoseal/models/embedder.py +282 -0
  181. laidline/watermark/video/_videoseal/models/extractor.py +213 -0
  182. laidline/watermark/video/_videoseal/models/videoseal.py +427 -0
  183. laidline/watermark/video/_videoseal/models/wam.py +248 -0
  184. laidline/watermark/video/_videoseal/modules/__init__.py +6 -0
  185. laidline/watermark/video/_videoseal/modules/common.py +304 -0
  186. laidline/watermark/video/_videoseal/modules/convnext.py +194 -0
  187. laidline/watermark/video/_videoseal/modules/dvmark.py +119 -0
  188. laidline/watermark/video/_videoseal/modules/hidden.py +121 -0
  189. laidline/watermark/video/_videoseal/modules/jnd.py +115 -0
  190. laidline/watermark/video/_videoseal/modules/msg_processor.py +121 -0
  191. laidline/watermark/video/_videoseal/modules/pixel_decoder.py +84 -0
  192. laidline/watermark/video/_videoseal/modules/unet.py +266 -0
  193. laidline/watermark/video/_videoseal/modules/vae.py +374 -0
  194. laidline/watermark/video/_videoseal/modules/vit.py +525 -0
  195. laidline/watermark/video/geometry.py +179 -0
  196. laidline/watermark/video/keyframes.py +496 -0
  197. laidline/watermark/video/loader.py +80 -0
  198. laidline/watermark/video/videoseal.py +335 -0
  199. laidline/watermark/video_ecc.py +203 -0
  200. laidline-1.0.0.dist-info/METADATA +218 -0
  201. laidline-1.0.0.dist-info/RECORD +205 -0
  202. laidline-1.0.0.dist-info/WHEEL +4 -0
  203. laidline-1.0.0.dist-info/entry_points.txt +2 -0
  204. laidline-1.0.0.dist-info/licenses/LICENSE +341 -0
  205. laidline-1.0.0.dist-info/licenses/THIRD_PARTY_NOTICES +101 -0
laidline/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Laidline: mark generative AI outputs inside your own pipeline.
2
+
3
+ Public API: ``Marker``, ``MarkClient``, ``MarkContext``, ``MarkedAsset``, ``DetectResult``.
4
+
5
+ The API classes are imported on first access (PEP 562), so ``import laidline.trust.verify`` or
6
+ ``laidline.licence.document`` does not load the marking stack (PIL, c2pa, numpy, torch): the licence, trust and
7
+ issuer modules are importable in a verifier, an issuing service or a Lambda that marks nothing.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ if TYPE_CHECKING:
15
+ from laidline.core.api import DetectResult, MarkClient, MarkContext, MarkedAsset, Marker, SystemInfo
16
+
17
+ __version__ = "1.0.0"
18
+ __all__ = ["DetectResult", "MarkClient", "MarkContext", "MarkedAsset", "Marker", "SystemInfo", "__version__"]
19
+
20
+ _API = frozenset(__all__) - {"__version__"}
21
+
22
+
23
+ def __getattr__(name: str) -> Any:
24
+ if name in _API:
25
+ from laidline.core import api
26
+
27
+ return getattr(api, name)
28
+ raise AttributeError(f"module 'laidline' has no attribute {name!r}")
29
+
30
+
31
+ def __dir__() -> list[str]:
32
+ return sorted(set(globals()) | _API)
@@ -0,0 +1,4 @@
1
+ """C2PA manifest building and signing (c2pa-python with a callback signer, RFC 3161 timestamps from the configured TSA),
2
+ signing backends (local PEM, AWS KMS, Vault/OpenBao Transit), CSRs, certificate validation, the time-based signing
3
+ keyring and normalised validator verdicts.
4
+ """
laidline/c2pa/certs.py ADDED
@@ -0,0 +1,185 @@
1
+ """Certificate helpers: chain parsing, boot-time validation, own-anchor extraction and the development CA.
2
+
3
+ Verified against c2pa-rs 0.90: the leaf MUST carry an Organization (O) in its subject (a CN-only subject makes
4
+ c2pa-rs report ``claimSignature.mismatch``), SubjectKeyIdentifier and AuthorityKeyIdentifier, KU digitalSignature,
5
+ and an EKU from c2pa-rs's accepted set (``ACCEPTED_EKUS``)."""
6
+
7
+ from __future__ import annotations
8
+
9
+ import datetime as dt
10
+ import hashlib
11
+
12
+ from cryptography import x509
13
+ from cryptography.hazmat.primitives import hashes, serialization
14
+ from cryptography.hazmat.primitives.asymmetric import ec
15
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
16
+
17
+ from laidline.core.errors import ConfigError
18
+
19
+ EKU_ID_KP_DOCUMENT_SIGNING = x509.ObjectIdentifier("1.3.6.1.5.5.7.3.36")
20
+ EKU_MS_C2PA = x509.ObjectIdentifier("1.3.6.1.4.1.311.76.59.1.9")
21
+ EKU_C2PA_SIGNING = x509.ObjectIdentifier("1.3.6.1.4.1.62558.2.1")
22
+ MS_DOCUMENT_SIGNING = x509.ObjectIdentifier("1.3.6.1.4.1.311.10.3.12")
23
+ # c2pa-rs sdk/src/crypto/cose/valid_eku_oids.cfg (c2patool-v0.27.22), less timeStamping and OCSPSigning, which are not
24
+ # for signing manifests. Microsoft Document Signing (MS_DOCUMENT_SIGNING) is not in it.
25
+ ACCEPTED_EKUS: frozenset[x509.ObjectIdentifier] = frozenset(
26
+ {ExtendedKeyUsageOID.EMAIL_PROTECTION, EKU_ID_KP_DOCUMENT_SIGNING, EKU_MS_C2PA, EKU_C2PA_SIGNING}
27
+ )
28
+
29
+
30
+ def load_chain(pem: str) -> list[x509.Certificate]:
31
+ try:
32
+ certs = x509.load_pem_x509_certificates(pem.encode())
33
+ except ValueError as e:
34
+ raise ConfigError(f"certificate chain is empty or malformed: {e}") from e
35
+ if not certs:
36
+ raise ConfigError("certificate chain is empty")
37
+ return certs
38
+
39
+
40
+ def cert_sha256(cert: x509.Certificate) -> str:
41
+ return hashlib.sha256(cert.public_bytes(serialization.Encoding.DER)).hexdigest()
42
+
43
+
44
+ def own_anchor_pem(chain_pem: str) -> str:
45
+ return load_chain(chain_pem)[-1].public_bytes(serialization.Encoding.PEM).decode()
46
+
47
+
48
+ def validate_signing_chain(
49
+ chain: list[x509.Certificate], backend_fingerprint: str, *, at: dt.datetime | None = None
50
+ ) -> None:
51
+ """Check the leaf against c2pa-rs's requirements and the backend's key. Validity is checked at ``at`` (default
52
+ now): a scheduled key is checked at its ``not_before``."""
53
+ leaf = chain[0]
54
+ now = at or dt.datetime.now(dt.UTC)
55
+ if not (leaf.not_valid_before_utc <= now <= leaf.not_valid_after_utc):
56
+ raise ConfigError("expired: signing certificate is outside its validity window")
57
+ if not leaf.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME):
58
+ raise ConfigError(
59
+ "subject_organization: c2pa validators need an Organization (O) in the signing certificate subject"
60
+ )
61
+ try:
62
+ ku = leaf.extensions.get_extension_for_class(x509.KeyUsage).value
63
+ except x509.ExtensionNotFound as e:
64
+ raise ConfigError("ku_digital_signature: KeyUsage extension missing") from e
65
+ if not ku.digital_signature:
66
+ raise ConfigError("ku_digital_signature: KeyUsage lacks digitalSignature")
67
+ try:
68
+ eku = leaf.extensions.get_extension_for_class(x509.ExtendedKeyUsage).value
69
+ except x509.ExtensionNotFound as e:
70
+ raise ConfigError("eku: ExtendedKeyUsage extension missing") from e
71
+ if not ACCEPTED_EKUS.intersection(eku):
72
+ if MS_DOCUMENT_SIGNING in eku:
73
+ raise ConfigError(
74
+ "eku: 1.3.6.1.4.1.311.10.3.12 is Microsoft Document Signing, which C2PA validators do not accept; "
75
+ "use emailProtection or id-kp-documentSigning 1.3.6.1.5.5.7.3.36"
76
+ )
77
+ raise ConfigError(
78
+ "eku: ExtendedKeyUsage must include emailProtection (1.3.6.1.5.5.7.3.4), id-kp-documentSigning "
79
+ "(1.3.6.1.5.5.7.3.36), MS C2PA (1.3.6.1.4.1.311.76.59.1.9) or C2PA signing (1.3.6.1.4.1.62558.2.1)"
80
+ )
81
+ spki = leaf.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
82
+ if hashlib.sha256(spki).hexdigest() != backend_fingerprint:
83
+ raise ConfigError("key_mismatch: signing certificate public key does not match the signing backend key")
84
+
85
+
86
+ def _base(
87
+ subject: x509.Name,
88
+ issuer: x509.Name,
89
+ public_key: ec.EllipticCurvePublicKey,
90
+ issuer_key: ec.EllipticCurvePrivateKey,
91
+ days: int,
92
+ ) -> x509.CertificateBuilder:
93
+ now = dt.datetime.now(dt.UTC)
94
+ return (
95
+ x509.CertificateBuilder()
96
+ .subject_name(subject)
97
+ .issuer_name(issuer)
98
+ .public_key(public_key)
99
+ .serial_number(x509.random_serial_number())
100
+ .not_valid_before(now - dt.timedelta(days=1))
101
+ .not_valid_after(now + dt.timedelta(days=days))
102
+ .add_extension(x509.SubjectKeyIdentifier.from_public_key(public_key), critical=False)
103
+ .add_extension(x509.AuthorityKeyIdentifier.from_issuer_public_key(issuer_key.public_key()), critical=False)
104
+ )
105
+
106
+
107
+ def _key_usage(*, ca: bool) -> x509.KeyUsage:
108
+ return x509.KeyUsage(
109
+ digital_signature=True,
110
+ content_commitment=not ca,
111
+ key_encipherment=False,
112
+ data_encipherment=False,
113
+ key_agreement=False,
114
+ key_cert_sign=ca,
115
+ crl_sign=ca,
116
+ encipher_only=False,
117
+ decipher_only=False,
118
+ )
119
+
120
+
121
+ def _new_ca(org: str) -> tuple[ec.EllipticCurvePrivateKey, x509.Certificate]:
122
+ ca_key = ec.generate_private_key(ec.SECP256R1())
123
+ ca_name = x509.Name(
124
+ [
125
+ x509.NameAttribute(NameOID.ORGANIZATION_NAME, org),
126
+ x509.NameAttribute(NameOID.COMMON_NAME, f"{org} Laidline Dev CA"),
127
+ ]
128
+ )
129
+ ca = (
130
+ _base(ca_name, ca_name, ca_key.public_key(), ca_key, 3650)
131
+ .add_extension(x509.BasicConstraints(ca=True, path_length=None), critical=True)
132
+ .add_extension(_key_usage(ca=True), critical=True)
133
+ .sign(ca_key, hashes.SHA256())
134
+ )
135
+ return ca_key, ca
136
+
137
+
138
+ def _issue_leaf(
139
+ ca_key: ec.EllipticCurvePrivateKey, ca: x509.Certificate, public_key: ec.EllipticCurvePublicKey, org: str, cn: str
140
+ ) -> x509.Certificate:
141
+ leaf_name = x509.Name(
142
+ [x509.NameAttribute(NameOID.ORGANIZATION_NAME, org), x509.NameAttribute(NameOID.COMMON_NAME, cn)]
143
+ )
144
+ return (
145
+ _base(leaf_name, ca.subject, public_key, ca_key, 365)
146
+ .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True)
147
+ .add_extension(_key_usage(ca=False), critical=True)
148
+ .add_extension(x509.ExtendedKeyUsage([ExtendedKeyUsageOID.EMAIL_PROTECTION]), critical=True)
149
+ .sign(ca_key, hashes.SHA256())
150
+ )
151
+
152
+
153
+ def generate_dev_chain(org: str, cn: str) -> tuple[str, str, str]:
154
+ """Returns (leaf_key_pem, chain_pem = leaf + ca, ca_pem).
155
+
156
+ Validators report 'valid, not trusted' unless the CA is loaded as an anchor."""
157
+ ca_key, ca = _new_ca(org)
158
+ leaf_key = ec.generate_private_key(ec.SECP256R1())
159
+ leaf = _issue_leaf(ca_key, ca, leaf_key.public_key(), org, cn)
160
+ key_pem = leaf_key.private_bytes(
161
+ serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption()
162
+ ).decode()
163
+ ca_pem = ca.public_bytes(serialization.Encoding.PEM).decode()
164
+ return key_pem, leaf.public_bytes(serialization.Encoding.PEM).decode() + ca_pem, ca_pem
165
+
166
+
167
+ def generate_dev_chain_for_public_key(org: str, cn: str, public_key: ec.EllipticCurvePublicKey) -> tuple[str, str]:
168
+ """Issue a dev-CA leaf over a key that lives elsewhere (AWS KMS ``ECC_NIST_P256``). Returns (chain_pem, ca_pem);
169
+ there is no private key file. The CA key is discarded, so re-running mints a new CA; re-distribute
170
+ ``dev-ca.pem``."""
171
+ if not isinstance(public_key.curve, ec.SECP256R1):
172
+ raise ConfigError("signing key must be EC P-256 (KMS key spec ECC_NIST_P256) for ES256")
173
+ ca_key, ca = _new_ca(org)
174
+ leaf = _issue_leaf(ca_key, ca, public_key, org, cn)
175
+ ca_pem = ca.public_bytes(serialization.Encoding.PEM).decode()
176
+ return leaf.public_bytes(serialization.Encoding.PEM).decode() + ca_pem, ca_pem
177
+
178
+
179
+ def signer_hashes(der: bytes) -> tuple[str, str]:
180
+ """``(spki_sha256, cert_sha256)`` of a DER certificate: SHA-256 hex of its SubjectPublicKeyInfo DER (what an issuer
181
+ certificate binds as ``signing_spki_sha256`` and ``SigningBackend.public_key_fingerprint`` returns) and of the
182
+ certificate itself (``SignResult.cert_sha256``). ``ValueError`` for bytes that are not a certificate."""
183
+ cert = x509.load_der_x509_certificate(der)
184
+ spki = cert.public_key().public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
185
+ return hashlib.sha256(spki).hexdigest(), hashlib.sha256(der).hexdigest()
laidline/c2pa/csr.py ADDED
@@ -0,0 +1,183 @@
1
+ """PKCS#10 certificate signing requests for keys held in AWS KMS, Vault/OpenBao Transit or a local PEM file.
2
+
3
+ A managed key cannot sign through cryptography's ``CertificateSigningRequestBuilder`` (it needs a private key object),
4
+ so the CSR is assembled around the backend:
5
+
6
+ 1. build a throwaway CSR with an ephemeral P-256 key carrying the subject and the extensionRequest;
7
+ 2. take its CertificationRequestInfo (``tbs_certrequest_bytes``: version, subject, subjectPKInfo, ``[0]`` attributes);
8
+ 3. swap the subjectPKInfo for the managed key's SPKI DER and re-encode the outer length;
9
+ 4. sign the new CertificationRequestInfo with ``backend.sign`` (ECDSA P-256 over SHA-256, P-1363 ``r||s``) and convert
10
+ the signature to DER;
11
+ 5. assemble ``SEQUENCE { CRI, SEQUENCE { OID ecdsa-with-SHA256 }, BIT STRING 00||sig }``;
12
+ 6. load the result with cryptography and check the signature and the SPKI before returning it.
13
+
14
+ Only the public key leaves the KMS or Vault; the ephemeral key is discarded.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import hashlib
20
+ from typing import Literal
21
+
22
+ from cryptography import x509
23
+ from cryptography.hazmat.primitives import hashes, serialization
24
+ from cryptography.hazmat.primitives.asymmetric import ec
25
+ from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
26
+ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID
27
+
28
+ from laidline.c2pa.certs import EKU_ID_KP_DOCUMENT_SIGNING
29
+ from laidline.c2pa.signer import SigningBackend
30
+ from laidline.core.errors import ConfigError, MarkError
31
+
32
+ Eku = Literal["email", "document-signing", "both"]
33
+
34
+ # AlgorithmIdentifier { OID 1.2.840.10045.4.3.2 ecdsa-with-SHA256 } with absent parameters (RFC 5758 section 3.2).
35
+ ECDSA_WITH_SHA256 = bytes.fromhex("300a06082a8648ce3d040302")
36
+
37
+ _EKUS: dict[str, list[x509.ObjectIdentifier]] = {
38
+ "email": [ExtendedKeyUsageOID.EMAIL_PROTECTION],
39
+ "document-signing": [EKU_ID_KP_DOCUMENT_SIGNING],
40
+ "both": [ExtendedKeyUsageOID.EMAIL_PROTECTION, EKU_ID_KP_DOCUMENT_SIGNING],
41
+ }
42
+
43
+
44
+ def read_tlv(buf: bytes, off: int) -> tuple[int, int, int]:
45
+ """Parse the DER header at ``buf[off]``: ``(tag, header_len, length)``. Single-byte tags and definite, minimally
46
+ encoded lengths only; the value must fit in ``buf``. Anything else raises ``ValueError``."""
47
+ if off < 0 or off + 2 > len(buf):
48
+ raise ValueError(f"DER truncated at offset {off}: no tag and length")
49
+ tag, first = buf[off], buf[off + 1]
50
+ if tag & 0x1F == 0x1F:
51
+ raise ValueError(f"DER multi-byte tag at offset {off} not supported")
52
+ if first < 0x80:
53
+ header_len, length = 2, first
54
+ elif first == 0x80:
55
+ raise ValueError(f"DER indefinite length at offset {off}")
56
+ else:
57
+ n = first & 0x7F
58
+ if n > 4:
59
+ raise ValueError(f"DER length of {n} bytes at offset {off} is too long")
60
+ if off + 2 + n > len(buf):
61
+ raise ValueError(f"DER truncated at offset {off}: length bytes missing")
62
+ length = int.from_bytes(buf[off + 2 : off + 2 + n], "big")
63
+ if length < 0x80 or buf[off + 2] == 0:
64
+ raise ValueError(f"DER length at offset {off} is not minimally encoded")
65
+ header_len = 2 + n
66
+ if off + header_len + length > len(buf):
67
+ raise ValueError(f"DER truncated at offset {off}: {length} value bytes declared, fewer present")
68
+ return tag, header_len, length
69
+
70
+
71
+ def encode_len(n: int) -> bytes:
72
+ """DER length octets for ``n``."""
73
+ if n < 0:
74
+ raise ValueError("negative length")
75
+ if n < 0x80:
76
+ return bytes([n])
77
+ body = n.to_bytes((n.bit_length() + 7) // 8, "big")
78
+ return bytes([0x80 | len(body)]) + body
79
+
80
+
81
+ def tlv(tag: int, body: bytes) -> bytes:
82
+ return bytes([tag]) + encode_len(len(body)) + body
83
+
84
+
85
+ def _children(buf: bytes) -> list[bytes]:
86
+ """The complete TLVs inside the constructed value ``buf`` (header included), which must be consumed exactly."""
87
+ out: list[bytes] = []
88
+ off = 0
89
+ while off < len(buf):
90
+ _, header_len, length = read_tlv(buf, off)
91
+ out.append(buf[off : off + header_len + length])
92
+ off += header_len + length
93
+ return out
94
+
95
+
96
+ def _swap_spki(cri: bytes, spki: bytes) -> bytes:
97
+ """Replace the subjectPKInfo of a CertificationRequestInfo SEQUENCE with ``spki``."""
98
+ tag, header_len, length = read_tlv(cri, 0)
99
+ if tag != 0x30 or header_len + length != len(cri):
100
+ raise ValueError("CertificationRequestInfo is not a single DER SEQUENCE")
101
+ children = _children(cri[header_len:])
102
+ if len(children) != 4 or [c[0] for c in children] != [0x02, 0x30, 0x30, 0xA0]:
103
+ raise ValueError("CertificationRequestInfo is not version, subject, subjectPKInfo, [0] attributes")
104
+ children[2] = spki
105
+ return tlv(0x30, b"".join(children))
106
+
107
+
108
+ def _template(org: str, cn: str, country: str | None, eku: Eku) -> bytes:
109
+ """Steps 1-2: the CertificationRequestInfo of a throwaway CSR over an ephemeral P-256 key."""
110
+ attrs = [x509.NameAttribute(NameOID.COUNTRY_NAME, country)] if country else []
111
+ attrs += [x509.NameAttribute(NameOID.ORGANIZATION_NAME, org), x509.NameAttribute(NameOID.COMMON_NAME, cn)]
112
+ ku = x509.KeyUsage(
113
+ digital_signature=True,
114
+ content_commitment=False,
115
+ key_encipherment=False,
116
+ data_encipherment=False,
117
+ key_agreement=False,
118
+ key_cert_sign=False,
119
+ crl_sign=False,
120
+ encipher_only=False,
121
+ decipher_only=False,
122
+ )
123
+ throwaway = (
124
+ x509.CertificateSigningRequestBuilder()
125
+ .subject_name(x509.Name(attrs))
126
+ .add_extension(ku, critical=True)
127
+ .add_extension(x509.ExtendedKeyUsage(_EKUS[eku]), critical=False)
128
+ .sign(ec.generate_private_key(ec.SECP256R1()), hashes.SHA256())
129
+ )
130
+ return throwaway.tbs_certrequest_bytes
131
+
132
+
133
+ def build_csr(
134
+ backend: SigningBackend,
135
+ *,
136
+ org: str,
137
+ cn: str,
138
+ country: str | None = None,
139
+ eku: Eku = "email",
140
+ ) -> x509.CertificateSigningRequest:
141
+ """A CSR for ``backend``'s key, signed by the backend (ECDSA with SHA-256). Subject ``C`` (optional), ``O``, ``CN``;
142
+ extensionRequest KU digitalSignature (critical) and the EKU ``eku`` names. Raises ``ConfigError`` for bad input or a
143
+ key that is not ES256 on P-256, and ``MarkError("csr_invalid")`` when the assembled CSR does not verify."""
144
+ if not org:
145
+ raise ConfigError("csr: an Organization (O) is required; c2pa validators need it in the leaf subject")
146
+ if not cn:
147
+ raise ConfigError("csr: a Common Name (CN) is required")
148
+ if country is not None and (len(country) != 2 or not country.isascii() or not country.isalpha()):
149
+ raise ConfigError(f"csr: country (C) must be a two-letter ISO 3166 code (got {country!r})")
150
+ if eku not in _EKUS:
151
+ raise ConfigError(f"csr: eku must be one of {', '.join(_EKUS)} (got {eku})")
152
+ if backend.alg != "es256":
153
+ raise ConfigError(f"csr: the signing key must be ES256 on P-256 (got {backend.alg})")
154
+ public_key = backend.public_key()
155
+ if not isinstance(public_key, ec.EllipticCurvePublicKey) or not isinstance(public_key.curve, ec.SECP256R1):
156
+ raise ConfigError("csr: the signing key must be EC P-256 (ES256)")
157
+ spki = public_key.public_bytes(serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo)
158
+ try:
159
+ cri = _swap_spki(_template(org, cn, country, eku), spki)
160
+ except ValueError as e:
161
+ raise MarkError("csr_invalid", f"cannot re-encode the CertificationRequestInfo: {e}") from e
162
+ raw = backend.sign(cri) # the backend hashes with SHA-256: an ecdsa-with-SHA256 signature over the CRI
163
+ if len(raw) != 64:
164
+ raise MarkError("csr_invalid", f"backend returned a {len(raw)}-byte signature, expected 64 (P-1363 r||s)")
165
+ sig = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
166
+ der = tlv(0x30, cri + ECDSA_WITH_SHA256 + tlv(0x03, b"\x00" + sig))
167
+ try:
168
+ csr = x509.load_der_x509_csr(der)
169
+ valid = csr.is_signature_valid
170
+ csr_spki = csr.public_key().public_bytes(
171
+ serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
172
+ )
173
+ except ValueError as e:
174
+ raise MarkError("csr_invalid", f"assembled CSR does not parse: {e}") from e
175
+ if not valid:
176
+ raise MarkError("csr_invalid", "the CSR signature does not verify against the backend's public key")
177
+ if csr_spki != spki or hashlib.sha256(csr_spki).hexdigest() != backend.public_key_fingerprint():
178
+ raise MarkError("csr_invalid", "the CSR public key is not the backend's key")
179
+ return csr
180
+
181
+
182
+ def csr_pem(csr: x509.CertificateSigningRequest) -> bytes:
183
+ return csr.public_bytes(serialization.Encoding.PEM)