EmbeddedSigner 0.1.1__tar.gz
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.
- embeddedsigner-0.1.1/EmbeddedSigner/__init__.py +5 -0
- embeddedsigner-0.1.1/EmbeddedSigner/_embed_signer.py +341 -0
- embeddedsigner-0.1.1/EmbeddedSigner/cli.py +325 -0
- embeddedsigner-0.1.1/LICENSE +201 -0
- embeddedsigner-0.1.1/PKG-INFO +333 -0
- embeddedsigner-0.1.1/README.md +234 -0
- embeddedsigner-0.1.1/pyproject.toml +181 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Embed XMP metadata and sign media payloads with Swarmauri plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from importlib import metadata
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, MutableMapping, Optional
|
|
11
|
+
from urllib.parse import parse_qs, urlparse
|
|
12
|
+
|
|
13
|
+
from EmbedXMP import EmbedXMP
|
|
14
|
+
from MediaSigner import MediaSigner
|
|
15
|
+
from swarmauri_core.crypto.types import KeyRef
|
|
16
|
+
from swarmauri_core.key_providers.IKeyProvider import IKeyProvider
|
|
17
|
+
from swarmauri_core.signing.types import Signature
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class _ParsedKeyRef:
|
|
22
|
+
provider: Optional[str]
|
|
23
|
+
kid: str
|
|
24
|
+
version: Optional[int]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EmbedSigner:
|
|
28
|
+
"""High-level helper that embeds XMP metadata and produces signatures."""
|
|
29
|
+
|
|
30
|
+
def __init__(
|
|
31
|
+
self,
|
|
32
|
+
*,
|
|
33
|
+
embedder: Optional[EmbedXMP] = None,
|
|
34
|
+
signer: Optional[MediaSigner] = None,
|
|
35
|
+
key_provider: Optional[IKeyProvider] = None,
|
|
36
|
+
key_provider_name: Optional[str] = None,
|
|
37
|
+
provider_plugins: Optional[
|
|
38
|
+
Mapping[str, Callable[[], IKeyProvider] | type[IKeyProvider]]
|
|
39
|
+
] = None,
|
|
40
|
+
eager_import: bool = True,
|
|
41
|
+
) -> None:
|
|
42
|
+
self._embedder = embedder or EmbedXMP(eager_import=eager_import)
|
|
43
|
+
self._provider_factories: MutableMapping[
|
|
44
|
+
str, Callable[[], IKeyProvider] | type[IKeyProvider]
|
|
45
|
+
] = self._discover_provider_factories()
|
|
46
|
+
if provider_plugins:
|
|
47
|
+
self._provider_factories.update(dict(provider_plugins))
|
|
48
|
+
self._providers: MutableMapping[str, IKeyProvider] = {}
|
|
49
|
+
self._default_provider: Optional[IKeyProvider] = None
|
|
50
|
+
self._default_provider_name: Optional[str] = None
|
|
51
|
+
|
|
52
|
+
if key_provider is not None:
|
|
53
|
+
self._default_provider = key_provider
|
|
54
|
+
self._default_provider_name = key_provider.__class__.__name__
|
|
55
|
+
elif key_provider_name is not None:
|
|
56
|
+
self._default_provider = self._instantiate_provider(key_provider_name)
|
|
57
|
+
self._default_provider_name = key_provider_name
|
|
58
|
+
|
|
59
|
+
provider_for_signer = self._default_provider
|
|
60
|
+
self._signer = signer or MediaSigner(key_provider=provider_for_signer)
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------------
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _discover_provider_factories() -> MutableMapping[
|
|
65
|
+
str, Callable[[], IKeyProvider] | type[IKeyProvider]
|
|
66
|
+
]:
|
|
67
|
+
factories: MutableMapping[
|
|
68
|
+
str, Callable[[], IKeyProvider] | type[IKeyProvider]
|
|
69
|
+
] = {}
|
|
70
|
+
try:
|
|
71
|
+
entries = metadata.entry_points(group="swarmauri.key_providers")
|
|
72
|
+
except TypeError: # pragma: no cover - Python <3.10 fallback
|
|
73
|
+
entries = metadata.entry_points()["swarmauri.key_providers"]
|
|
74
|
+
for entry in entries:
|
|
75
|
+
try:
|
|
76
|
+
provider_cls = entry.load()
|
|
77
|
+
except (
|
|
78
|
+
Exception
|
|
79
|
+
): # pragma: no cover - defensive against broken entry points
|
|
80
|
+
continue
|
|
81
|
+
if not inspect.isclass(provider_cls) and not callable(provider_cls):
|
|
82
|
+
continue
|
|
83
|
+
factories[entry.name] = provider_cls
|
|
84
|
+
return factories
|
|
85
|
+
|
|
86
|
+
def _instantiate_provider(self, name: str) -> IKeyProvider:
|
|
87
|
+
if name in self._providers:
|
|
88
|
+
return self._providers[name]
|
|
89
|
+
factory = self._provider_factories.get(name)
|
|
90
|
+
if factory is None:
|
|
91
|
+
raise ValueError(f"Unknown key provider '{name}'")
|
|
92
|
+
provider: IKeyProvider
|
|
93
|
+
if inspect.isclass(factory):
|
|
94
|
+
provider = factory() # type: ignore[call-arg]
|
|
95
|
+
else:
|
|
96
|
+
provider = factory()
|
|
97
|
+
self._providers[name] = provider
|
|
98
|
+
return provider
|
|
99
|
+
|
|
100
|
+
def _get_provider(self, name: Optional[str]) -> IKeyProvider:
|
|
101
|
+
if name is None:
|
|
102
|
+
if self._default_provider is not None:
|
|
103
|
+
return self._default_provider
|
|
104
|
+
if self._default_provider_name is not None:
|
|
105
|
+
return self._instantiate_provider(self._default_provider_name)
|
|
106
|
+
raise ValueError(
|
|
107
|
+
"No default key provider configured; include a provider name in the key reference."
|
|
108
|
+
)
|
|
109
|
+
if name == self._default_provider_name and self._default_provider is not None:
|
|
110
|
+
return self._default_provider
|
|
111
|
+
return self._instantiate_provider(name)
|
|
112
|
+
|
|
113
|
+
# ------------------------------------------------------------------
|
|
114
|
+
@staticmethod
|
|
115
|
+
def _parse_key_reference(value: str) -> _ParsedKeyRef:
|
|
116
|
+
parsed = urlparse(value)
|
|
117
|
+
if parsed.scheme:
|
|
118
|
+
provider = parsed.scheme
|
|
119
|
+
kid = (parsed.netloc + parsed.path).lstrip("/")
|
|
120
|
+
query = parse_qs(parsed.query, keep_blank_values=False)
|
|
121
|
+
version = None
|
|
122
|
+
if "version" in query and query["version"]:
|
|
123
|
+
try:
|
|
124
|
+
version = int(query["version"][0])
|
|
125
|
+
except (TypeError, ValueError) as exc:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"Invalid version component in key reference '{value}'"
|
|
128
|
+
) from exc
|
|
129
|
+
return _ParsedKeyRef(provider=provider, kid=kid, version=version)
|
|
130
|
+
token = value
|
|
131
|
+
provider_name = None
|
|
132
|
+
if ":" in token and not token.startswith("{"):
|
|
133
|
+
provider_name, _, token = token.partition(":")
|
|
134
|
+
kid, sep, version_token = token.partition("@")
|
|
135
|
+
version_number = None
|
|
136
|
+
if sep:
|
|
137
|
+
try:
|
|
138
|
+
version_number = int(version_token)
|
|
139
|
+
except ValueError as exc:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f"Invalid version component in key reference '{value}'"
|
|
142
|
+
) from exc
|
|
143
|
+
if not kid:
|
|
144
|
+
raise ValueError("Key reference must include a key identifier")
|
|
145
|
+
return _ParsedKeyRef(provider=provider_name, kid=kid, version=version_number)
|
|
146
|
+
|
|
147
|
+
async def _resolve_key(
|
|
148
|
+
self, key: KeyRef | Mapping[str, Any] | str
|
|
149
|
+
) -> KeyRef | Mapping[str, Any]:
|
|
150
|
+
if isinstance(key, Mapping):
|
|
151
|
+
return key
|
|
152
|
+
if isinstance(key, KeyRef):
|
|
153
|
+
return key
|
|
154
|
+
if isinstance(key, str):
|
|
155
|
+
ref = self._parse_key_reference(key)
|
|
156
|
+
provider = self._get_provider(ref.provider)
|
|
157
|
+
try:
|
|
158
|
+
resolved = await provider.get_key_by_ref(ref.kid, include_secret=True)
|
|
159
|
+
except NotImplementedError:
|
|
160
|
+
resolved = None
|
|
161
|
+
except AttributeError: # pragma: no cover - provider without method
|
|
162
|
+
resolved = None
|
|
163
|
+
if resolved is None:
|
|
164
|
+
resolved = await provider.get_key(
|
|
165
|
+
ref.kid, version=ref.version, include_secret=True
|
|
166
|
+
)
|
|
167
|
+
return resolved
|
|
168
|
+
raise TypeError(
|
|
169
|
+
"key must be a Mapping, KeyRef, or string reference understood by EmbedSigner"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
def embed_bytes(
|
|
174
|
+
self, data: bytes, xmp_xml: str, *, path: str | Path | None = None
|
|
175
|
+
) -> bytes:
|
|
176
|
+
"""Embed XMP metadata into *data* using the configured handlers."""
|
|
177
|
+
|
|
178
|
+
ref = str(path) if path is not None else None
|
|
179
|
+
return self._embedder.embed(data, xmp_xml, ref)
|
|
180
|
+
|
|
181
|
+
def read_xmp(self, data: bytes, *, path: str | Path | None = None) -> str | None:
|
|
182
|
+
"""Read XMP metadata from *data* using the configured handlers."""
|
|
183
|
+
|
|
184
|
+
ref = str(path) if path is not None else None
|
|
185
|
+
return self._embedder.read(data, ref)
|
|
186
|
+
|
|
187
|
+
def remove_xmp(self, data: bytes, *, path: str | Path | None = None) -> bytes:
|
|
188
|
+
"""Remove XMP metadata from *data* using the configured handlers."""
|
|
189
|
+
|
|
190
|
+
ref = str(path) if path is not None else None
|
|
191
|
+
return self._embedder.remove(data, ref)
|
|
192
|
+
|
|
193
|
+
def embed_file(
|
|
194
|
+
self,
|
|
195
|
+
path: str | Path,
|
|
196
|
+
xmp_xml: str,
|
|
197
|
+
*,
|
|
198
|
+
write_back: bool = True,
|
|
199
|
+
output: str | Path | None = None,
|
|
200
|
+
) -> bytes:
|
|
201
|
+
"""Embed XMP metadata directly into a file on disk."""
|
|
202
|
+
|
|
203
|
+
file_path = Path(path)
|
|
204
|
+
updated = self.embed_bytes(file_path.read_bytes(), xmp_xml, path=file_path)
|
|
205
|
+
target = Path(output) if output is not None else file_path
|
|
206
|
+
if write_back:
|
|
207
|
+
target.write_bytes(updated)
|
|
208
|
+
return updated
|
|
209
|
+
|
|
210
|
+
def read_xmp_file(self, path: str | Path) -> str | None:
|
|
211
|
+
"""Read XMP metadata from the file located at *path*."""
|
|
212
|
+
|
|
213
|
+
file_path = Path(path)
|
|
214
|
+
return self.read_xmp(file_path.read_bytes(), path=file_path)
|
|
215
|
+
|
|
216
|
+
def remove_xmp_file(
|
|
217
|
+
self,
|
|
218
|
+
path: str | Path,
|
|
219
|
+
*,
|
|
220
|
+
write_back: bool = False,
|
|
221
|
+
output: str | Path | None = None,
|
|
222
|
+
) -> bytes:
|
|
223
|
+
"""Remove XMP metadata from a file, optionally persisting the result."""
|
|
224
|
+
|
|
225
|
+
file_path = Path(path)
|
|
226
|
+
updated = self.remove_xmp(file_path.read_bytes(), path=file_path)
|
|
227
|
+
target = Path(output) if output is not None else file_path
|
|
228
|
+
if write_back:
|
|
229
|
+
target.write_bytes(updated)
|
|
230
|
+
return updated
|
|
231
|
+
|
|
232
|
+
# ------------------------------------------------------------------
|
|
233
|
+
async def sign_bytes(
|
|
234
|
+
self,
|
|
235
|
+
fmt: str,
|
|
236
|
+
key: KeyRef | Mapping[str, Any] | str,
|
|
237
|
+
payload: bytes,
|
|
238
|
+
*,
|
|
239
|
+
attached: bool = True,
|
|
240
|
+
alg: Optional[str] = None,
|
|
241
|
+
signer_opts: Optional[Mapping[str, Any]] = None,
|
|
242
|
+
) -> Sequence[Signature]:
|
|
243
|
+
"""Produce signatures for *payload* using the requested signer format."""
|
|
244
|
+
|
|
245
|
+
resolved_key = await self._resolve_key(key)
|
|
246
|
+
opts: dict[str, Any] = dict(signer_opts or {})
|
|
247
|
+
opts.setdefault("attached", attached)
|
|
248
|
+
return await self._signer.sign_bytes(
|
|
249
|
+
fmt, resolved_key, payload, alg=alg, opts=opts
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
async def embed_and_sign_bytes(
|
|
253
|
+
self,
|
|
254
|
+
data: bytes,
|
|
255
|
+
*,
|
|
256
|
+
fmt: str,
|
|
257
|
+
xmp_xml: str,
|
|
258
|
+
key: KeyRef | Mapping[str, Any] | str,
|
|
259
|
+
path: str | Path | None = None,
|
|
260
|
+
attached: bool = True,
|
|
261
|
+
alg: Optional[str] = None,
|
|
262
|
+
signer_opts: Optional[Mapping[str, Any]] = None,
|
|
263
|
+
) -> tuple[bytes, Sequence[Signature]]:
|
|
264
|
+
"""Embed metadata into *data* and sign the updated payload."""
|
|
265
|
+
|
|
266
|
+
embedded = self.embed_bytes(data, xmp_xml, path=path)
|
|
267
|
+
signatures = await self.sign_bytes(
|
|
268
|
+
fmt,
|
|
269
|
+
key,
|
|
270
|
+
embedded,
|
|
271
|
+
attached=attached,
|
|
272
|
+
alg=alg,
|
|
273
|
+
signer_opts=signer_opts,
|
|
274
|
+
)
|
|
275
|
+
return embedded, signatures
|
|
276
|
+
|
|
277
|
+
async def embed_and_sign_file(
|
|
278
|
+
self,
|
|
279
|
+
path: str | Path,
|
|
280
|
+
*,
|
|
281
|
+
fmt: str,
|
|
282
|
+
xmp_xml: str,
|
|
283
|
+
key: KeyRef | Mapping[str, Any] | str,
|
|
284
|
+
attached: bool = True,
|
|
285
|
+
alg: Optional[str] = None,
|
|
286
|
+
signer_opts: Optional[Mapping[str, Any]] = None,
|
|
287
|
+
write_back: bool = False,
|
|
288
|
+
) -> tuple[bytes, Sequence[Signature]]:
|
|
289
|
+
"""Embed metadata into *path* and optionally persist the signed bytes."""
|
|
290
|
+
|
|
291
|
+
file_path = Path(path)
|
|
292
|
+
embedded, signatures = await self.embed_and_sign_bytes(
|
|
293
|
+
file_path.read_bytes(),
|
|
294
|
+
fmt=fmt,
|
|
295
|
+
xmp_xml=xmp_xml,
|
|
296
|
+
key=key,
|
|
297
|
+
path=file_path,
|
|
298
|
+
attached=attached,
|
|
299
|
+
alg=alg,
|
|
300
|
+
signer_opts=signer_opts,
|
|
301
|
+
)
|
|
302
|
+
if write_back:
|
|
303
|
+
file_path.write_bytes(embedded)
|
|
304
|
+
return embedded, signatures
|
|
305
|
+
|
|
306
|
+
async def sign_file(
|
|
307
|
+
self,
|
|
308
|
+
path: str | Path,
|
|
309
|
+
*,
|
|
310
|
+
fmt: str,
|
|
311
|
+
key: KeyRef | Mapping[str, Any] | str,
|
|
312
|
+
attached: bool = True,
|
|
313
|
+
alg: Optional[str] = None,
|
|
314
|
+
signer_opts: Optional[Mapping[str, Any]] = None,
|
|
315
|
+
) -> Sequence[Signature]:
|
|
316
|
+
"""Sign the contents of *path* and return the generated signatures."""
|
|
317
|
+
|
|
318
|
+
file_path = Path(path)
|
|
319
|
+
payload = file_path.read_bytes()
|
|
320
|
+
return await self.sign_bytes(
|
|
321
|
+
fmt,
|
|
322
|
+
key,
|
|
323
|
+
payload,
|
|
324
|
+
attached=attached,
|
|
325
|
+
alg=alg,
|
|
326
|
+
signer_opts=signer_opts,
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
def supported_embed_handlers(self) -> Sequence[str]:
|
|
331
|
+
"""Return the names of registered XMP handlers."""
|
|
332
|
+
|
|
333
|
+
return tuple(
|
|
334
|
+
handler.__class__.__name__
|
|
335
|
+
for handler in getattr(self._embedder, "_handlers", [])
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def supported_signers(self) -> Sequence[str]:
|
|
339
|
+
"""Expose signer formats advertised by the underlying :class:`MediaSigner`."""
|
|
340
|
+
|
|
341
|
+
return tuple(self._signer.supported_formats())
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Command line interface for the :mod:`EmbeddedSigner` plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import base64
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Mapping, Sequence
|
|
12
|
+
|
|
13
|
+
from ._embed_signer import EmbedSigner
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _load_xmp_from_args(args: argparse.Namespace) -> str:
|
|
17
|
+
if args.xmp is not None and args.xmp_file is not None:
|
|
18
|
+
raise SystemExit("Specify either --xmp or --xmp-file, not both.")
|
|
19
|
+
if args.xmp is not None:
|
|
20
|
+
return args.xmp
|
|
21
|
+
if args.xmp_file is not None:
|
|
22
|
+
return Path(args.xmp_file).read_text(encoding="utf-8")
|
|
23
|
+
raise SystemExit("An XMP payload is required for this command.")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve_key_argument(args: argparse.Namespace) -> Any:
|
|
27
|
+
provided = [
|
|
28
|
+
value
|
|
29
|
+
for value in (args.key_ref, args.key_json, args.key_file)
|
|
30
|
+
if value is not None
|
|
31
|
+
]
|
|
32
|
+
if len(provided) != 1:
|
|
33
|
+
raise SystemExit("Provide exactly one of --key-ref, --key-json, or --key-file.")
|
|
34
|
+
if args.key_ref:
|
|
35
|
+
return args.key_ref
|
|
36
|
+
if args.key_json:
|
|
37
|
+
try:
|
|
38
|
+
return json.loads(args.key_json)
|
|
39
|
+
except json.JSONDecodeError as exc: # pragma: no cover - defensive
|
|
40
|
+
raise SystemExit(f"Invalid JSON passed to --key-json: {exc}") from exc
|
|
41
|
+
if args.key_file:
|
|
42
|
+
return json.loads(Path(args.key_file).read_text(encoding="utf-8"))
|
|
43
|
+
raise SystemExit("A key reference or JSON description must be supplied.")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _serialise_signature(signature: Mapping[str, Any]) -> Mapping[str, Any]:
|
|
47
|
+
payload: dict[str, Any] = dict(signature)
|
|
48
|
+
artifact = payload.get("artifact")
|
|
49
|
+
if isinstance(artifact, (bytes, bytearray)):
|
|
50
|
+
payload["artifact"] = base64.b64encode(artifact).decode("ascii")
|
|
51
|
+
payload.setdefault("artifact_encoding", "base64")
|
|
52
|
+
return payload
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _create_signer(args: argparse.Namespace) -> EmbedSigner:
|
|
56
|
+
provider_name = getattr(args, "key_provider", None)
|
|
57
|
+
return EmbedSigner(key_provider_name=provider_name)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _command_embed(args: argparse.Namespace) -> int:
|
|
61
|
+
signer = _create_signer(args)
|
|
62
|
+
xmp_xml = _load_xmp_from_args(args)
|
|
63
|
+
src = Path(args.input)
|
|
64
|
+
payload = src.read_bytes()
|
|
65
|
+
updated = signer.embed_bytes(payload, xmp_xml, path=src)
|
|
66
|
+
target = Path(args.output) if args.output else src
|
|
67
|
+
target.write_bytes(updated)
|
|
68
|
+
return 0
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _command_read(args: argparse.Namespace) -> int:
|
|
72
|
+
signer = _create_signer(args)
|
|
73
|
+
src = Path(args.input)
|
|
74
|
+
metadata = signer.read_xmp_file(src)
|
|
75
|
+
if metadata is None:
|
|
76
|
+
return 1
|
|
77
|
+
sys.stdout.write(metadata)
|
|
78
|
+
if not metadata.endswith("\n"):
|
|
79
|
+
sys.stdout.write("\n")
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _command_remove(args: argparse.Namespace) -> int:
|
|
84
|
+
signer = _create_signer(args)
|
|
85
|
+
src = Path(args.input)
|
|
86
|
+
updated = signer.remove_xmp_file(src)
|
|
87
|
+
target = Path(args.output) if args.output else src
|
|
88
|
+
target.write_bytes(updated)
|
|
89
|
+
return 0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
async def _async_sign(
|
|
93
|
+
signer: EmbedSigner,
|
|
94
|
+
*,
|
|
95
|
+
src: Path,
|
|
96
|
+
fmt: str,
|
|
97
|
+
key: Any,
|
|
98
|
+
attached: bool,
|
|
99
|
+
alg: str | None,
|
|
100
|
+
opts: Mapping[str, Any] | None,
|
|
101
|
+
) -> Sequence[Mapping[str, Any]]:
|
|
102
|
+
signatures = await signer.sign_file(
|
|
103
|
+
src,
|
|
104
|
+
fmt=fmt,
|
|
105
|
+
key=key,
|
|
106
|
+
attached=attached,
|
|
107
|
+
alg=alg,
|
|
108
|
+
signer_opts=opts,
|
|
109
|
+
)
|
|
110
|
+
return [_serialise_signature(sig) for sig in signatures]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _command_sign(args: argparse.Namespace) -> int:
|
|
114
|
+
signer = _create_signer(args)
|
|
115
|
+
src = Path(args.input)
|
|
116
|
+
key = _resolve_key_argument(args)
|
|
117
|
+
opts: dict[str, Any] | None = None
|
|
118
|
+
if args.option:
|
|
119
|
+
opts = {}
|
|
120
|
+
for token in args.option:
|
|
121
|
+
name, _, value = token.partition("=")
|
|
122
|
+
if not name:
|
|
123
|
+
raise SystemExit("Signer options must use the form name=value.")
|
|
124
|
+
opts[name] = value
|
|
125
|
+
signatures = asyncio.run(
|
|
126
|
+
_async_sign(
|
|
127
|
+
signer,
|
|
128
|
+
src=src,
|
|
129
|
+
fmt=args.format,
|
|
130
|
+
key=key,
|
|
131
|
+
attached=not args.detached,
|
|
132
|
+
alg=args.alg,
|
|
133
|
+
opts=opts,
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
output = json.dumps(signatures, indent=2)
|
|
137
|
+
if args.output:
|
|
138
|
+
Path(args.output).write_text(output, encoding="utf-8")
|
|
139
|
+
else:
|
|
140
|
+
sys.stdout.write(output + "\n")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
async def _async_embed_sign(
|
|
145
|
+
signer: EmbedSigner,
|
|
146
|
+
*,
|
|
147
|
+
src: Path,
|
|
148
|
+
fmt: str,
|
|
149
|
+
xmp_xml: str,
|
|
150
|
+
key: Any,
|
|
151
|
+
attached: bool,
|
|
152
|
+
alg: str | None,
|
|
153
|
+
opts: Mapping[str, Any] | None,
|
|
154
|
+
write_back: bool,
|
|
155
|
+
output: Path | None,
|
|
156
|
+
) -> Sequence[Mapping[str, Any]]:
|
|
157
|
+
embedded, signatures = await signer.embed_and_sign_file(
|
|
158
|
+
src,
|
|
159
|
+
fmt=fmt,
|
|
160
|
+
xmp_xml=xmp_xml,
|
|
161
|
+
key=key,
|
|
162
|
+
attached=attached,
|
|
163
|
+
alg=alg,
|
|
164
|
+
signer_opts=opts,
|
|
165
|
+
write_back=write_back and output is None,
|
|
166
|
+
)
|
|
167
|
+
if output is not None:
|
|
168
|
+
output.write_bytes(embedded)
|
|
169
|
+
return [_serialise_signature(sig) for sig in signatures]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _command_embed_sign(args: argparse.Namespace) -> int:
|
|
173
|
+
signer = _create_signer(args)
|
|
174
|
+
src = Path(args.input)
|
|
175
|
+
xmp_xml = _load_xmp_from_args(args)
|
|
176
|
+
key = _resolve_key_argument(args)
|
|
177
|
+
opts: dict[str, Any] | None = None
|
|
178
|
+
if args.option:
|
|
179
|
+
opts = {}
|
|
180
|
+
for token in args.option:
|
|
181
|
+
name, _, value = token.partition("=")
|
|
182
|
+
if not name:
|
|
183
|
+
raise SystemExit("Signer options must use the form name=value.")
|
|
184
|
+
opts[name] = value
|
|
185
|
+
output_path = Path(args.output) if args.output else None
|
|
186
|
+
signatures = asyncio.run(
|
|
187
|
+
_async_embed_sign(
|
|
188
|
+
signer,
|
|
189
|
+
src=src,
|
|
190
|
+
fmt=args.format,
|
|
191
|
+
xmp_xml=xmp_xml,
|
|
192
|
+
key=key,
|
|
193
|
+
attached=not args.detached,
|
|
194
|
+
alg=args.alg,
|
|
195
|
+
opts=opts,
|
|
196
|
+
write_back=args.write_back,
|
|
197
|
+
output=output_path,
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
output = json.dumps(signatures, indent=2)
|
|
201
|
+
if args.signature_output:
|
|
202
|
+
Path(args.signature_output).write_text(output, encoding="utf-8")
|
|
203
|
+
else:
|
|
204
|
+
sys.stdout.write(output + "\n")
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
209
|
+
parser = argparse.ArgumentParser(description="Embed XMP metadata and sign media.")
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
"--key-provider",
|
|
212
|
+
dest="key_provider",
|
|
213
|
+
help="Default key provider to use when resolving key references.",
|
|
214
|
+
)
|
|
215
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
216
|
+
|
|
217
|
+
embed_parser = subparsers.add_parser("embed", help="Embed XMP into a file")
|
|
218
|
+
embed_parser.add_argument("input", help="Path to the media file")
|
|
219
|
+
embed_parser.add_argument("--xmp", help="Inline XMP XML payload")
|
|
220
|
+
embed_parser.add_argument("--xmp-file", help="Read the XMP payload from a file")
|
|
221
|
+
embed_parser.add_argument("--output", help="Write the embedded media to this path")
|
|
222
|
+
embed_parser.set_defaults(func=_command_embed)
|
|
223
|
+
|
|
224
|
+
read_parser = subparsers.add_parser("read", help="Read XMP metadata from a file")
|
|
225
|
+
read_parser.add_argument("input", help="Path to the media file")
|
|
226
|
+
read_parser.set_defaults(func=_command_read)
|
|
227
|
+
|
|
228
|
+
remove_parser = subparsers.add_parser(
|
|
229
|
+
"remove", help="Strip XMP metadata from a file"
|
|
230
|
+
)
|
|
231
|
+
remove_parser.add_argument("input", help="Path to the media file")
|
|
232
|
+
remove_parser.add_argument("--output", help="Write the stripped media to this path")
|
|
233
|
+
remove_parser.set_defaults(func=_command_remove)
|
|
234
|
+
|
|
235
|
+
sign_parser = subparsers.add_parser("sign", help="Generate signatures for a file")
|
|
236
|
+
sign_parser.add_argument("input", help="Path to the media file")
|
|
237
|
+
sign_parser.add_argument("--format", required=True, help="MediaSigner format name")
|
|
238
|
+
sign_parser.add_argument("--key-ref", dest="key_ref", help="Key reference string")
|
|
239
|
+
sign_parser.add_argument(
|
|
240
|
+
"--key-json",
|
|
241
|
+
dest="key_json",
|
|
242
|
+
help="Inline JSON description of the signing key",
|
|
243
|
+
)
|
|
244
|
+
sign_parser.add_argument(
|
|
245
|
+
"--key-file",
|
|
246
|
+
dest="key_file",
|
|
247
|
+
help="Path to a JSON file describing the signing key",
|
|
248
|
+
)
|
|
249
|
+
sign_parser.add_argument(
|
|
250
|
+
"--option",
|
|
251
|
+
action="append",
|
|
252
|
+
help="Additional signer option in the form name=value",
|
|
253
|
+
)
|
|
254
|
+
sign_parser.add_argument("--alg", help="Explicit algorithm identifier")
|
|
255
|
+
sign_parser.add_argument(
|
|
256
|
+
"--detached",
|
|
257
|
+
action="store_true",
|
|
258
|
+
help="Produce detached signatures when supported",
|
|
259
|
+
)
|
|
260
|
+
sign_parser.add_argument(
|
|
261
|
+
"--output", help="Write the generated signatures to a file"
|
|
262
|
+
)
|
|
263
|
+
sign_parser.set_defaults(func=_command_sign)
|
|
264
|
+
|
|
265
|
+
embed_sign_parser = subparsers.add_parser(
|
|
266
|
+
"embed-sign", help="Embed metadata and sign the updated file"
|
|
267
|
+
)
|
|
268
|
+
embed_sign_parser.add_argument("input", help="Path to the media file")
|
|
269
|
+
embed_sign_parser.add_argument("--xmp", help="Inline XMP XML payload")
|
|
270
|
+
embed_sign_parser.add_argument(
|
|
271
|
+
"--xmp-file", help="Read the XMP payload from a file"
|
|
272
|
+
)
|
|
273
|
+
embed_sign_parser.add_argument(
|
|
274
|
+
"--format", required=True, help="MediaSigner format name"
|
|
275
|
+
)
|
|
276
|
+
embed_sign_parser.add_argument(
|
|
277
|
+
"--key-ref", dest="key_ref", help="Key reference string"
|
|
278
|
+
)
|
|
279
|
+
embed_sign_parser.add_argument(
|
|
280
|
+
"--key-json",
|
|
281
|
+
dest="key_json",
|
|
282
|
+
help="Inline JSON description of the signing key",
|
|
283
|
+
)
|
|
284
|
+
embed_sign_parser.add_argument(
|
|
285
|
+
"--key-file",
|
|
286
|
+
dest="key_file",
|
|
287
|
+
help="Path to a JSON file describing the signing key",
|
|
288
|
+
)
|
|
289
|
+
embed_sign_parser.add_argument(
|
|
290
|
+
"--option",
|
|
291
|
+
action="append",
|
|
292
|
+
help="Additional signer option in the form name=value",
|
|
293
|
+
)
|
|
294
|
+
embed_sign_parser.add_argument("--alg", help="Explicit algorithm identifier")
|
|
295
|
+
embed_sign_parser.add_argument(
|
|
296
|
+
"--detached",
|
|
297
|
+
action="store_true",
|
|
298
|
+
help="Produce detached signatures when supported",
|
|
299
|
+
)
|
|
300
|
+
embed_sign_parser.add_argument(
|
|
301
|
+
"--write-back",
|
|
302
|
+
action="store_true",
|
|
303
|
+
help="Persist embedded bytes back to the original input file",
|
|
304
|
+
)
|
|
305
|
+
embed_sign_parser.add_argument(
|
|
306
|
+
"--output",
|
|
307
|
+
help="Write embedded bytes to this path instead of the input file",
|
|
308
|
+
)
|
|
309
|
+
embed_sign_parser.add_argument(
|
|
310
|
+
"--signature-output",
|
|
311
|
+
help="Write generated signatures to this JSON file",
|
|
312
|
+
)
|
|
313
|
+
embed_sign_parser.set_defaults(func=_command_embed_sign)
|
|
314
|
+
|
|
315
|
+
return parser
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
319
|
+
parser = build_parser()
|
|
320
|
+
args = parser.parse_args(argv)
|
|
321
|
+
return args.func(args)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
if __name__ == "__main__": # pragma: no cover - CLI entry point
|
|
325
|
+
sys.exit(main())
|