Security analysis

Security analysis of the current implementation.

This page is a technical walkthrough of what the current release — v0.9.1 relay / v0.9.1 client (protocol v2) — is intended to protect, how each mechanism contributes to that protection, and where the current implementation still has known gaps. It describes the code as currently deployed, not the target design in the abstract.

Scope

What this system is, and what it is not.

Keylay is a coordination layer for remote multisig participants. It is not a wallet, not a signer, and not a custody service. Its job is narrower: move coordination data across distance while reducing how much trust must be placed in the relay and surrounding infrastructure.

For how these design choices differ from other remote coordination tools — Nunchuk, SigningRoom, Bitcoin Safe — see how Keylay compares.

The relay is intended to be a dumb message forwarder. It routes traffic on a channel and assigns simple roles, but it is not supposed to learn plaintext, hold session keys, or participate in signing. The central security question is therefore whether the relay can read, alter, or substitute coordination data without being detected or without already knowing the shared code.

Security goals

  • Relay should not learn message plaintext
  • Relay should not be able to substitute handshake keys unless it already knows the code
  • Later compromise of the code should not reveal prior session plaintext
  • Private keys should remain outside Keylay entirely

Non-goals

  • Endpoint compromise is not solved here
  • Code knowledge is treated as membership
  • Identity beyond the code is not established by the current protocol
  • Replay protection against the relay is provided (with one known gap, E5 below); counter-based replay within a compromised endpoint is not
Threat model

What adversary is being considered.

The current design assumes the relay may be observable and potentially malicious. It may record traffic, attempt to read message contents, attempt handshake substitution, or replay previously seen ciphertext. The current implementation is intended to prevent plaintext disclosure to the relay and to resist relay MITM by binding handshake messages to knowledge of the shared code. It does not prevent an attacker who already knows the code from acting as a legitimate participant.

Endpoint malware, compromised browsers, compromised phones, insecure out-of-band sharing of the code, and wallet-side verification failures remain outside what the protocol itself can solve. That limitation should be stated plainly, not hidden behind transport security claims.

Protocol walk-through

How the current implementation produces its security properties.

The security design of this protocol is described step by step. Each step contributes a limited property. No single step does all the work. Standard cryptographic protocols are referred to by abbreviation without elaboration.

1

Code entry

The code is normalized before anything is derived from it: it is lowercased and every character outside a–z 0–9 is removed, so 7k4m · 2xqt · b9 and 7k4m2xqtb9 are the same code. The 10-character minimum is checked after that removal, so punctuation cannot pad a short code. Generated codes use a 31-character alphabet (a–z without i l o, plus 2–9) and default to length 10; a hand-typed code may use the full 36-character lowercase-alphanumeric set, and the app warns (without blocking) when a typed code has very few distinct characters or is a sequential run. That warning does not catch common human patterns such as a word plus a year (for example bitcoin2024), which is open finding E8 below. Effective security still depends on the actual code used, so a technical reader should treat session-code strength as a parameterized question, not a fixed constant.

2

Channel derivation

The normalized code is first stretched: K_code = PBKDF2-HMAC-SHA512(code, salt 'keylay-v2-kdf-salt', 300,000 iterations), 32 bytes. Every value that depends on the code is derived from K_code, never from the raw code. The channel name the relay sees is SHA-256('keylay-v2-channel|' || K_code), first 32 hex characters. Through version 0.8 this was SHA-256(code), which a relay operator could invert offline at hash speed — about a day on one GPU for a generated code. Deriving it from the stretched key means each guess now costs a full PBKDF2 run. The channel name does not, by itself, authenticate peers or encrypt anything; its role is channel identification only. The salt is a fixed public constant by necessity — see the open item below.

3

HMAC key derivation from the code

The handshake HMAC key is SHA-256('keylay-v2-hmac|' || K_code) — a second value derived from the same single stretch, not a separate PBKDF2 run over the raw code. This HMAC key is used to prove knowledge of the code during handshake. It is not the AES session key, and message confidentiality does not depend directly on it. This matters because offline guessing cost against the code is not the same thing as decryption cost against past traffic.

4

Ephemeral X25519 key generation

Each participant generates an ephemeral X25519 key pair for the session. These keys are session-scoped, not long-lived identity keys. Their purpose is to produce a fresh Diffie-Hellman shared secret for each session. This is the basis of forward secrecy: if the ephemeral private key is discarded after handshake, later compromise of the code does not reveal old session keys.

5

Signed public-key exchange

Each peer exports its ephemeral public key, base64 encodes it, and signs that public key with the HMAC key derived from the code. The peer then sends the pair {pubkey, sig} inside a hello payload. The security contribution here is narrow but important: the relay cannot substitute a different public key unless it can also forge a valid HMAC, which requires knowing the code. Without this step, the relay could perform key substitution and place itself in the middle of the exchange.

6

X25519 shared secret and HKDF session key

Once a peer receives a valid foreign public key, it computes X25519 shared bits with its own ephemeral private key. Those bits are then imported as HKDF material, with K_code as the HKDF salt and info = 'keylay-v2-pair|' || channel || sorted(pubkey_A, pubkey_B). Both sides derive the same session key, and that key is tied to the code, the channel, and the exact handshake participants — a key agreed for one channel and pair cannot be transplanted to another. No session key is ever transmitted.

7

AES-GCM message protection

Messages are encrypted under AES-GCM with a random 12-byte initialization vector (IV) per message. The additional authenticated data (AAD) is the full envelope header, 'keylay-v2|' + type + '|' + channel + '|' + format + '|' + counter, where counter is a monotonically increasing integer included in the message. This binds each ciphertext to its message type, channel, payload format, and sequence position: a relay that modifies any of these fields in transit causes GCM tag failure. (Through version 0.8 only the counter was bound; the unauthenticated format field was finding S3, resolved here.) The receiver enforces strict monotonic ordering and rejects any message whose counter is not strictly greater than the last accepted value, so a previously recorded ciphertext cannot be replayed. One known exception: the October 2026 external report (finding E5 below) showed that when two incoming messages are processed at the same time, the receive counter can move backward and re-admit a replayed message. A fix is planned for the next client release.

8

Handshake completion and reconnect behavior

The state machine moves from IDLE to HELLO_SENT to ACTIVE. Duplicate retries are ignored. If the network drops, the app reconnects to the relay automatically and both sides run a fresh handshake. If a different key appears from a peer while a session is already active, the app ignores it and keeps the current session; a peer that genuinely restarted with a new key must reload to start over. This prevents silent peer-key replacement mid-session, and stops a rogue peer from forcing session resets by sending fresh keys.

What each step guarantees

No single mechanism secures the system by itself.

Channel hash

Gives a room identifier derived from the stretched code, so recovering the code from it costs a PBKDF2 run per guess. It does not provide authentication or encryption.

HMAC on public keys

Proves knowledge of the code during handshake and blocks relay key substitution unless the relay also knows the code.

Ephemeral X25519

Produces fresh shared secret material per session and enables forward secrecy once ephemeral private keys are discarded.

HKDF bound to channel and public keys

Derives the same AES session key on both sides and binds that key to the code, the channel, and the exact participant keypair combination.

AES-GCM

Protects message confidentiality and integrity under the current session key. The authenticated header (type, channel, format, counter) and strict receive-side counter enforcement prevent replay of recorded ciphertext and relay-side alteration of any header field (see E5 for a known counter race).

Peer key locked while active

Once a session is active, a hello carrying a different peer public key is ignored and the current session continues. A peer that genuinely restarted with a new key must reload to start over; a network drop instead triggers an automatic reconnect and a fresh handshake.

Security properties

What the protocol does and does not guarantee.

Protections the current implementation provides

  • Relay plaintext resistance — relay sees ciphertext and channel hash, not plaintext messages
  • Relay MITM resistance without code — relay cannot forge valid handshake signatures without knowledge of the code
  • Forward secrecy against later code compromise — past sessions are not decrypted merely by learning the code later, because session keys come from ephemeral X25519, not directly from the code
  • No session key transmission — both peers derive the key locally from the shared secret
  • No custody of wallet secrets — Keylay is outside the signing path

Protections the current implementation does not provide

  • No in-session protection if code is known — code knowledge is membership; an attacker who knows the code can join and participate
  • No endpoint compromise protection — malware on user devices can access plaintext before encryption or after decryption
  • No identity beyond code knowledge — protocol authentication is proof of code possession, not proof of real-world identity
  • No memory-hard password defense — PBKDF2-HMAC-SHA512 raises brute-force cost but is not memory-hard; Argon2id or scrypt would be stronger, and neither is available in the browser's native crypto (see Hardening gaps)
  • No per-session salt for code stretching — a relay that logs many channel identifiers can guess the code space once against all of them; inherent to deriving a rendezvous identifier from a shared code (see Open items, S11)
  • No metadata hiding — the relay sees IP addresses, connection timing, message sizes and counts, each message's transfer format (QR, UR, BBQr, or file) and sequence counter, and which two connections are paired
  • No authenticated roles — any session member can send a role claim and take the sender role; role and other control messages are not authenticated (see E9)
What Keylay does not protect against

Some risks remain outside the protocol.

Endpoint compromise

  • Compromised browser or operating system
  • Malware on the online coordination device
  • Wallet-side verification failures
  • Exfiltration before encryption or after decryption

Out-of-band failures

  • Insecure sharing of the session code
  • Human confusion about who is actually on the other end
  • Weak manually chosen codes
  • Operational mistakes outside the protocol itself
Session code analysis

What the session code protects, and how much protection it provides.

The session code does not encrypt your messages. That is the job of a session key generated fresh for every connection and discarded when it ends — meaning that even if your code were compromised later, past sessions cannot be decrypted.

The code's job is narrower: it authenticates the handshake. When two browsers exchange the ephemeral keys needed to start a session, the code proves to each side that the other party is legitimate and that no one has substituted different keys in transit. The real threat is not "can an attacker decrypt my messages by guessing the code?" but rather: can an attacker break the handshake authentication fast enough to intercept a live session, or prove after the fact that they could have?

An attacker who has captured the handshake transcript — which requires access to the relay server — can attempt every possible code on their own hardware, bypassing any server-side rate limiting. The system uses PBKDF2-HMAC-SHA512 with 300,000 iterations to make each guess expensive. The tables below assume roughly one million guesses per second on a GPU cluster — a figure estimated for the PBKDF2-SHA256 used through version 0.8. SHA-512 is slower per guess on GPU hardware, so against version 0.9 these tables are conservative: the real times are longer. Since 0.9 the same guessing work also recovers the code from the channel identifier the relay sees (previously that was a plain SHA-256 and far cheaper), so these tables now describe both attacks.

Crack time by alphabet and length

Average time to exhaust half the keyspace at 1M guesses per second.

Numeric only (0–9)

Not suitable at any practical length. A 12-digit numeric code falls within 6 days on a single GPU cluster. A parallelized attack reduces this to hours.

LengthAvg. crack time
6 digits< 1 second
8 digits~50 seconds
10 digits~1.4 hours
12 digits~5.8 days

Lowercase alphanumeric (a–z, 0–9)

The system minimum of 10 characters requires ~58 years on a single cluster — far longer than any session lasts, so guessing the code in real time is not a practical attack (assuming the code is random and stays secret). At the assumed rate, and for any attacker within a few orders of magnitude of it, a live break before the session closes is not practical.

LengthAvg. crack time
6 characters~18 minutes
8 characters~16.3 days
10 characters~57.9 years
12 characters~75,100 years

Generator alphabet (31 characters)

Codes the app generates use a–z without the look-alikes i l o, plus 2–9 — 31 symbols. This is the code space that applies when you use "Generate Random," so it is the honest floor: a 10-character generated code averages ~13 years on the same cluster, still far outside any live session. (Hand-typed codes may use the full 36-symbol set above. The app accepts letters and digits only; punctuation and spaces are removed before the length check, so they add nothing.)

LengthAvg. crack time
6 characters~7.4 minutes
8 characters~4.9 days
10 characters~13 years
12 characters~12,500 years
Why 10 characters

At the 10-character minimum, guessing the code during a live session is not practical.

A handshake completes in seconds. An attacker would need to crack the code before the session ends to mount a live man-in-the-middle attack. Six lowercase alphanumeric characters fall in 18 minutes — well within an active session window. At the 10-character minimum, cracking requires ~58 years on a single GPU cluster for the full lowercase-alphanumeric set, or ~13 years for a code from the generator's 31-symbol alphabet. At the assumed rate, and for any attacker within a few orders of magnitude of it, a live break before the session closes is not practical.

The same figure is also the cost of a retrospective attack: proving after the fact that interception was theoretically possible. Forward secrecy means past message content remains safe regardless, but a broken handshake undermines trust in the session's integrity. Ten characters is the minimum that makes this retrospective case economically unattractive for a single-cluster attacker.

All generated codes use a 31-character unambiguous alphabet (abcdefghjkmnpqrstuvwxyz23456789) with rejection sampling to eliminate modular bias. Human-chosen codes of any length are weaker than randomly generated ones — people choose predictably. Use the generator.

Assumptions

What these estimates rest on.

  • Single GPU cluster at 1M guesses/second. A parallelized attacker reduces crack times proportionally. For high-stakes use, treat these figures as optimistic and choose longer codes.
  • 300,000 PBKDF2-HMAC-SHA512 iterations. Raising this to 1,000,000 would push the 8-character baseline from ~16.3 days to ~54.3 days, at the cost of slightly slower session setup (~0.3–1 second). The 1M-guesses/second rate was estimated for SHA-256; SHA-512 is slower per guess on GPUs, so the tables understate the cost against version 0.9.
  • Per-session cost, not per-relay cost. The stretching salt is a fixed public constant (it has to be — see S11 under Open items), so an attacker who has logged many sessions' channel identifiers pays the sweep once and checks every session against it. The figures above are the cost to attack one code; a relay operator amortizing across N sessions pays roughly the same total to attack all N. The defense is the same either way: code length and randomness.
  • Requires the handshake transcript. This attack requires access to the relay server. A passive network observer cannot attempt it because traffic between browser and server is protected by TLS.
  • Code knowledge equals access. No cryptographic length compensates for a code disclosed through other means — shoulder surfing, insecure transmission, or a compromised device.
Implementation review

What a line-by-line review of the deployed code confirmed and found.

The source code was reviewed against every specific security claim on this page. Each claim was traced to the corresponding function or variable and evaluated independently. The review also covered the relay server, session isolation logic, and server-side logging. Keylay has had five internal, AI-assisted review revisions and one external researcher report (October 2026); no independent audit yet. The current internal revision (R5, version 0.9) reviewed the protocol v2 cryptographic path and the four finding fixes line-by-line against the 0.9.0 source and exercised them with the test harnesses in tools/; claims confirmed in R4 that v2 did not touch (server limits, logging, CSP, timers) were re-checked by inspection only. Version 0.9.1 changed only the relay (server.js), to fix external findings E1–E3; the 0.9.1 client differs from 0.9.0 only in its version string, and the two interoperate (both protocol v2).

Confirmed

Claims that check out against the deployed code.

Relay plaintext resistance

Confirmed. Every payload sent over the channel is AES-GCM ciphertext. The channel identifier visible to the relay is SHA-256('keylay-v2-channel|' || K_code) truncated to 32 hex characters, where K_code is the PBKDF2-stretched code. Neither the raw code nor anything derived from it without the stretch appears on the wire.

HMAC handshake authentication

Confirmed. Each peer signs its ephemeral public key with an HMAC-SHA256 key derived from K_code, the session code stretched with PBKDF2-HMAC-SHA512 (300,000 iterations). The relay cannot forge a valid signature without knowing the code.

Forward secrecy

Confirmed. The ephemeral X25519 private key is generated non-extractable and set to null immediately after session key derivation. The session key is not derivable from the code alone; it requires the private key that was discarded at handshake completion.

No session key transmission

Confirmed. Both peers derive the AES-256-GCM key locally from X25519 shared bits processed through HKDF, with K_code as the salt. The HKDF info field binds the derived key to the channel identifier and the sorted pair of ephemeral public keys.

Replay protection

Confirmed. AES-GCM additional authenticated data is the full envelope header — protocol tag, message type, channel, payload format, and a monotonic counter. The receiver tracks the last accepted counter and rejects any message that does not strictly advance it. The counter is advanced only after successful decryption. Verified with tools/v2-crypto-test.mjs. Exception: open finding E5 — concurrent message handling can move the counter backward and re-admit a replay. Fix planned.

Peer key change detection

Confirmed. If a different ephemeral public key arrives while a session is active, the incoming hello is rejected and the active session continues unchanged. A legitimate peer reconnecting with a new key must reload to restart the handshake from the beginning. This prevents a rogue peer from repeatedly forcing session resets by submitting fresh keys — a denial-of-service vector present in earlier versions. Limits: see open finding E9 — after a reconnect, a malicious relay can replay an old hello, because the handshake has no key-confirmation step.

Two-peer session enforcement

Confirmed at the server. A third connection attempt to an active session hash is refused and the socket is closed immediately.

Unencrypted data rejected

Confirmed. The client checks the encrypted flag on every incoming data message and silently discards any message that does not carry it.

Web Crypto only

Confirmed. All cryptographic operations — key generation, PBKDF2, HKDF, HMAC, AES-GCM — use the browser's native crypto.subtle implementation. No third-party cryptographic primitives are in the protocol path.

Server-side rate limiting

Confirmed. The relay enforces connection rate limits, message frequency limits, and payload size caps. The client-side pre-handshake message buffer also has a size cap. These limits raise the cost of flooding the relay; they are not a guarantee of availability. Version 0.9.1 hardened the relay against the crash and session-reset problems in external findings E1–E3. One known limit remains: the relay's 100-connection global cap can still be filled by a determined attacker using about 10 IPv4 addresses (10 connections each) or many IPv6 /64 address blocks. A higher cap and a per-/48 cap are planned.

Content Security Policy

Confirmed. A Content-Security-Policy is set for the application. The policy restricts where the page can send data: network connections are limited to the same origin and the canonical relay, so a successful XSS injection cannot exfiltrate data to an arbitrary external host. Object embedding, form actions, and base-URI changes are all blocked. Note: the current policy includes 'unsafe-inline' in script-src because the app ships as a single inline-script bundle. This means an injected inline script could still read in-memory state. Replacing 'unsafe-inline' with explicit per-block hash entries is planned and will tighten this further.

Only truncated IP prefixes in relay logs

Confirmed. The server logs truncated IP addresses only — the first two octets of an IPv4 address (a /16 prefix) or the first two groups of an IPv6 address are retained for operational purposes; the remainder is zeroed before the log entry is written. Keylay's relay logs store truncated prefixes only; logs rotate after 7 days. (Your network path, the hosting provider and the reverse proxy are outside this statement.) No session content, session codes, or coordination data appear in any log.

Issues found and resolved

Problems identified in review and fixed before this version shipped.

No server-side rate limiting or size enforcement

The relay server imposed no restrictions on connection rate, message frequency, or payload size. The client-side pre-handshake buffer also had no size cap. This created a straightforward denial-of-service path against the relay independent of the cryptographic protocol.

Resolved in v0.7. Rate limiting, message frequency caps, payload size limits, and a pre-handshake buffer cap added to the relay.

No Content Security Policy

The page set no Content-Security-Policy header. In the absence of a CSP, a successful XSS injection would have had unrestricted access to the DOM, WebSocket connection, session key variables, and all decrypted payloads.

Resolved in v0.7. CSP added, restricting script sources and resource loading.

Session code could reach the app host via the URL

The app accepted a session code supplied in the page URL (a ?code= parameter) and pre-filled it. A code in the URL travels with the page request to the app host and can be recorded in its access logs, browser history, or the Referer header — a path by which the code could reach the server, contradicting the property that the raw code never touches the host.

Resolved in v0.8.0. The URL code parameter was removed entirely; the code is entered manually only, so it never rides the request to the host.

Ephemeral private key was exportable

The per-session X25519 private key was generated as extractable. Nothing in the app exported it, but extractability meant an injected script could in principle read the live private key from memory, and forward secrecy relied on the key reference being cleared rather than on the browser enforcing it.

Resolved in v0.8.0. The key is now generated non-extractable, so the browser itself forbids export and forward secrecy is enforced by the platform.

Channel identifier could be inverted offline (S12)

Through v0.8.0 the channel identifier the relay sees was a plain SHA-256 of the code, truncated. A relay operator could guess the 10-character code offline at hash speed — about a day on one GPU for a generated code. Knowing the code does not by itself decrypt traffic (the session key also needs the ephemeral key exchange), but it defeats the property that the relay learns nothing about the code. Found during the protocol v2 design.

Resolved in v0.9.0. The code is stretched once with PBKDF2-HMAC-SHA512 (300,000 iterations) and the channel identifier, handshake HMAC key, and session-key salt are all derived from that stretched key. Each guess against the channel identifier now costs a full stretch.

Envelope format field was not authenticated (S3)

The GCM authenticated data bound only the counter. A relay could flip the format field and the client would still decrypt and run the relay-chosen branch. Demonstrated in a harness; low severity, contained.

Resolved in v0.9.0. The authenticated data is now the full header: protocol tag, message type, channel, format, and counter. Altering any of them fails decryption, verified by tools/v2-crypto-test.mjs.

Relayed decompression was not output-bounded (S4)

A relayed BBQr compressed payload was inflated with no output ceiling; a small crafted frame could expand roughly a thousandfold and crash a counterpart's tab. Member-gated and download-triggered, so low severity.

Resolved in v0.9.0. Inflation is streamed and aborts once output exceeds 8 MB, far above any real PSBT.

One vendored library lacked verifiable provenance (S5)

Two of the three inlined libraries hashed to their published builds; the QR encoder did not, so the "what was reviewed is what runs" guarantee could not be proven for it. No evidence of tampering.

Resolved in v0.9.0. All three (jsQR 1.4.0, qrcode 1.5.1, pako 2.1.0) are now byte-identical to their published releases. Sources and SHA-256 hashes are in the repository's PROVENANCE.md and on the verify page; node tools/verify-vendored.mjs checks them and runs as a release preflight.

Hardening gaps

Known areas for future improvement.

These are not bugs in the current implementation. They are areas where the security posture could be strengthened beyond what is deployed today.

PBKDF2 is not memory-hard

PBKDF2-HMAC-SHA512 at 300,000 iterations raises brute-force cost but is not memory-hard. Argon2id or scrypt would be significantly more resistant to GPU-accelerated offline guessing of the session code. This is blocked on browser support, not scheduled: neither is available in the browser's native crypto API (Web Crypto), and Keylay will not ship a hand-rolled or third-party implementation of a protocol primitive — the "what was reviewed is what runs" guarantee depends on every cryptographic primitive coming from the browser. It becomes actionable only if browsers add a memory-hard KDF natively, and would then be a breaking protocol change with a migration plan. v0.9.0 moved the stretch from SHA-256 to SHA-512 as the available interim step.

Session membership is gated by the code, not the relay

The relay enforces a two-participant limit per session, but that cap is policy, not cryptography — a compromised or replaced relay could admit more connections. What actually gates membership is the session code: a party who does not know the code cannot complete the code-authenticated handshake even if the relay lets them connect, and a party who does know the code is a legitimate participant by design. The protocol cannot distinguish an intended co-signer from anyone else holding the code, so the security of a session reduces to who has the code. Share it only through a trusted channel.

No out-of-band code channel verification

The protocol has no mechanism to verify the integrity of the channel used to share the session code. An adversary who can intercept or substitute the code during out-of-band sharing — by controlling the communication channel — can join as a legitimate participant. No in-band remedy exists for a fully compromised code-sharing channel.

No clickjacking protection header

Clickjacking is when another website loads a page inside a hidden frame and tricks the visitor into clicking on it. The standard defense is the Content Security Policy frame-ancestors directive, which tells the browser which sites may embed the page. Keylay's policy is delivered in a <meta> tag inside the page, and browsers ignore frame-ancestors there — it only works when sent as an HTTP response header from the server. Today no such header is set.

No formal third-party audit

These five internal revisions are AI-assisted reviews by the maintainers, not an independent audit. The October 2026 external researcher report is Keylay's first outside review, but it is also not an audit. A formal audit by an external security firm is on the roadmap and will be conducted before the project moves out of alpha.

Open items

Findings that remain open, and why.

Seven findings are open in total: one from the internal reviews (S11, below) and six from the October 2026 external report (E4–E9, in the next section). The August 2026 internal review (R4) surfaced four low-severity findings. Three (S3, S4, S5) are resolved in v0.9.0 and have moved to "Issues found and resolved" above. One (S11) remains, and it is disclosed here as a permanent property of the design rather than a bug awaiting a fix — because the point of this page is that the claims match the code.

Fixed key-derivation salt enables multi-target amplification (S11)

The PBKDF2 salt is a public constant, so the expensive key-stretching for a given code is identical across every session. A relay that logs many channel identifiers can sweep the code space once and test every logged session against it, instead of paying that cost per session.

Open — accepted by design. The August 2026 plan was to derive the salt per session from the channel identifier. Under protocol v2 that is circular — the channel identifier is itself derived from the stretched code — and on reflection no per-session salt is possible at this layer: two peers share nothing but the code before they connect, so the identifier they meet on must be computable from the code alone, which means the salt must be a constant both already know. What v2 changes is the price of the sweep: one PBKDF2-HMAC-SHA512 stretch per candidate code instead of one SHA-256. The levers that keep it expensive are the generator's 10-character, 31-symbol codes and the warning on weak hand-typed codes (which currently misses common human patterns — see E8). A memory-hard KDF would raise the per-guess cost further but would not remove the amortization, and is itself blocked on browser support (see Hardening gaps). Severity: low.

External report

Findings from the first outside review (October 2026).

On October 16, 2026, an independent security researcher reported nine findings privately by email. All were reproduced only against a local copy of the relay, not the hosted service, and were disclosed responsibly. This is Keylay's first outside review; it is not an audit. None of these findings breaks confidentiality: the relay still cannot read payloads. Three (E1–E3, all in the relay) are resolved in v0.9.1. Six (E4–E9) are open and planned for a later release; they are in the client unless noted.

One malformed message could crash the relay (E1)

The relay did not handle errors on individual WebSocket connections (the live connection between a browser and the relay). A single malformed message frame from any visitor crashed the whole relay process, ending every session. Severity: critical (availability).

Resolved in v0.9.1. Each connection now has its own error handler, so a bad frame closes only that connection.

One disconnect reset every session (E2)

When any connection closed, the relay told every active session that its peer had left. One anonymous connect-and-disconnect could reset everyone's session. Severity: high (availability).

Resolved in v0.9.1. Each connection is bound to the single session it joined, and only that session is updated when it leaves.

Join requests were not limited (E3)

The relay accepted session codes of any type and length, let one connection join unlimited sessions, and let a connection that never joined hold a slot indefinitely. Its per-address connection cap counted each full IPv6 address separately, so one machine could appear as many. Severity: high (availability and resources).

Resolved in v0.9.1. Channel identifiers must be 32 lowercase hexadecimal characters; one session per connection; a connection must join within 30 seconds; IPv6 addresses are counted per /64 block (the range normally assigned to one household or server).

Per-address cap depends on proxy configuration (E4)

Deployment issue, not client code. When the relay runs behind a reverse proxy (a front server that forwards traffic to it) without the TRUST_PROXY setting, every visitor appears to come from the proxy's address, so the per-address cap acts as one global cap. With TRUST_PROXY set but a proxy that does not overwrite the X-Real-IP header, a visitor can fake their address. Severity: medium (deployment).

Open — planned. Configuration guidance for relay operators.

Replay-counter race (E5)

When two incoming messages are processed at the same time, the receive counter can move backward, so a replayed message can be accepted. This contradicts the "strict monotonic" counter claim in step 7 and under Confirmed above. Severity: medium.

Open — planned for the next client release.

Received file names are not sanitized (E6)

A received binary file is saved under a file name chosen by the other participant, without cleaning it up and without checking that the file is really a PSBT (partially signed Bitcoin transaction). A malicious cosigner could deliver a misleadingly named file. Severity: medium.

Open — planned for the next client release.

QR decoder has no size or depth limits (E7)

The decoder for UR QR codes (Uniform Resources, a multi-part QR format, which carries data in CBOR, a compact binary data format) does not limit the lengths or nesting depth that incoming data declares. A crafted QR code or pasted text can hang or crash the browser tab. Severity: medium.

Open — planned for the next client release.

Weak-code warning misses common patterns (E8)

The warning for weak hand-typed codes catches very few distinct characters and sequential runs, but not common human patterns such as bitcoin2024. Because the key-derivation function (KDF) salt is fixed (S11), codes like these are the realistic exposure. Severity: medium to low.

Open — planned for the next client release.

Relay-initiated tricks within an open session (E9)

These require a malicious relay. The authenticated header does not record which direction a message travels, so the relay can send a client its own ciphertext back. After a reconnect, an old hello can be replayed, because the handshake has no key-confirmation step. "Peer left" and role messages are not authenticated. And a relay that accepts connections and immediately closes them causes an endless reconnect loop, about once per second. None of these reveals message contents. Severity: low.

Open — planned for the next client release.

The same researcher also reported, publicly and outside the private report, that the app's Content Security Policy only allows connections to the hosted relay. A self-hosted relay (for example ws://localhost:8080) is therefore blocked, and the app falls back to the cloud relay. A fix is planned; until then, add your relay's origin to the policy's connect-src. The public report also covered test and continuous-integration setup issues.

Review and versioning

Scope and version of this review.

Security claims on this page are specific to the version of the implementation they were reviewed against and may not apply to earlier or later versions. Each report documents a line-by-line review of the deployed code: what was confirmed, what was found, what was fixed, and what changed between revisions. In total: five internal, AI-assisted review revisions and one external researcher report (October 2026); no independent audit yet.

Report App version Date
External report (independent researcher)
First outside review; not an audit
v0.9.0 October 2026 Findings
Security Review R5 Current v0.9 (protocol v2) September 2026 PDF
Security Review R4 v0.8 August 2026 PDF
Security Review R3 v0.7 April 2026 PDF
Security Review R2 v0.6 April 2026 PDF
Security Review R1 v0.6 April 2026 PDF
Reporting a vulnerability

How to report a security problem.

If you find a security problem in Keylay, please report it privately first, so it can be fixed before details are public. Either:

Helpful things to include:

  • The version you tested (shown in the app footer) and whether it was the client, the relay, or both
  • What an attacker could do, and what they would need (a session code, control of the relay, and so on)
  • Steps to reproduce it, and any proof-of-concept code
  • How you would like to be credited, if at all

Please don't test against app.keylay.org. Run a local relay instead (node server.js from the repository), so real users' sessions are not affected.

We aim to acknowledge reports within 7 days and to coordinate disclosure with you. This contact information is also published in machine-readable form at /.well-known/security.txt.

Acknowledgments

Thank you to the people who have reported security issues responsibly:

  • An independent security researcher — October 2026 external report (E1–E9). Reported privately on October 16, 2026. We thank them for their careful report and responsible disclosure. (Credit will be updated at the reporter's request.)