Cryptext
← Volver a Cryptext

Security status — Cryptext v2 / app 0.2

Experimental implementation, not independently audited. Do not claim that it is unbreakable or production-ready for high-risk communications. Cryptographic tests verify specific properties of this implementation, not comprehensive security.

Threat model and identity

An active network attacker or compromised signaling/TURN service should not read or modify accepted application messages or impersonate the exact public ID selected through a trusted channel. Such services can deny service, expose metadata or record ciphertext. A malicious recipient or compromised endpoint is outside this protection.

An ID contains the entire 32-byte Ed25519 signing public key, uppercase hex with optional CT2- formatting. A separately generated X25519 static public key is signed with that Ed25519 key over UTF-8 cryptext.identity.v2\0 followed by the 32 raw agreement-key bytes. Private keys are generated by CryptoKit and stored together as 64 bytes in non-synchronizing WhenUnlockedThisDeviceOnly Keychain. Legacy 128-bit labels are rejected. Panic deletes both current and legacy identity items.

The ID authenticates device key possession, not a person's real-world identity. Compare or share the full ID/QR through a trusted independent channel. The short alias is only a display label and is not a secure comparison. There is no persistent contact book or key-change history. Following a replacement ID requires new trust. IDs are stable and linkable until erased.

Contact links are cryptext://contact/v2/<full-public-key>?relay=<WSS endpoint>. They contain no secret. Opening/scanning them does not unlock, start a session or automatically apply the endpoint. Custom schemes are not exclusive; another app can register one. Universal links are not implemented. Opening a received link is not proof of the sender's identity.

End-to-end protocol

SecureChannel uses the pinned upstream swift-noise implementation of Noise_KK_25519_ChaChaPoly_SHA256. CryptoKit performs native key generation and signatures. Noise is an additional application layer on both WebRTC's encrypted data channel and required-encryption MCSession; there is no plaintext or legacy downgrade.

Both peers validate the signed agreement key against the selected/accepted signing ID. The Noise prologue is UTF-8 cryptext.channel.v2\n<lowercase-call-UUID>\n<initiator-ID-hex>\n<responder-ID-hex>. Handshake payloads are empty. Both sides send the first transport message cryptext.channel-ready.v2 and wait for the peer's encrypted confirmation before accepting chat. A frame begins with byte 1 for handshake or 2 for transport. Transport uses separate send/receive cipher states and their monotonic nonces. Unexpected states, invalid authentication tags, replay/out-of-order ciphertext, exhausted limits and malformed application payloads terminate the channel.

Only after key confirmation does the service create an authenticated conversation. Text and receipts are encoded inside Noise; no user text enters signaling. Handshake references are discarded after Split; channel objects and session keys are released on termination. This is a session-based design with fresh ephemeral handshakes, not a Double/Triple Ratchet. Established-session forward secrecy depends on the Noise assumptions and effective disposal of ephemeral material. Swift-managed memory copies are not guaranteed physically zeroized. No per-message key deletion, post-compromise recovery or post-quantum protection is claimed. Session limits are one hour and 100,000 frames per direction; frames beyond a limit fail closed and require a new session.

Application payloads are versioned and limited to 4,000 characters / 32,768 encoded bytes; ciphertext frames are bounded. IDs are deduplicated for 512 received messages, receipts only match outgoing messages of the connected peer, history is capped at 200 messages and more than 40 incoming frames per second closes the session. File, stream, attachment, voice/video, group and multi-device transfers are unsupported.

Internet signaling and transport

The native WebRTC framework establishes a reliable ordered binary data channel. Only one accepted call is active. Signed SDP and ICE, explicit invitation consent, connection timeouts and bounded candidate queues precede the Noise handshake. A signed empty channel signal synchronizes native data-channel readiness; it is not sufficient to authenticate a conversation without Noise. Early transport frames are bounded and held until the handler is ready. Stale asynchronous callbacks are checked against the current call/connection.

Clients use ephemeral URLSession without cookies or caching. Release allows only wss:// endpoints with the exact /signal path, no credentials/query/fragment, and default system TLS trust. Debug additionally permits loopback ws:// for integration tests. No global ATS exception is enabled.

Node signaling challenges each connection with 32 random bytes. Registration signs cryptext.register.v2\n<base64-challenge>\n<full-ID>. Challenges expire after 10 seconds. Duplicate live IDs are rejected, rather than replacing another socket. Each forwarded frame signs cryptext.signal.v2\n<kind>\n<sender-ID>\n<recipient-ID>\n<lowercase-call-UUID>\n<base64-JSON-body> and carries version 2. The recipient independently validates the signature, peer and call; the service cannot substitute its own agreement key. Allowed kinds are offer, answer, candidate, channel, reject and close. No chat/file/general-purpose message operation exists. Schemas, base64, sizes, rates and buffers are bounded. Signals to offline IDs are rejected, never queued.

The signaling server keeps only live sockets, presence, anti-abuse counters and ephemeral signaling buffers in RAM. It imports no filesystem/database APIs, emits no payload/identity/IP logs, disables WebSocket compression, rejects browser origins by default and removes presence on disconnect. No server can guarantee metadata anonymity or availability. A signaling operator still sees ID pairs, public keys, SDP/ICE/IP information and connection times. TURN sees network endpoints and timing/volume; a direct peer can learn the other peer's IP. There is no Tor routing or forced-relay privacy mode.

TURN receives only DTLS/Noise ciphertext. With self-hosted coturn, short-lived credentials use HMAC-SHA1 REST authentication. With Cloudflare, the signaling server requests one-hour credentials using the configured TURN key ID and API token over HTTPS. Long-lived tokens/secrets remain server-side and are never returned to clients. Neither credential mechanism is an application message cipher. Public registration permits credential acquisition, so quotas/rates/monitoring and network abuse controls need production review. The prepared stack disables container logging/core dumps, makes application filesystems read-only with tmpfs and persists only TLS certificates/configuration. Host swap, crash collection, cloud-provider logs, packet captures, backups and third-party observability are operator responsibilities. Do not claim forensic absence of all traces.

No offline delivery, APNs, background messaging, federation, phone verification or phone directory is implemented. Both users need the same endpoint and an active unlocked app. Network restrictions can still prevent connectivity. A real Cloudflare TURN integration and the public Workers WSS endpoint have been exercised with two native peers inside one simulator: Debug forces relay-only ICE and checks selected local/remote candidate types on both peers. Physical-device/WAN lifecycle tests remain necessary.

Cloudflare credential issuance

The provided Firebase callable requires Firebase Auth and logs the UID; Cryptext does not call or modify that function. It reuses the Cloudflare TURN key via the signaling backend, authenticated by the existing Ed25519 challenge. No Firebase account, phone or UID is introduced. The upstream request has a fixed HTTPS origin/path, no redirects, only { "ttl": 3600 }, bounded response size, eight-second timeout and cancellation on disconnect. It sends no application ID or message content. Returned ICE URLs are restricted to Cloudflare's standard TURN/STUN hosts, and URL arrays are split to fit the native client's existing limits without losing TLS/TCP fallback URLs.

Credential issuance is limited to eight concurrent requests and 60 per minute per process, with no database or shared credential cache. The proven ID is reserved while issuance is pending, but is not routable until issuance succeeds. Repeated pending registrations are rejected. Failure returns a generic error and closes the connection; stale completions cannot restore disconnected presence. These controls do not make a publicly available ID equivalent to trusted Firebase account admission; public abuse controls and shared-account quotas still need deployment review.

Credentials are issued per registration, expire after one hour, and are not refreshed in an ongoing session. The client rejects starting/accepting a new call with expired credentials (30-second margin) and asks to reconnect. The Cloudflare profile has no local TURN process or TURN data volume. Cloudflare controls its own operational metadata/retention; this project cannot promise the absence of third-party traffic logs. Reusing the key shares its usage and revocation scope with the other application. The token is configured only in the ignored local deployment environment, not app resources, source files, fixtures or example configuration.

Transport channel: forward secrecy and key rotation

The session is established with Noise KK (e, es, ss / e, ee, se). Because the pattern mixes an ephemeral-ephemeral DH (ee) and the handshake state — including the ephemeral private keys — is dropped immediately after Split, a later compromise of the long-term static keys does not reveal recorded session traffic. This is forward secrecy for the duration of a session.

Transport keys are also rotated. Every 500 messages or 10 minutes, and on explicit user request, both peers derive new transport keys with a fresh Noise NN handshake (fresh ephemerals, prologue binding session ID, both IDs and the epoch number). A rotation request that arrives while a write is in flight buffers the message and releases it with the new keys, so rotation is transparent. This provides post-compromise recovery: extracting live transport keys does not decrypt traffic after the next rotation.

The rotation handshake travels inside the authenticated channel — a control byte plus the handshake message sealed with the current transport keys — so it cannot be forged or modified; tampering trips the AEAD and closes the link. There is no reordering window: both transports (MCSession reliable and the ordered WebRTC data channel) guarantee ordering, and stale-key frames fail closed.

Each session still ends after one hour or 100.000 frames per direction, which bounds the exposure window. Rotation does not protect against an attacker who records the live keys and the traffic of the same epoch, and it does not survive a full device compromise that captures keys on every unlock.

Device lifecycle and erasure

Device-owner authentication gates access and fails closed. Background/manual lock closes both transports, releases credentials/session keys and discards conversations. The persistent identity remains until panic so that the same ID is available on the next unlock. A synchronous native privacy cover sits above sheets during deactivation; SwiftUI also obscures content. Secure state restoration is declined.

Panic first writes a synchronous AfterFirstUnlockThisDeviceOnly Keychain pending-erasure marker, then deletes identity keys and app-controlled session data/preferences. If interrupted or deletion fails, normal access stays closed and retries on startup. Erasure is not reported successful on Keychain errors. Appearance preferences necessary for camouflage remain. The next authenticated access generates new keys/ID. Release excludes all Debug authentication and preview shortcuts.

No guarantee covers a compromised OS, malicious keyboard, authorized recipient retaining a copy, system memory copies, screenshots/photographs, clipboard/share extensions, device backups outside app control or physical overwriting of flash. The copied contact link has local-only clipboard options and 60-second expiry, but share destinations control their own copies. Camouflage does not conceal installation from device inspection. The app cannot erase the other person's device; connection closure discards the official client's volatile history only.

Dependencies and release gate

Noise: swift-libp2p/swift-noise revision 0adfd28786322784860fb3c8f228591c6e8fd92f (MIT). WebRTC: stasel/WebRTC exact 154.0.0, revision 0c0ad84dac6c1941c16a414dfcfba94691866e4c (third-party binary distribution, upstream checksum pinned by its manifest). Package.resolved pins transitive dependencies. Licenses are shipped in Cryptext/Resources/ThirdPartyNotices.txt. The upstream protocol specification and a known-answer vector do not substitute for auditing this Swift library and application integration. WebRTC/framework/OS diagnostic output must be inspected for release metadata exposure; do not enable verbose network diagnostics for users.

Tests cover key substitution, mutual confirmation, tampering, replay, session binding, plaintext rejection, key rotation (including in-flight buffering, stale-key rejection and rotation initiated by either side), safety-number agreement and binding to the session, a Noise KK known-answer vector, signaling possession/replay/schema controls, ephemeral TURN credentials and real native WebRTC exchange against local signaling. Local integration is not proof of WAN/NAT/TURN or physical-device behavior. Review binaries, dependency provenance/licenses/vulnerabilities, network traces, storage/backups, lifecycle races, erasure failure paths and App Review disclosures before shipping security claims. Obtain an independent security assessment before selling this as a high-security product.

Workers deployment adapter

cloudflare/src/worker.js implements the same v2 signaling contract using a single standard-WebSocket Durable Object. Shared registration and envelope verification live in server/src/protocol.js; both Node and Workers use the same issuer module. The adapter explicitly chooses ArrayBuffer binary delivery before accepting sockets, validates message byte limits and refuses browser origins and query strings. It has no storage, alarm or socket-attachment API calls. Restart closes connections and loses all application presence. The mandatory SQLite-backed namespace migration is infrastructure metadata; no application tables or persistent messages/contacts are created. Tests inspect the absence of application tables in workerd storage.

Standard sockets pin the object in RAM while connected, with provider duration accounting. Workers Observability and preview URLs are disabled in configuration. Provider operational/audit logs remain outside application control. Deployment requires a separate authenticated Wrangler session; the existing TURN token does not grant Workers publication rights. The configuration records the chosen account but contains no long-lived secret. TURN secrets are uploaded through stdin to Workers Secret bindings. Wrangler telemetry is disabled by the scripts. The Worker and tooling dependencies require their own release review and real-device/network checks.

The public deployment is cryptext-signaling.davidcantonnadales.workers.dev; the iOS default endpoint uses WSS /signal. The endpoint is public configuration, never a credential. Reset restores this default after removing custom server preferences and does not connect automatically. The production WSS + TURN integration passed on 2026-10-16; physical-device lifecycle and independent security review remain pending. Wrangler OAuth credentials use an encrypted file with a macOS Keychain key; permissions were limited to user/account read and Workers Scripts write, with background access explicitly authorized by the user.