mememage 0.1.0__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.
mememage-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Catmemes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,102 @@
1
+ Metadata-Version: 2.4
2
+ Name: mememage
3
+ Version: 0.1.0
4
+ Summary: Encode an identifier into an image's pixels; verify a JSON record against any copy
5
+ Author: Catmemes
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/sememtac/mememage
8
+ Keywords: steganography,image,watermark,content-hash,tamper-evident
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Topic :: Multimedia :: Graphics
12
+ Classifier: Topic :: Security :: Cryptography
13
+ Requires-Python: >=3.10
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: Pillow>=10.0
17
+ Provides-Extra: encrypt
18
+ Requires-Dist: cryptography>=41.0; extra == "encrypt"
19
+ Provides-Extra: heic
20
+ Requires-Dist: pillow-heif>=0.16; extra == "heic"
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=7.0; extra == "dev"
23
+ Requires-Dist: Pillow>=10.0; extra == "dev"
24
+ Requires-Dist: cryptography>=41.0; extra == "dev"
25
+ Requires-Dist: numpy>=1.24; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # Mememage
29
+
30
+ Encode an identifier into an image's pixels; verify a JSON record against any copy.
31
+
32
+ Mememage writes a 2-pixel-tall bar into the bottom rows of an image. The bar holds two values:
33
+
34
+ - **identifier** — a short string that points to a JSON record, stored separately (a server, a CDN, IPFS, a file).
35
+ - **content hash** — a SHA-256 over the record. `verify` recomputes it and compares against the bar.
36
+
37
+ The bar survives JPEG, resaves, screenshots, and re-uploads, so the identifier reads back from any copy. `encode` reads any image Pillow can open and writes a lossless PNG; `decode` and `verify` work on any format the bar survives. Record fields are arbitrary — captions, credits, generation parameters, links.
38
+
39
+ ```bash
40
+ pip install mememage # encode / decode / verify — Pillow included
41
+ # pip install "mememage[encrypt]" # adds AES-256 field encryption
42
+ ```
43
+
44
+ ## Quickstart
45
+
46
+ Three functions, all pure image operations:
47
+
48
+ ```python
49
+ import mememage
50
+
51
+ # encode — write the bar into the image, build the record from your fields
52
+ result = mememage.encode("photo.png", {"title": "Morning fog", "by": "catmemes"})
53
+ result.identifier # 'mememage-3dc5f03a747bb38e' (derived from the fields)
54
+ result.save("photo.json") # the record — store or serve it separately
55
+
56
+ # decode — read the bar back out of the pixels (the inverse of encode)
57
+ bar = mememage.decode("photo.jpg") # any format the bar survived: PNG, JPEG, a screenshot
58
+ bar.identifier, bar.content_hash
59
+
60
+ # verify — does a record match an image? (recomputed hash == the bar's)
61
+ mememage.verify("photo.jpg", result.record) # True if the record is intact
62
+ ```
63
+
64
+ You resolve the record from its identifier — look it up wherever you keep it, then verify:
65
+
66
+ ```python
67
+ bar = mememage.decode("photo.jpg") # identifier + content hash from the pixels
68
+ record = my_store[bar.identifier] # your storage: a dict, a file, a DB, a URL
69
+ mememage.verify("photo.jpg", record) # True if the record matches the image
70
+ ```
71
+
72
+ - **`encode` accepts any image** — a path, `bytes`, a PIL `Image`, or a numpy array (HEIC needs the `[heic]` extra) — and returns the barred image as `Record.image`. Given a destination — a path (in place, or a `.png` sibling for non-PNG), `out=<path.png>`, or `out=<stream>` (e.g. `BytesIO`) — it writes the file. Output is always PNG: the bar is lossless, and a lossy re-encode would corrupt it. An in-memory input with no destination never touches disk.
73
+ - **`decode` / `verify` accept the same in-memory forms** — a path, `bytes`, a file-like, a PIL `Image`, or a numpy array. No disk round-trip.
74
+ - **No network I/O** — `decode` returns the identifier; you resolve the record. Core is pure pixel + hash operations.
75
+
76
+ ## Encrypt private fields
77
+
78
+ - Mark fields `private` to encrypt them (AES-256-GCM via PBKDF2) under a password.
79
+ - The record still **verifies without the password** — the hash covers the ciphertext.
80
+ - `unlock` returns the decrypted fields. The password is not stored; only the ciphertext is kept in the record.
81
+
82
+ ```python
83
+ result = mememage.encode("photo.png", {"title": "Public", "gps": "45.5,-122.6"},
84
+ password="hunter2", private=["gps"])
85
+ mememage.verify("photo.png", result.record) # matches — no password
86
+ mememage.unlock(result, "hunter2")["gps"] # '45.5,-122.6'
87
+ ```
88
+
89
+ ## Command line
90
+
91
+ ```bash
92
+ mememage encode photo.png --field title="Morning fog" -o photo.json # write the record
93
+ mememage decode photo.jpg --record photo.json # VERIFIED (exit 0) / ALTERED (exit 1)
94
+ mememage decode photo.jpg # read the identifier only (no record)
95
+ ```
96
+
97
+ Without `-o`, the record is written next to the image as `<identifier>.json`.
98
+ `decode` exits 0 on a match.
99
+
100
+ ## License
101
+
102
+ MIT.
@@ -0,0 +1,75 @@
1
+ # Mememage
2
+
3
+ Encode an identifier into an image's pixels; verify a JSON record against any copy.
4
+
5
+ Mememage writes a 2-pixel-tall bar into the bottom rows of an image. The bar holds two values:
6
+
7
+ - **identifier** — a short string that points to a JSON record, stored separately (a server, a CDN, IPFS, a file).
8
+ - **content hash** — a SHA-256 over the record. `verify` recomputes it and compares against the bar.
9
+
10
+ The bar survives JPEG, resaves, screenshots, and re-uploads, so the identifier reads back from any copy. `encode` reads any image Pillow can open and writes a lossless PNG; `decode` and `verify` work on any format the bar survives. Record fields are arbitrary — captions, credits, generation parameters, links.
11
+
12
+ ```bash
13
+ pip install mememage # encode / decode / verify — Pillow included
14
+ # pip install "mememage[encrypt]" # adds AES-256 field encryption
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ Three functions, all pure image operations:
20
+
21
+ ```python
22
+ import mememage
23
+
24
+ # encode — write the bar into the image, build the record from your fields
25
+ result = mememage.encode("photo.png", {"title": "Morning fog", "by": "catmemes"})
26
+ result.identifier # 'mememage-3dc5f03a747bb38e' (derived from the fields)
27
+ result.save("photo.json") # the record — store or serve it separately
28
+
29
+ # decode — read the bar back out of the pixels (the inverse of encode)
30
+ bar = mememage.decode("photo.jpg") # any format the bar survived: PNG, JPEG, a screenshot
31
+ bar.identifier, bar.content_hash
32
+
33
+ # verify — does a record match an image? (recomputed hash == the bar's)
34
+ mememage.verify("photo.jpg", result.record) # True if the record is intact
35
+ ```
36
+
37
+ You resolve the record from its identifier — look it up wherever you keep it, then verify:
38
+
39
+ ```python
40
+ bar = mememage.decode("photo.jpg") # identifier + content hash from the pixels
41
+ record = my_store[bar.identifier] # your storage: a dict, a file, a DB, a URL
42
+ mememage.verify("photo.jpg", record) # True if the record matches the image
43
+ ```
44
+
45
+ - **`encode` accepts any image** — a path, `bytes`, a PIL `Image`, or a numpy array (HEIC needs the `[heic]` extra) — and returns the barred image as `Record.image`. Given a destination — a path (in place, or a `.png` sibling for non-PNG), `out=<path.png>`, or `out=<stream>` (e.g. `BytesIO`) — it writes the file. Output is always PNG: the bar is lossless, and a lossy re-encode would corrupt it. An in-memory input with no destination never touches disk.
46
+ - **`decode` / `verify` accept the same in-memory forms** — a path, `bytes`, a file-like, a PIL `Image`, or a numpy array. No disk round-trip.
47
+ - **No network I/O** — `decode` returns the identifier; you resolve the record. Core is pure pixel + hash operations.
48
+
49
+ ## Encrypt private fields
50
+
51
+ - Mark fields `private` to encrypt them (AES-256-GCM via PBKDF2) under a password.
52
+ - The record still **verifies without the password** — the hash covers the ciphertext.
53
+ - `unlock` returns the decrypted fields. The password is not stored; only the ciphertext is kept in the record.
54
+
55
+ ```python
56
+ result = mememage.encode("photo.png", {"title": "Public", "gps": "45.5,-122.6"},
57
+ password="hunter2", private=["gps"])
58
+ mememage.verify("photo.png", result.record) # matches — no password
59
+ mememage.unlock(result, "hunter2")["gps"] # '45.5,-122.6'
60
+ ```
61
+
62
+ ## Command line
63
+
64
+ ```bash
65
+ mememage encode photo.png --field title="Morning fog" -o photo.json # write the record
66
+ mememage decode photo.jpg --record photo.json # VERIFIED (exit 0) / ALTERED (exit 1)
67
+ mememage decode photo.jpg # read the identifier only (no record)
68
+ ```
69
+
70
+ Without `-o`, the record is written next to the image as `<identifier>.json`.
71
+ `decode` exits 0 on a match.
72
+
73
+ ## License
74
+
75
+ MIT.
@@ -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
+ ]
@@ -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()