Lock a secret until a future moment — so that nobody, not even you, can read it early.
time-safe timelock-encrypts your secrets, stores only the ciphertext in a GitHub repo, and lets you reveal them only after a date you choose. The lock is enforced by cryptography — the drand distributed randomness beacon via tlock — not by an honour system.
No decryption key is ever stored, anywhere. Until the unlock time arrives, the key literally does not exist — so neither you, nor GitHub, nor anyone who copies the repo can decrypt early.
A keyboard-driven terminal app built with Textual. (Python rewrite of the original Java version.)
| Vaults | Secret (ready) | Add secret |
|---|---|---|
Requirements: Python 3.12+ and uv; the drand tle binary; a private GitHub repo + a token with contents write access (add secrets + workflows for email delivery).
# get the tlock CLI (pick your OS/arch from the releases page)
mkdir -p .tools
curl -sL https://github.com/drand/tlock/releases/download/v1.2.0/tlock_1.2.0_darwin_arm64.tar.gz | tar -xz -C .tools tle
# run it
uv sync
uv run timesafeIn the app:
| Key | Does |
|---|---|
n |
add a vault — creates and/or initializes the repo, or just registers one another machine already set up |
c |
copy the secret id (on a secret's detail screen) |
a |
add a secret — name, a duration (30m, 2h, 7d, 1d12h), optional delivery email, text |
d |
reveal a ready secret locally |
s |
email a ready secret's plaintext to its address |
r |
renew — re-lock a ready secret for a new duration |
x |
delete · g re-link Gmail · q quit |
Alongside the TUI there's a CLI for other programs to drive — no TTY, no prompts, works from cron or a
systemd unit. Bare timesafe still opens the TUI.
uv tool install timesafe # wheels bundle the `tle` binary — nothing else to fetch
# ...or, on a host with no usable modern Python (no pip, no venv, no container):
curl -sL https://github.com/louisalexander/time-safe/releases/latest/download/timesafe-linux-amd64 \
-o ~/.local/bin/timesafe && chmod +x ~/.local/bin/timesafePoint it at a vault with two environment variables, then:
Token scopes:
contentswrite is enough to add and reveal secrets. Email delivery needsworkflowwrite as well (time-safe writes a per-secret workflow only forsecretswrite to store the Gmail credentials.
export TIMESAFE_VAULT=me/my-vault TIMESAFE_GITHUB_TOKEN=ghp_...
# Generate a secret and lock it — never on screen, never in argv, never in shell history.
openssl rand -base64 32 | timesafe add --name break-glass --duration 10d --secret-stdin
# {"id":"3f2a91c4-...","name":"break-glass","unlock_at":"2026-08-17T12:00:00+00:00",
# "round":19273645,"vault_path":"vault/secrets/3f2a91c4-....tle","pushed":true}
timesafe status --id "$ID" --json # {"ready":false,"seconds_remaining":863995,...}
timesafe reveal --id "$ID" # plaintext only, no trailing newline; exit 3 if not yet
timesafe list --json
timesafe init --vault me/new-vault --name seedbox --create # idempotent; always private
timesafe renew --id "$ID" --duration 30d # re-lock an already-unlocked secret
timesafe send --id "$ID" # dispatch the email delivery workflow
timesafe delete --id "$ID" --yes # irreversible, so --yes is the confirmationReads retry transient GitHub failures (3 attempts, ≤10s total) so a blip doesn't fail an unattended
break-glass check; --no-retry opts out. Writes are never retried — add isn't idempotent.
| Exit | Meaning |
|---|---|
0 |
ok |
2 |
usage error |
3 |
not yet unlockable — distinct from a failure, so callers can poll |
4 |
vault / network error |
5 |
no such secret |
Errors are JSON on stderr with a stable code field. The same logic is importable — timesafe.api.add / status / reveal / list_secrets / delete / renew / send / link_gmail / init — if you'd rather skip
the subprocess.
Persist the
idthataddreturns. It's the vault's authoritative key. Secret names are not unique and nothing enforces it, so never construct an id from a name;--namelookup exists as a convenience and fails loudly if it's ambiguous.
📖 Full reference, including cron/systemd examples: CLI & automation
Email uses your own Google Cloud OAuth client:
- Google Cloud Console → enable the Gmail API.
- OAuth consent screen: External, scope
https://www.googleapis.com/auth/gmail.send; add your address as a Test user or Publish (publishing avoids the 7-day refresh-token expiry). - Credentials → OAuth client ID → "Desktop app" (not "TVs and Limited Input devices" — Google's device flow rejects
gmail.send; time-safe uses the loopback flow). Note the client id + secret. - In time-safe press
g, enter the Gmail address + client id/secret → Link → approve in the browser.
The refresh token + client secret are stored only as GitHub Actions secrets in the vault repo — never locally.
Headless hosts: step 4 needs a browser, but only once, and not necessarily on the target machine. Once you hold a refresh token, seal it into any vault without a TUI:
export TIMESAFE_OAUTH_CLIENT_SECRET=GOCSPX-...
printf '%s' "$REFRESH_TOKEN" | timesafe link-gmail --gmail vault@gmail.com \
--client-id 1234-abc.apps.googleusercontent.com --token-stdin- Confidentiality before the unlock time is cryptographic (drand threshold) — the repo can be seen without leaking anything early.
- After unlock, anyone with repo read access can decrypt — so keep the vault repo private.
- Trust assumptions: the drand threshold isn't compromised before the unlock time, and drand mainnet stays live (a robust multi-org network; the chain hash is pinned per secret).
- A real time-lock means the unlock time is immutable — there's no "extend." To re-time a secret, reveal it after unlock and add it again (or Renew a ready one).
📖 Full docs: https://louisalexander.github.io/time-safe/ — getting started · architecture · usage · security
uv run pytest # unit + Textual Pilot tests
TIMESAFE_LIVE=1 uv run pytest tests/test_tle.py -k roundtrip # live drand roundtrip
uv run python scripts/make_screenshots.py # regenerate docs/screenshots
