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.
- laidline/__init__.py +32 -0
- laidline/c2pa/__init__.py +4 -0
- laidline/c2pa/certs.py +185 -0
- laidline/c2pa/csr.py +183 -0
- laidline/c2pa/keyring.py +388 -0
- laidline/c2pa/manifest.py +99 -0
- laidline/c2pa/sign.py +307 -0
- laidline/c2pa/signer.py +319 -0
- laidline/c2pa/validators.py +169 -0
- laidline/cli/__init__.py +1 -0
- laidline/cli/main.py +2794 -0
- laidline/cli/options.py +101 -0
- laidline/compliance/__init__.py +21 -0
- laidline/compliance/_packaged.py +48 -0
- laidline/compliance/audio_robustness.py +215 -0
- laidline/compliance/data/audio_robustness.json +10876 -0
- laidline/compliance/data/nulls_audio.json +617 -0
- laidline/compliance/data/nulls_image.json +144 -0
- laidline/compliance/data/nulls_text.json +239 -0
- laidline/compliance/data/nulls_video.json +181 -0
- laidline/compliance/data/nulls_voice.json +466 -0
- laidline/compliance/data/phantom.json +738 -0
- laidline/compliance/data/robustness.json +13970 -0
- laidline/compliance/data/text_robustness.json +6320 -0
- laidline/compliance/data/video_robustness.json +8541 -0
- laidline/compliance/nulls.py +162 -0
- laidline/compliance/phantom.py +85 -0
- laidline/compliance/rendering.py +33 -0
- laidline/compliance/robustness.py +117 -0
- laidline/compliance/stats.py +101 -0
- laidline/compliance/templates/licence-clause.md.j2 +5 -0
- laidline/compliance/templates/measure-4.1-marking-method.md.j2 +29 -0
- laidline/compliance/templates/measure-4.2-detection-access.md.j2 +18 -0
- laidline/compliance/templates/unlicensed-notice.md.j2 +4 -0
- laidline/compliance/text_robustness.py +90 -0
- laidline/compliance/video_robustness.py +166 -0
- laidline/core/__init__.py +1 -0
- laidline/core/api.py +2196 -0
- laidline/core/audio.py +808 -0
- laidline/core/config.py +1404 -0
- laidline/core/cuda_precision.py +98 -0
- laidline/core/errors.py +34 -0
- laidline/core/ids.py +49 -0
- laidline/core/issuer_ranges.py +9 -0
- laidline/core/logs.py +13 -0
- laidline/core/process_marker.py +48 -0
- laidline/core/types.py +140 -0
- laidline/core/video.py +1283 -0
- laidline/detect/__init__.py +3 -0
- laidline/detect/arbiter.py +664 -0
- laidline/detect/attest.py +431 -0
- laidline/detect/audio_check.py +272 -0
- laidline/detect/audio_confirm.py +116 -0
- laidline/detect/audio_payload.py +810 -0
- laidline/detect/c2pa_check.py +171 -0
- laidline/detect/grading.py +46 -0
- laidline/detect/issuer_check.py +326 -0
- laidline/detect/registry.py +768 -0
- laidline/detect/state.py +608 -0
- laidline/detect/text_check.py +184 -0
- laidline/detect/tiers.py +249 -0
- laidline/detect/video_check.py +414 -0
- laidline/detect/watermark_check.py +844 -0
- laidline/evidence/__init__.py +3 -0
- laidline/evidence/chain.py +254 -0
- laidline/evidence/checkpoint.py +786 -0
- laidline/evidence/data/tsa-roots-public.pem +81 -0
- laidline/evidence/entry.py +275 -0
- laidline/evidence/export.py +1320 -0
- laidline/evidence/metrics.py +53 -0
- laidline/evidence/sinks/__init__.py +48 -0
- laidline/evidence/sinks/async_replica.py +258 -0
- laidline/evidence/sinks/base.py +36 -0
- laidline/evidence/sinks/file.py +214 -0
- laidline/evidence/sinks/postgres.py +550 -0
- laidline/evidence/sinks/s3.py +892 -0
- laidline/evidence/standalone_verify.py +1472 -0
- laidline/evidence/tsa.py +279 -0
- laidline/evidence/verify.py +16 -0
- laidline/ingest/__init__.py +3 -0
- laidline/ingest/cloudrun.py +240 -0
- laidline/ingest/lambda_handler.py +181 -0
- laidline/ingest/serverless.py +28 -0
- laidline/ingest/sources/__init__.py +1 -0
- laidline/ingest/sources/base.py +30 -0
- laidline/ingest/sources/lambda_sqs.py +82 -0
- laidline/ingest/sources/local.py +67 -0
- laidline/ingest/sources/r2_queues.py +130 -0
- laidline/ingest/sources/s3_sqs.py +124 -0
- laidline/ingest/store.py +175 -0
- laidline/ingest/worker.py +229 -0
- laidline/issuer/__init__.py +3 -0
- laidline/issuer/certificate.py +233 -0
- laidline/issuer/client.py +135 -0
- laidline/issuer/data/directory.json +1 -0
- laidline/issuer/directory.py +297 -0
- laidline/issuer/proof.py +164 -0
- laidline/issuer/ranges.py +57 -0
- laidline/issuer/request.py +136 -0
- laidline/licence/__init__.py +4 -0
- laidline/licence/config.py +77 -0
- laidline/licence/data/weights-0.0.0-test.json +62 -0
- laidline/licence/data/weights-1.0.1.json +61 -0
- laidline/licence/document.py +305 -0
- laidline/licence/meter.py +283 -0
- laidline/licence/refresh.py +387 -0
- laidline/licence/state.py +401 -0
- laidline/licence/statement.py +206 -0
- laidline/licence/terms.py +9 -0
- laidline/licence/usage_report.py +217 -0
- laidline/licence/weights.py +235 -0
- laidline/media/__init__.py +1 -0
- laidline/media/audio.py +510 -0
- laidline/media/audio_fingerprint.py +423 -0
- laidline/media/audio_fp_fixed.py +181 -0
- laidline/media/fingerprint.py +72 -0
- laidline/media/image.py +72 -0
- laidline/media/sniff.py +127 -0
- laidline/media/video.py +474 -0
- laidline/models/__init__.py +1 -0
- laidline/models/pins.json +40 -0
- laidline/models/tokenizers.py +65 -0
- laidline/models/weights.py +181 -0
- laidline/py.typed +0 -0
- laidline/server/__init__.py +3 -0
- laidline/server/app.py +303 -0
- laidline/server/assets.py +87 -0
- laidline/server/inbound.py +216 -0
- laidline/server/jobs/__init__.py +1 -0
- laidline/server/jobs/runner.py +435 -0
- laidline/server/jobs/store.py +190 -0
- laidline/server/netguard.py +166 -0
- laidline/server/proxy/__init__.py +1 -0
- laidline/server/proxy/audio.py +340 -0
- laidline/server/proxy/generic.py +466 -0
- laidline/server/proxy/images.py +430 -0
- laidline/server/proxy/openai.py +198 -0
- laidline/server/proxy/text.py +424 -0
- laidline/server/proxy/upstream.py +114 -0
- laidline/server/routes/__init__.py +2 -0
- laidline/server/routes/admin.py +227 -0
- laidline/server/routes/assets.py +22 -0
- laidline/server/routes/detect.py +306 -0
- laidline/server/routes/health.py +54 -0
- laidline/server/routes/jobs.py +26 -0
- laidline/server/routes/mark.py +223 -0
- laidline/trust/__init__.py +2 -0
- laidline/trust/roots.py +80 -0
- laidline/trust/verify.py +292 -0
- laidline/watermark/__init__.py +4 -0
- laidline/watermark/audio/__init__.py +1 -0
- laidline/watermark/audio/audioseal.py +1235 -0
- laidline/watermark/audio/p70.py +644 -0
- laidline/watermark/bch.py +381 -0
- laidline/watermark/image/__init__.py +2 -0
- laidline/watermark/image/localise.py +254 -0
- laidline/watermark/image/pixelseal.py +561 -0
- laidline/watermark/image/trustmark.py +511 -0
- laidline/watermark/image/trustmark_onnx.py +265 -0
- laidline/watermark/image_code.py +427 -0
- laidline/watermark/key_select.py +275 -0
- laidline/watermark/keyed_payload.py +193 -0
- laidline/watermark/payload.py +135 -0
- laidline/watermark/payload_keys.py +517 -0
- laidline/watermark/softdecode.py +206 -0
- laidline/watermark/text/__init__.py +1 -0
- laidline/watermark/text/detect.py +175 -0
- laidline/watermark/text/keys.py +51 -0
- laidline/watermark/text/kgw.py +314 -0
- laidline/watermark/text/synthid.py +111 -0
- laidline/watermark/video/__init__.py +1 -0
- laidline/watermark/video/_videoseal/LICENSE +21 -0
- laidline/watermark/video/_videoseal/VENDOR.md +83 -0
- laidline/watermark/video/_videoseal/__init__.py +8 -0
- laidline/watermark/video/_videoseal/cards/pixelseal.yaml +40 -0
- laidline/watermark/video/_videoseal/cards/videoseal_1.0.yaml +40 -0
- laidline/watermark/video/_videoseal/configs/attenuation.yaml +15 -0
- laidline/watermark/video/_videoseal/models/__init__.py +8 -0
- laidline/watermark/video/_videoseal/models/blender.py +105 -0
- laidline/watermark/video/_videoseal/models/embedder.py +282 -0
- laidline/watermark/video/_videoseal/models/extractor.py +213 -0
- laidline/watermark/video/_videoseal/models/videoseal.py +427 -0
- laidline/watermark/video/_videoseal/models/wam.py +248 -0
- laidline/watermark/video/_videoseal/modules/__init__.py +6 -0
- laidline/watermark/video/_videoseal/modules/common.py +304 -0
- laidline/watermark/video/_videoseal/modules/convnext.py +194 -0
- laidline/watermark/video/_videoseal/modules/dvmark.py +119 -0
- laidline/watermark/video/_videoseal/modules/hidden.py +121 -0
- laidline/watermark/video/_videoseal/modules/jnd.py +115 -0
- laidline/watermark/video/_videoseal/modules/msg_processor.py +121 -0
- laidline/watermark/video/_videoseal/modules/pixel_decoder.py +84 -0
- laidline/watermark/video/_videoseal/modules/unet.py +266 -0
- laidline/watermark/video/_videoseal/modules/vae.py +374 -0
- laidline/watermark/video/_videoseal/modules/vit.py +525 -0
- laidline/watermark/video/geometry.py +179 -0
- laidline/watermark/video/keyframes.py +496 -0
- laidline/watermark/video/loader.py +80 -0
- laidline/watermark/video/videoseal.py +335 -0
- laidline/watermark/video_ecc.py +203 -0
- laidline-1.0.0.dist-info/METADATA +218 -0
- laidline-1.0.0.dist-info/RECORD +205 -0
- laidline-1.0.0.dist-info/WHEEL +4 -0
- laidline-1.0.0.dist-info/entry_points.txt +2 -0
- laidline-1.0.0.dist-info/licenses/LICENSE +341 -0
- 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)
|