simplepush 3.2.0__tar.gz → 3.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: simplepush
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Python client for Simplepush (tasks, notifications, events, end-to-end encryption).
5
5
  Project-URL: Homepage, https://simplepu.sh
6
6
  Project-URL: Documentation, https://simplepu.sh/guide/python-sdk
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "simplepush"
7
- version = "3.2.0"
7
+ version = "3.3.0"
8
8
  description = "Python client for Simplepush (tasks, notifications, events, end-to-end encryption)."
9
9
  readme = "README.md"
10
10
  license = { file = "LICENSE" }
@@ -38,7 +38,8 @@ from ._extras import CRYPTO_HINT, MissingCryptoExtra
38
38
  _CRYPTO_NAMES = (
39
39
  "Decryptor", "OrgDecryptor", "Keyring", "DerivedKey",
40
40
  "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",
42
43
  )
43
44
 
44
45
  try:
@@ -46,13 +47,17 @@ try:
46
47
  Decryptor, OrgDecryptor, Keyring, DerivedKey,
47
48
  derive_key, encrypt, decrypt, key_fingerprint,
48
49
  )
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
+ )
50
54
  # Literal list (not `list(_CRYPTO_NAMES)`) so static checkers can follow
51
55
  # the export list; keep it in sync with _CRYPTO_NAMES above.
52
56
  __all__ += [
53
57
  "Decryptor", "OrgDecryptor", "Keyring", "DerivedKey",
54
58
  "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",
56
61
  ]
57
62
  except MissingCryptoExtra:
58
63
  # pynacl is absent: keep the crypto names out of the namespace (and out of
@@ -0,0 +1,261 @@
1
+ """Schema-driven decryption of the wire JSON the read surface returns: task
2
+ payloads, chains, summaries, submissions, raw events.
3
+
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).
20
+
21
+ ring = client.keyring()
22
+ page = decrypt_task_payload(read, ring) # page.value, page.undecryptable
23
+ """
24
+
25
+ import copy
26
+ import json
27
+ from typing import Any, NamedTuple
28
+
29
+ from .crypto import decrypt
30
+
31
+
32
+ class DecryptedWire(NamedTuple):
33
+ value: Any
34
+ undecryptable: int
35
+
36
+
37
+ class _State:
38
+ __slots__ = ("undecryptable",)
39
+
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
55
+ key = keyring.key_for_marker(marker)
56
+ 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:
260
+ return None
261
+ return decrypt_event(event.raw, keyring).value.get("data")
@@ -1,54 +0,0 @@
1
- """Best-effort decryption of an event's ``data`` payload via a `Keyring`.
2
-
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:
8
-
9
- ring = client.keyring()
10
- async for event in client.events():
11
- data = try_decrypt_event_data(event, ring) # decrypted dict, or None
12
- """
13
-
14
- import copy
15
- import re
16
-
17
- from .crypto import decrypt
18
-
19
- _CIPHERTEXT_RE = re.compile(r"^[A-Za-z0-9+/=]+$")
20
-
21
-
22
- def looks_like_ciphertext(s: str) -> bool:
23
- return 40 <= len(s) <= 8192 and bool(_CIPHERTEXT_RE.match(s))
24
-
25
-
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
33
- key = keyring.key_for_marker(marker)
34
- if key is None:
35
- 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
File without changes
File without changes
File without changes