BLAST Protocol—Biometric-Linked Asymmetric Session Tunnels
The BLAST tunnel protocol in detail: X25519 ECDH key agreement, HKDF-SHA-256 session key derivation, AES-256-GCM tunnel encryption, stateless session persistence, and lifecycle management.
Protocol overview
BLAST opens a short-lived encrypted tunnel between a client and the PasskeyBridge platform. The client sends an X25519 public key, the platform answers with its own, and both sides reach a shared secret from which a per-session AES-256-GCM key is derived. Session material is encrypted and stored in shield_blast_sessions, so any edge isolate can serve the next call in the tunnel.
A tunnel runs between the client and the platform. There is no peer-to-peer mode: blast_encrypt produces ciphertext the platform can decrypt, and no second party is enrolled in the session.
Who can call it. An API key carrying the ingest scope, presented as x-pb-api-key, on a tenant whose plan is enterprise. A dashboard session JWT for a tenant admin is also accepted. Every action counts against the tenant's per-minute quota.
Where it lives. https://api.passkeybridge.io/v1/shield-blast. A request sent straight to the Supabase functions host answers 403 Direct access denied. A GET or HEAD on the endpoint answers {"status":"ok","function":"shield-blast"} without authentication, which is what the status page pings.
Patent. PBBLAST, application 64/006,808, filed and pending USPTO examination.
Key exchange (X25519 ECDH)
blast_init performs the exchange, derives the session key, stores the session and returns the server's public key.
POST https://api.passkeybridge.io/v1/shield-blast
x-pb-api-key: pb_live_...
Content-Type: application/json{
"action": "blast_init",
"tenant_id": "00000000-0000-0000-0000-000000000000",
"client_public_key_hex": "3a7f...64 hex characters total...9c",
"ttl_ms": 300000
}| Field | Type | Required | Notes |
|---|---|---|---|
action | string | yes | blast_init |
tenant_id | uuid | yes | Must be the tenant the API key belongs to |
client_public_key_hex | string | yes | Exactly 64 hex characters, the raw 32-byte X25519 public key |
ttl_ms | number | no | Session lifetime. Any finite value above zero is accepted and no upper bound is enforced. Default 300000, five minutes |
Response:
{
"ok": true,
"correlationId": "c-8f2a1e...",
"serverPublicKeyHex": "b41d...64 hex characters total...02",
"session": {
"sessionId": "9e14c0a7b2f35d6188ac4e7f0b3d9152",
"keyFingerprint": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"createdAt": 1758038400000,
"ttlMs": 300000,
"entropyAudit": null
}
}sessionId is 16 random bytes as 32 hex characters. createdAt is epoch milliseconds, a number rather than a timestamp string. keyFingerprint is the first 16 bytes of SHA-256 over the shared secret, written to the audit row for this tunnel so the session can be traced without exposing key material. entropyAudit is null on most calls; see the entropy section below.
Keep serverPublicKeyHex and sessionId. The server public key is what the client needs to derive the same tunnel key locally, and sessionId addresses the tunnel on every later call.
Session key derivation (HKDF-SHA-256)
The X25519 shared secret is never used as an encryption key. It is the input keying material for HKDF-SHA-256, which produces the 256-bit AES-GCM tunnel key.
| Parameter | Value | Size |
|---|---|---|
| Input keying material | The X25519 shared secret | 32 bytes |
| Salt | A fresh nonce from the entropy generator, stored as salt_hex | 32 bytes |
| Info | The constant blast-tunnel-v1: followed by the session id | Variable |
| Output | AES-GCM key | 256 bits |
The info string is what separates sessions. Two tunnels that somehow shared an X25519 secret would still derive different tunnel keys, because the session id differs.
The platform re-derives this key on every blast_encrypt call from the stored material. Nothing about the key is held in isolate memory between requests.
Tunnel encryption (AES-256-GCM)
blast_encrypt encrypts one payload through an open tunnel. A fresh 12-byte IV is generated per call, and ciphertext and IV come back base64 encoded.
{
"action": "blast_encrypt",
"tenant_id": "00000000-0000-0000-0000-000000000000",
"session_id": "9e14c0a7b2f35d6188ac4e7f0b3d9152",
"plaintext": "sensitive payload"
}{
"ok": true,
"correlationId": "c-31b9de...",
"ciphertext": "<base64>",
"iv": "<base64>",
"sessionId": "9e14c0a7b2f35d6188ac4e7f0b3d9152"
}Size limit. The function reads the whole request body under a 64 KB cap and answers 413 Payload too large above it. The separate 1 MiB plaintext ceiling in the source is therefore unreachable through this endpoint: 64 KB of JSON is the real limit, and a payload near it must account for base64 growth in the response.
Failed sessions answer 500 today. Before encrypting, the platform checks that the session exists for this tenant, has not been torn down and has not expired. Each of those failures raises inside the function and is caught by the outer handler, so the caller receives 500 Internal server error with no detail, while the specific reason is written to the function log against the correlationId. Call blast_info first when you need to tell an expired tunnel from a missing one.
blast_encrypt writes no audit row. blast_init and blast_teardown do.
Stateless session persistence
Edge functions hold nothing between invocations, so the tunnel lives in shield_blast_sessions. Secret values are encrypted with the platform key before the row is written.
| Column | Content | At rest |
|---|---|---|
session_id | 32 hex characters, the lookup key | Plaintext |
tenant_id | Owning tenant | Plaintext, used for row-level isolation |
server_private_key_encrypted | Server X25519 private key, PKCS8 | AES-256-GCM |
server_public_key_hex | Server X25519 public key | Plaintext, a public value |
client_public_key_hex | Client X25519 public key | Plaintext, a public value |
shared_secret_encrypted | The ECDH shared secret | AES-256-GCM |
tunnel_key_encrypted | sharedSecretHex:saltHex:sessionId, the triple needed to re-derive the key | AES-256-GCM |
salt_hex | The HKDF salt | Plaintext, useless without the shared secret |
key_fingerprint | 16-byte digest of the shared secret | Plaintext, used in audit rows |
expires_at, torn_down, torn_down_at | Lifecycle state | Plaintext |
On each blast_encrypt the platform loads tunnel_key_encrypted, decrypts it, splits it on : and re-runs the HKDF derivation, then encrypts and discards the key.
Row-level security lets tenant admins read their own sessions, which is what the dashboard tab uses. The function itself writes with the service role.
Session lifecycle
Five actions, all on the same endpoint and all behind the same ingest scope and Enterprise gate.
| Action | Body fields beyond action and tenant_id | Returns |
|---|---|---|
blast_init | client_public_key_hex, optional ttl_ms | serverPublicKeyHex and the session descriptor |
blast_encrypt | session_id, plaintext | ciphertext, iv, sessionId |
blast_info | session_id | Session status, below |
blast_teardown | session_id | tornDown as a boolean |
blast_count | none | activeSessions as a number |
blast_info never fails on an unknown session. A session belonging to another tenant is indistinguishable from one that never existed:
{
"ok": true,
"correlationId": "c-77aa02...",
"exists": true,
"expired": false,
"tornDown": false,
"keyFingerprint": "a1b2c3d4e5f60718293a4b5c6d7e8f90",
"createdAt": "2026-09-16T14:30:00.000Z",
"expiresAt": "2026-09-16T14:35:00.000Z",
"remainingMs": 180000
}When the session is unknown, exists is false and every other field is null.
blast_teardown is idempotent. It returns tornDown: true exactly once, on the call that actually closed the tunnel, and false afterwards or for a session this tenant does not own. Either way the audit row is written, with result success or denied.
A session is encryptable only while torn_down is false and expires_at is in the future. The database routine cleanup_expired_records() sets torn_down and torn_down_at on every expired session that is still open, and reports the number under blast_sessions_torn_down. Expiry is enforced on every call regardless, so an uncleaned row is never usable.
Errors
| Status | Body | Cause |
|---|---|---|
| 400 | tenant_id required | tenant_id missing or not a string |
| 400 | client_public_key_hex required (64-char hex string) | Wrong length or non-hex characters |
| 400 | session_id required | Missing on encrypt, info or teardown |
| 400 | plaintext must be a non-empty string | Missing or empty plaintext |
| 400 | Invalid JSON body | Body did not parse |
| 400 | Unknown action | Action outside the five listed above |
| 401 | Invalid API key with header x-pb-reason: invalid-key | Key unknown, inactive, or minted for another tenant |
| 403 | API key missing 'ingest' scope with header x-pb-reason: missing-scope | Key lacks the scope |
| 403 | Direct access denied. Use https://api.passkeybridge.io/v1/{function}. | Request bypassed the public host |
| 403 | BLAST Tunnels requires an Enterprise subscription, code plan_required | Plan below Enterprise |
| 402 | code payment_required | Enterprise plan whose billing entitlement is revoked |
| 405 | Method not allowed | A method other than GET, HEAD, POST or OPTIONS |
| 413 | Payload too large | Request body above 64 KB |
| 429 | code and header x-pb-reason: quota-exceeded | Per-minute tenant quota exhausted |
| 500 | Internal server error | Session missing, expired or torn down on encrypt; also any unexpected failure |
The 403 for the plan gate carries current_plan and upgrade_url. See error codes and troubleshooting for the shared header conventions.
Quantum-sourced entropy
The session salt and session id come from the platform's seeded generator, an AES-CTR DRBG whose seed is drawn from a quantum source where one is configured.
| Order | Provider | Requires |
|---|---|---|
| 1 | Outshift QRNG, Cisco quantum hardware | OUTSHIFT_QRNG_API_KEY |
| 2 | QCi uQRNG, photonic quantum hardware | QCI_QRNG_ACCESS_TOKEN and QCI_QRNG_PASSWORD |
| 3 | ANU QRNG, free prototyping tier | nothing |
| 4 | Operating system CSPRNG | nothing |
Every draw runs the two NIST SP 800-90B section 4.4 continuous health tests before it is accepted. A failing draw is rejected and the next provider is tried. The accepted seed is then mixed by XOR with local CSPRNG bytes, so a stale or cached provider response cannot repeat a seed.
The generator does not reseed per call. It reseeds when the current seed is 60 seconds old or has served 1,000 requests, and that refresh runs in the background off the still-valid key so signing never waits on an external fetch. The first call in a cold isolate seeds synchronously.
That is why session.entropyAudit is null on most blast_init calls. It is populated only when that call surfaced a reseed, and then it looks like this:
{
"raw_entropy_hash": "<SHA-384 hex of the mixed seed>",
"source_provider": "outshift_qrng",
"seed_size_bits": 256,
"metadata": {
"reseeded_at": "2026-09-16T14:30:00.000Z",
"reseed_reason": "interval_or_count",
"health_tests": { "pass": true, "repetition_count": 0, "adaptive_proportion": 0 }
}
}reseed_reason is initial on the first seed of an isolate. An entropy_bits_per_byte field and a rejected_draws array appear only when the provider reported the former or an earlier draw failed its health tests. Treat a source_provider of fallback_csprng as a signal that the quantum providers were unreachable or unconfigured.
The BLAST path does not write the seed to shield_entropy_pool itself. The audit is queued in the isolate and written by the next caller there that holds a database client, so a seed observed in a blast_init response may appear in the pool slightly later. Signed-artifact provenance is covered in recursive entropy chaining.
Verify it worked
- Call
blast_countfor the tenant. TheactiveSessionsnumber should include the tunnel you just opened. - Call
blast_infowith the session id.existsistrue,tornDownisfalse, andremainingMscounts down toward the TTL you asked for. - Open Dashboard, Session tunnels. The tab lists the 50 most recent sessions for the tenant with tiles for total sessions, active tunnels, torn down and expired. Your session appears by its first 16 hex characters with an Active badge and the minutes remaining, alongside its key fingerprint, salt prefix and both public key prefixes. The tab is Enterprise only and reads the table directly, so it reflects rows rather than a cached count.
- For the audit trail, look in the audit log for
blast_initon resource typeblast_sessionwith your session id as the resource id. The row's metadata carries the samekeyFingerprintand thecorrelationIdfrom the response.