youpdated 0.1.0__tar.gz → 0.2.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.
Files changed (43) hide show
  1. youpdated-0.2.0/CHANGELOG.md +111 -0
  2. {youpdated-0.1.0 → youpdated-0.2.0}/PKG-INFO +107 -4
  3. {youpdated-0.1.0 → youpdated-0.2.0}/README.md +103 -3
  4. {youpdated-0.1.0 → youpdated-0.2.0}/SECURITY.md +19 -1
  5. {youpdated-0.1.0 → youpdated-0.2.0}/pyproject.toml +3 -2
  6. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/__init__.py +1 -1
  7. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/cli.py +166 -10
  8. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/config.py +18 -2
  9. youpdated-0.2.0/youpdated/crypto.py +216 -0
  10. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/http.py +50 -3
  11. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/browser.py +17 -10
  12. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/state.py +51 -2
  13. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/PKG-INFO +107 -4
  14. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/SOURCES.txt +1 -0
  15. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/requires.txt +4 -0
  16. youpdated-0.1.0/CHANGELOG.md +0 -36
  17. {youpdated-0.1.0 → youpdated-0.2.0}/ADDING_SOURCES.md +0 -0
  18. {youpdated-0.1.0 → youpdated-0.2.0}/CONTRIBUTING.md +0 -0
  19. {youpdated-0.1.0 → youpdated-0.2.0}/LICENSE +0 -0
  20. {youpdated-0.1.0 → youpdated-0.2.0}/MANIFEST.in +0 -0
  21. {youpdated-0.1.0 → youpdated-0.2.0}/setup.cfg +0 -0
  22. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/__main__.py +0 -0
  23. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/cleanup.py +0 -0
  24. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/models.py +0 -0
  25. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/py.typed +0 -0
  26. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/registry.py +0 -0
  27. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/render/__init__.py +0 -0
  28. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/render/json_out.py +0 -0
  29. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/render/rss_out.py +0 -0
  30. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/render/terminal.py +0 -0
  31. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/runner.py +0 -0
  32. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/__init__.py +0 -0
  33. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/base.py +0 -0
  34. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/feed.py +0 -0
  35. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/generic.py +0 -0
  36. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/github.py +0 -0
  37. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/itch.py +0 -0
  38. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/npm.py +0 -0
  39. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/steam.py +0 -0
  40. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated/sources/youtube.py +0 -0
  41. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/dependency_links.txt +0 -0
  42. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/entry_points.txt +0 -0
  43. {youpdated-0.1.0 → youpdated-0.2.0}/youpdated.egg-info/top_level.txt +0 -0
@@ -0,0 +1,111 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to
5
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [0.2.0] — 2026-08-19
8
+
9
+ ### Added
10
+
11
+ - **Encryption at rest for the config and history** ([#5](https://github.com/Void1-1/youpdated/issues/5)).
12
+ `youpdated encrypt` (alias `set-encrypted`) converts an existing setup in place; `youpdated decrypt`
13
+ converts it back. Every command detects an encrypted setup on its own and asks for the passphrase
14
+ once, or reads it from `YOUPDATED_PASSPHRASE` for unattended runs.
15
+
16
+ Files are encrypted whole with AES-256-GCM under a scrypt-derived key (n=2¹⁶, r=8, p=1), with the
17
+ KDF parameters authenticated as additional data so they cannot be downgraded. Decryption happens
18
+ **in memory**: the config is parsed from a decrypted buffer and the SQLite database is
19
+ deserialized into an in-memory database and written back encrypted when the run ends, so no
20
+ plaintext copy is put on disk even mid-run. A read-only run leaves the file byte-for-byte alone.
21
+
22
+ Needs the `cryptography` package: `pip install 'youpdated[encryption]'`. Installs without it
23
+ behave exactly as before.
24
+
25
+ - **`youpdated init --encrypt`** writes the starter config already encrypted, so a setup that is
26
+ meant to be private never has a plaintext config on disk at all — unlike `init` then `encrypt`,
27
+ which leaves the original blocks in free space.
28
+
29
+ - **A proxy preflight.** When `privacy.proxy` is set, the proxy is checked before the run starts
30
+ and the run is refused with exit `1` if it is unreachable. `--test` reports it instead of
31
+ aborting.
32
+
33
+ - **`youpdated.crypto` is a documented standalone module.** `from youpdated import crypto` gives
34
+ you the container directly; everything in its `__all__` is a supported surface. It stays an
35
+ optional *dependency* rather than a separate distribution on purpose: nothing in it imports
36
+ `cryptography` at module scope, so a plain install already pays nothing for it, and shipping the
37
+ container format apart from the code that reads it would risk version skew on files that are the
38
+ user's only copy.
39
+
40
+ ### Changed
41
+
42
+ - **A dead proxy now stops the run instead of failing every target.** Requests already failed
43
+ closed (httpx routes everything through the proxy and never falls back to a direct connection)
44
+ but with Tor off, a run would fail each target separately, record an empty baseline, and still
45
+ exit `0`. In a cron log that is indistinguishable from "nothing new". It now exits `1` before the
46
+ state database is even opened, so nothing is recorded.
47
+
48
+ ### Fixed
49
+
50
+ - **A broken SOCKS handshake escaped the retry path.** `socksio` raises `ProtocolError`, which is
51
+ not an `httpx.HTTPError`, so a proxy port answering with something that is not SOCKS5 bypassed
52
+ the retries and surfaced as a raw exception rather than a `FetchError`. The client now treats
53
+ `SOCKSError` as a network error like any other.
54
+
55
+ - **`tests/test_cleanup.py` could delete a real `./youpdated.yaml`.** The fixture redirected the
56
+ config and data directories but not the working directory, so `find_traces()` picked up the
57
+ project config of whoever ran the suite from a directory that had one, and `remove_traces()`
58
+ deleted it. The fixture now chdirs to the temp directory.
59
+
60
+ ## [0.1.1] — 2026-08-18
61
+
62
+ ### Fixed
63
+
64
+ - **`browser` / Brave: a server error killed the target instead of falling back.** The Brave source
65
+ reads the GitHub REST API and keeps the `.atom` feed as a fallback, but only a 403 or 429 reached
66
+ it. A 5xx: a timeout, or a DNS failure, was retried, then raised, and the whole target was
67
+ reported as failed. Observed against `api.github.com` returning 504. Any failure the HTTP client
68
+ gives up on now falls back to the atom feed; a genuine outage of *both* still reports an error.
69
+
70
+ ### Changed
71
+
72
+ - Retry backoff is configurable on the HTTP client (`retry_backoff`), so the test suite no longer spends real seconds exercising retry paths. The suite went from ~4.9s to ~0.4s.
73
+
74
+ ### Added
75
+
76
+ - A `release` workflow that publishes to PyPI via trusted publishing when a GitHub Release is
77
+ published, gated on the full 13-job test matrix and on the tag matching the version in
78
+ `pyproject.toml`.
79
+
80
+ ## [0.1.0] — 2026-08-18
81
+
82
+ First release.
83
+
84
+ ### Added
85
+
86
+ - **Seven sources**, all working without accounts or API keys:
87
+ - `github` — releases, tags, and commits via `.atom` feeds
88
+ - `npm` — newly published versions from public registry
89
+ - `steam` — patch notes and news; resolves the store name from a bare appid
90
+ - `itch` — devlog posts and new builds, fingerprinted from the game page
91
+ - `youtube` — channels and playlists, with Invidious and Data API fallback
92
+ - `browser` — Chrome, Brave, Firefox, and Edge releases across platforms and channels
93
+ - `feed` — any RSS/Atom URL, for apps without a dedicated source
94
+ - **Plugin architecture**: sources register in-tree with `@register` or ship from a third-party package through a `youpdated.sources` entry point.
95
+ - **Config**: every source entry takes a bare value for the common case or a
96
+ mapping for advanced options
97
+ - **Incremental reporting**: a SQLite history so each run reports what changed. First run records a baseline.
98
+ - **Three output formats**: terminal report, `--json`, and `--rss`
99
+ - **Privacy controls**: optional SOCKS/HTTP proxy covering every request, user-agent rotation, per-host request pacing with jitter, per-request cookie clearing, and conditional GETs. `--test` prints URLs without sending
100
+ - **`youpdated uninstall`** to remove every file from the tool. Refuses directories it didn't create or have other files.
101
+
102
+ ### Known issues
103
+
104
+ - YouTube's RSS endpoint throttles occassionaly and 404s valid URLs; fallback covers, but a run can still fail all three. Retry or set `privacy.proxy`.
105
+ - Some itch games publish no "Updated" timestamp, so build updates are reported undated.
106
+ - Firefox publishes current versions, so it reports one item per channel.
107
+ - Edge exposes release notes only for the stable and beta channels. (But like, it's Edge, why do you want to know when it updates?)
108
+
109
+ [0.2.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.2.0
110
+ [0.1.1]: https://github.com/Void1-1/youpdated/releases/tag/v0.1.1
111
+ [0.1.0]: https://github.com/Void1-1/youpdated/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: youpdated
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Simple update tracker for games, apps, and packages
5
5
  Author-email: Void1-1 <161782542+Void1-1@users.noreply.github.com>
6
6
  License-Expression: MIT
@@ -32,9 +32,12 @@ Requires-Dist: feedparser>=6.0
32
32
  Requires-Dist: rich>=13.0
33
33
  Requires-Dist: PyYAML>=6.0
34
34
  Requires-Dist: platformdirs>=4.0
35
+ Provides-Extra: encryption
36
+ Requires-Dist: cryptography>=42.0; extra == "encryption"
35
37
  Provides-Extra: dev
36
38
  Requires-Dist: pytest>=8.0; extra == "dev"
37
39
  Requires-Dist: respx>=0.21; extra == "dev"
40
+ Requires-Dist: cryptography>=42.0; extra == "dev"
38
41
  Provides-Extra: publish
39
42
  Requires-Dist: build>=1.2; extra == "publish"
40
43
  Requires-Dist: twine>=5.0; extra == "publish"
@@ -99,7 +102,7 @@ it on your `PATH`:
99
102
 
100
103
  ```sh
101
104
  export PATH="$PWD/.venv/bin:$PATH" # macOS / Linux
102
- youpdated --version # -> youpdated 0.1.0
105
+ youpdated --version # -> youpdated 0.2.0
103
106
  ```
104
107
 
105
108
  ```powershell
@@ -261,6 +264,9 @@ youpdated check -v # show each request, its status, and item
261
264
  youpdated check --state ./test.db # use a throwaway history file
262
265
  youpdated sources # list available sources
263
266
  youpdated init --force # rewrite the starter config
267
+ youpdated init --encrypt # starter config written encrypted from the start
268
+ youpdated encrypt # lock the config and history behind a passphrase
269
+ youpdated decrypt # convert them back to plain files
264
270
  youpdated uninstall --test # list every file the tool wrote
265
271
  ```
266
272
 
@@ -272,7 +278,7 @@ Running `youpdated` with no arguments runs `youpdated check`.
272
278
  youpdated check --json | jq '.updates[] | select(.source == "github") | .title'
273
279
  ```
274
280
 
275
- Exit codes: `0` success, `1` config error, `2` with `--fail-on-error` when a source failed, `130` on interrupt.
281
+ Exit codes: `0` success, `1` config, encryption, or proxy error, `2` with `--fail-on-error` when a source failed, `130` on interrupt.
276
282
 
277
283
  ## Running it on schedule
278
284
 
@@ -323,6 +329,12 @@ Once a day is plenty, most of these sources change slowly, and conditional reque
323
329
  | Everything reports as new again | The history database was deleted or `--state` points somewhere new. |
324
330
  | `youpdated: command not found` | The virtualenv isn't on your `PATH`. Use `.venv/bin/youpdated`. |
325
331
  | `ModuleNotFoundError: youpdated` | You used `pip install -e .` on MacOS |
332
+ | `Encryption error: wrong passphrase, or the file has been modified` | The passphrase doesn't match, or the file was edited or truncated. Nothing is written on a failed unlock. |
333
+ | `Encryption error: ... no terminal to ask on` | A cron or piped run can't prompt. Set `YOUPDATED_PASSPHRASE`. |
334
+ | `encryption needs the cryptography package` | `pip install 'youpdated[encryption]'`. |
335
+ | `... is not encrypted, but a passphrase was given` | A half-converted setup. Re-run `youpdated encrypt` to finish it. |
336
+ | `Proxy error: cannot reach the proxy at ...` | `privacy.proxy` is set but nothing is listening. Start Tor (or your proxy), or remove the line. Nothing was fetched or recorded. |
337
+ | Every source fails with `ProtocolError: Malformed reply` | Something is listening on the proxy port but isn't a SOCKS5 proxy. Check the port and scheme. |
326
338
 
327
339
  To start over from a clean slate, delete the history database (the path is in the table in [step 2](#2-create-your-config)); your config is untouched.
328
340
 
@@ -357,16 +369,107 @@ It prints the command to remove the package itself. (which it can't do while run
357
369
 
358
370
  If you installed into a dedicated virtualenv, deleting that directory removes the package too.
359
371
 
372
+ ## Encryption at rest
373
+
374
+ By default the config and the history database are plain files. Anyone with your login, or your unencrypted backups, can read the list. To lock them behind a passphrase:
375
+
376
+ ```sh
377
+ pip install 'youpdated[encryption]' # pulls in `cryptography`; not needed otherwise
378
+ youpdated encrypt # `set-encrypted` also works
379
+ ```
380
+
381
+ Starting fresh, skip the plaintext step entirely. `init --encrypt` writes the starter config already encrypted, so it never exists in the clear at all:
382
+
383
+ ```sh
384
+ youpdated init --encrypt
385
+ ```
386
+
387
+ You can't edit in place: run `youpdated decrypt`, edit, then `youpdated encrypt` again. That does put the config on disk in plaintext while you work on it, so `init --encrypt` is rarely worth anything, or you edit before encrypting the first time.
388
+
389
+ It asks for a passphrase twice and rewrites both files in place. From then on every command asks for it once, decrypts **into memory**, and writes back encrypted when the run ends.
390
+
391
+ ```sh
392
+ youpdated check # Passphrase: ...
393
+ youpdated decrypt # back to plain files
394
+ ```
395
+
396
+ For cron and other unattended runs there is no terminal to ask on, so pass the passphrase in the environment instead:
397
+
398
+ ```cron
399
+ 0 9 * * * YOUPDATED_PASSPHRASE='...' /path/to/.venv/bin/youpdated check >> ~/youpdated.log 2>&1
400
+ ```
401
+
402
+ That trades the passphrase's secrecy for the crontab's. Ensure it is more protected than your config was. Reading it from a file your shell sources, or from a keychain helper, is stronger.
403
+
404
+ **What this does and does not protect.** Files are encrypted whole with AES-256-GCM under a key derived from the passphrase with scrypt (n=2¹⁶, r=8), and the tag is checked on every read, so a modified file is refused rather than trusted. It does **not** cover anything while Youpdated is running: the passphrase and the decrypted config are in the process's memory, and `YOUPDATED_PASSPHRASE` is visible to other processes of the same user. Nor does it erase the plaintext that was already on the disk. `encrypt` overwrites the file, but the old blocks may survive in free space until they are reused.
405
+
406
+ **There is no recovery.** Don't forget your passphrase, or you'll have to recreate configs.
407
+
408
+ | | Encrypted | Plain |
409
+ | --- | --- | --- |
410
+ | `youpdated check` | asks for the passphrase | runs |
411
+ | `cat ~/.config/youpdated/config.yaml` | binary | readable |
412
+ | `sqlite3 state.sqlite3 'select * from seen'` | `file is not a database` | readable |
413
+ | File permissions | `0600` on macOS/Linux, see below on Windows | as created |
414
+
415
+ **On Windows the file mode does nothing.** There are no POSIX permission bits, so the `0600` the
416
+ writer asks for is ignored apart from the read-only flag, and access is decided by the ACL the file
417
+ inherits from its directory. In the default location under `%LOCALAPPDATA%` that already excludes
418
+ other standard users. The encryption itself is unaffected and works
419
+ identically on all three platforms.
420
+
421
+ ### Using it as a module
422
+
423
+ [youpdated/crypto.py](https://github.com/Void1-1/youpdated/blob/main/youpdated/crypto.py) stands alone.
424
+
425
+ ```python
426
+ from youpdated import crypto
427
+
428
+ blob = crypto.encrypt(b"anything", "passphrase")
429
+ crypto.write_private("secret.bin", blob) # atomic, 0600
430
+ assert crypto.decrypt(blob, "passphrase") == b"anything"
431
+ ```
432
+
433
+ Everything in `crypto.__all__` is supported, the container format included: `encrypt`, `decrypt`, `is_encrypted`, `is_encrypted_file`, `write_private`, `prompt_passphrase`, `available`, `require_backend`, `EncryptionError`, `MAGIC`, `VERSION`, `PASSPHRASE_ENV`. A `decrypt` on a file this version can't read raises rather than guessing, and `VERSION` is bumped if the layout ever changes.
434
+
435
+ The same passphrase threads through the two core entry points, so you can drive an encrypted setup without the CLI:
436
+
437
+ ```python
438
+ from youpdated.config import load_config
439
+ from youpdated.state import State
440
+
441
+ config = load_config("youpdated.yaml", passphrase="...")
442
+ with State("state.sqlite3", passphrase="...") as state:
443
+ print(state.seen_count())
444
+ ```
445
+
446
+ ```sh
447
+ $ python -c "from youpdated.cli import main; main(['sources'])" && python -c "
448
+ import sys, youpdated.cli
449
+ print('cryptography loaded:', any(m.startswith('cryptography') for m in sys.modules))"
450
+ cryptography loaded: False
451
+ ```
452
+
453
+ Don't use encryption with untrusted sources or non-official code without verifying security yourself!
454
+
360
455
  ## Privacy
361
456
 
362
457
  - **No credentials.** Every source works unauthenticated. `GITHUB_TOKEN` and `YOUTUBE_API_KEY` are read from the environment if set (only for raising rate limits) and are never written to config.
363
- - **Local only.** Config and history live in your platform's config/data directories. Nothing is sent anywhere except the sources you list.
458
+ - **Local only.** Config and history live in your platform's config/data directories. Nothing is sent anywhere except the sources you list. They can be [encrypted at rest](#encryption-at-rest).
364
459
  - **One HTTP path.** Every request goes through [youpdated/http.py](https://github.com/Void1-1/youpdated/blob/main/youpdated/http.py). `privacy.proxy` covers all traffic. To check:
365
460
 
366
461
  ```sh
367
462
  youpdated check --all --no-save # with privacy.proxy: socks5://127.0.0.1:9
368
463
  ```
369
464
 
465
+ - **The proxy fails closed.** There is no direct fallback: if `privacy.proxy` is set and the proxy
466
+ is unreachable, requests error rather than going around it. Youpdated also checks the proxy is
467
+ up *before* the run starts and refuses with exit `1` if it isn't, so a run with Tor down can't
468
+ quietly report "nothing new" and record an empty baseline. `--test` reports the proxy as
469
+ unreachable instead of aborting, since it sends nothing either way.
470
+ - **Hostnames are resolved by the proxy, not by you.** SOCKS5 CONNECT sends the domain name to the
471
+ proxy (`ATYP 3`), so your local resolver never sees which sites you watch. A proxied request with a locally-resolved hostname would leak the whole list to your DNS server.
472
+
370
473
  - **Cookies are discarded** on every request, so nothing accumulates across a run.
371
474
  - **Requests are paced** per host with random jitter instead of arriving in a burst.
372
475
  - **Conditional GETs** (ETag / Last-Modified) mean unchanged feeds are re-fetched cheaply, which is both faster and less fingerprintable. `--all` turns them off, since a 304 has no items.
@@ -57,7 +57,7 @@ it on your `PATH`:
57
57
 
58
58
  ```sh
59
59
  export PATH="$PWD/.venv/bin:$PATH" # macOS / Linux
60
- youpdated --version # -> youpdated 0.1.0
60
+ youpdated --version # -> youpdated 0.2.0
61
61
  ```
62
62
 
63
63
  ```powershell
@@ -219,6 +219,9 @@ youpdated check -v # show each request, its status, and item
219
219
  youpdated check --state ./test.db # use a throwaway history file
220
220
  youpdated sources # list available sources
221
221
  youpdated init --force # rewrite the starter config
222
+ youpdated init --encrypt # starter config written encrypted from the start
223
+ youpdated encrypt # lock the config and history behind a passphrase
224
+ youpdated decrypt # convert them back to plain files
222
225
  youpdated uninstall --test # list every file the tool wrote
223
226
  ```
224
227
 
@@ -230,7 +233,7 @@ Running `youpdated` with no arguments runs `youpdated check`.
230
233
  youpdated check --json | jq '.updates[] | select(.source == "github") | .title'
231
234
  ```
232
235
 
233
- Exit codes: `0` success, `1` config error, `2` with `--fail-on-error` when a source failed, `130` on interrupt.
236
+ Exit codes: `0` success, `1` config, encryption, or proxy error, `2` with `--fail-on-error` when a source failed, `130` on interrupt.
234
237
 
235
238
  ## Running it on schedule
236
239
 
@@ -281,6 +284,12 @@ Once a day is plenty, most of these sources change slowly, and conditional reque
281
284
  | Everything reports as new again | The history database was deleted or `--state` points somewhere new. |
282
285
  | `youpdated: command not found` | The virtualenv isn't on your `PATH`. Use `.venv/bin/youpdated`. |
283
286
  | `ModuleNotFoundError: youpdated` | You used `pip install -e .` on MacOS |
287
+ | `Encryption error: wrong passphrase, or the file has been modified` | The passphrase doesn't match, or the file was edited or truncated. Nothing is written on a failed unlock. |
288
+ | `Encryption error: ... no terminal to ask on` | A cron or piped run can't prompt. Set `YOUPDATED_PASSPHRASE`. |
289
+ | `encryption needs the cryptography package` | `pip install 'youpdated[encryption]'`. |
290
+ | `... is not encrypted, but a passphrase was given` | A half-converted setup. Re-run `youpdated encrypt` to finish it. |
291
+ | `Proxy error: cannot reach the proxy at ...` | `privacy.proxy` is set but nothing is listening. Start Tor (or your proxy), or remove the line. Nothing was fetched or recorded. |
292
+ | Every source fails with `ProtocolError: Malformed reply` | Something is listening on the proxy port but isn't a SOCKS5 proxy. Check the port and scheme. |
284
293
 
285
294
  To start over from a clean slate, delete the history database (the path is in the table in [step 2](#2-create-your-config)); your config is untouched.
286
295
 
@@ -315,16 +324,107 @@ It prints the command to remove the package itself. (which it can't do while run
315
324
 
316
325
  If you installed into a dedicated virtualenv, deleting that directory removes the package too.
317
326
 
327
+ ## Encryption at rest
328
+
329
+ By default the config and the history database are plain files. Anyone with your login, or your unencrypted backups, can read the list. To lock them behind a passphrase:
330
+
331
+ ```sh
332
+ pip install 'youpdated[encryption]' # pulls in `cryptography`; not needed otherwise
333
+ youpdated encrypt # `set-encrypted` also works
334
+ ```
335
+
336
+ Starting fresh, skip the plaintext step entirely. `init --encrypt` writes the starter config already encrypted, so it never exists in the clear at all:
337
+
338
+ ```sh
339
+ youpdated init --encrypt
340
+ ```
341
+
342
+ You can't edit in place: run `youpdated decrypt`, edit, then `youpdated encrypt` again. That does put the config on disk in plaintext while you work on it, so `init --encrypt` is rarely worth anything, or you edit before encrypting the first time.
343
+
344
+ It asks for a passphrase twice and rewrites both files in place. From then on every command asks for it once, decrypts **into memory**, and writes back encrypted when the run ends.
345
+
346
+ ```sh
347
+ youpdated check # Passphrase: ...
348
+ youpdated decrypt # back to plain files
349
+ ```
350
+
351
+ For cron and other unattended runs there is no terminal to ask on, so pass the passphrase in the environment instead:
352
+
353
+ ```cron
354
+ 0 9 * * * YOUPDATED_PASSPHRASE='...' /path/to/.venv/bin/youpdated check >> ~/youpdated.log 2>&1
355
+ ```
356
+
357
+ That trades the passphrase's secrecy for the crontab's. Ensure it is more protected than your config was. Reading it from a file your shell sources, or from a keychain helper, is stronger.
358
+
359
+ **What this does and does not protect.** Files are encrypted whole with AES-256-GCM under a key derived from the passphrase with scrypt (n=2¹⁶, r=8), and the tag is checked on every read, so a modified file is refused rather than trusted. It does **not** cover anything while Youpdated is running: the passphrase and the decrypted config are in the process's memory, and `YOUPDATED_PASSPHRASE` is visible to other processes of the same user. Nor does it erase the plaintext that was already on the disk. `encrypt` overwrites the file, but the old blocks may survive in free space until they are reused.
360
+
361
+ **There is no recovery.** Don't forget your passphrase, or you'll have to recreate configs.
362
+
363
+ | | Encrypted | Plain |
364
+ | --- | --- | --- |
365
+ | `youpdated check` | asks for the passphrase | runs |
366
+ | `cat ~/.config/youpdated/config.yaml` | binary | readable |
367
+ | `sqlite3 state.sqlite3 'select * from seen'` | `file is not a database` | readable |
368
+ | File permissions | `0600` on macOS/Linux, see below on Windows | as created |
369
+
370
+ **On Windows the file mode does nothing.** There are no POSIX permission bits, so the `0600` the
371
+ writer asks for is ignored apart from the read-only flag, and access is decided by the ACL the file
372
+ inherits from its directory. In the default location under `%LOCALAPPDATA%` that already excludes
373
+ other standard users. The encryption itself is unaffected and works
374
+ identically on all three platforms.
375
+
376
+ ### Using it as a module
377
+
378
+ [youpdated/crypto.py](https://github.com/Void1-1/youpdated/blob/main/youpdated/crypto.py) stands alone.
379
+
380
+ ```python
381
+ from youpdated import crypto
382
+
383
+ blob = crypto.encrypt(b"anything", "passphrase")
384
+ crypto.write_private("secret.bin", blob) # atomic, 0600
385
+ assert crypto.decrypt(blob, "passphrase") == b"anything"
386
+ ```
387
+
388
+ Everything in `crypto.__all__` is supported, the container format included: `encrypt`, `decrypt`, `is_encrypted`, `is_encrypted_file`, `write_private`, `prompt_passphrase`, `available`, `require_backend`, `EncryptionError`, `MAGIC`, `VERSION`, `PASSPHRASE_ENV`. A `decrypt` on a file this version can't read raises rather than guessing, and `VERSION` is bumped if the layout ever changes.
389
+
390
+ The same passphrase threads through the two core entry points, so you can drive an encrypted setup without the CLI:
391
+
392
+ ```python
393
+ from youpdated.config import load_config
394
+ from youpdated.state import State
395
+
396
+ config = load_config("youpdated.yaml", passphrase="...")
397
+ with State("state.sqlite3", passphrase="...") as state:
398
+ print(state.seen_count())
399
+ ```
400
+
401
+ ```sh
402
+ $ python -c "from youpdated.cli import main; main(['sources'])" && python -c "
403
+ import sys, youpdated.cli
404
+ print('cryptography loaded:', any(m.startswith('cryptography') for m in sys.modules))"
405
+ cryptography loaded: False
406
+ ```
407
+
408
+ Don't use encryption with untrusted sources or non-official code without verifying security yourself!
409
+
318
410
  ## Privacy
319
411
 
320
412
  - **No credentials.** Every source works unauthenticated. `GITHUB_TOKEN` and `YOUTUBE_API_KEY` are read from the environment if set (only for raising rate limits) and are never written to config.
321
- - **Local only.** Config and history live in your platform's config/data directories. Nothing is sent anywhere except the sources you list.
413
+ - **Local only.** Config and history live in your platform's config/data directories. Nothing is sent anywhere except the sources you list. They can be [encrypted at rest](#encryption-at-rest).
322
414
  - **One HTTP path.** Every request goes through [youpdated/http.py](https://github.com/Void1-1/youpdated/blob/main/youpdated/http.py). `privacy.proxy` covers all traffic. To check:
323
415
 
324
416
  ```sh
325
417
  youpdated check --all --no-save # with privacy.proxy: socks5://127.0.0.1:9
326
418
  ```
327
419
 
420
+ - **The proxy fails closed.** There is no direct fallback: if `privacy.proxy` is set and the proxy
421
+ is unreachable, requests error rather than going around it. Youpdated also checks the proxy is
422
+ up *before* the run starts and refuses with exit `1` if it isn't, so a run with Tor down can't
423
+ quietly report "nothing new" and record an empty baseline. `--test` reports the proxy as
424
+ unreachable instead of aborting, since it sends nothing either way.
425
+ - **Hostnames are resolved by the proxy, not by you.** SOCKS5 CONNECT sends the domain name to the
426
+ proxy (`ATYP 3`), so your local resolver never sees which sites you watch. A proxied request with a locally-resolved hostname would leak the whole list to your DNS server.
427
+
328
428
  - **Cookies are discarded** on every request, so nothing accumulates across a run.
329
429
  - **Requests are paced** per host with random jitter instead of arriving in a burst.
330
430
  - **Conditional GETs** (ETag / Last-Modified) mean unchanged feeds are re-fetched cheaply, which is both faster and less fingerprintable. `--all` turns them off, since a 304 has no items.
@@ -21,11 +21,18 @@ Youpdated is a local CLI that reads public endpoints. The interesting attack sur
21
21
  - **Privacy leaks.** Youpdated promises that when `privacy.proxy` is set, *every* request goes
22
22
  through it, and that nothing is sent anywhere except the configured sources. A path that bypasses
23
23
  the proxy, leaks the config or history off the machine, or attaches identifying data to requests
24
- is a vulnerability, not just a bug.
24
+ is a vulnerability, not just a bug. This includes failing *open*: if the proxy is unreachable, no
25
+ request may fall back to a direct connection. It also includes resolving a watched hostname
26
+ locally instead of handing it to the proxy, which would leak the whole watch list to your DNS
27
+ resolver even though the requests themselves are proxied.
25
28
  - **Credential handling.** `GITHUB_TOKEN` and `YOUTUBE_API_KEY` are read from the environment and
26
29
  must never be written to disk, logged, or sent to any host other than the one they belong to.
27
30
  - **`youpdated uninstall`.** It deletes files. Any way to make it remove something outside its own
28
31
  config and data directories is in scope.
32
+ - **Encryption at rest.** When the config or history is encrypted, the passphrase and the decrypted
33
+ contents must exist only in memory. Anything that writes plaintext to disk, logs the passphrase,
34
+ accepts a file whose authentication tag does not verify, or lets the KDF parameters be weakened
35
+ is in scope. So is a way to read an encrypted file's contents without the passphrase.
29
36
 
30
37
  ## What's out of scope
31
38
 
@@ -34,5 +41,16 @@ Youpdated is a local CLI that reads public endpoints. The interesting attack sur
34
41
  - Rate limiting or blocking by an upstream service (YouTube's feed does this intermittently by
35
42
  design: see the README).
36
43
  - Requiring a proxy or Tor to be running. Youpdated does not start or manage one.
44
+ - Anything reachable while Youpdated is running with an unlocked setup: the passphrase is in the
45
+ process's memory by necessity, and `YOUPDATED_PASSPHRASE` is readable by other processes of the
46
+ same user. Encryption at rest protects the files, not a live session.
47
+ - Plaintext left in free space by `youpdated encrypt`. It overwrites the file; recovering the old
48
+ blocks from the underlying device is out of scope, and not something a userspace tool can
49
+ reliably prevent on modern storage.
50
+ - File permissions on Windows. The `0600` mode requested when writing is a POSIX concept; Windows
51
+ ignores it and applies the ACL inherited from the containing directory. The default
52
+ `%LOCALAPPDATA%` location is already user-scoped, but a `--config` or `--state` path in a shared
53
+ directory is not. Restricting an ACL needs `pywin32` or shelling out to `icacls`, which this
54
+ project does not depend on. The file contents stay encrypted either way.
37
55
  - Vulnerabilities in dependencies with no exploitable path through this code. Report those upstream;
38
56
  Dependabot tracks version bumps here.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "youpdated"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "Simple update tracker for games, apps, and packages"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -50,7 +50,8 @@ dependencies = [
50
50
  ]
51
51
 
52
52
  [project.optional-dependencies]
53
- dev = ["pytest>=8.0", "respx>=0.21"]
53
+ encryption = ["cryptography>=42.0"]
54
+ dev = ["pytest>=8.0", "respx>=0.21", "cryptography>=42.0"]
54
55
  publish = ["build>=1.2", "twine>=5.0"]
55
56
 
56
57
  [project.urls]
@@ -1,3 +1,3 @@
1
1
  """Youpdated, a simple update tracker."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.0"