mememage 0.1.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.
mememage/__init__.py ADDED
@@ -0,0 +1,37 @@
1
+ """Mememage — a bar in an image's pixels points to a JSON record.
2
+
3
+ Stamp a 2-pixel bar into an image; the bar carries an **identifier** (a key to a
4
+ JSON record stored separately) and a **content hash** (a SHA-256 over the record).
5
+ The core API is three functions, all pure image operations:
6
+
7
+ - ``encode(image, fields)`` — write the bar, build the record from your fields.
8
+ - ``decode(image)`` — read the bar back: identifier + content hash.
9
+ - ``verify(image, record)`` — does a record match the image, by hash?
10
+
11
+ Resolving the record (a dict, a file, a DB, a URL) is the caller's — core does not
12
+ fetch. Optional field encryption: ``encode(password=…, private=[…])``
13
+ (AES-256-GCM), revealed with ``unlock``.
14
+
15
+ ``pip install mememage`` (Pillow included); add ``[encrypt]`` for field encryption.
16
+ """
17
+
18
+ try:
19
+ from importlib.metadata import version as _pkg_version
20
+ __version__ = _pkg_version("mememage")
21
+ except Exception: # running from a source tree that isn't installed
22
+ __version__ = "0.1.0"
23
+
24
+ from mememage.api import (
25
+ Bar, Record, Verification, decode, encode, is_encrypted, unlock, verify,
26
+ )
27
+
28
+ __all__ = [
29
+ "encode",
30
+ "decode",
31
+ "verify",
32
+ "unlock",
33
+ "is_encrypted",
34
+ "Record",
35
+ "Bar",
36
+ "Verification",
37
+ ]
mememage/__main__.py ADDED
@@ -0,0 +1,192 @@
1
+ """Mememage core CLI — `encode` / `decode` from a shell.
2
+
3
+ mememage encode photo.png --field title="Morning fog" -o photo.json # write the record
4
+ mememage decode photo.jpg --record photo.json # VERIFIED / ALTERED
5
+
6
+ Without -o, the record is written next to the image as <identifier>.json.
7
+ `decode` exits 0 only on a match, so it drops straight into a CI gate.
8
+ """
9
+ import argparse
10
+ import sys
11
+
12
+
13
+ def _resolve_password(env_name, prompt):
14
+ """A password WITHOUT putting it in argv (visible in `ps` / shell history).
15
+ From ``--password-env VAR`` if given, else an interactive getpass prompt on a
16
+ TTY, else an error (non-interactive needs the env var)."""
17
+ import getpass
18
+ import os
19
+ if env_name:
20
+ val = os.environ.get(env_name)
21
+ if not val:
22
+ print(f"Error: env var {env_name} is unset/empty", file=sys.stderr)
23
+ sys.exit(1)
24
+ return val
25
+ if sys.stdin.isatty():
26
+ return getpass.getpass(prompt)
27
+ print("Error: no password — pass --password-env VAR (non-interactive)",
28
+ file=sys.stderr)
29
+ sys.exit(1)
30
+
31
+
32
+ def cmd_encode(args):
33
+ """Write a bar + build an open-hash record from arbitrary fields."""
34
+ import json as _json
35
+ import os
36
+ import mememage
37
+
38
+ fields = {}
39
+ if args.fields:
40
+ try:
41
+ src = sys.stdin.read() if args.fields == "-" else \
42
+ open(args.fields, encoding="utf-8").read()
43
+ loaded = _json.loads(src)
44
+ except Exception as e:
45
+ print(f"Error reading --fields: {e}", file=sys.stderr)
46
+ sys.exit(1)
47
+ if not isinstance(loaded, dict):
48
+ print("Error: --fields must be a JSON object", file=sys.stderr)
49
+ sys.exit(1)
50
+ fields.update(loaded)
51
+ for kv in (args.field or []):
52
+ if "=" not in kv:
53
+ print(f"Error: --field expects KEY=VALUE, got {kv!r}", file=sys.stderr)
54
+ sys.exit(1)
55
+ k, v = kv.split("=", 1)
56
+ fields[k] = v
57
+
58
+ # Field visibility — encrypt private fields behind a password.
59
+ password = None
60
+ private = None
61
+ if args.encrypt or args.private:
62
+ password = _resolve_password(args.password_env, "Encrypt password: ")
63
+ if args.private:
64
+ private = [k.strip() for k in args.private.split(",") if k.strip()]
65
+
66
+ try:
67
+ result = mememage.encode(args.image, fields, prefix=args.prefix,
68
+ identifier=args.identifier,
69
+ password=password, private=private)
70
+ except (ValueError, RuntimeError) as e:
71
+ print(f"Error: {e}", file=sys.stderr)
72
+ sys.exit(1)
73
+
74
+ # Name the record by its IDENTIFIER (not the image), so a record is found
75
+ # by the code the bar carries. Plain .json for the core; .soul is the
76
+ # provenance chain's. Matches the main CLI.
77
+ out = args.out or os.path.join(
78
+ os.path.dirname(args.image), result.identifier + ".json")
79
+ result.save(out)
80
+ print(f"Encoded {args.image}")
81
+ print(f" image: {result.image_path}")
82
+ print(f" identifier: {result.identifier}")
83
+ print(f" content hash: {result.content_hash}")
84
+ if result.record.get("encrypted_fields"):
85
+ print(f" encrypted: {len(private) if private else 'all'} field(s)")
86
+ print(f" record: {out}")
87
+
88
+
89
+ def cmd_decode(args):
90
+ """Read the bar (identifier + content hash). With --record, also verify it."""
91
+ import json as _json
92
+ import mememage
93
+
94
+ bar = mememage.decode(args.image)
95
+ if bar is None:
96
+ print("No Mememage bar in the image.", file=sys.stderr)
97
+ sys.exit(1)
98
+
99
+ # Pure read.
100
+ if not args.record:
101
+ if args.json:
102
+ print(_json.dumps({"identifier": bar.identifier,
103
+ "content_hash": bar.content_hash}))
104
+ else:
105
+ print(f"Bar: {bar.identifier}")
106
+ print(f"Hash: {bar.content_hash}")
107
+ return
108
+
109
+ # --record: verify against a LOCAL record file (no network — resolving is yours).
110
+ try:
111
+ with open(args.record, encoding="utf-8") as f:
112
+ record = _json.load(f)
113
+ except Exception as e:
114
+ print(f"Error reading --record: {e}", file=sys.stderr)
115
+ sys.exit(2)
116
+ v = mememage.verify(args.image, record)
117
+
118
+ if args.json:
119
+ print(_json.dumps({"identifier": bar.identifier, "content_hash": bar.content_hash,
120
+ "match": bool(v), "reason": v.reason}))
121
+ sys.exit(0 if v else 1)
122
+
123
+ print(f"Bar: {bar.identifier}")
124
+ print(f"Hash: {bar.content_hash}")
125
+ if v:
126
+ print("VERIFIED — record matches the image")
127
+ if record.get("encrypted_fields"):
128
+ if args.unlock or args.password_env:
129
+ password = _resolve_password(args.password_env, "Unlock password: ")
130
+ try:
131
+ view = mememage.unlock(record, password)
132
+ print("UNLOCKED — private fields decrypted:")
133
+ _core = ("identifier", "content_hash", "hash_version",
134
+ "signature", "encrypted_fields")
135
+ for k, val in sorted(view.items()):
136
+ if not (k.startswith("_") or k in _core):
137
+ print(f" {k}: {val}")
138
+ except Exception:
139
+ print("(wrong password — could not decrypt)")
140
+ else:
141
+ print("ENCRYPTED — private fields (pass --unlock / --password-env to reveal)")
142
+ else:
143
+ print(f"ALTERED — {v.reason}")
144
+ sys.exit(0 if v else 1)
145
+
146
+
147
+ def main():
148
+ p = argparse.ArgumentParser(prog="mememage",
149
+ description="Encode an identifier into an image's pixels; "
150
+ "verify a JSON record against any copy.")
151
+ sub = p.add_subparsers(dest="command")
152
+
153
+ pe = sub.add_parser("encode", help="Encode a bar + build a record from your fields")
154
+ pe.add_argument("image", help="PNG image to encode (modified in place)")
155
+ pe.add_argument("--field", action="append", metavar="KEY=VALUE",
156
+ help="A record field (repeatable). String values; use --fields for typed/nested.")
157
+ pe.add_argument("--fields", metavar="JSON_FILE",
158
+ help="Read record fields from a JSON object file ('-' for stdin)")
159
+ pe.add_argument("--prefix", default="mememage",
160
+ help="Identifier prefix (default: mememage)")
161
+ pe.add_argument("--identifier", help="Override the content-addressed identifier")
162
+ pe.add_argument("--encrypt", action="store_true",
163
+ help="Encrypt ALL fields behind a password (private record)")
164
+ pe.add_argument("--private", metavar="F1,F2",
165
+ help="Encrypt only these comma-separated fields (rest public)")
166
+ pe.add_argument("--password-env", metavar="VAR",
167
+ help="Read the encrypt password from this env var (else prompt)")
168
+ pe.add_argument("-o", "--out",
169
+ help="Record output path (default: <identifier>.json next to the image)")
170
+
171
+ pd = sub.add_parser("decode", help="Read the bar (identifier + content hash); with --record, verify")
172
+ pd.add_argument("image", help="Image to decode (PNG, JPEG, screenshot)")
173
+ pd.add_argument("--record", dest="record", metavar="FILE",
174
+ help="A local record file (JSON) to verify the image against")
175
+ pd.add_argument("--unlock", action="store_true",
176
+ help="With --record: decrypt the record's private fields (prompts for password)")
177
+ pd.add_argument("--password-env", metavar="VAR",
178
+ help="Read the unlock password from this env var (else prompt)")
179
+ pd.add_argument("--json", action="store_true", help="Machine-readable JSON output")
180
+
181
+ args = p.parse_args()
182
+ if args.command == "encode":
183
+ cmd_encode(args)
184
+ elif args.command == "decode":
185
+ cmd_decode(args)
186
+ else:
187
+ p.print_help()
188
+ sys.exit(1)
189
+
190
+
191
+ if __name__ == "__main__":
192
+ main()
mememage/api.py ADDED
@@ -0,0 +1,347 @@
1
+ """encode / decode / verify — the Mememage core API.
2
+
3
+ The whole of Mememage core: write a bar into an image, get back a record whose
4
+ fields you chose, and verify the data against the image by math alone. Just the
5
+ bar, the record, and the hash — no networking, no record schema, no field
6
+ semantics; anything more belongs to the application built on top. This is the
7
+ small surface a tool can ``pip install`` and use::
8
+
9
+ import mememage
10
+ result = mememage.encode("shot.png", {"title": "a cat"}) # write the bar + record
11
+ result.save("shot.json") # store the record separately
12
+ ...
13
+ bar = mememage.decode("shot.jpg") # read it back: identifier + content hash
14
+ if mememage.verify("shot.jpg", result.record): # the record matches the image
15
+ ...
16
+
17
+ The bar carries exactly two things: the IDENTIFIER (a key to a record stored
18
+ separately — core does not fetch it) and the CONTENT HASH (a SHA-256 over the
19
+ record). ``decode`` reads them back out; ``verify`` re-hashes a record and checks
20
+ it against the hash in the pixels. The ``open`` hash makes every field of the
21
+ record tamper-evident. Core stops at integrity (hash + optional field encryption);
22
+ identity and authorship (signing) are out of scope.
23
+
24
+ Pillow is required (the bar codec, shipped in the base install); ``cryptography``
25
+ only if you encrypt fields.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import json
31
+ import re
32
+ from collections import namedtuple
33
+ from dataclasses import dataclass
34
+
35
+ from mememage import bar, hashing
36
+
37
+ OPEN = hashing.OPEN_HASH_VERSION # "open"
38
+
39
+ # Keys encode computes / reserves — passing them in `fields` is a mistake, not a
40
+ # silent override. `signature` is reserved (not just computed): the open hash
41
+ # leaves it OUT (the structurally-circular slot), so a detached signature can be
42
+ # attached later by an external tool without re-hashing — a user field
43
+ # named `signature` would therefore go unprotected, so it's refused.
44
+ _RESERVED = {"identifier", "content_hash", "hash_version", "signature",
45
+ "encrypted_fields"}
46
+
47
+ # Identifier prefix rule: starts with a letter, ends alphanumeric, 3-10 chars,
48
+ # URL/path-safe; capped at 10 so the bar still fits a 512px image.
49
+ _PREFIX_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_-]{1,8}[A-Za-z0-9]$")
50
+
51
+
52
+ Bar = namedtuple("Bar", ["identifier", "content_hash"])
53
+
54
+
55
+ @dataclass
56
+ class Record:
57
+ """What :func:`encode` returns: the ``record`` (your fields + identifier +
58
+ content hash), the now-barred ``image`` (a PIL Image, always in memory), and
59
+ ``image_path`` (where it was written, or None for an in-memory-only encode).
60
+ Store the record wherever you like; a verifier finds it by identifier and
61
+ trusts it by hash."""
62
+ record: dict
63
+ image_path: "str | None" = None
64
+ image: "object | None" = None # the barred PIL Image (in-memory output)
65
+
66
+ @property
67
+ def identifier(self) -> str:
68
+ return self.record["identifier"]
69
+
70
+ @property
71
+ def content_hash(self) -> str:
72
+ return self.record["content_hash"]
73
+
74
+ def to_json(self, indent: int = 2) -> str:
75
+ return json.dumps(self.record, indent=indent, ensure_ascii=False)
76
+
77
+ def save(self, path: str) -> str:
78
+ """Write the record to ``path`` as JSON and return the path. Put it on
79
+ your own server, a CDN, IPFS, a file — the verifier is source-agnostic
80
+ and trusts it by hash alone."""
81
+ with open(path, "w", encoding="utf-8") as f:
82
+ f.write(self.to_json())
83
+ return path
84
+
85
+
86
+ @dataclass
87
+ class Verification:
88
+ """What :func:`verify` returns. Truthy iff the record matches the image — the
89
+ re-hashed record equals the content hash baked in the pixels. ``reason``
90
+ explains a failure (empty on success). (Core verifies integrity only;
91
+ authorship/signatures are out of scope.) For the bar's identifier or hash,
92
+ call :func:`decode`."""
93
+ match: bool
94
+ reason: str = ""
95
+
96
+ def __bool__(self) -> bool:
97
+ return self.match
98
+
99
+
100
+ def _validate_prefix(prefix: str) -> None:
101
+ if not isinstance(prefix, str) or not _PREFIX_RE.match(prefix):
102
+ raise ValueError(
103
+ f"invalid prefix {prefix!r}: 3-10 chars, [A-Za-z][A-Za-z0-9_-]*"
104
+ "[A-Za-z0-9] — URL/path/filename-safe (the bar must fit a 512px image)")
105
+
106
+
107
+ def _canonical(obj) -> str:
108
+ """The canonical JSON the hash + identifier derive from — sorted keys,
109
+ normalized floats (1.0 → 1, matching JS), no whitespace."""
110
+ return json.dumps(hashing._normalize_for_hash(obj), sort_keys=True,
111
+ separators=(",", ":"), ensure_ascii=True)
112
+
113
+
114
+ def _swap_to_png(path: str) -> str:
115
+ """``/a/photo.jpg`` -> ``/a/photo.png`` — the lossless output for a non-PNG input."""
116
+ import os
117
+ return os.path.splitext(path)[0] + ".png"
118
+
119
+
120
+ def _content_identifier(fields: dict, prefix: str) -> str:
121
+ """Content-address the record: ``<prefix>-<16 hex of canonical(fields)>``.
122
+ Same fields → same identifier (natural dedup); include a unique field
123
+ (timestamp, id) for a fresh one. The data determines its own name."""
124
+ digest = hashlib.sha256(_canonical(fields).encode("utf-8")).hexdigest()[:16]
125
+ return f"{prefix}-{digest}"
126
+
127
+
128
+ def encode(image, fields=None, *, prefix="mememage", identifier=None,
129
+ password=None, private=None, out=None) -> Record:
130
+ """Write a Mememage bar into an image and return its :class:`Record`.
131
+
132
+ Adds ``identifier``, ``content_hash`` and ``hash_version="open"`` to your
133
+ fields, hashes everything (the ``open`` model — every field tamper-evident),
134
+ and embeds ``<identifier>\\0<content_hash>`` into the bottom two pixel rows.
135
+
136
+ Reads **any image** — a path, raw ``bytes``, a file-like object, a PIL Image,
137
+ or a numpy array (HEIC paths need the ``[heic]`` extra). The barred image is
138
+ always returned in memory as ``Record.image`` (a PIL Image). It is also
139
+ written to disk as a **lossless PNG** when there's a destination — the bar is
140
+ exact pixel data, so a lossy save would scramble it:
141
+
142
+ - a **path** input with no ``out`` → in place if PNG, else a ``.png`` sibling.
143
+ - ``out=<path.png>`` → written there. ``out=<file-like>`` (e.g. ``BytesIO``) →
144
+ written to the stream. ``out`` must be PNG.
145
+ - an **in-memory** input (PIL/bytes/ndarray) with no ``out`` → no disk; the
146
+ barred image is ``Record.image`` only.
147
+
148
+ Field visibility (``password``): your own encryption. With a password, the
149
+ private fields leave the cleartext record and become an ``encrypted_fields``
150
+ envelope (AES-256-GCM via PBKDF2). The content hash then covers the
151
+ CIPHERTEXT — so the record still verifies *without* the password (the proof
152
+ is over the encrypted shell), and tampering with the ciphertext breaks it.
153
+ Reveal the fields with :func:`unlock` (or in the browser decoder by typing the
154
+ password). The password is not stored; only the ciphertext is kept.
155
+
156
+ Args:
157
+ image: the source image — a path, bytes, a file-like, a PIL Image, or a
158
+ numpy array. Never mutated (encode works on a copy).
159
+ fields: your data as a JSON-serializable dict (None → identifier + hash
160
+ only). Reserved keys (identifier/content_hash/hash_version/
161
+ signature/encrypted_fields) can't be passed.
162
+ prefix: identifier namespace, 3-10 chars, IA-safe. Default ``mememage``.
163
+ identifier: override the auto content-addressed identifier with your own
164
+ ``<prefix>-...`` string.
165
+ password: encrypt private fields under this passphrase. Needs the
166
+ [encrypt] extra (cryptography). None → fully public record.
167
+ private: which field names to encrypt (list). With a password, ``None``
168
+ encrypts ALL your fields; a list encrypts just those, leaving the
169
+ rest public. Without a password it's an error.
170
+ out: a destination for the barred PNG — a ``.png`` path or a file-like
171
+ object. None → see the path/in-memory rules above.
172
+
173
+ Returns: :class:`Record` (``record`` + ``image`` (the barred PIL Image) +
174
+ ``image_path`` (the PNG written, or None)). On an encrypted record the
175
+ ``record`` holds the public fields + ``encrypted_fields``; the private
176
+ fields live only in the envelope.
177
+
178
+ Raises: ``ValueError`` if ``out`` isn't a ``.png``, on a reserved/`_`-prefixed
179
+ key, a bad prefix, ``private`` without ``password``, or an unknown
180
+ ``private`` name; ``RuntimeError`` if ``password`` is set but cryptography
181
+ is unavailable.
182
+ """
183
+ fields = dict(fields or {})
184
+ clash = _RESERVED & set(fields)
185
+ if clash:
186
+ raise ValueError(f"encode computes these — don't pass them: {sorted(clash)}")
187
+ underscored = sorted(k for k in fields if isinstance(k, str) and k.startswith("_"))
188
+ if underscored:
189
+ raise ValueError(
190
+ f"`_`-prefixed keys are reserved for decoder internals and are NOT "
191
+ f"hashed (they'd be unprotected): {underscored}")
192
+
193
+ record = dict(fields)
194
+ record["hash_version"] = OPEN
195
+
196
+ # Identity. Content-addressed from YOUR fields (stable whether or not you
197
+ # sign), unless you supplied one. The bar carries a CANONICAL
198
+ # ``<prefix>-<16 hex>`` identifier, so an override must take that shape.
199
+ ident = identifier
200
+ if ident is None:
201
+ _validate_prefix(prefix)
202
+ ident = _content_identifier(fields, prefix)
203
+ else:
204
+ pre, sep, idhex = ident.rpartition("-")
205
+ if not (sep and len(idhex) == 16
206
+ and all(c in "0123456789abcdef" for c in idhex)):
207
+ raise ValueError(
208
+ f"identifier must be canonical <prefix>-<16 lower-hex>, got {ident!r}")
209
+ _validate_prefix(pre) # a supplied identifier's prefix obeys the same
210
+ # contract as the prefix= auto-path — one rule.
211
+ record["identifier"] = ident
212
+
213
+ # Field visibility — encrypt the private fields behind a password BEFORE the
214
+ # hash, so the proof covers the ciphertext (tamper-evident shell; verifies
215
+ # without the password). The encrypted_fields envelope is AES-256-GCM and
216
+ # decryptable in any browser via SubtleCrypto.
217
+ if password is not None:
218
+ from mememage import crypto
219
+ if not crypto.is_encryption_available():
220
+ raise RuntimeError("field encryption needs the cryptography library "
221
+ "(`pip install mememage[encrypt]`).")
222
+ if private is not None:
223
+ unknown = [k for k in private if k not in fields]
224
+ if unknown:
225
+ raise ValueError(f"private names fields you didn't pass: {sorted(unknown)}")
226
+ priv_keys = list(fields) if private is None else [k for k in private if k in fields]
227
+ priv = {}
228
+ for k in priv_keys:
229
+ priv[k] = record.pop(k) # leaves the cleartext shell
230
+ if priv:
231
+ record["encrypted_fields"] = crypto.encrypt_field(
232
+ json.dumps(priv, sort_keys=True, separators=(",", ":")), password)
233
+ elif private:
234
+ raise ValueError("private=… needs a password=…")
235
+
236
+ # Proof. The content hash covers identity + the public shell + the ciphertext.
237
+ content_hash = hashing.compute_content_hash(record)
238
+ record["content_hash"] = content_hash
239
+
240
+ # Bar the pixels in memory (any input form -> a new barred RGB PIL Image).
241
+ barred = bar.embed_into(image, ident, content_hash)
242
+
243
+ # Write a lossless PNG, or keep it in memory. The bar is exact pixel data, so
244
+ # any written file must be PNG (a lossy save would scramble it).
245
+ import os
246
+ written = None
247
+ if out is not None:
248
+ if isinstance(out, (str, os.PathLike)):
249
+ if not str(out).lower().endswith(".png"):
250
+ raise ValueError("encode writes a lossless PNG (the bar can't survive "
251
+ f"lossy formats); out must end in .png, got {out}")
252
+ barred.save(out, "PNG")
253
+ written = str(out)
254
+ else: # file-like (e.g. BytesIO)
255
+ barred.save(out, "PNG")
256
+ elif isinstance(image, (str, os.PathLike)):
257
+ # Path input, no out: write in place (PNG) or to a `.png` sibling.
258
+ src = str(image)
259
+ written = src if src.lower().endswith(".png") else _swap_to_png(src)
260
+ barred.save(written, "PNG")
261
+ # else: in-memory input, no out -> Record.image only (no disk).
262
+
263
+ return Record(record=record, image_path=written, image=barred)
264
+
265
+
266
+ def decode(image, all_bars=False) -> "Bar | None | list[Bar]":
267
+ """Read the bar's payload out of an image. The inverse of :func:`encode`.
268
+
269
+ By default returns the FIRST (bottom-most) bar as ``Bar(identifier,
270
+ content_hash)``, or None — the common case: one image, one record.
271
+
272
+ With ``all_bars=True`` returns a LIST of *every* bar in the image (empty if
273
+ none) — for images stamped with more than one. Each bar is located wherever
274
+ it sits: the bottom (where :func:`encode` writes it), a different height,
275
+ horizontally offset, or pasted in from another image. CRC + Reed-Solomon
276
+ reject false matches, so bar-ish content never yields a spurious entry.
277
+
278
+ ``image`` is anything in memory or on disk — a path, raw ``bytes``, a
279
+ file-like object, a PIL ``Image``, or a numpy array. No network, no disk
280
+ round-trip: just the values stamped in the pixels.
281
+
282
+ The identifier points to a record you store separately; resolving it is yours
283
+ (a dict, a file, a DB, a URL — core doesn't fetch). The content hash lets
284
+ :func:`verify` confirm that record against the image."""
285
+ if all_bars:
286
+ return [Bar(identifier=i, content_hash=h) for (i, h) in bar.extract_bars(image)]
287
+ result = bar.extract_bar(image)
288
+ if not result:
289
+ return None
290
+ identifier, content_hash = result
291
+ return Bar(identifier=identifier, content_hash=content_hash)
292
+
293
+
294
+ def is_encrypted(record) -> bool:
295
+ """True if the record carries an ``encrypted_fields`` envelope (private
296
+ fields behind a password)."""
297
+ rec = record.record if isinstance(record, Record) else (record or {})
298
+ return bool(rec.get("encrypted_fields"))
299
+
300
+
301
+ def unlock(record, password) -> dict:
302
+ """Decrypt an encrypted record's private fields and return the full readable
303
+ view (public fields + the decrypted private fields; ``encrypted_fields``
304
+ dropped).
305
+
306
+ The ENCRYPTED record is what you :func:`verify` — its hash is over the
307
+ ciphertext. This is the *readable* view for display; don't re-hash it. A
308
+ record with no ``encrypted_fields`` is returned unchanged. Raises ``ValueError``
309
+ on the wrong password.
310
+ """
311
+ rec = record.record if isinstance(record, Record) else dict(record)
312
+ env = rec.get("encrypted_fields")
313
+ if not env:
314
+ return dict(rec)
315
+ from mememage import crypto
316
+ private = json.loads(crypto.decrypt_field(env, password))
317
+ view = {k: v for k, v in rec.items() if k != "encrypted_fields"}
318
+ view.update(private)
319
+ return view
320
+
321
+
322
+ def verify(image, record) -> Verification:
323
+ """Verify a record against an image. Returns a :class:`Verification` (truthy iff
324
+ the data matches — the re-hashed record equals the content hash in the bar).
325
+
326
+ Reads the bar, recomputes the content hash over the record, and compares. A
327
+ match means the data is intact and belongs to this image. (Core verifies
328
+ integrity only; authorship/signatures are out of scope.)
329
+
330
+ Args:
331
+ image: the barred image — a path, bytes, a file-like, a PIL Image, or a
332
+ numpy array.
333
+ record: the record (a dict) or a :class:`Record`.
334
+ """
335
+ rec = record.record if isinstance(record, Record) else record
336
+
337
+ bar_data = decode(image)
338
+ if bar_data is None:
339
+ return Verification(False, "no Mememage bar in the image")
340
+
341
+ recomputed = hashing.compute_content_hash(rec)
342
+ if recomputed != bar_data.content_hash:
343
+ return Verification(False, f"hash mismatch: image bar says {bar_data.content_hash}, "
344
+ f"data recomputes to {recomputed}")
345
+ return Verification(True)
346
+
347
+