STM Teams · self-hosted

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.

Scope

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.

One machine, many teams

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

shell · creator
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

shell · joiner
# 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
The one manual handoff is on purpose

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 stm installed 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:

shell · on the host
# 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)
Binding 0.0.0.0

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#

VariableDefaultPurpose
STM_TEAM_ADMIN_TOKENunsetRequired to create teams. When unset, team creation is disabled (the server still serves existing teams). Checked with a constant-time comparison.
STM_TEAM_DBstm-teams.sqlitePath to the SQLite database file. Holds ciphertext blobs, member public keys + sealed envelopes, token hashes, and the signed audit log.
STM_TEAM_HOST127.0.0.1Bind address. Use 0.0.0.0 only behind a TLS reverse proxy.
STM_TEAM_PORT8787Listening 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:

shell · admin machine
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:

shell · admin machine
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 --all shares 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.
Personal by default

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".

The team key lives in your keychain

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.

  1. 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>
  2. Teammate requests enrollment, publishing only their public keys:
    stm teams enroll-request — prints their member id for an existing member to enroll.
  3. 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.
  4. Teammate accepts, unwrapping the sealed team key and verifying it against the fingerprint before it touches the keychain:
    stm teams accept (or stm teams accept --fingerprint <fp>). Accept refuses if the unwrapped key does not match the fingerprint, or if no fingerprint is available — pass --unverified only if you knowingly accept the risk.
  5. 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.)

Key-substitution defense runs both ways

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 --summary to 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#

What the self-hosted server does and does not learn

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:default N 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.