Security analysis

Security analysis of the current implementation.

This page is a technical walkthrough of what version 0.9 (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; 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. 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.

8

Handshake completion and reconnect behavior

The state machine moves from IDLE to HELLO_SENT to ACTIVE. Duplicate retries are ignored. If a different peer public key appears after activation, the current session is wiped and a full handshake restart is triggered. This is a limited but useful defense against silent peer-key replacement on reconnect.

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.

State reset on peer-key change

Detects reconnect with a different peer public key and forces a new handshake instead of continuing silently.

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)
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 — structurally infeasible as a real-time attack. No attacker can crack the code before a session completes.

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

The minimum is the point where real-time interception becomes impossible.

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. No current hardware configuration can break the handshake before a session closes.

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. This is a manual technical review with AI-assisted analysis, not a formal third-party security audit. The current 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.

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.

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.

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. No straightforward denial-of-service path against the relay through message volume or size remains.

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.

No full IP addresses logged

Confirmed. The server logs truncated IP addresses only — the first two octets are retained for operational purposes; the remainder is zeroed before the log entry is written. No full visitor IP addresses are stored at any layer. 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 audited 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 audited 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 formal third-party audit

This review is thorough and AI-assisted, but it is not an independent third-party cryptographic 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.

The August 2026 code audit 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 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. 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.

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. This is a manual technical review with AI-assisted analysis, not a formal third-party security audit.

Report App version Date
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