BROKER

Approvals (B-mode)

B-mode credentials go through consent brokering: every single call needs the owner's explicit approval. The credential is decrypted only in the owner's browser and sealed end-to-end to your public key — Tesska's servers never touch plaintext. This page covers how a caller initiates a request, polls for the decision, and opens the ciphertext.

How it works

A POST /v1/login against a B-mode credential does not return a token directly. Instead it creates an approval request:

  • Your call hits a B-mode credential → you get kind: "pending_approval" plus a requestId
  • Tesska pushes a notification to the owner's phone (installable PWA approval app)
  • The owner unlocks their vault; the credential is decrypted in their browser and sealed end-to-end to your public key
  • You poll GET /v1/approvals/:id and receive the ciphertext resultCt / resultMeta
  • You decrypt locally with the private key you generated for this call — done

Zero plaintext on the server, end to end.B-mode credentials are stored as zero-knowledge ciphertext, and the approval result is ciphertext sealed to your public key. Tesska can decrypt neither the vault nor the delivery.

Step 1 · Generate a key pair and make the call

For each call, generate a one-time ECDH P-256 key pair, export the public key in raw format, base64-encode it, and pass it as clientPubKey in /v1/login. For B-mode credentials clientPubKey is required — omitting it returns 400. The private key stays in memory; never store or send it.

// 1) Generate an ephemeral ECDH P-256 key pair — the private key never leaves memory const kp = await crypto.subtle.generateKey( { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"], ); const raw = new Uint8Array(await crypto.subtle.exportKey("raw", kp.publicKey)); const clientPubKey = btoa(String.fromCharCode(...raw)); // 2) POST /v1/login — clientPubKey is REQUIRED for B-mode credentials (400 if missing) const res = await fetch("https://tesska.com/v1/login", { method: "POST", headers: { "Authorization": "Bearer tsk_your_project_key", "Content-Type": "application/json", }, body: JSON.stringify({ platform: "github", account: "deploy-bot", clientPubKey }), }); const data = await res.json(); // => { "kind": "pending_approval", "requestId": "…", "expiresAt": "…" }

Step 2 · Poll for the decision

Approval requests expire after 5 minutes (see expiresAt). Once the owner approves on their phone, GET /v1/approvals/:id returns approved along with the ciphertext; a rejection returns denied; a timeout returns expired — just send a new /v1/login.

  • resultCt is burn-after-read: the server destroys the ciphertext on the first approved read. Decrypt immediately — do not poll again.
  • A 2–5 second interval is plenty. The endpoint is scoped to your project key; you can never read another project's approvals.
const POLL_MS = 3000; async function waitForApproval(requestId) { for (;;) { const r = await fetch(`https://tesska.com/v1/approvals/${requestId}`, { headers: { Authorization: "Bearer tsk_your_project_key" }, }); const body = await r.json(); // approved → { status, resultCt, resultMeta } — ciphertext is burned after this read if (body.status === "approved") return body; if (body.status === "denied") throw new Error("Denied by the owner"); if (body.status === "expired") throw new Error("Request expired — send a new /v1/login"); await new Promise((resolve) => setTimeout(resolve, POLL_MS)); } }

Step 3 · Decrypt the credential

resultMeta is a JSON string { ephPub, iv }: ephPub is the ephemeral public key generated in the owner's browser (base64 raw), iv is a 12-byte AES-GCM nonce. Run ECDH with your private key, derive an AES-256-GCM key via HKDF-SHA-256 (empty salt, info credbroker-e2e-v1), and open resultCt:

const fromB64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); // resultMeta is a JSON string: { "ephPub": "<base64>", "iv": "<base64>" } const { ephPub, iv } = JSON.parse(resultMeta); const ownerPub = await crypto.subtle.importKey( "raw", fromB64(ephPub), { name: "ECDH", namedCurve: "P-256" }, false, [], ); const bits = await crypto.subtle.deriveBits( { name: "ECDH", public: ownerPub }, kp.privateKey, 256, ); const hk = await crypto.subtle.importKey("raw", bits, "HKDF", false, ["deriveKey"]); const aes = await crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("credbroker-e2e-v1") }, hk, { name: "AES-GCM", length: 256 }, false, ["decrypt"], ); const credential = new TextDecoder().decode( await crypto.subtle.decrypt({ name: "AES-GCM", iv: fromB64(iv) }, aes, fromB64(resultCt)), );

What happens on the owner's side

While you are polling, this is the full chain on the owner's end:

  • A push notification reaches their phone (PWA approval app) — the payload carries metadata only, never ciphertext or plaintext
  • The owner reviews the request: which project, which account, when it expires
  • Approve → unlock the vault; the credential is decrypted only inside their browser
  • The browser seals it to your clientPubKey on the spot (ECDH + HKDF + AES-256-GCM) and hands the ciphertext back to the server for you to pick up

WebhooksPlanned

Webhook callbacks for approval results are not live yet. Polling GET /v1/approvals/:id covers the same use case today — the approval window is only 5 minutes, so short polling is cheap. This page will be updated once webhooks ship.