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 +37 -0
- mememage/__main__.py +192 -0
- mememage/api.py +347 -0
- mememage/bar.py +1159 -0
- mememage/crypto.py +81 -0
- mememage/hashing.py +68 -0
- mememage/rs.py +276 -0
- mememage-0.1.0.dist-info/METADATA +102 -0
- mememage-0.1.0.dist-info/RECORD +13 -0
- mememage-0.1.0.dist-info/WHEEL +5 -0
- mememage-0.1.0.dist-info/entry_points.txt +2 -0
- mememage-0.1.0.dist-info/licenses/LICENSE +21 -0
- mememage-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
|