Skip to content

Commit 8e8935e

Browse files
committed
feat!: decrypt wire JSON by field schema (decrypt_task_payload, decrypt_task_summary, decrypt_submission, decrypt_event); remove looks_like_ciphertext
1 parent d6e8104 commit 8e8935e

4 files changed

Lines changed: 437 additions & 47 deletions

File tree

src/simplepush/__init__.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,21 +38,26 @@
3838
_CRYPTO_NAMES = (
3939
"Decryptor", "OrgDecryptor", "Keyring", "DerivedKey",
4040
"derive_key", "encrypt", "decrypt", "key_fingerprint",
41-
"try_decrypt_event_data", "looks_like_ciphertext",
41+
"try_decrypt_event_data", "DecryptedWire",
42+
"decrypt_task_payload", "decrypt_task_summary", "decrypt_submission", "decrypt_event",
4243
)
4344

4445
try:
4546
from .crypto import (
4647
Decryptor, OrgDecryptor, Keyring, DerivedKey,
4748
derive_key, encrypt, decrypt, key_fingerprint,
4849
)
49-
from .decrypt import try_decrypt_event_data, looks_like_ciphertext
50+
from .decrypt import (
51+
try_decrypt_event_data, DecryptedWire,
52+
decrypt_task_payload, decrypt_task_summary, decrypt_submission, decrypt_event,
53+
)
5054
# Literal list (not `list(_CRYPTO_NAMES)`) so static checkers can follow
5155
# the export list; keep it in sync with _CRYPTO_NAMES above.
5256
__all__ += [
5357
"Decryptor", "OrgDecryptor", "Keyring", "DerivedKey",
5458
"derive_key", "encrypt", "decrypt", "key_fingerprint",
55-
"try_decrypt_event_data", "looks_like_ciphertext",
59+
"try_decrypt_event_data", "DecryptedWire",
60+
"decrypt_task_payload", "decrypt_task_summary", "decrypt_submission", "decrypt_event",
5661
]
5762
except MissingCryptoExtra:
5863
# pynacl is absent: keep the crypto names out of the namespace (and out of

src/simplepush/decrypt.py

Lines changed: 245 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,261 @@
1-
"""Best-effort decryption of an event's ``data`` payload via a `Keyring`.
1+
"""Schema-driven decryption of the wire JSON the read surface returns: task
2+
payloads, chains, summaries, submissions, raw events.
23
3-
Mirrors the TS SDK's ``tryDecryptEventData``: for an encrypted event, resolve the
4-
key from its marker and recursively decrypt every base64 string in ``data`` that
5-
looks like spush ciphertext. Cheap heuristic — spush ciphertext is base64 of
6-
>=28 bytes, so the encoded form is >=40 base64 chars. Use it to decrypt the raw
7-
``Client.events()`` feed across many candidate passwords:
4+
The encrypted fields are exactly the ones the send side seals (`api.py`) and
5+
the watch views decrypt (`client.py`); this module mirrors that map field by
6+
field.
7+
8+
Marker rules:
9+
- a payload/summary/entry marker covers its sender-authored fields;
10+
- an answer record's own ``encryption`` wins over the envelope's (answers
11+
sealed after an org key rotation carry newer keys than the task);
12+
- replies, decline notes and cancel notes carry strictly their own marker
13+
(authored after the send; absent marker = plaintext note);
14+
- a location's coords ride as one ``encrypted`` JSON blob, expanded in
15+
place, exactly like a slider input's sealed scale.
16+
17+
Every known-encrypted field under a marker either decrypts or increments
18+
``undecryptable``; nothing else is ever attempted. A new encrypted wire field
19+
must be added here (and to the watch views).
820
921
ring = client.keyring()
10-
async for event in client.events():
11-
data = try_decrypt_event_data(event, ring) # decrypted dict, or None
22+
page = decrypt_task_payload(read, ring) # page.value, page.undecryptable
1223
"""
1324

1425
import copy
15-
import re
26+
import json
27+
from typing import Any, NamedTuple
1628

1729
from .crypto import decrypt
1830

19-
_CIPHERTEXT_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
2031

32+
class DecryptedWire(NamedTuple):
33+
value: Any
34+
undecryptable: int
2135

22-
def looks_like_ciphertext(s: str) -> bool:
23-
return 40 <= len(s) <= 8192 and bool(_CIPHERTEXT_RE.match(s))
2436

37+
class _State:
38+
__slots__ = ("undecryptable",)
2539

26-
def try_decrypt_event_data(event, keyring) -> dict | None:
27-
"""Return a decrypted copy of ``event.data``, or None when the event isn't
28-
encrypted or no held key matches its marker. Ciphertext that fails to decrypt
29-
is left in place."""
30-
marker = event.raw.get("encryption")
31-
if not marker:
32-
return None
40+
def __init__(self) -> None:
41+
self.undecryptable = 0
42+
43+
44+
def _marker_of(v: Any) -> dict | None:
45+
return v if isinstance(v, dict) and isinstance(v.get("type"), str) else None
46+
47+
48+
def _dec_field(o: dict, field: str, marker: dict | None, keyring, st: _State) -> None:
49+
"""Decrypt ``o[field]`` in place when it is a string and a marker applies.
50+
No marker = plaintext field, left alone; a marker with no matching key or a
51+
failed authentication counts as undecryptable and leaves the ciphertext."""
52+
v = o.get(field)
53+
if not isinstance(v, str) or marker is None:
54+
return
3355
key = keyring.key_for_marker(marker)
3456
if key is None:
57+
st.undecryptable += 1
58+
return
59+
try:
60+
o[field] = decrypt(v, key)
61+
except Exception:
62+
st.undecryptable += 1
63+
64+
65+
def _dec_list(o: dict, field: str, marker: dict | None, keyring, st: _State) -> None:
66+
items = o.get(field)
67+
if not isinstance(items, list):
68+
return
69+
for i, v in enumerate(items):
70+
wrap = {"v": v}
71+
_dec_field(wrap, "v", marker, keyring, st)
72+
items[i] = wrap["v"]
73+
74+
75+
def _dec_blob(o: dict, marker: dict | None, keyring, st: _State) -> None:
76+
"""Decrypt-and-JSON-expand an ``{"encrypted": ...}`` blob (slider scale,
77+
location coords) onto the record itself, dropping the blob on success."""
78+
v = o.get("encrypted")
79+
if not isinstance(v, str) or marker is None:
80+
return
81+
key = keyring.key_for_marker(marker)
82+
if key is None:
83+
st.undecryptable += 1
84+
return
85+
try:
86+
plain = json.loads(decrypt(v, key))
87+
except Exception:
88+
st.undecryptable += 1
89+
return
90+
if isinstance(plain, dict):
91+
del o["encrypted"]
92+
o.update(plain)
93+
else:
94+
st.undecryptable += 1
95+
96+
97+
def _dec_input(inp: Any, marker: dict | None, keyring, st: _State) -> None:
98+
if not isinstance(inp, dict):
99+
return
100+
_dec_field(inp, "description", marker, keyring, st)
101+
kind = inp.get("type")
102+
if kind == "text":
103+
_dec_field(inp, "defaultValue", marker, keyring, st)
104+
elif kind == "choice":
105+
_dec_list(inp, "options", marker, keyring, st)
106+
elif kind == "actions":
107+
for a in inp.get("actions") or []:
108+
if isinstance(a, dict):
109+
_dec_field(a, "key", marker, keyring, st)
110+
_dec_field(a, "label", marker, keyring, st)
111+
elif kind == "slider":
112+
_dec_blob(inp, marker, keyring, st)
113+
114+
115+
def _dec_upload(u: Any, envelope: dict | None, keyring, st: _State) -> None:
116+
"""An answer record (``textUploaded``, ``choiceSelected``, ...), as it appears
117+
both in event data and in a payload's ``uploads``. Own marker wins over the
118+
envelope's. File-kind records carry only plaintext metadata."""
119+
if not isinstance(u, dict):
120+
return
121+
marker = _marker_of(u.get("encryption")) or envelope
122+
u.pop("encryption", None)
123+
kind = u.get("type")
124+
if kind in ("textUploaded", "sliderUploaded"):
125+
_dec_field(u, "value", marker, keyring, st)
126+
elif kind == "choiceSelected":
127+
_dec_field(u, "selectedValue", marker, keyring, st)
128+
elif kind == "multiChoiceSelected":
129+
_dec_list(u, "selectedValues", marker, keyring, st)
130+
elif kind == "actionSelected":
131+
_dec_field(u, "selectedKey", marker, keyring, st)
132+
elif kind == "locationUploaded":
133+
# The coords are flattened onto the record itself, no nested key.
134+
_dec_blob(u, marker, keyring, st)
135+
136+
137+
def _dec_message_body(container: dict, marker: dict | None, keyring, st: _State) -> None:
138+
"""A reply/submission body plus inline location, under the given marker."""
139+
body = container.get("body")
140+
if isinstance(body, dict) and body.get("type") == "text":
141+
_dec_field(body, "value", marker, keyring, st)
142+
loc = container.get("location")
143+
if isinstance(loc, dict):
144+
_dec_blob(loc, marker, keyring, st)
145+
146+
147+
def _dec_reply_record(r: Any, keyring, st: _State) -> None:
148+
"""A reply off a payload's ``replies``: its own marker only."""
149+
if not isinstance(r, dict):
150+
return
151+
marker = _marker_of(r.pop("encryption", None))
152+
_dec_message_body(r, marker, keyring, st)
153+
154+
155+
def _dec_note_record(r: Any, keyring, st: _State) -> None:
156+
"""A decline record / the cancellation block: the note's own marker only."""
157+
if not isinstance(r, dict):
158+
return
159+
marker = _marker_of(r.pop("encryption", None))
160+
_dec_field(r, "note", marker, keyring, st)
161+
162+
163+
def _dec_payload_in_place(p: dict, keyring, st: _State) -> None:
164+
marker = _marker_of(p.pop("encryption", None))
165+
for f in ("tag", "title", "content"):
166+
_dec_field(p, f, marker, keyring, st)
167+
for a in p.get("attachments") or []:
168+
if isinstance(a, dict) and a.get("type") == "link":
169+
_dec_field(a, "url", marker, keyring, st)
170+
for i in p.get("inputs") or []:
171+
_dec_input(i, marker, keyring, st)
172+
for u in p.get("uploads") or []:
173+
_dec_upload(u, marker, keyring, st)
174+
for r in p.get("replies") or []:
175+
_dec_reply_record(r, keyring, st)
176+
for d in p.get("declines") or []:
177+
_dec_note_record(d, keyring, st)
178+
_dec_note_record(p.get("cancellation"), keyring, st)
179+
180+
181+
def decrypt_task_payload(value: Any, keyring) -> DecryptedWire:
182+
"""A task or subtask payload (the shapes chain reads return)."""
183+
st = _State()
184+
out = copy.deepcopy(value)
185+
if isinstance(out, dict):
186+
_dec_payload_in_place(out, keyring, st)
187+
return DecryptedWire(out, st.undecryptable)
188+
189+
190+
def decrypt_task_summary(value: Any, keyring) -> DecryptedWire:
191+
"""A task index / group roster row: ``title`` is its only sealed field."""
192+
st = _State()
193+
out = copy.deepcopy(value)
194+
if isinstance(out, dict):
195+
marker = _marker_of(out.pop("encryption", None))
196+
_dec_field(out, "title", marker, keyring, st)
197+
return DecryptedWire(out, st.undecryptable)
198+
199+
200+
def decrypt_submission(value: Any, keyring, marker: dict | None) -> DecryptedWire:
201+
"""A submission (body + inline location) under its feed entry's marker;
202+
the submission carries no marker of its own."""
203+
st = _State()
204+
out = copy.deepcopy(value)
205+
if isinstance(out, dict):
206+
_dec_message_body(out, marker, keyring, st)
207+
return DecryptedWire(out, st.undecryptable)
208+
209+
210+
def decrypt_event(raw: Any, keyring) -> DecryptedWire:
211+
"""One wire event's ``data``, decrypted in place on a copy of the raw event
212+
dict by event-data type. Unrecognized types pass through untouched."""
213+
st = _State()
214+
out = copy.deepcopy(raw)
215+
data = out.get("data") if isinstance(out, dict) else None
216+
if not isinstance(data, dict):
217+
return DecryptedWire(out, 0)
218+
marker = _marker_of(out.get("encryption"))
219+
kind = data.get("type")
220+
if kind in ("taskInputUploaded", "taskInputCompleted", "subtaskInputUploaded", "subtaskInputCompleted"):
221+
_dec_upload(data.get("inputUploaded"), marker, keyring, st)
222+
elif kind in ("taskCompleted", "subtaskCompleted"):
223+
for u in data.get("inputsUploaded") or []:
224+
_dec_upload(u, marker, keyring, st)
225+
elif kind == "replyAppended":
226+
reply = data.get("reply")
227+
if isinstance(reply, dict):
228+
_dec_message_body(reply, marker, keyring, st)
229+
elif kind == "submissionCreated":
230+
submission = data.get("submission")
231+
if isinstance(submission, dict):
232+
_dec_message_body(submission, marker, keyring, st)
233+
elif kind == "notificationCompleted":
234+
reply = data.get("reply")
235+
if isinstance(reply, dict):
236+
rt = reply.get("type")
237+
if rt == "text":
238+
_dec_field(reply, "value", marker, keyring, st)
239+
elif rt == "choice":
240+
_dec_field(reply, "selectedValue", marker, keyring, st)
241+
elif rt == "actions":
242+
_dec_field(reply, "selectedKey", marker, keyring, st)
243+
elif kind in (
244+
"taskCanceled", "subtaskCanceled",
245+
"taskDeclinedByRecipient", "subtaskDeclinedByRecipient",
246+
"taskDeclined", "subtaskDeclined",
247+
):
248+
# The envelope marker on these is the note's own; reason/supersededBy
249+
# stay plaintext.
250+
_dec_field(data, "note", marker, keyring, st)
251+
return DecryptedWire(out, st.undecryptable)
252+
253+
254+
def try_decrypt_event_data(event, keyring) -> dict | None:
255+
"""A decrypted copy of ``event.data``, or None when the event isn't
256+
encrypted or no held key matches its marker. A field that fails to decrypt
257+
(e.g. sealed under a newer org key) is left as ciphertext."""
258+
marker = _marker_of(event.raw.get("encryption"))
259+
if marker is None or keyring.key_for_marker(marker) is None:
35260
return None
36-
# event.data is always a dict, so inline its branch of _decrypt_in_place
37-
# (which would just run this same comprehension) to keep the return a dict.
38-
data = copy.deepcopy(event.data)
39-
return {k: _decrypt_in_place(v, key) for k, v in data.items()}
40-
41-
42-
def _decrypt_in_place(value, key: bytes):
43-
if isinstance(value, str):
44-
if looks_like_ciphertext(value):
45-
try:
46-
return decrypt(value, key)
47-
except Exception:
48-
return value # leave ciphertext in place
49-
return value
50-
if isinstance(value, list):
51-
return [_decrypt_in_place(v, key) for v in value]
52-
if isinstance(value, dict):
53-
return {k: _decrypt_in_place(v, key) for k, v in value.items()}
54-
return value
261+
return decrypt_event(event.raw, keyring).value.get("data")

tests/e2e/test_cross_sdk.py

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
pytest.importorskip("nacl", reason="crypto extra required: pip install 'simplepush[crypto]'")
1414

15-
from simplepush import Client, derive_key, looks_like_ciphertext # noqa: E402
15+
from simplepush import Client, derive_key # noqa: E402
1616
from simplepush.crypto import decrypt # noqa: E402 (bare `simplepush.decrypt` is shadowed by the submodule)
1717

1818

@@ -50,7 +50,7 @@ def test_python_encrypted_send_decrypts_in_js(conn, js_helper):
5050
assert out["decrypted"], "JS keyring cannot read a python send"
5151

5252

53-
def test_wire_shape_parity_for_encrypted_tagged_sends(conn, sender_read, js_helper):
53+
def test_wire_shape_parity_for_encrypted_tagged_sends(conn, sender_read, password_salt, js_helper):
5454
"""Send the same logical task from both SDKs; the at-rest payloads must
5555
have the same shape AND classify identically as ciphertext/plaintext per
5656
field. Catches one-sided encryption or naming drift mechanically."""
@@ -62,18 +62,23 @@ def test_wire_shape_parity_for_encrypted_tagged_sends(conn, sender_read, js_help
6262
)
6363
out = js_helper("send-parity", PW, s)
6464

65-
shape_py = _shape(sender_read(py_task.task_id))
66-
shape_js = _shape(sender_read(out["taskId"]))
65+
key = derive_key(PW, password_salt).symmetric_key
66+
shape_py = _shape(sender_read(py_task.task_id), key)
67+
shape_js = _shape(sender_read(out["taskId"]), key)
6768
assert shape_py == shape_js, f"\npython: {shape_py}\njs: {shape_js}"
6869

6970

7071
PARITY_FIELDS = ("title", "content", "tag", "contentFormat", "autoCommit", "inputs")
7172

7273

73-
def _shape(read: dict):
74+
def _shape(read: dict, key: bytes):
7475
def classify(v):
7576
if isinstance(v, str):
76-
return "enc" if looks_like_ciphertext(v) else "plain"
77+
try:
78+
decrypt(v, key)
79+
return "enc"
80+
except Exception:
81+
return "plain"
7782
if isinstance(v, dict):
7883
return {k: classify(x) for k, x in sorted(v.items()) if x is not None}
7984
if isinstance(v, list):

0 commit comments

Comments
 (0)