Secrets, pairing & device auth
Encrypted secret store, device pairing and bearer tokens, service tokens, and value redaction.
Revka keeps credentials out of plaintext. API keys and tokens in your config are sealed with authenticated encryption before they touch disk, devices authenticate with bearer tokens that are stored only as hashes, trusted local sidecars use a separate service token, and anything that does reach a log is truncated by a redaction helper. This page covers the cryptographic design behind all of it: the ChaCha20-Poly1305 secret store, the [secrets] config key, the device-pairing guard and its bearer tokens, the service token, the redact() helper, and the one-way migration from the legacy enc: format to enc2:.
Reach for this page when you want to understand how secrets are protected on disk, recover or back up the encryption key, harden device authentication, or migrate older encrypted values. For the REST pairing endpoints and device-management API (request/response shapes, device registry, rotation) see Pairing & authentication. For the design rationale across all layers see the Security model.
Encrypted secret store
Section titled “Encrypted secret store”Secrets written to config — provider API keys, channel tokens, webhook secrets — are encrypted with ChaCha20-Poly1305 AEAD before storage. This is defense-in-depth: the config file holds only hex-encoded ciphertext, so a casual grep, a git log, or an accidental commit never exposes a raw key.
How it works:
- A single 32-byte (256-bit) random key is generated from the OS CSPRNG and stored at
~/.revka/.secret_key(hex-encoded). It is created automatically the first time a value is encrypted. - Each encryption generates a fresh random 12-byte nonce, so encrypting the same plaintext twice yields different ciphertext — there is no known-plaintext leak.
- The Poly1305 authentication tag makes the ciphertext tamper-evident: decryption fails outright if a single byte is altered or the wrong key is used.
- Stored values use the format
enc2:<hex(nonce ‖ ciphertext ‖ tag)>(12 bytes nonce + N bytes ciphertext + 16 bytes tag).
The agent encrypts new values automatically — you do not call an encrypt command. After saving a credential, the value in your config looks like this:
[provider]api_key = "enc2:9f3c1a...e7b204" # ChaCha20-Poly1305 ciphertext, not the real key[secrets] config
Section titled “[secrets] config”Encryption is on by default. Sovereign users who prefer plaintext config can turn it off.
[secrets]encrypt = true # default — set false to store secrets as plaintext| Key | Type | Default | Meaning |
|---|---|---|---|
secrets.encrypt | boolean | true | When true, secrets are sealed as enc2: ciphertext before being written. When false, values are stored verbatim. |
When secrets.encrypt = false, encryption is a no-op: encrypt() returns the plaintext unchanged, and the config holds the raw value. Empty strings are never encrypted regardless of the setting.
The key file
Section titled “The key file”| Property | Value |
|---|---|
| Path | ~/.revka/.secret_key |
| Contents | 32-byte key, hex-encoded (64 hex chars) |
| POSIX permissions | 0600, created atomically via O_CREAT | O_EXCL so no readable window exists |
| Windows permissions | takeown sets ownership, then icacls /inheritance:r /grant:r <user>:F restricts access to the current user |
enc: to enc2: migration
Section titled “enc: to enc2: migration”Earlier Revka builds used a weak XOR cipher with the enc: prefix. That format is insecure (vulnerable to known-plaintext attacks) and is kept only so existing configs keep working. The current secure format is enc2: (ChaCha20-Poly1305).
Migration is automatic and one-way on read:
- A value with
enc:is decrypted with the legacy XOR algorithm, then immediately re-encrypted toenc2:, and the upgraded value is persisted back to config. A warning is logged each time a legacy value is decrypted. - A value with
enc2:is already secure and is left as-is. - A value with no prefix is treated as plaintext and returned unchanged.
| Prefix | Algorithm | Status |
|---|---|---|
enc2: | ChaCha20-Poly1305 AEAD | Current, secure |
enc: | XOR repeating-key cipher | Legacy, insecure — auto-migrated on read |
| (none) | — | Plaintext, returned as-is |
Detection helpers used internally (and surfaced in diagnostics) classify a value’s state:
is_encrypted()— true for eitherenc:orenc2:.is_secure_encrypted()— true only forenc2:.needs_migration()— true for aenc:value that should be upgraded.
Device pairing & bearer token auth
Section titled “Device pairing & bearer token auth”Beyond the gateway’s REST pairing flow, Revka’s security layer includes a PairingGuard that backs device authentication for the gateway and channels. It mints bearer tokens, stores them as hashes, and defends the pairing step against brute force.
How it works:
- On first startup with
require_pairing = trueand no existing tokens, a 6-digit one-time pairing code (CSPRNG) is printed to the terminal. - A client submits the code and receives a bearer token carrying 256-bit (32 bytes) of entropy, returned hex-encoded with a prefix. The plaintext token is shown exactly once.
- Tokens are persisted only as their SHA-256 hash — the guard never stores a plaintext token. Both plaintext tokens and pre-hashed (64-char hex) tokens are accepted on load for backward compatibility.
- The pairing code is compared with constant-time equality to avoid timing side-channels.
[gateway]require_pairing = true # default — set false to disable auth (local-only use)allow_public_bind = false # must be explicit to bind beyond localhost| Key | Type | Default | Meaning |
|---|---|---|---|
gateway.require_pairing | boolean | true | Require a bearer token on protected /api/* routes. Bootstrap/liveness endpoints are exceptions: /api/pair accepts the pairing code with no token, and /api/status returns basic liveness unauthenticated (full detail only when authenticated). false disables auth entirely. |
gateway.allow_public_bind | boolean | false | Must be set explicitly to bind beyond localhost without a tunnel. |
gateway.paired_tokens | list | (managed) | Hashed tokens, written automatically on successful pairing. Do not edit by hand. |
Brute-force lockout
Section titled “Brute-force lockout”The pairing guard tracks failed code submissions per client. After too many failures, that client is locked out:
| Constant | Value | Meaning |
|---|---|---|
MAX_PAIR_ATTEMPTS | 5 | Failed code attempts before lockout |
PAIR_LOCKOUT_SECS | 300 | Lockout duration (5 minutes) |
Lockout is per client, not global, so one attacker cannot lock out everyone. A correct code submitted before the threshold still pairs normally. (The gateway adds a second, IP-based AuthRateLimiter layer on top of this for the REST endpoints — see Pairing & authentication.)
First-use flow
Section titled “First-use flow”-
Start the gateway. It prints a one-time pairing code to the terminal.
Terminal window revka gateway# Pairing code: 123456 -
Exchange the code for a token (see the pairing API for the full request and response shape).
Terminal window curl -X POST http://127.0.0.1:42617/api/pair \-H 'Content-Type: application/json' \-d '{"code": "123456", "device_name": "My Laptop", "device_type": "cli"}' -
Save the returned token — it is displayed only once. The guard stores only its SHA-256 hash.
-
Authenticate every later request with the token.
Terminal window curl http://127.0.0.1:42617/api/status \-H "Authorization: Bearer <token>"
The code is consumed on first use — pair once per code, then rotate to issue another. Because tokens persist to config on success, a daemon restart does not require re-pairing.
Service token
Section titled “Service token”Trusted local sidecars — chiefly the operator-mcp runtime — authenticate machine-to-machine with a service token rather than a user bearer token. It is sent in its own header:
X-Revka-Service-Token: <service-token>This header is accepted alongside Authorization: Bearer on the routes that need privileged local access. The service token is auto-generated at gateway startup and is a distinct, more privileged credential than user bearer tokens — it is the path that decrypts stored workflow credentials and writes the cost ledger, and the same key signs workspace asset URLs.
Sensitive value redaction
Section titled “Sensitive value redaction”To keep secrets out of logs, Revka uses a module-level redact() helper throughout the codebase. It shows the first 4 characters followed by ***, using char-boundary-safe indexing so it never panics on multi-byte UTF-8:
redact("sk-ant-abcdef...") → "sk-a***"redact("abcd") → "***" // 4 or fewer chars → fully maskedThis is deliberately simple — enough to recognize which credential a log line refers to without revealing it. It is not a zeroize-on-drop primitive, so it does not scrub secrets from memory; pair it with the encrypted secret store and outbound leak detection for full coverage. redact() is a manual helper that must be called explicitly at the point a sensitive value is logged. It is applied at the call sites that opt into it — there is no automatic logging interceptor, so a value is only redacted if code routes it through redact().
Hardening checklist
Section titled “Hardening checklist”-
Keep
secrets.encrypt = trueso all credentials are stored asenc2:ciphertext. -
Back up
~/.revka/.secret_keyto a secure location — without it, encrypted secrets are unrecoverable. -
Keep
require_pairing = trueand never combinerequire_pairing = falsewithallow_public_bind = true. -
Migrate legacy values by loading the agent once with the original key present, so any
enc:values auto-upgrade toenc2:. -
Protect the service token — restrict it to trusted local sidecars and never expose it to browsers.