hugin-crypto
v1.0.0
Published
Cryptographic functions used in hugin.chat — noble (pure-JS) ML-KEM + XSalsa20-Poly1305 over CryptoNote (kryptokrona) key derivation. Runs in Node, Bare, Hermes/React Native.
Maintainers
Readme
hugin-crypto
Cryptographic layer used by hugin.chat. Pure-JS, runs on Node, Bare, and Hermes/React Native — no native modules.
Under the hood:
- ML-KEM-512 (FIPS-203,
@noble/post-quantum) for the post-quantum key exchange. - XSalsa20-Poly1305 (
@noble/ciphers) for symmetric authenticated encryption — wire-compatible with libsodiumcrypto_secretbox_easy. - CryptoNote ECDH (
kryptokrona-utils) for the outer per-message key derivation from the recipient's xkr view key. - Ed25519 signatures (via
kryptokrona-utils) so every plaintext friend request is authenticated by the sender's spend key.
Protocol
Every private message is an ephemeral, signed friend-request-shaped box — a fresh one-time key per message gives an unlinkable view tag on the wire. Three states layer on top:
| State | What goes out | Encryption |
|-|-|-|
| Initial (no shared secret yet) | kem_pub in the outer plaintext | Outer only (ECDH-derived key) |
| First reply (peer's kem_pub seen) | kem_ct + inner-encrypted body | Outer and inner (ML-KEM-derived key) |
| Established | Inner-encrypted body only | Outer and inner (double-encrypt) |
The kem_pub and kem_ct are carried inside the outer secretbox, never on
the wire itself — a passive observer sees only {box, t, txKey, vt}.
Handshake
Alice (initiator) Bob (responder)
─────────────────────────────────────────────────────────────────
createFriendRequest({ myKemPub: alicePub }) ──── wire ────►
openFriendRequest()
→ handshake.peerKemPub
→ encapsulate(alicePub)
= { ciphertext, sharedSecret }
save sharedSecret as messageKey
◄──── reply ────
createFriendRequest({
messageKey,
kemCiphertext: ciphertext,
})
openFriendRequest()
→ handshake.sharedSecret (already decapsulated)
→ outer decrypt → inner decrypt with messageKey
Both sides now hold the same 32-byte messageKey.
Every subsequent message is inner-encrypted with it before the outer wrap.Once established, an attacker has to break both the per-conversation ephemeral
ECDH wrap and the long-lived ML-KEM-derived messageKey to recover plaintext.
Wire shape
encodeExtra(wire) → hex( JSON.stringify(wire) )
wire = {
box: hex(secretboxEncrypt(outerPlaintext, boxKey, nonceFromTimestamp(t))),
t: unix-ms,
txKey: 32-byte ephemeral CryptoNote public key (hex),
vt: 2-byte view tag, // cheap "is this for me" scan
}boxKey = cn_fast_hash('box' || derivation);vt = cn_fast_hash(derivation)[0:2] — domain-separated so the tag does not
leak key bytes.
API
import {
createFriendRequest, openFriendRequest,
encryptMessage, decryptMessage, // aliases of the above
encodeExtra, decodeExtra,
generateKemKeypair, encapsulate, decapsulate,
messageHash,
KEM_PUB_BYTES, KEM_CIPHERTEXT_BYTES, KEM_SHARED_BYTES,
} from 'hugin-crypto';Long-lived identity
Each device holds one long-lived ML-KEM keypair. Persist both halves; expose the public key to the friend-request path and the secret to the decrypt path.
const identity = generateKemKeypair();
// { publicKey: 800 bytes, secretKey: 1632 bytes }Sending: Alice → Bob (initial)
const wire = await createFriendRequest({
message: 'hi',
sender: { address: aliceAddress, privateSpendKey: alicePrivSpend },
toAddress: bobAddress,
name: 'alice',
myKemPub: identity.publicKey, // include on every send until we hold messageKey
});
const extra = encodeExtra(wire); // hex(JSON) — ready for the wireReceiving
const wire = decodeExtra(extraHex);
const result = await openFriendRequest(
wire,
{
privateViewKey: bobPrivView,
getMessageKey: async (fromAddress) => {
// Return the 32-byte shared secret for this contact, or null.
const contact = await loadContact(fromAddress);
return contact?.messageKey ?? null;
},
},
identity.secretKey, // for auto-decapsulation of kem_ct
);
if (!result) return; // view-tag miss, or auth/decrypt failure
// result: {
// type: 'friend-request' | 'message',
// from, msg, name, t, verified,
// handshake?: { peerKemPub?, sharedSecret? }
// }
// Persist handshake progress BEFORE saving anything else:
if (result.handshake?.peerKemPub) {
// Peer's initial → we encapsulate against it.
const { ciphertext, sharedSecret } = encapsulate(result.handshake.peerKemPub);
await saveContactKemState(result.from, {
messageKey: sharedSecret,
pendingKemCapsule: ciphertext, // ship on our next outgoing send
peerKemPubHex: uintToHex(result.handshake.peerKemPub),
});
} else if (result.handshake?.sharedSecret) {
// Peer's first reply — hugin-crypto already decapsulated for us.
await saveContactKemState(result.from, { messageKey: result.handshake.sharedSecret });
}Sending: Alice → Bob (established)
const state = await loadContactKemState(bobAddress);
const wire = await createFriendRequest({
message: 'second',
sender: { address: aliceAddress, privateSpendKey: alicePrivSpend },
toAddress: bobAddress,
messageKey: state.messageKey, // triggers inner encryption
kemCiphertext: state.pendingKemCapsule, // included on first reply only
});Clear pendingKemCapsule after a successful send.
Detecting a peer that restored / rotated
onReceivedPeerKemPub (or your equivalent) should compare the incoming kem_pub
to the one you cached on the contact row:
- No stored pub → first handshake, encapsulate and persist.
- Stored pub matches incoming → in-flight duplicate (peer re-broadcasts their
pub on every message until they hold a
messageKey) — keep the existingmessageKey; do NOT re-encapsulate or you will silently desync. - Stored pub differs from incoming → peer restored their account and has a
fresh ML-KEM keypair. Discard the old
messageKey, re-encapsulate against the new pub, ship the newkem_cton your next send.
Content-addressed message id
messageHash(extraHex) // SHA-256 of the wire bytes → 64 hex charsBoth sides derive the same id from the same wire — no random id needs to ride along, dedup is hash-keyed everywhere.
Constants
| | Bytes | Hex chars | |-|-|-| | ML-KEM public key | 800 | 1600 | | ML-KEM secret key | 1632 | 3264 | | ML-KEM ciphertext | 768 | 1536 | | ML-KEM shared secret | 32 | 64 | | secretbox key | 32 | 64 | | secretbox nonce | 24 | 48 | | secretbox MAC | 16 | 32 |
Build / test
npm install
npm run build # tsc -> dist/
npm test # node --testNo native modules — no Python, no node-gyp, no platform-specific build step.
License
GPL-3.0-or-later
