ciba-cli
v2.3.4
Published
CIBA-like backchannel auth CLI with Hocuspocus token delivery
Readme
CIBA Auth Flow — Usage & Security
Install
npm i -g ciba-cliQuick Start
# Start background session (opens browser once for login)
ciba start --url <server-url>
# Call an API (no browser, reads token from Yjs via keychain)
curl -H "Authorization: Bearer $(ciba token)" https://api.example.com/endpoint
# Call again — instant, no re-auth
curl -X POST \
-H "Authorization: Bearer $(ciba token)" \
-H "Content-Type: application/json" \
-d '{"message":"Hello","contextId":"ctx-1"}' \
https://api.example.com/agents/my-agent
# Done
ciba stopOne-Shot Mode
curl -H "Authorization: Bearer $(ciba --url <server-url>)" https://api.example.com/endpointCommands
| Command | Description |
|---------|-------------|
| ciba start --url <server> | Authenticate, detach, save session |
| ciba token | Read token (connects to Yjs, decrypts with keychain) |
| ciba token --resource <urn> | Read token for a specific resource (auto-exchanges if missing) |
| ciba add --resource <urn> | Pre-fetch an additional resource token |
| ciba status | Check if session is active |
| ciba stop | Kill background process, clear keychain |
| ciba --url <server> | One-shot: authenticate, print, exit |
Security Model
Layers of Protection
┌─────────────────────────────────────────────────────────────────┐
│ Layer 1: Transport — only proof-of-possession (PoP) connects │
│ │
│ CLI connects to /sync/device via Hocuspocus WebSocket. │
│ Auth: onAuthenticate verifies SHA-256(challenge) === verifier │
│ Without the challenge, you can't connect to the device doc. │
│ → Eavesdroppers on local network can't even see the doc. │
├─────────────────────────────────────────────────────────────────┤
│ Layer 2: Encryption — token encrypted at rest in device doc │
│ │
│ Server encrypts the token with AES-256-GCM keyed by the │
│ challenge before writing to the Yjs doc. │
│ Even if someone accessed the raw Yjs data, they can't decrypt │
│ without the challenge. │
├─────────────────────────────────────────────────────────────────┤
│ Layer 3: Keychain — challenge protected by OS │
│ │
│ The challenge (decryption key) is stored in: │
│ • macOS: Keychain (security add-generic-password) │
│ • Linux: GNOME Keyring (secret-tool) │
│ │
│ Protected by the OS login session. Only the authenticated │
│ user on this machine can read it. Not exportable cross-machine│
│ Other processes on the same machine CAN read it if they run │
│ as the same user (same as any keychain-stored credential). │
├─────────────────────────────────────────────────────────────────┤
│ Layer 4: Server-side — challenge never in shared/global state │
│ │
│ Global state only stores the verifier = SHA-256(challenge). │
│ Can't reverse to get challenge. │
│ The challenge is written encrypted (server-only key) into │
│ the ephemeral device doc during connection setup. │
└─────────────────────────────────────────────────────────────────┘What's stored where
| Secret | Storage | Who can read |
|--------|---------|-------------|
| Challenge (decryption key) | OS Keychain | Same user, same machine |
| Verifier (SHA-256 of challenge) | Global Yjs doc | Anyone with access (safe — can't reverse) |
| Encrypted challenge (server key) | Device doc _server map | Server only (AES-256-GCM with server salt) |
| Encrypted token | Device doc tokens map | Anyone who connects (but useless without challenge) |
| Token (plaintext) | Never stored | Only in memory after decrypt |
Connection security
- Only a client that knows the challenge can connect to the device doc (Hocuspocus
onAuthenticate— proof of possession) - The device doc is ephemeral (in-memory, no persistence) — gone when the server restarts
ciba startkeeps the Hocuspocus connection alive (holds the doc in memory)ciba tokenre-connects using the challenge from keychain — same PoP auth required- On
ciba stop: keychain entries deleted, background process killed, doc expires
Threat model
| Threat | Mitigation |
|--------|-----------|
| Network eavesdropper | WSS (TLS) + can't connect without challenge (PoP) |
| Local process snooping Yjs | Token is encrypted — useless without challenge |
| Stolen keychain entry | Requires OS-level compromise (login password / biometric) |
| Server compromise | Server only has encrypted challenge (its own key) — not the plaintext token |
| Replay of old tokens | Tokens have expiry (exp claim) |
| Different machine | Keychain is machine-local — can't use session from another device |
Architecture
CLI (ciba) Server IDP
│ │ │
│─ GET /auth/par/authorize ─────▶│ │
│ (sends challenge) │ stores verifier=S256(challenge) │
│◀── {requestId, url} ──────────│ │
│ │ │
│─ WSS /sync/device ────────────▶│ onAuthenticate: S256(ch)==verifier │
│ (token=challenge, PoP) │ onLoadDocument: encrypt(ch,salt) │
│ │ │
│ [user opens url in browser] │ │
│ │─── login redirect ──────────────▶│
│ │◀── code ────────────────────────│
│ │─── token exchange ──────────────▶│
│ │◀── access_token ────────────────│
│ │ │
│ │ encrypt(token, challenge) │
│ │ write to device doc │
│ │ │
│◀── Yjs update (encrypted) ────│ │
│ decrypt(token, challenge) │ │
│ ✓ done │ │
│ │ │
│ [ciba token — later] │ │
│ read challenge from keychain │ │
│─ WSS /sync/device ────────────▶│ onAuthenticate: same PoP check │
│◀── Yjs sync (encrypted) ──────│ │
│ decrypt with keychain challenge│ │
│ ✓ token │ │CIBA Protocol Mapping
| CIBA Standard | This Implementation |
|---|---|
| Backchannel Authentication Endpoint | GET /auth/par/authorize |
| auth_req_id | requestId |
| Token Delivery (poll/push/ping) | Hocuspocus WebSocket (push) |
| Authentication Device | User's browser |
| Token Endpoint | Yjs device doc (encrypted) |
| Client Authentication | PKCE challenge as PoP |
Environment Variables
| Variable | Description |
|----------|-------------|
| CIBA_URL | Default server URL (instead of --url) |
| CIBA_RESOURCE | Default resource (instead of --resource) |
Resource Types
| Resource URN | Token type | Use case |
|---|---|---|
| urn:d:<destination> (default: unified-gateway) | User session token | Calls through API proxy |
| urn:sap:identity:api:name:<name> | Exchanged user token | API with specific audience |
| urn:sap:identity:application:provider:name:<app> | Exchanged user token | Direct service call (needs IAS dependency) |
Multi-Resource Sessions
# Authenticate once
ciba start --url <server>
# Default token
curl -H "Authorization: Bearer $(ciba token)" https://api/default
# Request additional resource (auto-exchanges if not cached)
curl -H "Authorization: Bearer $(ciba token --resource urn:sap:identity:api:name:my-api)" https://other-api/
# Pre-fetch a resource
ciba add --resource "urn:sap:identity:application:provider:name:my-service"
ciba stopHow It Works — Step by Step
ciba startgenerates a randomchallenge(the shared secret)- Calls
/auth/par/authorize?challenge=<ch>— server storesverifier = SHA-256(challenge) - CLI connects to
/sync/devicevia HocuspocusProvider (doc = requestId, token = challenge) - Hocuspocus
onAuthenticateverifiesSHA-256(token) === verifier— only the CLI can connect onLoadDocumentencrypts the challenge with a server-only key and stores it in the device doc- Browser opens the auth URL — user logs in via standard OAuth
- After login, server reads encrypted challenge from device doc, decrypts with server key
- Server encrypts the access token with the challenge, writes to device doc
tokensmap - CLI observes the Yjs update, decrypts with challenge — has the token
- Challenge saved to OS Keychain, background process stays connected
ciba tokenreads challenge from Keychain, reconnects to Hocuspocus (same PoP), reads + decrypts tokenciba stopkills process, clears Keychain
