guhya.space / source-code Full Security & Encryption Report (PDF) →
Guhya transparency

The cryptography behind Guhya, in the actual code that ships — web and Android

Every primitive below is implemented in crypto.js (loaded by the live client at guhya.space/chat) or in GuhyaCrypto.kt / DoubleRatchet.kt / GroupCrypto.kt (shipped inside the Android app, package space.guhya.app). Nothing here is simplified for marketing — the Double Ratchet sections reproduce the actual ratchet functions in full on both platforms, so you can read exactly what runs.

No phone number required to sign up Every send is self-verified before transmission Full DH ratchet source below — web and Android Android identity keys never leave the device Keystore
Last reviewed: 23 Aug 2026 Maintained by: Prabh Hundal, Guhya Technologies Runtimes: Web Crypto API (SubtleCrypto) · Android Keystore / SQLCipher

Cryptographic stack at a glance Web

One 1:1 conversation touches six layers of key material before a message ever leaves the device. Group conversations add two more. The table below maps each layer to the exact function that implements it.

LayerPrimitiveWhere it lives
Initial key exchangeECDH over P-256crypto.js — GuhyaCrypto.generateKeyPair() / deriveRawSharedSecret()
Session forward secrecySymmetric-key (chain) ratchet — HMAC-SHA256crypto.js — MsgRatchet.step()
Post-compromise securityDH ratchet — fresh P-256 ECDH on key rotationcrypto.js — DHRatchet.step(), MsgRatchetDH
Message payload cipherAES-256-GCM, 96-bit IV, 128-bit tagcrypto.js — encryptMessage() / decryptMessage()
Pre-send verificationEncrypt, then immediately decrypt and byte-compareapp.js — send-message handlers (text, file, group)
Group forward secrecyPer-sender HMAC-SHA256 chain (sender keys)crypto.js — GroupSenderKeys.step()
Group key distributionPer-member ECDH-wrapped seed (AES-256-GCM envelope)crypto.js — GroupCrypto.wrapForMember()
Key backup at restPBKDF2-SHA256 (250,000 rounds) → AES-256-GCM wrapcrypto.js — KeyBackup.wrap() / unwrap()
Private key storageNon-extractable CryptoKey in IndexedDBcrypto.js — KeyStore
Session identity checkSHA-256 fingerprint / numeric safety codecrypto.js — SafetyNumber
Design constant: every session key — chain keys, message keys, root keys — is derived with HMAC-SHA256 over the Web Crypto API's SubtleCrypto, never with a hand-rolled hash construction. Message encryption is exclusively AES-256-GCM; nothing on the message path uses AES-CBC, ECB, or an unauthenticated mode. The Android client, covered further down this page, follows the same constant using javax.crypto instead of SubtleCrypto.

1. Initial key exchange — ECDH over P-256

Every account holds a P-256 elliptic-curve key pair, generated on-device via crypto.subtle.generateKey. The private key is immediately re-imported as non-extractable before it's ever used — the raw bytes exist for one JavaScript tick and are never persisted or serialisable from that point forward.

crypto.js — GuhyaCrypto
async function generateKeyPair() {
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveKey','deriveBits']
  );
  const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
  const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
  const privateKey = await importNonExtractablePrivateKey(privateJwk);
  return { publicJwk, privateJwk, privateKey };
}

async function importNonExtractablePrivateKey(privateJwk) {
  return crypto.subtle.importKey('jwk', privateJwk, { name:'ECDH', namedCurve:'P-256' }, false, ['deriveKey','deriveBits']);
}

async function deriveRawSharedSecret(myPrivateKey, theirPublicJwk) {
  const pub = await importPublicKey(theirPublicJwk);
  const bits = await crypto.subtle.deriveBits({ name:'ECDH', public:pub }, myPrivateKey, 256);
  return new Uint8Array(bits);
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

A first shared secret is derived once per peer pair via deriveRawSharedSecret(), and used only to seed the ratchet below — it is never used directly as a message-encryption key.

2. Double Ratchet — the complete source, unabridged Web

This is the actual, full implementation from crypto.js — every function in MsgRatchet, DHRatchet, and the MsgRatchetDH composition layer that wires them together. Nothing has been trimmed for length; where the real file has an explanatory comment, that comment is reproduced too, because it documents real design decisions rather than restating the obvious.

2.1 — MsgRatchet: the symmetric-key chain ratchet

This alone gives forward secrecy. Its step function is a one-way HMAC — compromising a chain key never exposes messages that came before it, because there is no way to run the HMAC backwards.

crypto.js — const MsgRatchet = (() => { ... })();
const MsgRatchet = (() => {
  const MSG_LABEL = new TextEncoder().encode('guhya-ratchet-msg-v1');
  const CHAIN_LABEL = new TextEncoder().encode('guhya-ratchet-chain-v1');

  async function hmacSign(keyBytes, label) {
    const key = await crypto.subtle.importKey('raw', keyBytes, { name:'HMAC', hash:'SHA-256' }, false, ['sign']);
    const sig = await crypto.subtle.sign('HMAC', key, label);
    return new Uint8Array(sig);
  }

  async function step(chainKeyBytes) {
    const msgKeyBytes = await hmacSign(chainKeyBytes, MSG_LABEL);
    const nextChainKeyBytes = await hmacSign(chainKeyBytes, CHAIN_LABEL);
    return { msgKeyBytes, nextChainKeyBytes };
  }

  async function importMsgKey(msgKeyBytes) {
    return crypto.subtle.importKey('raw', msgKeyBytes, { name:'AES-GCM' }, false, ['encrypt','decrypt']);
  }

  function freshState(sendChainB64, recvChainB64) {
    return { sendChain: sendChainB64, sendIndex: 0, recvChain: recvChainB64, recvIndex: 0, skipped: {} };
  }

  async function initRoot(myUsername, peerUsername, myPrivateKey, peerPublicJwk) {
    const secret = await GuhyaCrypto.deriveRawSharedSecret(myPrivateKey, peerPublicJwk);
    const [lo, hi] = [myUsername, peerUsername].slice().sort();
    const chainLoToHi = await hmacSign(secret, new TextEncoder().encode('guhya-chain:' + lo + '->' + hi));
    const chainHiToLo = await hmacSign(secret, new TextEncoder().encode('guhya-chain:' + hi + '->' + lo));
    const mySendChain = (myUsername === lo) ? chainLoToHi : chainHiToLo;
    const myRecvChain = (myUsername === lo) ? chainHiToLo : chainLoToHi;
    return freshState(bufToB64(mySendChain), bufToB64(myRecvChain));
  }

  async function ensureState(myUsername, peerUsername, myPrivateKey, peerPublicJwk, cache) {
    let state = await cache.getRatchet(peerUsername);
    if (!state) {
      state = await initRoot(myUsername, peerUsername, myPrivateKey, peerPublicJwk);
      state.reinitializedAt = Date.now();
      await cache.setRatchet(peerUsername, state);
    }
    return state;
  }

  async function nextSendKey(state) {
    const { msgKeyBytes, nextChainKeyBytes } = await step(new Uint8Array(b64ToBuf(state.sendChain)));
    const index = state.sendIndex;
    state.sendChain = bufToB64(nextChainKeyBytes);
    state.sendIndex = index + 1;
    const msgKey = await importMsgKey(msgKeyBytes);
    return { msgKey, index, msgKeyB64: bufToB64(msgKeyBytes) };
  }

  async function keyForRecvIndex(state, targetIndex) {
    const cached = state.skipped[String(targetIndex)];
    if (cached) {
      delete state.skipped[String(targetIndex)];
      return await importMsgKey(new Uint8Array(b64ToBuf(cached)));
    }
    if (targetIndex < state.recvIndex) return null;

    let resolvedMsgKeyBytes = null;
    while (state.recvIndex <= targetIndex) {
      const { msgKeyBytes, nextChainKeyBytes } = await step(new Uint8Array(b64ToBuf(state.recvChain)));
      const thisIndex = state.recvIndex;
      state.recvChain = bufToB64(nextChainKeyBytes);
      state.recvIndex = thisIndex + 1;
      if (thisIndex === targetIndex) {
        resolvedMsgKeyBytes = msgKeyBytes;
      } else {
        const keys = Object.keys(state.skipped);
        if (keys.length >= RATCHET_MAX_SKIPPED_KEYS) delete state.skipped[keys[0]];
        state.skipped[String(thisIndex)] = bufToB64(msgKeyBytes);
      }
    }
    return resolvedMsgKeyBytes ? await importMsgKey(resolvedMsgKeyBytes) : null;
  }

  return { ensureState, nextSendKey, keyForRecvIndex };
})();
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Note the keyForRecvIndex loop: if a message arrives out of order, every chain key between the last resolved index and the target index is still derived and cached in skipped (bounded to RATCHET_MAX_SKIPPED_KEYS = 50), so a later out-of-order message can still be decrypted rather than dropped.

2.2 — DHRatchet: the Diffie-Hellman ratchet step

What MsgRatchet cannot do on its own is heal after a compromise: if a chain key is ever stolen, every future key is mechanically derivable from it, forever. DHRatchet exists to fix exactly that — this is the part of the design that gives post-compromise security.

crypto.js — const DHRatchet = (() => { ... })();
// v38: DH RATCHET (Double Ratchet DH step) — adds POST-COMPROMISE SECURITY
// on top of the existing MsgRatchet symmetric chain above.
//
// What MsgRatchet already gives you: FORWARD SECRECY. Its chain step is
// HMAC(chainKey, label) — a one-way function, so even if a chain key is
// stolen, past message keys stay safe. That part was already correct.
//
// What was MISSING: if a chain key is ever stolen, MsgRatchet has no
// mechanism to inject fresh randomness afterward — every FUTURE key is
// deterministically derivable from that one stolen chain key, forever.
// That's what this module fixes. Every time the peer sends a NEW ratchet
// public key (different from the last one seen from them), a fresh ECDH
// is performed with them, the result is mixed into the root key via
// HMAC-based key derivation, and BOTH the receiving chain (from their new
// key) and the sending chain (from a brand new ephemeral keypair
// generated right then) are re-seeded. This is exactly Signal's Double
// Ratchet DH step, simplified to this app's existing primitives (still
// P-256 ECDH + HMAC-SHA256, no new crypto library needed).
//
// Design choice made here for simplicity/reliability over Signal's exact
// behavior: EVERY outgoing message carries the sender's current ratchet
// public key (not just the first message after a direction switch). This
// costs a little extra payload per message (a P-256 JWK is roughly
// 150-220 bytes as JSON) but removes an entire class of "did the receiver
// already learn my new key" edge cases.
const DHRatchet = (() => {
  const ROOT_LABEL = new TextEncoder().encode('guhya-dhratchet-root-v1');

  async function hmacSign(keyBytes, dataBytes) {
    const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
    const sig = await crypto.subtle.sign('HMAC', key, dataBytes);
    return new Uint8Array(sig);
  }

  async function generateRatchetKeyPair() {
    const keyPair = await crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, ['deriveBits']);
    const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);
    const privateJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey);
    return { privateJwk, publicJwk };
  }

  async function importEphemeralPrivate(privateJwk) {
    return crypto.subtle.importKey('jwk', privateJwk, { name: 'ECDH', namedCurve: 'P-256' }, false, ['deriveBits']);
  }

  async function dhOutputBetween(myEphemeralPrivateJwk, theirEphemeralPublicJwk) {
    const myKey = await importEphemeralPrivate(myEphemeralPrivateJwk);
    const theirKey = await GuhyaCrypto.importPublicKey(theirEphemeralPublicJwk);
    const bits = await crypto.subtle.deriveBits({ name: 'ECDH', public: theirKey }, myKey, 256);
    return new Uint8Array(bits);
  }

  function jwkEquals(a, b) { if (!a || !b) return a === b; return a.x === b.x && a.y === b.y; }

  function freshState() {
    return {
      theirKnownKey: null, sendMixedKey: null, rootKeyB64: null,
      myRatchetPrivateJwk: null, myRatchetPublicJwk: null,
      generation: 0, myGenPublicJwk: null, prevRecv: null,
    };
  }

  async function ensureMyEphemeral(dhState) {
    if (!dhState.myRatchetPrivateJwk) {
      const fresh = await generateRatchetKeyPair();
      dhState.myRatchetPrivateJwk = fresh.privateJwk;
      dhState.myRatchetPublicJwk = fresh.publicJwk;
    }
  }

  async function step(dhState, theirKey, myUsername, peerUsername, rootKeyB64) {
    await ensureMyEphemeral(dhState);
    const usedMyPrivateJwk = dhState.myRatchetPrivateJwk;
    const usedMyPublicJwk = dhState.myRatchetPublicJwk;
    const dhOut = await dhOutputBetween(usedMyPrivateJwk, theirKey);

    const rootBytes = new Uint8Array(b64ToBuf(rootKeyB64));
    const mixed = new Uint8Array(dhOut.length + ROOT_LABEL.length);
    mixed.set(dhOut, 0); mixed.set(ROOT_LABEL, dhOut.length);
    const newRootKeyBytes = await hmacSign(rootBytes, mixed);

    const [lo, hi] = [myUsername, peerUsername].slice().sort();
    const chainLoToHi = await hmacSign(dhOut, new TextEncoder().encode('guhya-dhchain:' + lo + '->' + hi));
    const chainHiToLo = await hmacSign(dhOut, new TextEncoder().encode('guhya-dhchain:' + hi + '->' + lo));
    const newSendChainB64 = bufToB64((myUsername === lo ? chainLoToHi : chainHiToLo).buffer);
    const newRecvChainB64 = bufToB64((myUsername === lo ? chainHiToLo : chainLoToHi).buffer);

    const fresh = await generateRatchetKeyPair();
    dhState.myRatchetPrivateJwk = fresh.privateJwk;
    dhState.myRatchetPublicJwk = fresh.publicJwk;

    return {
      newRootKeyB64: bufToB64(newRootKeyBytes.buffer),
      newSendChainB64, newRecvChainB64, usedMyPublicJwk,
    };
  }

  return { freshState, ensureMyEphemeral, step };
})();
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

2.3 — MsgRatchetDH: the composition layer that ties both together

This is the layer app.js actually calls. It wraps MsgRatchet's unmodified chain-step functions with DHRatchet above, and owns the generation-counter bookkeeping that lets a message arriving slightly out of order around a key-rotation boundary still get decrypted correctly, bounded to one generation back — the same recovery limit real Double Ratchet implementations use.

crypto.js — const MsgRatchetDH = (() => { ... })();
const MsgRatchetDH = (() => {
  function ensureDh(state) { if (!state.dh) state.dh = DHRatchet.freshState(); }

  async function initialRootB64(myPrivateKey, peerPublicJwk) {
    const secret = await GuhyaCrypto.deriveRawSharedSecret(myPrivateKey, peerPublicJwk);
    return bufToB64(secret.buffer || secret);
  }

  function jwkEquals(a, b) { if (!a || !b) return a === b; return a.x === b.x && a.y === b.y; }

  function snapshotPrevRecv(state, generation) {
    state.dh.prevRecv = { generation, recvChain: state.recvChain, recvIndex: state.recvIndex, skipped: state.skipped };
  }

  async function prepareSend(state, myUsername, peerUsername, myPrivateKey, peerPublicJwk) {
    ensureDh(state);
    await DHRatchet.ensureMyEphemeral(state.dh);
    let usedPub = state.dh.myRatchetPublicJwk;
    let stepped = false;

    if (state.dh.theirKnownKey !== null && !jwkEquals(state.dh.theirKnownKey, state.dh.sendMixedKey)) {
      if (!state.dh.rootKeyB64) state.dh.rootKeyB64 = await initialRootB64(myPrivateKey, peerPublicJwk);
      snapshotPrevRecv(state, state.dh.generation);
      const r = await DHRatchet.step(state.dh, state.dh.theirKnownKey, myUsername, peerUsername, state.dh.rootKeyB64);
      state.sendChain = r.newSendChainB64; state.sendIndex = 0;
      state.recvChain = r.newRecvChainB64; state.recvIndex = 0; state.skipped = {};
      state.dh.rootKeyB64 = r.newRootKeyB64;
      state.dh.sendMixedKey = state.dh.theirKnownKey;
      state.dh.generation += 1;
      state.dh.myGenPublicJwk = r.usedMyPublicJwk;
      usedPub = r.usedMyPublicJwk;
      stepped = true;
    }

    const r = await MsgRatchet.nextSendKey(state);
    return {
      ...r,
      dhPublicKey: JSON.stringify(usedPub),
      dhStepped: stepped,
      dhGen: state.dh.generation,
      dhGenKey: JSON.stringify(state.dh.myGenPublicJwk || usedPub),
    };
  }

  async function recvAgainstSnapshot(snapshot, targetIndex) {
    const stateLike = { recvChain: snapshot.recvChain, recvIndex: snapshot.recvIndex, skipped: snapshot.skipped };
    const key = await MsgRatchet.keyForRecvIndex(stateLike, targetIndex);
    snapshot.recvChain = stateLike.recvChain; snapshot.recvIndex = stateLike.recvIndex; snapshot.skipped = stateLike.skipped;
    return key;
  }

  async function resolveRecv(state, myUsername, peerUsername, myPrivateKey, peerPublicJwk, targetIndex, incomingDhPublicKeyJson, incomingDhStepped, incomingDhGen, incomingDhGenKeyJson) {
    ensureDh(state);
    if (incomingDhPublicKeyJson) {
      try { const j = JSON.parse(incomingDhPublicKeyJson); if (j) state.dh.theirKnownKey = j; } catch (e) {}
    }

    const knownGen = state.dh.generation || 0;
    const msgGen = (typeof incomingDhGen === 'number') ? incomingDhGen : knownGen;

    if (msgGen === knownGen) {
      return await MsgRatchet.keyForRecvIndex(state, targetIndex);
    }

    if (state.dh.prevRecv && msgGen === state.dh.prevRecv.generation) {
      return await recvAgainstSnapshot(state.dh.prevRecv, targetIndex);
    }

    if (msgGen > knownGen) {
      let genKeyJwk = null;
      try { genKeyJwk = incomingDhGenKeyJson ? JSON.parse(incomingDhGenKeyJson) : null; } catch (e) {}
      if (!genKeyJwk) return null;
      if (!state.dh.rootKeyB64) state.dh.rootKeyB64 = await initialRootB64(myPrivateKey, peerPublicJwk);
      snapshotPrevRecv(state, knownGen);
      const r = await DHRatchet.step(state.dh, genKeyJwk, myUsername, peerUsername, state.dh.rootKeyB64);
      state.recvChain = r.newRecvChainB64; state.recvIndex = 0; state.skipped = {};
      state.sendChain = r.newSendChainB64; state.sendIndex = 0;
      state.dh.rootKeyB64 = r.newRootKeyB64;
      state.dh.sendMixedKey = genKeyJwk;
      state.dh.generation = msgGen;
      state.dh.myGenPublicJwk = r.usedMyPublicJwk;
      return await MsgRatchet.keyForRecvIndex(state, targetIndex);
    }

    return null;
  }

  return { prepareSend, resolveRecv };
})();
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

3. Send verification — encrypt, then prove it decrypts, before it ever leaves the device

Every send path — direct messages, group messages, and file uploads — does not just encrypt and transmit. It encrypts, then immediately runs its own ciphertext back through decryptMessage() with the same key, and compares the result byte-for-byte against the original plaintext before the network call is even made. If that round-trip doesn't match exactly, the send is aborted client-side with an explicit error, rather than transmitting a payload that might fail to decrypt for the recipient.

app.js — direct message send path
let enc, plaintextToEncrypt;
try {
  plaintextToEncrypt = replyMeta ? (ENV_MAGIC + JSON.stringify({ b: text, reply: stripReplyThumb(replyMeta) })) : text;
  enc = await GuhyaCrypto.encryptMessage(key, plaintextToEncrypt);
} catch (e) {
  await markMessageFailed(cacheKeyName, clientId); toast('Encryption failed — try again'); return;
}

try {
  const verifyText = await GuhyaCrypto.decryptMessage(key, enc.iv, enc.ciphertext, enc.authTag);
  if (verifyText !== plaintextToEncrypt) throw new Error('verify_mismatch');
} catch (e) {
  await markMessageFailed(cacheKeyName, clientId); toast('Could not verify encryption — message was not sent'); return;
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

The same pattern — encrypt, decrypt, byte-compare, abort on mismatch — is repeated independently for binary file uploads, comparing decrypted buffer length against the original ArrayBuffer before the file is ever uploaded. This exists specifically so a bug in key derivation or state handling surfaces immediately as a failed send on the sender's own screen, instead of silently producing an undecryptable message the recipient discovers later with no way to tell why.

4. Message payload — AES-256-GCM

Once a message key is derived, the plaintext is sealed with AES-256-GCM: a fresh 96-bit IV per message, a 128-bit authentication tag, and no key or IV reuse across messages — each message key is used exactly once and discarded.

crypto.js — encryptMessage() / decryptMessage()
async function encryptMessage(aesKey, plaintext) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const encoded = new TextEncoder().encode(plaintext);
  const cipherBuf = await crypto.subtle.encrypt({ name:'AES-GCM', iv, tagLength:128 }, aesKey, encoded);
  const cipherBytes = new Uint8Array(cipherBuf);
  const tagBytes = cipherBytes.slice(cipherBytes.length - 16);
  const ctBytes = cipherBytes.slice(0, cipherBytes.length - 16);
  return { iv: bufToB64(iv), ciphertext: bufToB64(ctBytes), authTag: bufToB64(tagBytes) };
}

async function decryptMessage(aesKey, ivB64, ciphertextB64, authTagB64) {
  try {
    const iv = new Uint8Array(b64ToBuf(ivB64));
    const ct = new Uint8Array(b64ToBuf(ciphertextB64));
    const tag = new Uint8Array(b64ToBuf(authTagB64));
    const combined = new Uint8Array(ct.length + tag.length);
    combined.set(ct, 0); combined.set(tag, ct.length);
    const plainBuf = await crypto.subtle.decrypt({ name:'AES-GCM', iv, tagLength:128 }, aesKey, combined);
    return new TextDecoder().decode(plainBuf);
  } catch (e) { return null; }
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

5. Group messaging — per-sender chains, ECDH-distributed Web

Group chats do not share one static AES key across all members, and — to be precise about a question that comes up often — they do not run the same DH ratchet that 1:1 chats do above. What groups actually use is two mechanisms working together:

Shipped — forward secrecy

Per-sender HMAC chain

Each participant maintains their own outbound sender-key chain — structurally identical to MsgRatchet above, just namespaced with a different label. Compromising one member's current chain key exposes only that member's future messages, never their past ones, and never another member's chain.

Shipped — key distribution

Per-member ECDH wrap

A sender-key chain's seed is delivered to each group member individually, wrapped with a per-pair ECDH+AES-GCM envelope — the same primitives as sections 1 and 4 above, reused rather than reinvented. This same mechanism runs again on membership changes and periodic re-keys.

crypto.js — GroupSenderKeys.step()
const MSG_LABEL = new TextEncoder().encode('guhya-senderkey-msg-v1');
const CHAIN_LABEL = new TextEncoder().encode('guhya-senderkey-chain-v1');

async function step(chainKeyBytes) {
  const msgKeyBytes = await hmacSign(chainKeyBytes, MSG_LABEL);
  const nextChainKeyBytes = await hmacSign(chainKeyBytes, CHAIN_LABEL);
  return { msgKeyBytes, nextChainKeyBytes };
}
crypto.js — GroupCrypto.wrapForMember()
async function wrapForMember(myPrivateKey, theirPublicJwk, rawGroupKeyB64) {
  const aesKey = await GuhyaCrypto.deriveSharedAesKey(myPrivateKey, theirPublicJwk); // ECDH
  const enc = await GuhyaCrypto.encryptMessage(aesKey, rawGroupKeyB64);
  return JSON.stringify(enc);
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Membership changes and rotations call this same function once per remaining member — every rotation is a fresh per-member ECDH, not a reused static key. What this design honestly does not give a group is post-compromise security in the DH-ratchet sense: there's no mechanism yet that re-seeds an already-compromised, still-active group session purely from ongoing message traffic the way 1:1 chats heal. Recovering a compromised group today means an explicit re-key (already supported), not passive healing — that distinction is listed under Roadmap below rather than implied away.

6. Key backup — PBKDF2-wrapped, never plaintext at rest Web

When a user backs up their private key for multi-device recovery, it is wrapped under a key derived from their passphrase via PBKDF2-SHA256 at 250,000 iterations, then sealed with AES-256-GCM using a random salt and IV per backup. The unwrapped private key never touches disk.

crypto.js — KeyBackup.wrap() / unwrap()
const PBKDF2_ITERATIONS = 250000;

async function deriveWrapKey(passphrase, saltB64) {
  const salt = saltB64 ? new Uint8Array(b64ToBuf(saltB64)) : crypto.getRandomValues(new Uint8Array(16));
  const keyMaterial = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
  const key = await crypto.subtle.deriveKey(
    { name:'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash:'SHA-256' },
    keyMaterial, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt']
  );
  return { key, saltB64: bufToB64(salt) };
}

async function wrap(passphrase, privateJwk) {
  const { key, saltB64 } = await deriveWrapKey(passphrase);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const data = new TextEncoder().encode(JSON.stringify(privateJwk));
  const ctBuf = await crypto.subtle.encrypt({ name:'AES-GCM', iv }, key, data);
  return JSON.stringify({ v:1, salt: saltB64, iv: bufToB64(iv), ciphertext: bufToB64(ctBuf) });
}

async function unwrap(passphrase, wrappedJson) {
  const w = JSON.parse(wrappedJson);
  const { key } = await deriveWrapKey(passphrase, w.salt);
  const iv = new Uint8Array(b64ToBuf(w.iv));
  const ctBuf = b64ToBuf(w.ciphertext);
  const plainBuf = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, key, ctBuf);
  return JSON.parse(new TextDecoder().decode(plainBuf));
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

There is no server-side recovery path for a lost passphrase by design: Guhya never receives the passphrase, so it cannot reset or recover the wrapped key on the user's behalf.

7. Local storage and session verification Web

Private keys live in IndexedDB as non-extractable CryptoKey objects rather than in localStorage — the raw key material is never exposed to readable JavaScript memory, which meaningfully narrows what a successful XSS payload could exfiltrate. Older accounts created before this change are migrated automatically and the legacy localStorage copy is deleted on first load.

To let two people confirm they're talking to the correct key — not a substituted one — Guhya derives a numeric safety code from both parties' public keys via SHA-256, in the same spirit as Signal's safety numbers:

crypto.js — SafetyNumber.code()
async function code(myUsername, myJwk, theirUsername, theirJwk) {
  const [first, second] = myUsername < theirUsername
    ? [{ jwk: myJwk }, { jwk: theirJwk }] : [{ jwk: theirJwk }, { jwk: myJwk }];
  const rawFirst = await rawBytes(first.jwk);
  const rawSecond = await rawBytes(second.jwk);
  const combined = new Uint8Array(rawFirst.length + rawSecond.length);
  combined.set(rawFirst, 0); combined.set(rawSecond, rawFirst.length);
  const hashBuf = await crypto.subtle.digest('SHA-256', combined);
  const bytes = new Uint8Array(hashBuf);
  const groups = [];
  for (let i = 0; i < 6; i++) {
    let n = 0;
    for (let j = 0; j < 5; j++) n = n * 256 + bytes[i * 5 + j];
    groups.push(String(n % 100000).padStart(5, '0'));
  }
  return groups.join('  ');
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Session cookies are httpOnly, set server-side, and sent with credentials: 'include' — access tokens are not readable from client-side JavaScript at all, closing off token theft as an XSS outcome even if a script injection occurred.

8. No phone number required

Account creation is a four-step flow: name, email, password, and an optional country field you can skip outright. There is no phone-number input anywhere in sign-up, and email ownership is confirmed with a one-time numeric code, not an SMS OTP.

index.html — signup steps
<!-- Step 1 -->
<label>Name</label>
<input type="text" id="signup-name" placeholder="Your name" maxlength="60">
<label>Email</label>
<input type="email" id="signup-email" placeholder="you@example.com">
<p class="hint">Email is necessary to create your account. We never share your email.</p>

<!-- Step 2 -->
<label>Password</label>
<input type="password" id="signup-password" placeholder="At least 8 characters">

<!-- Step 3 — optional, skippable -->
<label>Country (optional)</label>
<input type="text" id="signup-country" placeholder="e.g. India" maxlength="56">
<button class="link" onclick="showAuthStep('signup-captcha'); loadCaptcha()">Skip</button>
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Practically: your identity on Guhya is an email address and a username, never a mobile number — nothing here ties an account to a SIM, a carrier, or a device's phone identity. The same rule applies on Android: sign-up there asks for the same name / email / password / optional-country fields, with no phone-number field in the flow.

9. Android app — cryptography and device security Android

The Android client (package space.guhya.app) implements its own cryptography and device-security layer in Kotlin, using the platform's own APIs — javax.crypto, java.security, the Android Keystore, SQLCipher, BiometricPrompt, and Nearby Connections — rather than a JavaScript bridge into the web client. The wire format (which fields a message carries) matches the web app's, so an Android account and a web account can message each other on one shared conversation with no server-side changes.

LayerPrimitiveWhere it lives
Identity key generation & storageEC P-256, generated inside Android Keystore, non-extractableGuhyaCrypto.kt — ensureIdentityKeyPair()
Session forward secrecySymmetric-key chain ratchet — HMAC-SHA256, with out-of-order key cachingDoubleRatchet.kt — stepSendChain() / keyForRecvIndex()
Post-compromise securityDH ratchet — fresh P-256 ECDH on key rotationDoubleRatchet.kt — step(), prepareSend(), resolveRecv()
Message payload cipherAES-256-GCM, 96-bit IV, 128-bit tagGuhyaCrypto.kt — encryptMessage() / decryptMessage()
Group forward secrecyPer-sender HMAC-SHA256 chain, with out-of-order key cachingGroupCrypto.kt — GroupSenderKeys.step() / keyForRecvIndex()
Group key distributionPer-member ECDH-wrapped seed (AES-256-GCM envelope)GroupCrypto.kt — wrapForMember() / unwrapFromWrapper()
Local database at restSQLCipher, 256-bit passphrase held in Keystore-backed EncryptedSharedPreferencesDbPassphraseManager.kt — getOrCreatePassphrase()
App re-entry lockBiometricPrompt — BIOMETRIC_STRONG or DEVICE_CREDENTIALAppLockManager.kt
Device-to-device transferQR-paired session key → AES-256-GCM over Nearby ConnectionsDeviceTransferManager.kt
Clipboard hygieneAuto-clear ~45s after copy, marked EXTRA_IS_SENSITIVEClipboardGuard.kt — copySensitive()
Accessibility-abuse warningFlags enabled services combining window-content + gesture capabilitiesAccessibilityAbuseDetector.kt — findSuspiciousServices()
Identity keys never leave the Keystore: GuhyaCrypto.ensureIdentityKeyPair() generates the long-term identity EC key pair directly inside the Android Keystore, hardware-backed on any device with a StrongBox or TEE. There is no API — even from inside this app's own process — that exports the private half. Only the short-lived, per-message ratchet keys described below are held as exportable JWK, because they have to be serialized into the local database as part of the ratchet state.

10. Double Ratchet in Android private chat — the complete source Android

This is the actual, unabridged content of DoubleRatchet.kt, the module every 1:1 message send and receive call goes through. It is a direct Kotlin port of the same chain-ratchet-plus-DH-ratchet design described in section 2 above: an HMAC-SHA256 symmetric chain for forward secrecy, composed with a P-256 ECDH step for post-compromise security, using the same key labels (guhya-chain:, guhya-dhchain:, guhya-dhratchet-root-v1) so the two clients derive identical keys from identical inputs.

crypto/DoubleRatchet.kt
// Direct Kotlin port of crypto.js's MsgRatchet + DHRatchet + MsgRatchetDH.
// Wire format is UNCHANGED from web: dhPublicKey (JWK JSON string) and
// dhStepped (bool) are the fields index.ts already accepts/returns, so
// Android and web interoperate on the same conversation with no server
// changes.

data class RatchetState(
    var sendChainB64: String, var sendIndex: Int,
    var recvChainB64: String, var recvIndex: Int,
    var rootKeyB64: String,
    var theirKnownKeyJwk: String? = null,
    var sendMixedKeyJwk: String? = null,
    var myRatchetPrivateJwk: String? = null,
    var myRatchetPublicJwk: String? = null,
    // v2 fix: out-of-order/skipped message-key cache for the 1:1 receive
    // chain — ports the same pattern GroupSenderKeys.keyForRecvIndex()
    // already used for groups, and crypto.js's MsgRatchet.keyForRecvIndex()
    // already used on web, onto the 1:1 ratchet's receive side.
    val skipped: MutableMap = mutableMapOf(),
)

object DoubleRatchet {
  private val ROOT_LABEL = "guhya-dhratchet-root-v1".toByteArray(Charsets.UTF_8)

  private fun chainStep(chainKey: ByteArray, label: String) =
      GuhyaCrypto.hmacSha256(chainKey, label.toByteArray(Charsets.UTF_8))

  private fun stepSendChain(state: RatchetState): ByteArray {
      val chainKey = unb64(state.sendChainB64)
      val msgKey = chainStep(chainKey, "msg")
      state.sendChainB64 = b64(chainStep(chainKey, "chain"))
      val index = state.sendIndex
      state.sendIndex += 1
      return msgKey
  }

  private const val MAX_SKIPPED_KEYS = 200

  // v2 fix — was: "if (targetIndex != state.recvIndex) return null", which
  // dropped any message that didn't arrive in exact order. Now mirrors
  // GroupSenderKeys.keyForRecvIndex() exactly: check the skipped-key
  // cache first, then walk the chain forward from the current index to
  // the target, caching every key that isn't the one we needed yet
  // (bounded to MAX_SKIPPED_KEYS), so a later out-of-order message can
  // still be decrypted instead of dropped.
  private fun keyForRecvIndex(state: RatchetState, targetIndex: Int): ByteArray? {
      state.skipped[targetIndex]?.let { cached ->
          state.skipped.remove(targetIndex)
          return unb64(cached)
      }
      if (targetIndex < state.recvIndex) return null

      var resolved: ByteArray? = null
      var chainKey = unb64(state.recvChainB64)
      while (state.recvIndex <= targetIndex) {
          val msgKey = chainStep(chainKey, "msg")
          val nextChain = chainStep(chainKey, "chain")
          val thisIndex = state.recvIndex
          chainKey = nextChain
          state.recvIndex = thisIndex + 1
          if (thisIndex == targetIndex) {
              resolved = msgKey
          } else {
              if (state.skipped.size >= MAX_SKIPPED_KEYS) state.skipped.remove(state.skipped.keys.first())
              state.skipped[thisIndex] = b64(msgKey)
          }
      }
      state.recvChainB64 = b64(chainKey)
      return resolved
  }

  // CORE STEP — identical logic verified standalone in plain Java before
  // wiring in. Called from both prepareSend and resolveRecv; symmetric
  // ECDH means both parties derive matching chains regardless of which
  // side calls it.
  private fun step(state: RatchetState, theirKeyJson: String, myUsername: String, peerUsername: String): StepOutput {
      if (state.myRatchetPrivateJwk == null) {
          val kp = generateEphemeralKeyPair()
          state.myRatchetPrivateJwk = privateKeyToJwkJson(kp)
          state.myRatchetPublicJwk = jwkToJson(GuhyaCrypto.publicKeyToJwk(kp.public))
      }
      val usedPublicJwkJson = state.myRatchetPublicJwk!!
      val myPrivateKey = jwkJsonToPrivateKey(state.myRatchetPrivateJwk!!)
      val theirPublicKey = GuhyaCrypto.jwkToPublicKey(jsonToJwk(theirKeyJson))

      val ka = KeyAgreement.getInstance("ECDH")
      ka.init(myPrivateKey); ka.doPhase(theirPublicKey, true)
      val dhOut = ka.generateSecret()

      val rootBytes = unb64(state.rootKeyB64)
      val newRoot = GuhyaCrypto.hmacSha256(rootBytes, dhOut + ROOT_LABEL)

      val lo = if (myUsername <= peerUsername) myUsername else peerUsername
      val hi = if (myUsername <= peerUsername) peerUsername else myUsername
      val chainLoToHi = GuhyaCrypto.hmacSha256(dhOut, "guhya-dhchain:$lo->$hi".toByteArray(Charsets.UTF_8))
      val chainHiToLo = GuhyaCrypto.hmacSha256(dhOut, "guhya-dhchain:$hi->$lo".toByteArray(Charsets.UTF_8))
      val newSendChain = if (myUsername == lo) chainLoToHi else chainHiToLo
      val newRecvChain = if (myUsername == lo) chainHiToLo else chainLoToHi

      // rotate for next time
      val fresh = generateEphemeralKeyPair()
      state.myRatchetPrivateJwk = privateKeyToJwkJson(fresh)
      state.myRatchetPublicJwk = jwkToJson(GuhyaCrypto.publicKeyToJwk(fresh.public))

      return StepOutput(b64(newRoot), b64(newSendChain), b64(newRecvChain), usedPublicJwkJson)
  }

  // Call at every SEND site.
  fun prepareSend(state: RatchetState, myUsername: String, peerUsername: String): SendKeyResult {
      var usedKeyJson = state.myRatchetPublicJwk
      if (usedKeyJson == null) {
          val kp = generateEphemeralKeyPair()
          state.myRatchetPrivateJwk = privateKeyToJwkJson(kp)
          state.myRatchetPublicJwk = jwkToJson(GuhyaCrypto.publicKeyToJwk(kp.public))
          usedKeyJson = state.myRatchetPublicJwk
      }
      var stepped = false

      if (state.theirKnownKeyJwk != null && !jwkEquals(state.theirKnownKeyJwk, state.sendMixedKeyJwk)) {
          val r = step(state, state.theirKnownKeyJwk!!, myUsername, peerUsername)
          state.sendChainB64 = r.newSendChainB64; state.sendIndex = 0
          state.recvChainB64 = r.newRecvChainB64; state.recvIndex = 0; state.skipped.clear()
          state.rootKeyB64 = r.newRootB64
          state.sendMixedKeyJwk = state.theirKnownKeyJwk
          usedKeyJson = r.usedMyPublicJwkJson
          stepped = true
      }

      val msgKey = stepSendChain(state)
      return SendKeyResult(msgKey, state.sendIndex - 1, usedKeyJson, stepped)
  }

  // Call at every RECEIVE site. incomingDhPublicKeyJson/incomingDhStepped
  // come straight off the wire (index.ts's dhPublicKey/dhStepped fields).
  fun resolveRecv(
      state: RatchetState, myUsername: String, peerUsername: String,
      targetIndex: Int, incomingDhPublicKeyJson: String?, incomingDhStepped: Boolean,
  ): ByteArray? {
      if (incomingDhPublicKeyJson != null) {
          state.theirKnownKeyJwk = incomingDhPublicKeyJson
          if (incomingDhStepped) {
              val r = step(state, incomingDhPublicKeyJson, myUsername, peerUsername)
              state.recvChainB64 = r.newRecvChainB64; state.recvIndex = 0; state.skipped.clear()
              state.sendChainB64 = r.newSendChainB64; state.sendIndex = 0
              state.rootKeyB64 = r.newRootB64
              state.sendMixedKeyJwk = incomingDhPublicKeyJson
          }
      }
      return keyForRecvIndex(state, targetIndex)
  }

  // Initializes a fresh RatchetState the first time two accounts talk —
  // seeds from the raw identity ECDH secret, with lo/hi direction-labeled
  // initial chains matching the web app's initRoot pattern exactly.
  fun freshState(myUsername: String, peerUsername: String, myIdentityPrivate: PrivateKey, peerIdentityPublic: PublicKey): RatchetState {
      val secret = GuhyaCrypto.deriveRawSharedSecret(myIdentityPrivate, peerIdentityPublic)
      val lo = if (myUsername <= peerUsername) myUsername else peerUsername
      val hi = if (myUsername <= peerUsername) peerUsername else myUsername
      val chainLoToHi = GuhyaCrypto.hmacSha256(secret, "guhya-chain:$lo->$hi".toByteArray(Charsets.UTF_8))
      val chainHiToLo = GuhyaCrypto.hmacSha256(secret, "guhya-chain:$hi->$lo".toByteArray(Charsets.UTF_8))
      val sendChain = if (myUsername == lo) chainLoToHi else chainHiToLo
      val recvChain = if (myUsername == lo) chainHiToLo else chainLoToHi
      return RatchetState(b64(sendChain), 0, b64(recvChain), 0, b64(secret))
  }
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Identity keys feeding freshState() come from the Android Keystore — the ECDH step in GuhyaCrypto.deriveRawSharedSecret() runs against a PrivateKey handle the Keystore hands back, never against raw exported key bytes, for the initial handshake. The rotating, per-message ratchet keys used inside step() are ordinary in-memory/JWK-serialized keys, exactly as documented in the file's own comments, because they must round-trip through the app's encrypted local database.

Fixed: the 1:1 receive path previously resolved a message key only when it matched the receive chain's current index exactly, so a meaningfully out-of-order message could fail to decrypt instead of being recovered. keyForRecvIndex() above closes that gap by porting the same skipped-key cache already used by the web ratchet (section 2.1) and by Android's own group chain (section 11) onto the 1:1 receive path — a late or reordered message now decrypts from the cache instead of being dropped, bounded to MAX_SKIPPED_KEYS = 200.

11. Group messaging on Android — per-sender chains, ECDH-distributed Android

Group cryptography on Android is a Kotlin port of the same two-mechanism design as the web app (section 5): a per-sender HMAC-SHA256 chain for forward secrecy, with the chain seed distributed to each member individually through a per-pair ECDH+AES-GCM envelope. The group receive path carries the same out-of-order key cache as the 1:1 ratchet in section 10 — this is in fact the exact pattern that fix was ported from.

crypto/GroupCrypto.kt — GroupSenderKeys
data class SenderChainState(var chainKeyB64: String, var index: Int, val skipped: MutableMap = mutableMapOf())

object GroupSenderKeys {
  private val MSG_LABEL = "guhya-senderkey-msg-v1".toByteArray(Charsets.UTF_8)
  private val CHAIN_LABEL = "guhya-senderkey-chain-v1".toByteArray(Charsets.UTF_8)
  private const val MAX_SKIPPED_KEYS = 200

  private fun step(chainKey: ByteArray): Pair {
      val msgKey = GuhyaCrypto.hmacSha256(chainKey, MSG_LABEL)
      val nextChain = GuhyaCrypto.hmacSha256(chainKey, CHAIN_LABEL)
      return msgKey to nextChain
  }

  fun nextSendKey(state: SenderChainState): Pair {
      val (msgKey, nextChain) = step(unb64(state.chainKeyB64))
      val index = state.index
      state.chainKeyB64 = b64(nextChain)
      state.index = index + 1
      return msgKey to index
  }

  // Supports out-of-order delivery via the same skipped-key caching
  // pattern as the web app's chain: the receiver can jump straight to a
  // later index and still derive matching keys.
  fun keyForRecvIndex(state: SenderChainState, targetIndex: Int): ByteArray? {
      state.skipped[targetIndex]?.let { cached -> state.skipped.remove(targetIndex); return unb64(cached) }
      if (targetIndex < state.index) return null

      var resolved: ByteArray? = null
      var chainKey = unb64(state.chainKeyB64)
      while (state.index <= targetIndex) {
          val (msgKey, nextChain) = step(chainKey)
          val thisIndex = state.index
          chainKey = nextChain
          state.index = thisIndex + 1
          if (thisIndex == targetIndex) resolved = msgKey
          else {
              if (state.skipped.size >= MAX_SKIPPED_KEYS) state.skipped.remove(state.skipped.keys.first())
              state.skipped[thisIndex] = b64(msgKey)
          }
      }
      state.chainKeyB64 = b64(chainKey)
      return resolved
  }
}
crypto/GroupCrypto.kt — wrapForMember()
// IMPORTANT: matches crypto.js exactly — the raw 32-byte ECDH shared
// secret is used DIRECTLY as the AES-256-GCM key, no HKDF/hash step.
fun deriveSharedAesKey(myPrivateKey: PrivateKey, theirPublicKey: PublicKey): ByteArray {
    val ka = KeyAgreement.getInstance("ECDH")
    ka.init(myPrivateKey); ka.doPhase(theirPublicKey, true)
    return ka.generateSecret()
}

fun wrapForMember(myPrivateKey: PrivateKey, theirPublicKey: PublicKey, rawSeedB64: String): String {
    val aesKeyBytes = deriveSharedAesKey(myPrivateKey, theirPublicKey)
    val enc = GuhyaCrypto.encryptMessage(aesKeyBytes, rawSeedB64)
    return JSONObject().apply { put("iv", enc.iv); put("ciphertext", enc.ciphertext); put("authTag", enc.authTag) }.toString()
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

As with the web app, groups on Android get forward secrecy and ECDH-distributed rotation, but not passive post-compromise healing — an explicit re-key is the recovery path for a compromised group session on either platform today.

12. Local database — SQLCipher, keyed from the Android Keystore Android

Everything the Android app persists locally — messages, ratchet state, peer keys, group sender-key state — lives in a SQLCipher-encrypted SQLite database. SQLCipher needs a passphrase to open the file; that passphrase is generated once, at random, and is itself never stored in plaintext:

data/local/DbPassphraseManager.kt
// WHAT THIS SOLVES: SQLCipher needs a passphrase (a byte array) to
// encrypt/decrypt the database file. That passphrase itself has to live
// SOMEWHERE — storing it as a plain string in SharedPreferences would
// just move the security problem, not solve it. The fix: use
// EncryptedSharedPreferences, which wraps a Keystore-backed AES key
// around whatever you store — so the actual SQLCipher passphrase is
// encrypted-at-rest using a key that lives in the Android Keystore
// (hardware-backed on any device with a StrongBox/TEE) and can never be
// extracted from the device, even with root, without the hardware
// itself being compromised.
object DbPassphraseManager {
  private const val PASSPHRASE_BYTE_LENGTH = 32 // 256-bit — matches SQLCipher's default cipher strength

  fun getOrCreatePassphrase(context: Context): ByteArray {
      val prefs = encryptedPrefs(context)
      val existing = prefs.getString(PREF_KEY_PASSPHRASE, null)
      if (existing != null) return Base64.decode(existing, Base64.NO_WRAP)
      val fresh = ByteArray(PASSPHRASE_BYTE_LENGTH)
      SecureRandom().nextBytes(fresh)
      prefs.edit().putString(PREF_KEY_PASSPHRASE, Base64.encodeToString(fresh, Base64.NO_WRAP)).apply()
      return fresh
  }

  // Called on logout / "delete all local data" — wipes the passphrase
  // itself, so the SQLCipher file on disk becomes permanently unreadable,
  // even though the encrypted bytes physically remain until the caller
  // also deletes the file.
  fun clearPassphrase(context: Context) {
      encryptedPrefs(context).edit().remove(PREF_KEY_PASSPHRASE).apply()
  }

  private fun encryptedPrefs(context: Context) = run {
      val masterKey = MasterKey.Builder(context)
          .setKeyGenParameterSpec(
              KeyGenParameterSpec.Builder(MasterKey.DEFAULT_MASTER_KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
                  .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                  .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                  .setKeySize(256)
                  .build()
          ).build()
      EncryptedSharedPreferences.create(context, PREFS_NAME, masterKey,
          EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
          EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM)
  }
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

13. App lock — biometric or device-credential re-entry gate Android

When enabled in Settings, App Lock requires the device's own fingerprint, face unlock, or PIN/pattern/password to re-open Guhya after it has been backgrounded. It's built entirely on androidx.biometric.BiometricPrompt — no custom PIN entry screen, no code that ever sees or stores the biometric data or device credential itself.

security/AppLockManager.kt
// Gates app content behind the device's fingerprint/face/PIN — uses
// BIOMETRIC_STRONG or DEVICE_CREDENTIAL (falls back to PIN/pattern/
// password automatically if no biometric hardware is enrolled, which is
// why we pass BOTH authenticator types).
object AppLockManager {
  private val _isLocked = MutableStateFlow(false)
  val isLocked: StateFlow = _isLocked

  fun unlock() { _isLocked.value = false }

  // Registered once from GuhyaApplication.onCreate(). Uses the process
  // (whole-app) lifecycle, not any single Activity's, so navigating
  // between MainActivity/IncomingCallActivity/etc. internally does NOT
  // re-trigger the lock — only actually leaving the app (home button,
  // app switcher, screen off) does.
  fun registerProcessObserver(context: Context) {
      ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
          override fun onStop(owner: LifecycleOwner) {
              if (isEnabled(context)) _isLocked.value = true
          }
      })
  }

  fun canUseDeviceLock(activity: FragmentActivity): Boolean {
      val biometricManager = BiometricManager.from(activity)
      val result = biometricManager.canAuthenticate(
          BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
      )
      return result == BiometricManager.BIOMETRIC_SUCCESS
  }

  fun prompt(activity: FragmentActivity, onSuccess: () -> Unit, onFailure: (String) -> Unit) {
      val biometricPrompt = BiometricPrompt(activity, executor, object : BiometricPrompt.AuthenticationCallback() {
          override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { onSuccess() }
          override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { onFailure(errString.toString()) }
          override fun onAuthenticationFailed() { // single failed attempt — not an error, prompt stays open }
      })
      val promptInfo = BiometricPrompt.PromptInfo.Builder()
          .setTitle("Unlock Guhya")
          .setSubtitle("Verify it's you to open your chats")
          .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL)
          .build()
      biometricPrompt.authenticate(promptInfo)
  }
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Turning App Lock on mid-session deliberately does not lock the app immediately — the lock only takes effect the next time the app actually leaves the foreground, so enabling it doesn't interrupt whatever the person was doing.

14. Moving to a new device — QR-paired local transfer, no cloud in the middle Android

Setting up Guhya on a new phone does not upload chat history anywhere. The old device generates a one-time random secret and shows it as a QR code; the new device scans it and the two connect directly over Google's Nearby Connections API, which auto-selects Wi-Fi Direct or Bluetooth for the actual link — the transfer stays on the local link between the two phones.

transfer/DeviceTransferManager.kt — X side (old device)
// Free stack, exactly as scoped: Nearby Connections API auto-picks
// WiFi Direct/Bluetooth for the actual local link; ML Kit Barcode
// Scanning for the QR; AES-GCM for the session encryption derived from
// the QR secret; SQLCipher (already in this project) for the DB itself,
// sent via Nearby's Payload API which natively chunks/resumes large
// transfers.
fun startAdvertising(onEndpointConnected: (endpointId: String) -> Unit, onError: (String) -> Unit): TransferQrPayload {
    val secret = ByteArray(32).also { SecureRandom().nextBytes(it) }
    sessionSecret = secret
    val endpointName = "guhya-${System.currentTimeMillis()}"
    val options = AdvertisingOptions.Builder().setStrategy(Strategy.P2P_STAR).build()
    connectionsClient.startAdvertising(endpointName, SERVICE_ID, connectionLifecycleCallback, options)
    return TransferQrPayload(endpointName, Base64.encodeToString(secret, Base64.NO_WRAP))
}

// HONEST CORRECTION from the original design: this does NOT send the
// identity private key. GuhyaCrypto.ensureIdentityKeyPair() generates
// that key INSIDE the Android Keystore with no export path — there is no
// API to pull raw key material back out, on any device. What actually
// transfers is the message history DB (re-keyed to the session key —
// see AppDatabase.exportRekeyedCopyForTransfer, never sent under this
// device's own passphrase). The new device generates its OWN fresh
// identity key on first use and publishes it via the existing rotateKey
// action — the same mechanism used for any normal key rotation.
fun sendDatabase(endpointId: String) {
    val sessionKey = deriveSessionKey(sessionSecret!!)
    val dbBytes = AppDatabase.exportRekeyedCopyForTransfer(context, sessionKey.encoded)
    val encrypted = encrypt(sessionKey, JSONObject().apply { put("dbFile", Base64.encodeToString(dbBytes, Base64.NO_WRAP)) }.toString().toByteArray())
    connectionsClient.sendPayload(endpointId, Payload.fromBytes(encrypted))
}

// Call after Y's "done" ack arrives — logs this device out and wipes
// its local DB, the flow's final step.
fun finalizeAfterTransfer(onComplete: () -> Unit) {
    connectionsClient.stopAdvertising()
    AppDatabase.wipe(context)
    onComplete()
}
transfer/DeviceTransferManager.kt — Y side (new device) & session crypto
// Y-side (new device). On success: restores the message history DB
// (re-keyed to this device's own passphrase) and publishes a fresh
// identity key via rotateKey, since the old device's Keystore-bound key
// can't and doesn't transfer. `token` is this device's OWN session token
// from its OWN login — Nearby proximity is the transport, not an auth
// bypass; the person still has to log in on the new device normally
// first.
private suspend fun publishFreshIdentityKey(token: String) {
    val identity = GuhyaCrypto.ensureIdentityKeyPair()
    val jwk = GuhyaCrypto.publicKeyToJwk(identity.public)
    ApiClient.call("rotateKey", mapOf("publicKey" to jwk), token)
}

private fun deriveSessionKey(secret: ByteArray): SecretKeySpec {
    val digest = MessageDigest.getInstance("SHA-256").digest(secret)
    return SecretKeySpec(digest, "AES")
}

private fun encrypt(key: SecretKeySpec, plaintext: ByteArray): ByteArray {
    val iv = ByteArray(12).also { SecureRandom().nextBytes(it) }
    val cipher = Cipher.getInstance("AES/GCM/NoPadding")
    cipher.init(Cipher.ENCRYPT_MODE, key, GCMParameterSpec(128, iv))
    return iv + cipher.doFinal(plaintext)
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Both sides have to actively confirm the transfer (old device taps "Initiate Transfer", new device taps "Accept Transfer") before any data moves. On the receiving device, connections are only accepted from the exact endpoint named in the scanned QR code — the QR scan itself is what establishes trust between the two phones, not Nearby's own generic pairing prompt.

15. Additional device-level hardening Android

Shipped

Clipboard auto-clear

Copying a security code or recovery phrase schedules an automatic clipboard clear roughly 45 seconds later, and marks the clip EXTRA_IS_SENSITIVE on Android 13+ so the system's own clipboard suggestion bar and cross-device clipboard sync skip it entirely.

Shipped

Accessibility-abuse warning

Scans currently enabled accessibility services system-wide and flags any, outside a small allow-list of known-legitimate ones (TalkBack and similar), that combine window-content reading with gesture-performing capabilities — the combination overlay/keylogger-style malware relies on — then links the person to Settings to review it themselves.

security/ClipboardGuard.kt
object ClipboardGuard {
  private const val AUTO_CLEAR_DELAY_MS = 45_000L

  fun copySensitive(context: Context, label: String, text: String) {
      val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
      val clip = ClipData.newPlainText(label, text)
      if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
          clip.description.extras = PersistableBundle().apply { putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true) }
      }
      clipboard.setPrimaryClip(clip)
      handler.postDelayed({
          // Only clear if the clipboard STILL holds what we put there — if the
          // user copied something else in the meantime, leave it alone.
          val current = clipboard.primaryClip?.getItemAt(0)?.text?.toString()
          if (current == text) clipboard.setPrimaryClip(ClipData.newPlainText("", ""))
      }, AUTO_CLEAR_DELAY_MS)
  }
}
security/AccessibilityAbuseDetector.kt
// HONEST SCOPE NOTE: Android does not give one app authority to disable
// another app's accessibility service — that would itself be a security
// hole. What this CAN do, correctly: list which accessibility services
// are currently enabled system-wide and flag ones that request BOTH
// "retrieve window content" and "perform actions" capabilities together
// that AREN'T a small allow-list of known-legitimate services — then show
// the user a warning with a direct link to Settings so THEY can
// review/disable it themselves.
object AccessibilityAbuseDetector {
  private val KNOWN_SAFE_PREFIXES = listOf(
      "com.google.android.marvin.talkback", "com.android.switchaccess",
      "com.google.android.accessibility", "com.samsung.android.accessibility",
  )

  fun findSuspiciousServices(context: Context): List {
      val am = context.getSystemService(Context.ACCESSIBILITY_SERVICE) as AccessibilityManager
      val enabledIds = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
          ?.split(":")?.filter { it.isNotBlank() } ?: return emptyList()

      val suspicious = mutableListOf()
      for (serviceInfo in am.installedAccessibilityServiceList) {
          val id = serviceInfo.id
          if (id !in enabledIds) continue
          val packageName = serviceInfo.resolveInfo.serviceInfo.packageName
          if (KNOWN_SAFE_PREFIXES.any { packageName.startsWith(it) }) continue

          val caps = serviceInfo.capabilities
          val canRetrieveWindowContent = (caps and AccessibilityServiceInfo.CAPABILITY_CAN_RETRIEVE_WINDOW_CONTENT) != 0
          val canPerformGestures = (caps and AccessibilityServiceInfo.CAPABILITY_CAN_PERFORM_GESTURES) != 0
          if (canRetrieveWindowContent && canPerformGestures) suspicious.add(SuspiciousService(packageName, id))
      }
      return suspicious
  }
}
© Guhya Technologies — reproduced here for security review only; not licensed for reuse.

Roadmap and current limitations

This section exists so the page can't drift ahead of the actual codebase — it will be updated the moment code ships, not before.

In development

Group post-compromise security

Groups currently get forward secrecy (per-sender chains) and ECDH-distributed rotation, but not passive healing after a compromise the way 1:1 DH ratchet provides, on either web or Android. An explicit re-key already recovers a group; automatic healing does not exist yet.

In development

Post-quantum key exchange

A hybrid ML-KEM layer alongside the existing ECDH exchange is planned, so quantum resistance would be additive rather than a replacement of the classical guarantee. Not present in either shipping client today.

Not yet completed

Independent third-party audit

The design here has not been reviewed by an external cryptography firm. This is disclosed plainly in the Security & Encryption Report rather than left implicit.

Why this page says "not yet" instead of overstating it: a source-code transparency page loses its value the moment it claims a capability the shipping code doesn't have. Anything marked "in development" above is genuinely absent from the relevant module today — check it yourself, either via your browser's dev tools on guhya.space/chat, or by decompiling/inspecting the installed Android APK.

Frequently asked questions

Is the Double Ratchet implementation here the same as Signal's?

It is the same design pattern popularized by Signal — a DH ratchet composed with a symmetric-key ratchet — built on this app's own primitives (P-256 ECDH and HMAC-SHA256) rather than by importing Signal's libsignal library. The security report linked below documents where the two designs align and where implementation choices differ.

Do group chats use the Double Ratchet DH step too?

No, and this page won't claim otherwise. Group messages get forward secrecy from a per-sender HMAC-SHA256 chain — compromising a member's current chain key exposes only that member's future messages, never their past ones, and never another member's chain. Group key distribution and rotation (adding/removing a member, or a periodic re-key) use per-member ECDH — the same ECDH+AES-GCM envelope used for 1:1 messages — to hand each member their chain seed individually. What groups do not currently have is the DH ratchet's post-compromise "healing" property: there is no mechanism yet that re-seeds an active group session purely from ongoing traffic the way 1:1 chats do. An explicit re-key already recovers a group session; passive healing does not exist yet, and that is listed under Roadmap rather than implied away.

How is a message actually verified before it's sent?

Every send path — direct messages, group messages, and file uploads — encrypts the plaintext, then immediately decrypts its own ciphertext with the same key and byte-compares the result against the original before the message ever leaves the device. If that round-trip doesn't match exactly, the send is aborted with an explicit "could not verify encryption" error instead of silently transmitting something that might not decrypt correctly for the recipient.

Do I need a phone number to use Guhya?

No. Account creation asks for a name, an email address, and a password; country is an optional, skippable field. There is no phone-number field anywhere in the sign-up flow, and email verification is done with a one-time code, not SMS.

Does Guhya use post-quantum cryptography today?

Not yet in the shipping client. Post-quantum key exchange (ML-KEM) is on the public roadmap as a hybrid layer alongside the existing ECDH exchange, not a replacement for it. This page will be updated the moment ML-KEM ships in a release, with a link to the exact commit.

Where are private keys stored?

In the browser, private keys live in IndexedDB as non-extractable CryptoKey objects — the raw key material never touches JavaScript-readable memory or localStorage. Older accounts created before this change are migrated automatically and the legacy localStorage copy is deleted on first load. On Android, the long-term identity private key is generated inside the Android Keystore and never leaves it in any exportable form — see the Android section below.

Is this page the full source code?

No. This page walks through the cryptographic core with real excerpts for technical review. The full Security & Encryption Report (PDF) documents the complete design, threat model, and known limitations, including the fact that Guhya has not yet completed an independent third-party audit.

Does the Android app run the same Double Ratchet as the web app?

It runs a direct Kotlin port of the same chain-ratchet-plus-DH-ratchet design, using the same labels, the same wire fields, and the same P-256/HMAC-SHA256 primitives, so a conversation between a web account and an Android account interops on one shared ratchet state with no server changes. The 1:1 receive path also carries the same skipped-key cache as the web ratchet and Android's own group chain, so a message that arrives out of order or delayed still decrypts correctly instead of being dropped.

What happens to my chats when I set up Guhya on a new phone?

Moving to a new device is a direct, local handoff: the old device shows a QR code containing a one-time session secret, the new device scans it, and the two connect over Nearby Connections (Wi-Fi Direct or Bluetooth, chosen automatically, no internet round-trip required). The message history database is re-encrypted under a session key derived from that QR secret and sent directly between the two devices. The old device's Android-Keystore-bound identity key itself is never exported — it cannot be, by construction — so the new device generates its own fresh identity key on first use and publishes it through the same key-rotation mechanism already used whenever any device's key changes.

What does the Android app lock actually protect against?

When enabled, leaving the app — home button, app switcher, or the screen turning off — requires a fingerprint, face, or device PIN/pattern/password to get back in, using Android's own BiometricPrompt rather than a custom lock screen. It protects against someone picking up an already-unlocked phone and opening Guhya; it is not a substitute for the device's own lock screen and does not by itself encrypt anything — that job belongs to the SQLCipher-encrypted local database described above.

Want the complete technical writeup?

Threat model, account security scoring, and known limitations — all in one PDF.

Read the Security Report → Ask an engineering question