Share credentials without sharing them.
STM Teams is self-hostable, zero-knowledge credential sharing and usage attribution across a team. Credentials are encrypted on a member's machine with a team key the server never sees; the server stores only ciphertext. Members are enrolled by public key — no shared passphrase is ever transmitted — and every usage report is signed, so the audit log attributes activity to a cryptographically-verified member.
How it fits together#
One machine (or a small VM) runs the server. Each member runs the stm CLI locally. The server is a
dumb, encrypted store: it holds an opaque vault blob, per-member sealed envelopes, and a signed audit log. All
encryption and decryption happens on member machines. A fully-compromised server (stolen database, malicious host)
yields no key — only ciphertext it cannot decrypt and token hashes it cannot reverse.
The Teams server is a plain Bun program and runs anywhere Bun does — a laptop for a trial, or a small server behind a TLS reverse proxy for real use. It is independent of the macOS-only runtime hooks.
Prerequisites#
- Bun on the host that runs the server, and
stminstalled on each member's machine. - A network path from members to the server. For anything beyond a single-machine trial, put the server behind a TLS reverse proxy (Caddy / nginx) — the server itself is transport-agnostic about TLS.
- An admin token you generate and keep secret. Team creation is disabled unless it is set, so a misconfigured public server cannot have teams created on it.
Run the server#
Generate an admin token, then start the server with it in the environment:
# a high-entropy admin token — keep it secret
export STM_TEAM_ADMIN_TOKEN="$(openssl rand -hex 32)"
export STM_TEAM_DB=/var/lib/stm-teams/stm-teams.sqlite
export STM_TEAM_HOST=0.0.0.0
export STM_TEAM_PORT=8787
stm teams serve
On start it prints the bind address, the database path, and whether team creation is enabled:
stm teams server on http://0.0.0.0:8787 (db: /var/lib/stm-teams/stm-teams.sqlite) team creation ENABLED (admin token set)
Bind 0.0.0.0 only behind a TLS reverse proxy. The server speaks plain HTTP; terminate TLS at the
proxy and forward to the server on loopback. For a single-machine trial, leave STM_TEAM_HOST at its
default of 127.0.0.1.
Environment variables#
| Variable | Default | Purpose |
|---|---|---|
STM_TEAM_ADMIN_TOKEN | unset | Required to create teams. When unset, team creation is disabled (the server still serves existing teams). Checked with a constant-time comparison. |
STM_TEAM_DB | stm-teams.sqlite | Path to the SQLite database file. Holds ciphertext blobs, member public keys + sealed envelopes, token hashes, and the signed audit log. |
STM_TEAM_HOST | 127.0.0.1 | Bind address. Use 0.0.0.0 only behind a TLS reverse proxy. |
STM_TEAM_PORT | 8787 | Listening port. |
Team bearer tokens are stored only as SHA-256 hashes, so a leaked database does not reveal live tokens. The server caps the encrypted vault at 10 MiB and bounds every request body, so an oversized upload cannot fill the disk or exhaust memory.
Create a team#
An admin runs stm teams init against the running server. This generates the team key, self-enrolls the admin as the first member, and prints the team token teammates use to join:
stm teams init \ --server https://teams.example.com \ --admin "$STM_TEAM_ADMIN_TOKEN" \ --name "Acme AI"
The admin token authorizes team creation only; it may also come from STM_TEAM_ADMIN_TOKEN in the
environment. Keep the returned team token safe — it authorizes vault and audit access for the whole team. Then push
the admin's keys so members have something to pull:
stm teams push
Push & pull the vault#
The shared vault is AES-256-GCM, keyed by PBKDF2-SHA512 from the team key. Encryption and decryption happen entirely on member machines; the plaintext never leaves the process and the server receives only the ciphertext blob.
stm teams push— encrypt your local active keys with the team key and upload the blob. Unresolved-locally keys are skipped rather than uploaded as a hole.stm teams pull— download the blob, decrypt it locally, and add any keys you don't already have. Keys already present are left untouched.
On each member's machine the team key is stored in the OS keychain, never on disk in plaintext. The local
teams.json (mode 0600) holds only the server URL and the team bearer token.
Public-key enrollment#
New members join without anyone transmitting a shared passphrase. Each member has an X25519 sealing keypair and
an Ed25519 signing keypair; their member id is sha256(sealPub‖signPub) truncated to 128 bits, so
the id is self-certifying — it fingerprints both public keys at once. Enrollment seals the team key to the joiner's
sealing key using an X25519 sealed box (ephemeral ECDH → HKDF-SHA256 → AES-256-GCM); only the joiner's private key
can open it.
- Teammate joins with the team token:
stm teams join --server https://teams.example.com --token <team-token> - Teammate requests enrollment, publishing only their public keys:
stm teams enroll-request— prints their member id for an existing member to enroll. - An existing member enrolls them, sealing the team key to their keys:
stm teams enroll <member-id>— the CLI verifies the fetched key set matches the member id before sealing, so the team key is never sealed to a substituted key. - Teammate accepts, unwrapping the sealed team key locally into their keychain:
stm teams accept - Teammate pulls the vault:
stm teams pull
Use stm teams members to list members and their enrollment status. (A shared-passphrase path exists via stm teams passphrase for setups that prefer it, but public-key enrollment is the recommended flow — nothing secret crosses the wire.)
Because the member id fingerprints the sealing and signing key together, a server or man-in-the-middle cannot swap one key for its own without changing the id — which the enrolling member checks before sealing. The same binding is what makes audit attribution unforgeable.
Signed audit attribution#
STM Teams gives a team a shared, tamper-evident record of key usage — without ever collecting a key value.
stm teams audit-push— send this machine's local key-use events (placeholder commands only, never a resolved key), each signed with your Ed25519 key. The server verifies the signature and attributes the event to the verified member id — a client cannot forge who it is.stm teams audit— show the combined team log, with each entry attributed to the cryptographically-verified actor.stm teams status/stm teams leave— show local team status, or remove this machine's team config.
Honest limits#
Zero-knowledge applies to credentials, not to all metadata. Read these before you deploy.
- Credentials are never visible to the server — it stores ciphertext it cannot decrypt, and token hashes it cannot reverse.
- Usage metadata is plaintext to the server you host. The audit log's tool, label, and counts are readable by the server operator. Credentials are not, but the fact that "someone used
openai:defaultN times" is. - The audit actor is cryptographically verified — it is the Ed25519-signed member id, not a self-asserted name. This is a genuine integrity guarantee.
- Per-member dollar cost is an estimate. Providers bill per key, not per caller, so attributing spend to an individual member is an approximation, not an invoice.
- Run it behind TLS. The server speaks plain HTTP and is transport-agnostic; you are responsible for terminating TLS at a reverse proxy for any non-local deployment.
The cryptography, in context
How the sealed box, the Ed25519 signatures, and the zero-knowledge server fit the overall threat model.
Read the security model → CLIEvery teams subcommand
The full stm teams surface — serve, init, join, enroll, accept, push, pull, audit, and more.