Threat model, E2E encryption details, and security boundaries for airelay.
- Host machine — you control it, has access to code
- Phone/client device — you control it, stores
e2e_secret
- Relay server — may be compromised, operated by third party
- Network — eavesdropping, packet injection, MITM possible
- Attacker does NOT have access to host machine or phone
- Attacker CAN compromise the relay
- Attacker CAN intercept/modify network traffic
Attack: Relay operator logs all WebSocket messages to read terminal sessions.
Mitigation: End-to-end encryption (ECDH + AES-GCM). All input, output, scrollback messages have encrypted e2e payload. Relay only sees ciphertext.
Evidence:
// Relay sees this (cannot decrypt `ct`):
{
"type": "output",
"session_id": "3e29ea37-...",
"e2e": {
"v": 1,
"iv": "Rz8pL3V2Nw==",
"ct": "m8vK3Lp9...4jF2qA=="
}
}Attack: Relay sends fake messages pretending to be the agent daemon.
Mitigation: Agent authenticates with HMAC signature only the real host knows:
Authorization: HMAC host_id=<uuid>, ts=<timestamp>, sig=HMAC-SHA256(host_secret, "host_id:timestamp")
The signature protects against third parties on the network path. Note the honest boundary: the relay
itself MUST hold host_secret (it verifies the agent's HMAC), so the relay — or anyone who can read its
DB — can always connect AS the agent. That is why the E2E handshake key (e2e_secret) is an independent
secret the relay never sees (see threat 3), and why the terminal content protections must not depend on
secrets the relay holds.
Attack: Attacker intercepts e2e_hello and replaces client's ECDH public key with their own, establishing separate E2E sessions with client and agent.
Mitigation: HMAC signature over public keys using pre-shared e2e_secret:
sig = HMAC-SHA256(e2e_secret, pubkey)
Client and agent both verify signatures. e2e_secret is generated independently at setup time and is
NOT derived from host_secret — the relay holds host_secret for agent auth, so a derived E2E key
would put the handshake within the relay's reach and break this mitigation entirely. The relay (and DB
readers) cannot derive e2e_secret, cannot forge a valid signature, and cannot complete a MITM handshake.
(Pre-fix deployments: the first airelay command after upgrading migrates the legacy derived value to a
persisted standalone secret; phones must re-scan the QR to learn the new secret.)
Attack a: Replay JWT
Relay records a JWT, replays it later to impersonate client.
Mitigation: JTI (JWT ID) tracking. Relay stores used JTIs in SQLite. Second use rejected.
Attack b: Replay HMAC signature
Attacker records agent's Authorization header, replays it.
Mitigation: Timestamp in HMAC message. Relay rejects signatures older than ±30 seconds.
Attack c: Replay encrypted terminal input
Relay captures a legitimate encrypted input (e.g. a y\n sent to confirm a
permission prompt) and replays it verbatim later, hoping to re-confirm the
same prompt while the user is away.
Mitigation: Every E2E-encrypted input carries a per-connection sequence
number bound into the GCM AAD. The agent tracks the counter and rejects any
input whose seq is not strictly greater than the last accepted one — and
mutating the seq to pass that check breaks the GCM tag. (JSON path: seq
field on the e2e payload. Binary path: first 4 bytes of the payload,
big-endian.)
Attack: Modify ciphertext in e2e.ct to change terminal output.
Mitigation: AES-GCM includes 16-byte authentication tag. Decryption fails if ciphertext was modified.
Attack: Attacker compromises one E2E session key, uses it to decrypt future sessions.
Mitigation: Forward secrecy. New ECDH keypair generated for every connection. Old session keys cannot decrypt new sessions.
Attack: Instead of breaking the crypto, the relay simply drops
e2e_hello/e2e_ack frames so the session stays in plaintext mode.
Mitigation (defense in depth):
- Agent: the handshake window is armed on the FIRST relayed message of a connection (any client request proves a client is on the other end). After the 10 s window lapses without E2E — or once any handshake has completed — plaintext output AND plaintext input are both rejected for the rest of the connection (input and output enforcement are symmetric: a relay that suppresses the handshake cannot read the terminal, nor keep driving it blind with injected keystrokes).
- Client: a watchdog warns the user if the handshake has not completed shortly after auth.
Attack: The user opens https://<their-relay>/#<base64 payload> where the
payload reuses a KNOWN host_id but points url at an attacker relay and
carries an attacker e2e_secret. The web app would silently overwrite the
stored host entry and connect to the attacker, who plays a complete, valid
E2E handshake — everything typed next flows to the attacker with all
client-side checks passing.
Mitigation: When a QR payload would CHANGE an existing entry's relay URL or e2e secret, the web client requires explicit confirmation before writing; a decline leaves the existing configuration untouched. Identical re-scans (normal re-pairing) remain silent.
Attack: The relay forges forwarded protocol frames (e.g. a
sessions_list with a hostile agent_name) to inject HTML/JS into the
phone's web app, which holds every host's session tokens and e2e secrets in
localStorage.
Mitigation: All relay-forwarded metadata rendered by the web client is
HTML-escaped before insertion, and the app ships a Content-Security-Policy
(script-src 'self') so injected markup cannot execute.
Used for:
- Agent authentication (
Authorizationheader) - Public key signatures in E2E handshake
Properties:
- Secure PRF (pseudorandom function)
- Resistant to length extension attacks
- Standard: FIPS 198-1
Curve: P-256 (prime256v1, secp256r1)
Used for: Key exchange in E2E handshake
Why P-256:
- NIST standard, widely supported
- Native in WebCrypto API (browser) and Node.js
crypto - 128-bit security level
Alternative considered: X25519 (Curve25519)
- Higher performance, newer design
- Not in WebCrypto standard (requires external library)
Used for: Deriving AES key from ECDH shared secret
session_key = HKDF-SHA256(
ikm: shared_secret, // 32 bytes from ECDH
salt: "airelay-e2e-v1", // domain separation
info: pubkey_a || pubkey_b, // binds to this handshake
length: 32 // AES-256 key
)
Why HKDF:
- Extract uniform randomness from ECDH output
- Bind key to specific context (salt + info)
- Standard: RFC 5869
Used for: Encrypting terminal I/O
Mode: Galois/Counter Mode Key size: 256 bits Nonce size: 12 bytes (96 bits) Tag size: 16 bytes (128 bits)
Properties:
- Authenticated encryption (confidentiality + integrity)
- Nonce must never repeat for same key
- Standard: NIST SP 800-38D
Nonce generation:
- Random 12 bytes per message (
crypto.randomBytes(12)) - Safe: P(collision) < 2^-32 for 2^32 messages
Two INDEPENDENT secrets (the relay holds only the first):
host_secret (64 hex, random) e2e_secret (64 hex, random)
lives on: host machine + relay lives on: host machine + phone (QR only)
| |
HMAC key for agent↔relay auth Signs ECDH public keys
(NOT derivable from host_secret —
the relay must not reach the E2E key)
|
(e2e_secret shared via QR code to phone)
↓
Client generates ECDH keypair Agent generates ECDH keypair
pubkey_c, privkey_c pubkey_a, privkey_a
| |
| |
Client → Agent: {pub: pubkey_c, sig: HMAC(e2e_secret, pubkey_c)}
| |
└────────────────────────────────┤
↓
Agent verifies sig, computes:
shared_secret = ECDH(privkey_a, pubkey_c)
↓
Agent → Client: {pub: pubkey_a, sig: HMAC(e2e_secret, pubkey_a)}
↓ |
Client verifies sig, computes: |
shared_secret = ECDH(privkey_c, pubkey_a) ←───┘
|
└─ HKDF-SHA256(shared_secret, ...)
→ session_key (32 bytes, AES-256)
|
┌─────────────────┴─────────────────┐
↓ ↓
Encrypt terminal input Encrypt terminal output
AES-256-GCM(session_key, ...) AES-256-GCM(session_key, ...)
1. Agent computes: sig = HMAC-SHA256(host_secret, `${host_id}:${ts}`)
2. Agent sends: Authorization: HMAC host_id=..., ts=..., sig=...
3. Relay looks up host_secret by host_id
4. Relay recomputes HMAC, compares in constant time
5. Relay checks: now - 60 < ts < now + 60
6. Accept or reject (4001)
Security properties:
- Replay window: 120 seconds
- No host_secret transmitted
- Timing-safe comparison (prevents timing attacks)
1. Agent generates JWT:
{hostId, jti:<random-uuid>, iat:<now>, exp:<now+TTL>}
Signed with host_secret (HMAC-SHA256)
2. Client sends: {type:'auth', token:<jwt>}
3. Relay verifies:
- JWT signature (using host_secret from database)
- exp > now (not expired)
- jti not in `jtis` table (not replayed)
4. Relay marks jti used
5. Relay generates session_token:
- 64 random bytes → hex
- Stores in session_tokens table (expires in 7 days)
6. Relay sends: {type:'session_token_issued', session_token}
7. Client stores session_token, discards JWT
Security properties:
- JWT valid for a short TTL (default 24h via
airelay gen-token, configurable with--ttl; keep it short — the JWT is single-use) - Single-use (JTI prevents replay)
- Session token valid for 7 days
- Session token is opaque (unpredictable, cannot be forged)
1. Client sends: {type:'auth', token:<session_token>}
2. Relay looks up session_token in database
3. Relay checks: expires_at > now
4. Accept or reject
Security properties:
- No JWT needed (avoid re-scanning QR)
- Token rotation on first connect (JWT → session_token)
- Automatic expiry (7 days)
1. Client: ephemeral ECDH keypair (pubkey_c, privkey_c)
sig_c = HMAC-SHA256(e2e_secret, pubkey_c)
2. Client → Agent: {type:'e2e_hello', pub:pubkey_c, sig:sig_c}
3. Agent: verify sig_c == HMAC-SHA256(e2e_secret, pubkey_c)
- If invalid → abort, no E2E
- If valid → continue
4. Agent: ephemeral ECDH keypair (pubkey_a, privkey_a)
sig_a = HMAC-SHA256(e2e_secret, pubkey_a)
shared_secret = ECDH(privkey_a, pubkey_c)
5. Agent → Client: {type:'e2e_ack', pub:pubkey_a, sig:sig_a}
6. Client: verify sig_a == HMAC-SHA256(e2e_secret, pubkey_a)
- If invalid → abort, no E2E
- If valid → continue
7. Client: shared_secret = ECDH(privkey_c, pubkey_a)
8. Both: session_key = HKDF-SHA256(shared_secret, salt, info)
Security properties:
- Mutual authentication (both sides verify signatures)
- MITM protection (attacker cannot forge signatures without e2e_secret)
- Forward secrecy (ephemeral keys, destroyed after handshake)
- Binding (HKDF info includes both public keys)
Impact: Attacker gains access to host_secret and e2e_secret. Can decrypt all future E2E sessions.
Mitigation: Keep host machine secure. Standard system hardening applies.
Out of scope: airelay cannot protect against host compromise.
Impact: Attacker gains access to e2e_secret and session_token. Can decrypt E2E sessions and impersonate client.
Mitigation: Phone lock screen, biometric auth, remote wipe.
Out of scope: airelay cannot protect against phone compromise.
Impact: Attacker controls relay server (including its SQLite DB, which
holds every host's host_secret and session tokens).
Cannot do:
- Read terminal I/O (encrypted E2E;
e2e_secretis NOT stored on the relay and is NOT derivable fromhost_secret) - Forge the E2E handshake (no
e2e_secret— ECDH pubkey signatures fail)
Can do:
- Deny service (refuse connections)
- Connect AS any agent (it holds
host_secretfor auth) — i.e. serve forged protocol frames to clients; this is why client-side E2E verification and the downgrade watchdog exist - Present its own session tokens as a client (session tokens live in its DB) — the agent's E2E enforcement keeps such a client from reading terminal output, and from driving it once the handshake window lapses
- Log metadata (host_id, session_id, message timestamps, sizes)
- Traffic analysis (message frequency, payload sizes)
Mitigation: E2E encryption with an independent handshake secret. Metadata leakage and the impersonation abilities above are unavoidable in a relay architecture — the E2E layer is what keeps them from becoming content disclosure.
Impact: Attacker intercepts/modifies WebSocket traffic.
Cannot do:
- Read terminal I/O (encrypted E2E)
- Tamper with messages (AES-GCM tag)
- Replay messages (timestamp/JTI)
- MITM handshake (signature verification)
Can do:
- Drop messages (DoS)
- Traffic analysis (same as relay)
Mitigation: TLS (WSS) for transport. E2E for payload. Signature for handshake.
- No telemetry: airelay does not phone home
- No accounts: no email, no password, no user database
- Local-first: terminal data never leaves host in plaintext
- Relay is zero-knowledge: cannot read content
| Primitive | Standard | Status |
|---|---|---|
| HMAC-SHA256 | FIPS 198-1 | Approved |
| ECDH P-256 | NIST SP 800-56A | Approved |
| HKDF-SHA256 | RFC 5869 | Published |
| AES-256-GCM | NIST SP 800-38D | Approved |
All primitives are NIST-approved or RFC-standard.
| Threat | Mitigation | Residual Risk |
|---|---|---|
| Relay reads I/O | E2E encryption | None |
| Relay impersonates host | HMAC signature | None |
| MITM handshake | HMAC signature | None |
| Replay JWT | JTI tracking | None |
| Replay HMAC | Timestamp | None |
| Tamper messages | AES-GCM tag | None |
| Steal future keys | Forward secrecy | None |
| Metadata leakage | (unavoidable) | Traffic analysis |
| Host compromise | (out of scope) | Total compromise |
| Phone compromise | (out of scope) | Total compromise |
| Relay DoS | (out of scope) | Availability |
Current: session_token is tied to one device.
Future: Separate Principal (durable identity) from Credential (device token). Allows:
- Multiple devices per host
- Device revocation
- Credential rotation
Current: Authenticated = full access.
Future: Permission levels:
read— view sessions, scrollbackwrite— send input, create sessionsmanage— modify config, revoke devices
Current: No persistent log of security events.
Future: Append-only log:
- New device paired
- Session created/attached
- Failed auth attempts
- Device revoked
Current: No rate limits.
Future: Per-connection limits:
- Max 100 msg/sec
- Max 1 MB/sec output
- Max 10 concurrent sessions
Current: All traffic through relay.
Future: Optional LAN/Tailscale direct connection:
- Skip relay entirely
- Lower latency
- No metadata leakage to relay