chore: flatten repository history

This commit is contained in:
randogoth 2026-10-09 13:47:42 +03:00
commit 3a03fab876
30 changed files with 7223 additions and 0 deletions

92
web/js/noise.js Normal file
View file

@ -0,0 +1,92 @@
// Noise_NX_25519_ChaChaPoly_SHA256 initiator (SPEC.md §4), rev-34 semantics.
// The initiator is anonymous; the responder's static key arrives encrypted in
// message two, which is what server pinning checks.
import { aeadDecrypt, aeadEncrypt, concat, hkdfSha256, randomBytes, sha256, utf8Bytes, x25519, x25519Base } from "./crypto.js";
const PROTOCOL = "Noise_NX_25519_ChaChaPoly_SHA256"; // exactly 32 bytes, so h = name
const PROLOGUE = utf8Bytes("smolmail/1");
// Noise's ChaChaPoly nonce: 4 zero bytes then the counter as u64 LE.
function nonce(n) {
const out = new Uint8Array(12);
new DataView(out.buffer).setBigUint64(4, BigInt(n), true);
return out;
}
// One direction of the post-handshake transport. The key is unique per
// session, so the counter starting at zero is safe.
class CipherState {
constructor(key) {
this.key = key;
this.counter = 0;
}
encrypt(plaintext) {
const sealed = aeadEncrypt(this.key, nonce(this.counter), plaintext, new Uint8Array(0));
this.counter++;
return sealed;
}
decrypt(sealed) {
const plaintext = aeadDecrypt(this.key, nonce(this.counter), sealed, new Uint8Array(0));
this.counter++;
return plaintext;
}
}
export class NxInitiator {
constructor() {
this.h = utf8Bytes(PROTOCOL);
this.ck = this.h.slice();
this.mixHash(PROLOGUE);
this.key = null;
}
mixHash(data) {
this.h = sha256(concat(this.h, data));
}
mixKey(ikm) {
const okm = hkdfSha256(ikm, this.ck, new Uint8Array(0), 64);
this.ck = okm.slice(0, 32);
this.key = okm.slice(32);
}
// Message one is just our ephemeral public key. No key is set yet, so the
// empty payload travels in the clear — and is still mixed into h.
// `esk` is pinned only by the test vectors, like the spec's fixed-ephemeral
// envelope.
writeMessage1(esk) {
this.esk = esk ?? randomBytes(32);
this.epk = x25519Base(this.esk);
this.mixHash(this.epk);
this.mixHash(new Uint8Array(0));
return this.epk;
}
// Message two: e (plaintext), ee, then the responder's static and the
// (empty) payload as AEAD ciphertexts chained through h. Each MixKey
// restarts the nonce at zero.
readMessage2(message) {
if (message.length !== 32 + 48 + 16)
throw new Error(`unexpected NX message length ${message.length}`);
const re = message.slice(0, 32);
this.mixHash(re);
this.mixKey(x25519(this.esk, re));
this.serverStatic = this.decryptAndHash(message.slice(32, 80));
this.mixKey(x25519(this.esk, this.serverStatic)); // es
const payload = this.decryptAndHash(message.slice(80));
if (payload.length !== 0) throw new Error("unexpected payload in handshake");
this.handshakeHash = this.h;
// Split(): two transport keys from the final chaining key, zero-length ikm
const okm = hkdfSha256(new Uint8Array(0), this.ck, new Uint8Array(0), 64);
return { send: new CipherState(okm.slice(0, 32)), recv: new CipherState(okm.slice(32)) };
}
decryptAndHash(sealed) {
const plaintext = aeadDecrypt(this.key, nonce(0), sealed, this.h);
this.mixHash(sealed);
return plaintext;
}
}