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.

Last reviewed September 16, 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. 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
}
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. 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.

ParameterValueSize
Input keying materialThe X25519 shared secret32 bytes
SaltA fresh nonce from the entropy generator, stored as salt_hex32 bytes
InfoThe constant blast-tunnel-v1: followed by the session idVariable
OutputAES-GCM key256 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.

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 ECDH shared secretAES-256-GCM
tunnel_key_encryptedsharedSecretHex:saltHex:sessionId, the triple needed to re-derive the keyAES-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, 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.

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
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
500Internal server errorSession 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.

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. 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

  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