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.
- {simplepush-3.2.0 → simplepush-3.3.0}/PKG-INFO +1 -1
- {simplepush-3.2.0 → simplepush-3.3.0}/pyproject.toml +1 -1
- {simplepush-3.2.0 → simplepush-3.3.0}/src/simplepush/__init__.py +8 -3
- simplepush-3.3.0/src/simplepush/decrypt.py +261 -0
- simplepush-3.2.0/src/simplepush/decrypt.py +0 -54
- {simplepush-3.2.0 → simplepush-3.3.0}/.gitignore +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/.worktrees/pg-resilience/LICENSE +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/.worktrees/pg-resilience/README.md +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/.worktrees/pg-resilience/pyproject.toml +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/LICENSE +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/README.md +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/src/simplepush/_extras.py +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/src/simplepush/api.py +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/src/simplepush/client.py +0 -0
- {simplepush-3.2.0 → simplepush-3.3.0}/src/simplepush/crypto.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simplepush
|
|
3
|
-
Version: 3.
|
|
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.
|
|
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", "
|
|
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
|
|
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", "
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|