Docs / Protocol (no CLI)

Protocol

Everything a client needs to read a service's variables without the dynv CLI: every byte format, key derivation, signature input and check, plus test vectors to prove your client gets them right.

The normative text is docs/PROTOCOL.md in the Dynv repository. This page is the tour. Most people should use dynv run or the Node, Python or Go SDK's direct mode. Read on if you are writing a client in another language, or want to know exactly what those do.

Two ways to run

dynv runDirect (SDK, or your own client)
Holds the service tokenthe supervisor; your app never sees ityour app process
Your app getsplain env vars and a loopback agentthe token and what it decrypts
Restart-mode variablesrestarted for youyour app decides
Installthe dynv binaryyour language’s standard crypto

In direct mode, anything that can read your app's memory or environment can take the token and keep reading the service's variables until you revoke it. The server still sees only ciphertext either way. More on the tradeoff.

Primitives

AES-256-GCM, HKDF-SHA256, SHA-256 and Ed25519: the WebCrypto set, available everywhere. Encoding is base64url without padding, strictly: reject =, stray characters and non-canonical trailing bits.

Tokens and authentication

A service token is dynv_pt_<id>.<secret>, with a 16-character id and a 32-byte secret. Two keys come from the secret with HKDF-SHA256 (salt dynv/v1, 32 bytes): the auth key (info auth|pt|<id>), which you send, and the wrap key (info wrap|pt|<id>), which opens your envelope and never leaves the client. The server stores only a SHA-256 of the auth key.

auth_header.py
import base64, os
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

def unb64u(s):  # base64url without padding
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))

token = os.environ["DYNV_TOKEN"]                  # dynv_pt_<id>.<secret>
kind, rest = token[5:7], token[8:]
token_id, secret = rest.split(".")
auth_key = HKDF(hashes.SHA256(), 32, salt=b"dynv/v1", info=f"auth|{kind}|{token_id}".encode()).derive(unb64u(secret))
print(f"Authorization: Dynv {kind}.{token_id}.{base64.urlsafe_b64encode(auth_key).rstrip(b'=').decode()}")

Formats

WhatFormat
Tokendynv_pt_<id>.<b64u(secret)>
Auth headerAuthorization: Dynv pt.<id>.<b64u(auth key)>
Sealed (AES-256-GCM)sealed = b64u( nonce[12] ‖ ciphertext ‖ tag[16] )
Signed messagesigned message = UTF-8( aad ) ‖ 0x0A ‖ UTF-8( ct )
Padded plaintextplaintext = u32_be(len(json)) ‖ json ‖ 0x00 …
Content hashblob_hash(b) = hex( SHA-256( "<version>|<dek_gen>|<signer>|" ‖ ct ‖ "|" ‖ sig ) )

Every ciphertext is bound to what it is and whose it is by its additional data (AAD), so the server can't move one into another slot:

CiphertextAAD
Envelope (under the token’s wrap key)dynv/v1/envelope|<account>|<process>|<token>
Process blobdynv/v1/blob|<account>|<process>|<version>|<gen>
Shared-group blobdynv/v1/shared-blob|<account>|<group>|<version>|<gen>
Slot (group key under the process key)dynv/v1/shared-slot|<account>|<group>|<process>|<process_gen>|<group_gen>

Two requests

http
GET /v1/self
GET /v1/self/watch?version=V&gen=G&timeout=S&sv=F
Authorization: Dynv pt.<id>.<b64u(auth key)>

The watch returns 200 as soon as anything changed, else 204 after up to 50 seconds. Both return {account_id, process_id, envelope_ct, blob, shared?}: ciphertext only. Require https, and never follow a redirect (it could carry your auth header elsewhere).

Errors are {"error": "…", "code": "…"}. Never print or log the error text or any other part of the body: a hostile server could put your credentials in it, split so no redaction catches them. Look the code up in your own table of messages (crates/dynv-proto/testdata/error-codes.json), and fall back to a message for the HTTP status.

Opening a response

  1. Check the ids. Account and process ids are 16 base64url characters, before they go anywhere.
  2. Open the envelope. With your token’s wrap key. It holds the service’s data key and the public keys allowed to sign its documents.
  3. Enforce the generation floor. Never accept an envelope, slot or blob below the highest key generation you accepted. Envelope and blob must also be from the same rotation; if not, retry shortly.
  4. Verify, then decrypt the document. The signer must be one the envelope lists. Verify the Ed25519 signature first, then decrypt.
  5. Check the name. If you configured a service name, the document must carry it.
  6. Refuse rollbacks and forks. Nothing older than, or different at, the newest version you accepted.
  7. Open each shared group. Only groups the signed document lists. Slot → group key → verified group document, whose signed roster must list your service. A failed group keeps its last verified state.
  8. Merge. Groups in the listed order, later wins; your service’s own variables win over all. Removed keys stay unset.
  9. Resolve templates. A variable flagged as a template (${NAME} references, $$ for $) resolves against the merged variables only, never the host environment. One that fails keeps its last good value, or stays unset on a fresh start.
  10. Persist, then accept. Write the encrypted cache first; only then advance the floors and apply.

A refused response leaves the app on its last good values. Every refusal has a stable code, so your client can be tested against the vectors:

CodeMeaning
malformed_idAn account, process or group id is not 16 base64url characters.
envelopeThe envelope does not open with this token (another token, process or account).
generation_rollbackAn envelope, slot or blob is below the highest key generation this client already accepted, even at a newer document version.
gen_mismatchEnvelope and blob are from different key generations: a rotation is in flight. Retry after 250 ms.
blobMalformed, unsigned, signed by a key the envelope does not list, or does not decrypt.
process_nameThe document is for another service than the one configured.
rollbackOlder than a version this client already accepted.
forkDifferent content at a version this client already accepted.

A shared group that fails is handled on its own: the rest still applies, and the group keeps its last verified values (or contributes nothing yet).

Group reasonMeaning
not_deliveredThe document lists the group but the server did not send it.
generation_rollbackThe slot or group blob is below the generation floor this client already accepted.
slot_genThe slot was made for another key generation of the service.
slotThe slot does not open with this service’s key.
blobThe group document is malformed, untrusted or does not decrypt.
rosterThe group’s signed roster does not list this service (detached).
rollback / forkAs above, per group.

Watching and the cache

  • Long-poll /v1/self/watch with the version and key generation you accepted, and the fingerprint of the groups you received. On errors back off from 1 s to about 2 minutes, and keep the current values.
  • Keep a cache file: the last accepted response, still encrypted. Load it before the first live fetch, so your floors survive a restart, and boot from it when the server is down. Write it atomically (exclusive temp file, 0600, fsync, rename, fsync the directory) before applying an update. If the write fails, refuse the update.
  • Without a cache, a freshly started client trusts the first validly signed document it sees.

Test vectors

crates/dynv-proto/testdata/protocol-vectors.json holds the primitives (base64url, tokens, AEAD, a sealed blob, fingerprints) and 21 multi-step scenarios for the whole state machine: groups and precedence, every refusal, rollback and fork across steps, failed and kept groups, templates, awkward key names. For each scenario, start a fresh client, feed it the responses in order and compare what it accepts or refuses. The Rust client, the Node, Python and Go SDKs, and the Python reference client all pass the same file in CI.

Implementations

LanguageWhere
Rustcrates/dynv/src/process.rs: what dynv run uses
TypeScriptsdk/ts/src/direct.ts: @dynv/sdk's direct mode (Node 20+, Bun, Deno)
Python SDKsdk/python: the dynv package, with sync and asyncio APIs
Go SDKsdk/go: the Go client, with agent, direct and env modes
Python reference clientclients/python/dynv_client.py: one file, needs only cryptography
sh
pip install cryptography
export DYNV_URL=https://api.dynv.dev DYNV_PROCESS=web DYNV_TOKEN=dynv_pt_…

python3 clients/python/dynv_client.py env     # the verified, decrypted env as JSON
python3 clients/python/dynv_client.py watch   # one JSON line per change