Encrypted delivery and host keys
BetaWhen a service token pulls config, reads it with exec --env, or leases a credential, the answer comes back encrypted to a post-quantum key as well as over TLS. Bind the token to a host key and it only answers to that one host: a copy of the token anywhere else gets nothing, and your admins are told.
What happens on every call
The CLI and the SDKs send a public key with each request. The answer is encrypted to it with X-Wing (ML-KEM-768 with X25519) and AES-256-GCM, and the client refuses an answer that isn't. With no host key, the client makes a fresh key pair for each call and forgets it afterwards.
- A recording of the traffic can't be opened later, even by someone who breaks the TLS key exchange with a future quantum computer.
- A proxy or load balancer that ends TLS, and anything that logs bodies there, sees ciphertext.
- Each answer is bound to its request: it opens only for that request's key, nonce and endpoint.
This needs no setup. Values at rest were already AES-256-GCM under your workspace's key; this covers the trip to your host.
Binding a token to a host
A host key is an X-Wing key pair made on the host. The private half stays in a file only the service's user can read. You give its public half to a new service token, and from then on that token answers only encrypted to it.
# 1. On the host, as the user the service runs as
tilldev secrets host-key init
# ✓ Made a host key at /home/app/.tilldev/host-key (only this user can read it; it never leaves this host)
# fingerprint SHA256:4mJ0p1u9oQyq2xG0l7m1bqz9wVd0Zb0g8c6pYwU1H3s
# public key /home/app/.tilldev/host-key.pub
# 2. Where you manage tokens, with host-key.pub copied over (it is public)
tilldev secrets tokens create --project web --env prod --host-key @host-key.pub --quiet > web.token
# 3. On the host
TILLSECRETS_TOKEN="$(cat web.token)" tilldev secrets exec --env prod -- node server.jsIn the dashboard, paste the contents of host-key.pub into Host key when you create a token. The token list shows each bound token's fingerprint. A token's host key can't be changed: to move a service to a new host, make the new host's key and a new token, then revoke the old one.
Where clients look for the key
| Order | Where | Use it for |
|---|---|---|
| 1 | --host-key <file> | The CLI, one command |
| 2 | TILLSECRETS_HOST_KEY_FILE | A key kept somewhere other than the home directory |
| 3 | TILLSECRETS_HOST_KEY | The file’s contents, on a platform with no disk (a Worker secret, a PaaS variable) |
| 4 | ~/.tilldev/host-key | The default from host-key init |
The CLI and the Node SDK refuse a key file that other users can read, the way ssh does. tilldev secrets host-key show prints the public key and fingerprint again.
In code
Node reads the same places as the CLI:
import { load } from '@tillstack/secrets-node'
// Finds the key in TILLSECRETS_HOST_KEY_FILE, TILLSECRETS_HOST_KEY or ~/.tilldev/host-key.
await load()On the edge, pass the key from a secret:
import { createClient } from '@tillstack/secrets-edge'
interface Env {
TILLSECRETS_TOKEN: string
TILLSECRETS_HOST_KEY: string
}
export default {
async fetch(_req: Request, env: Env) {
const { secrets } = await createClient({ token: env.TILLSECRETS_TOKEN, hostKey: env.TILLSECRETS_HOST_KEY }).pull()
return new Response(secrets.STRIPE_KEY ? 'configured' : 'missing')
},
}@tillstack/secrets-core takes the same hostKey option. seal: false, or --no-seal in the CLI, turns encryption off; a bound token then gets nothing.
When a bound token is used without its key
$ TILLSECRETS_TOKEN=ts_… tilldev secrets pull
✗ 403: this token answers only encrypted to its host key (SHA256:4mJ0p1u9…); give the host its key with TILLSECRETS_HOST_KEYThe answer is 403 with reason host_key_required (no key offered) or host_key_mismatch (a different key), before anything is decrypted. Then:
- A high-severity anomaly of kind
host_keyopens for the token, with the IP and endpoint. Repeats count on the same one until it is resolved. - Every owner and admin is emailed once per anomaly, and it goes to any TillPulse rule that matches it.
- The audit log records
token.host_key_refused.
If the host just hasn't been given its key yet, give it the key and resolve the anomaly as Setup. Otherwise the token has been copied: revoke it.
For your own client
The same scheme covers POST /api/secrets/pull, the lease service's POST /v1/lease and GET /v1/config:
POST /api/secrets/pull
Authorization: Bearer ts_…
x-tillsecrets-seal-key: <X-Wing public key, 1216 bytes, base64url>
x-tillsecrets-seal-nonce: <16–64 random bytes, base64url, new for each request>
→ 200 {"sealed": "xwing-aes256gcm.v1|<KEM ciphertext>.<nonce>.<ciphertext + tag>",
"seal_key": "SHA256:<fingerprint of that public key>"}
context = "tillsecrets-delivery/v1\n" + purpose + "\n" + seal_key + "\n" + request nonce
(purpose: pull, lease or config)
key = HKDF-SHA256(X-Wing shared secret, salt = empty, info = context), 32 bytes
opened = AES-256-GCM(key, 12-byte nonce, additional data = context)
= the JSON the endpoint returns without sealingA host key file holds the base64 of the 32-byte X-Wing seed; the key pair is derived from it. @noble/post-quantum implements X-Wing if you are writing a client in JavaScript.
What this does and doesn’t cover
- TillSecrets decrypts a value to encrypt it to your host. This protects the trip to the host, not a compromised TillSecrets.
- A host that is broken into has both the token and the key. Scope tokens to the keys a host needs, and revoke on the first alert.
- Revoking a lease early needs only the token; it returns no values.
- Dashboard reveals, the broker and CI delivery don't use host keys; they run under a person's approval or a protected branch instead.
- X-Wing is an IETF draft. Its construction is fixed, and each answer names the scheme it uses so a later one can be added.
- Everything else in TillDev, and what is still classical, is in the cryptography inventory.
API
POST /api/secrets/projects/{id}/tokens
{"name": "web", "scope": "read", "environment_id": "…", "host_key": "<host-key.pub contents>"}
→ 201 {"token": {"id": "…", "token_prefix": "ts_4f1c", "host_key_fp": "SHA256:…", "secret": "ts_…"}}Owners and admins mint tokens. See the API reference.