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.
A machine can belong to several teams at once. Each join/init adds a team under a
local handle and makes it current; stm teams list shows them, stm teams use <name>
switches, and any command takes --team <name> to target another. Each team has its own key in the
keychain; your signing identity is one per machine and shared across teams.
Quickstart (the fast path)#
Enrollment is five ordered steps across two people. stm teams quickstart collapses each side to a
single command and auto-fills the next command's values — the same commands and the same cryptography
as the detailed walkthrough below, with far less to remember. Once your server is running, this is all most members
ever need.
Create a team
stm teams quickstart create \ --server https://teams.example.com \ --admin "$STM_TEAM_ADMIN_TOKEN" \ --name acme
It creates the team, enrolls you, and prints a copy-paste invite — the exact quickstart join command teammates run, pre-filled with the server URL, team token, and team-key fingerprint.
Join a team
# 1. paste the invite from the creator — joins AND requests enrollment, # then prints your member-id to send back to them. stm teams quickstart join \ --server https://teams.example.com \ --token <team-token> \ --fingerprint <team-key-fingerprint> # 2. an existing member enrolls you: stm teams enroll <your-member-id> # 3. finish — unwraps the team key (verifying the fingerprint) + pulls the vault. stm teams quickstart finish
A member approving you is a security property, not missing automation: the team key is sealed to your
public key by a human who verifies your member-id. Quickstart automates everything around that human check —
it does not remove it. The lower-level commands (init / join / enroll-request
/ accept / pull) still exist for scripting and are documented below.
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 share <tool>:<label>/unshare— mark a key shared (goes to the team) or personal (kept local). Keys are personal by default: a credential is never shared with a team unless you say so.stm teams push— encrypt your shared keys with the team key and upload the blob; personal and unscoped keys are held back (it tells you how many, and how to share them).stm teams push --allshares every active key at once. 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. A pulled team key is marked shared, so re-pushing keeps sharing it. Keys already present are left untouched.
A key you add is personal until you stm teams share it — push holds back anything unscoped, so you never broadcast a credential to a team by accident. Use --all only when you really mean "share everything active".
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. The team creator shares one short team-key fingerprint out-of-band so the joiner can confirm it
received the real team key, not one a malicious server substituted.
- Teammate joins with the team token, recording the team-key fingerprint the creator shares out-of-band:
stm teams join --server https://teams.example.com --token <team-token> --fingerprint <team-key-fingerprint> - 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, and prints the team-key fingerprint to pass to the joiner out-of-band. - Teammate accepts, unwrapping the sealed team key and verifying it against the fingerprint before it touches the keychain:
stm teams accept(orstm teams accept --fingerprint <fp>). Accept refuses if the unwrapped key does not match the fingerprint, or if no fingerprint is available — pass--unverifiedonly if you knowingly accept the risk. - 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.)
The member id fingerprints the sealing and signing key together, so 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 (and the
same binding makes audit attribution unforgeable). In the other direction, an active malicious server could seal a
key it chose to a member's real public key, and the sealed box would open cleanly. That is why
accept verifies the unwrapped team key against the out-of-band team-key fingerprint and refuses
on a mismatch — a substituted key is caught before anything is ever pushed under it.
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.
Per-member usage#
When calls go through the credential broker, STM records a signed usage record per call — the metered counterpart to the audit log. A usage record is metadata only: the tool, the label, the HTTP method, the upstream path (query string stripped), the response status, and the response size — never a key, a request body, or a response body.
stm teams usage-push— send this machine's brokered-call usage records, each signed with your Ed25519 key. The server attributes every record to the verified member id; an unsigned report is refused.stm teams usage— read the team's usage log. Narrow it with--tool <tool>or--member <id>, cap it with--limit N, or pass--summaryto aggregate calls per member and per key.
The local dashboard also has a read-only Teams tab that shows the teams this machine belongs to, the current team's members, and this same usage log — the daemon fetches team data server-side, so the browser only ever talks to your own loopback.
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 and usage logs' tool, label, method, path, status, 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 — quickstart, serve, init, join, enroll, accept, push, pull, audit, and more.