Skip to main content

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.

Last reviewed October 20, 2026Fresh

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
}
FieldTypeRequiredNotes
actionstringyesblast_init
tenant_iduuidyesMust be the tenant the API key belongs to
client_public_key_hexstringyesExactly 64 hex characters, the raw 32-byte X25519 public key
ttl_msnumbernoSession 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_b64stringnoRaw 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.

ParameterClassical sessionHybrid session
Input keying materialThe X25519 shared secret, 32 bytesML-KEM shared secret (32) then X25519 shared secret (32), 64 bytes
SaltA fresh nonce from the entropy generator, stored as salt_hex, 32 bytesThe same
Infoblast-tunnel-v1: followed by the session idblast-tunnel-v2: followed by the session id
OutputAES-GCM key, 256 bitsAES-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.

ColumnContentAt rest
session_id32 hex characters, the lookup keyPlaintext
tenant_idOwning tenantPlaintext, used for row-level isolation
server_private_key_encryptedServer X25519 private key, PKCS8AES-256-GCM
server_public_key_hexServer X25519 public keyPlaintext, a public value
client_public_key_hexClient X25519 public keyPlaintext, a public value
shared_secret_encryptedThe HKDF input: the X25519 secret, or the 64-byte hybrid concatenationAES-256-GCM
tunnel_key_encryptedsharedSecretHex:saltHex:sessionId for a classical session, or v2:ikmHex:saltHex:sessionId for a hybrid oneAES-256-GCM
salt_hexThe HKDF saltPlaintext, useless without the shared secret
key_fingerprint16-byte digest of the shared secretPlaintext, used in audit rows
expires_at, torn_down, torn_down_atLifecycle statePlaintext

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.

ActionBody fields beyond action and tenant_idReturns
blast_initclient_public_key_hex, optional ttl_msserverPublicKeyHex and the session descriptor
blast_encryptsession_id, plaintextciphertext, iv, sessionId
blast_infosession_idSession status, below
blast_teardownsession_idtornDown as a boolean
blast_countnoneactiveSessions 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

StatusBodyCause
400tenant_id requiredtenant_id missing or not a string
400client_public_key_hex required (64-char hex string)Wrong length or non-hex characters
400session_id requiredMissing on encrypt, info or teardown
400plaintext must be a non-empty stringMissing or empty plaintext
400Invalid JSON bodyBody did not parse
400Unknown actionAction outside the five listed above
400ttl_ms exceeds the maximum of 3600000 millisecondsblast_init with ttl_ms above one hour
401Invalid API key with header x-pb-reason: invalid-keyKey unknown, inactive, or minted for another tenant
403API key missing 'ingest' scope with header x-pb-reason: missing-scopeKey lacks the scope
403Direct access denied. Use https://api.passkeybridge.io/v1/{function}.Request bypassed the public host
403BLAST Tunnels requires an Enterprise subscription, code plan_requiredPlan below Enterprise
402code payment_requiredEnterprise plan whose billing entitlement is revoked
405Method not allowedA method other than GET, HEAD, POST or OPTIONS
413Payload too largeRequest body above 64 KB
429code and header x-pb-reason: quota-exceededPer-minute tenant quota exhausted
404BLAST session not foundblast_encrypt for a session this tenant does not have
409BLAST session has been torn downblast_encrypt after teardown
410BLAST session expiredblast_encrypt after expires_at
500Internal server errorUnexpected 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.

OrderProviderRequires
1Outshift QRNG, Cisco quantum hardwareOUTSHIFT_QRNG_API_KEY
2QCi uQRNG, photonic quantum hardwareQCI_QRNG_ACCESS_TOKEN and QCI_QRNG_PASSWORD
3ANU QRNG, free prototyping tiernothing
4Operating system CSPRNGnothing

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

  1. Call blast_count for the tenant. The activeSessions number should include the tunnel you just opened.
  2. Call blast_info with the session id. exists is true, tornDown is false, and remainingMs counts down toward the TTL you asked for.
  3. 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.
  4. For the audit trail, look in the audit log for blast_init on resource type blast_session with your session id as the resource id. The row's metadata carries the same keyFingerprint and the correlationId from the response.

Related from the blog