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.