Architecture
Overview
time-safe stores only timelock ciphertext in a GitHub repo — there is no key anywhere. Decryption requires the drand beacon's signature for a future round, which the network only publishes once that time arrives.
flowchart TD
APP["Textual app"]
REG["~/.timesafe/vaults.json\n(+ OS keychain for PATs)"]
TLE["tle (drand timelock CLI)"]
DRAND["drand quicknet beacon"]
REPO["GitHub vault repo\n(ciphertext + workflows)"]
GA["GitHub Actions\n(on-demand)"]
GMAIL["Gmail API\n(OAuth)"]
APP --> REG
APP -->|encrypt / reveal| TLE
TLE <-->|round signature| DRAND
APP -->|push .tle / dispatch| REPO
REPO --> GA
GA -->|tle -d at T| GMAIL
Encryption (add)
- The app fetches drand
quicknetinfo and maps the unlock time → a round number. tletimelock-encrypts the plaintext to that round.- The ciphertext is pushed to
vault/secrets/<id>.tle; metadata (id,name,unlock_at,drand_round,drand_chain,created_at, optionaldelivery_email) to<id>.meta; and a per-secretworkflow_dispatchworkflow to.github/workflows/unlock-<id>.yml.
No key file is created or stored. The unlock time is encoded in the ciphertext, not in any retrievable secret.
Reveal (local)
Once now ≥ unlock_at, the app fetches <id>.tle and runs tle -d, which pulls the now-public round signature from drand and decrypts in memory. Before the round, tle reports "too early" and the app keeps counting down. Plaintext never touches disk.
Email delivery (optional)
"Email it" dispatches the per-secret workflow. The job installs tle, runs vault/scripts/send_secret.py which:
tle -dthe ciphertext (this is the server-side time gate — fails before T),- exchanges the vault's Gmail OAuth refresh token (a repo Actions secret) for an access token,
- sends the plaintext via the Gmail API to the secret's delivery address.
On a revoked/expired token it exits non-zero and opens a "Gmail re-authorization required" issue.
Renew
A ready secret can be re-locked: the app decrypts it (possible now), encrypts the plaintext to a new future round, and overwrites the ciphertext/metadata/workflow. A still-locked secret cannot be renewed — it can't be read to re-encrypt.
Module layout
| Module | Responsibility |
|---|---|
config/registry.py |
~/.timesafe/vaults.json — the only local state (vault name + repo) |
config/credentials.py |
GitHub PATs in the OS keychain via keyring |
timelock/drand.py |
fetch beacon info; map a date → round |
timelock/tle.py |
encrypt / decrypt via the tle binary; NotYetUnlocked |
vault/secret.py |
the Secret model + .meta JSON |
vault/workflow.py |
the unlock-workflow YAML + the send_secret.py delivery script |
vault/vault.py |
orchestration: init, put, list, reveal, renew, delete, dispatch, relink |
github/client.py |
thin httpx GitHub REST client |
github/secrets_api.py |
PyNaCl sealed-box for writing Actions secrets |
oauth/loopback_flow.py |
Gmail OAuth (loopback/installed-app, PKCE) |
api.py |
the UI-free surface both the CLI and the TUI call: add, status, list, reveal, delete, renew, send, link-gmail, init. Selector-taking entry points resolve, then delegate to the _secret implementations the TUI calls directly |
github/retry.py |
bounded backoff for idempotent reads only — writes never retry |
errors.py |
typed errors, each binding a stable code to an exit code |
resolve.py |
headless vault + token resolution (env first, then keychain) |
validation.py · format.py |
duration/email/repo validation and countdown rendering, shared by both surfaces |
screens/ |
Textual screens (picker, add vault, gmail link, list, detail, add secret, reveal, renew). Clients of api.py — no screen talks to Vault directly |
Tests
Pure logic (crypto round math, secret model, workflow strings, OAuth helpers, registry) is unit-tested; the GitHub client is tested against a mocked HTTP transport (respx); screens are exercised with Textual's Pilot. The live drand roundtrip is opt-in (TIMESAFE_LIVE=1).