BLAST Protocol—Biometric-Linked Asymmetric Session Tunnels
The BLAST tunnel protocol: X25519 key agreement, optional hybrid ML-KEM-768, HKDF-SHA-256 key derivation, AES-256-GCM encryption and session lifecycle.
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. A client that also sends an ML-KEM-768 encapsulation key gets a hybrid session; a client that omits it stays on X25519. 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. The tunnel is described in a filed application. No patent has been granted.
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 in milliseconds. Default 300000, five minutes. A number above 3600000 (one hour) is 400. A missing or non-positive value uses the default |
client_mlkem_public_key_b64 | string | no | Raw 1,184-byte ML-KEM-768 encapsulation key, standard or URL-safe base64. Present, the server encapsulates and the session is hybrid. Absent, the session stays on X25519. Not a string, or not 1,184 bytes, is 400. A runtime without ML-KEM-768 answers 503 and does not fall back |
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.
A hybrid init adds two fields: kem is the string X25519+ML-KEM-768, and mlkemCiphertextB64 is the 1,088-byte ciphertext. The client decapsulates that ciphertext with its ML-KEM private key, concatenates the 32-byte ML-KEM shared secret in front of the 32-byte X25519 shared secret, and derives with the v2 info string below. The fingerprint is over that concatenation, not over the X25519 secret alone.
Keep serverPublicKeyHex and sessionId. The server public key is what the client needs to finish the X25519 half 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. A hybrid session uses a longer input: the ML-KEM shared secret, then the X25519 shared secret.
| Parameter | Classical session | Hybrid session |
|---|---|---|
| Input keying material | The X25519 shared secret, 32 bytes | ML-KEM shared secret (32) then X25519 shared secret (32), 64 bytes |
| Salt | A fresh nonce from the entropy generator, stored as salt_hex, 32 bytes | The same |
| Info | blast-tunnel-v1: followed by the session id | blast-tunnel-v2: followed by the session id |
| Output | AES-GCM key, 256 bits | AES-GCM key, 256 bits |
The info string is what separates sessions, and the version prefix is what separates a hybrid derivation from a classical one that happened to share an X25519 secret.
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. Before encrypting, the platform checks that the session exists for this tenant, has not been torn down and has not expired. A missing session is 404 BLAST session not found. A torn-down session is 409 BLAST session has been torn down. An expired session is 410 BLAST session expired. blast_info still reports those states without an error status.
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 HKDF input: the X25519 secret, or the 64-byte hybrid concatenation | AES-256-GCM |
tunnel_key_encrypted | sharedSecretHex:saltHex:sessionId for a classical session, or v2:ikmHex:saltHex:sessionId for a hybrid one | 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, and re-runs the HKDF derivation. A value that starts with v2: is the hybrid material and uses the v2 info string. Anything else is the classical triple. The key is discarded after the call.
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 |
| 400 | ttl_ms exceeds the maximum of 3600000 milliseconds | blast_init with ttl_ms above one hour |
| 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 |
| 404 | BLAST session not found | blast_encrypt for a session this tenant does not have |
| 409 | BLAST session has been torn down | blast_encrypt after teardown |
| 410 | BLAST session expired | blast_encrypt after expires_at |
| 500 | Internal server error | Unexpected failure while opening or using a tunnel |
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. A background reseed starts when the current seed is 60 seconds old or has served 1,000 requests, and that seed keeps serving while the refresh runs. When the seed is 5 minutes old or has served 5,000 requests, whichever comes first, the next call waits for a new seed and returns no bytes until that reseed succeeds. 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.