mirror of
https://github.com/luxfi/zwing.git
synced 2026-09-24 00:24:38 +00:00
zwing: full PQ stack in Python and TypeScript with 100% coverage
Port the complete Z-Wing protocol to Python and TypeScript, no longer
just the X-Wing combiner. Each language now ships:
identity Hybrid Ed25519 + ML-DSA-65 + X-Wing keypair, marshal/parse,
sign/verify, constant-time equality
kem Full IETF X-Wing encapsulate/decapsulate (ML-KEM-768 +
X25519 + SHA3-256 combiner) with wire-byte compat
handshake 1-RTT mutual-auth handshake mirroring Go state machine —
same labels (lux.zwing.v1/handshake-init etc), same wire
format (HandshakeInit / HandshakeResponse / transcript hash
/ AEAD-sealed responder identity)
channel Post-handshake ChaCha20-Poly1305 with sequence-numbered
nonces (high 4 zero, low 8 BE counter), MAX 1 MiB frames
Python implementation:
- cryptography (Ed25519/X25519/ChaCha20-Poly1305) + kyber-py (ML-KEM)
+ dilithium-py (ML-DSA). Pure Python, zero native deps.
- test_full.py: 39 tests covering happy paths + every error branch
- py/__init__.py modules at 100% line coverage:
channel.py 51 stmts 100%
handshake.py 159 stmts 100%
identity.py 91 stmts 100%
kem.py 26 stmts 100%
zwing.py 27 stmts 100%
TypeScript implementation:
- @noble/post-quantum (ml-kem768, ml-dsa65) + @noble/curves (ed25519,
x25519) + @noble/ciphers (chacha20poly1305) + @noble/hashes
- full.test.ts: 32 tests covering full e2e + every wire-decode error
- vitest --coverage v8: 100% statements / 100% branches / 100%
functions / 100% lines across every source file (channel.ts,
handshake.ts, identity.ts, kem.ts, zwing.ts)
Cross-language KAT (72df...fe4ed) still pins on the X-Wing combiner.
Full handshake wire-byte interop is now possible Go↔Python (both use
FIPS 204 ML-DSA-65 with identical 3309-byte signatures); Rust uses
PQClean Dilithium3 which has the same byte sizes as Round 3 — full
Rust↔Go interop test pending a unified ML-DSA-65 source.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
pnpm-lock.yaml
|
||||
coverage/
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Z-Wing post-handshake encrypted channel for TypeScript.
|
||||
*
|
||||
* Wraps a `DuplexStream` with ChaCha20-Poly1305 records using
|
||||
* sequence-numbered nonces. Wire format matches the Go and Rust
|
||||
* implementations byte-for-byte.
|
||||
*/
|
||||
|
||||
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
|
||||
import {
|
||||
DuplexStream,
|
||||
HandshakeOutput,
|
||||
MAX_FRAME_SIZE,
|
||||
readFrame,
|
||||
writeFrame,
|
||||
} from "./handshake.js";
|
||||
import type { IdentityPublic } from "./identity.js";
|
||||
|
||||
const CHACHA_OVERHEAD = 16;
|
||||
const NONCE_LEN = 12;
|
||||
|
||||
/** Build the 12-byte nonce from a 64-bit BE counter. */
|
||||
export function nonceFor(seq: bigint): Uint8Array {
|
||||
const n = new Uint8Array(NONCE_LEN);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
n[NONCE_LEN - 1 - i] = Number((seq >> BigInt(i * 8)) & 0xffn);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
export class Channel {
|
||||
private inner: DuplexStream;
|
||||
public readonly remote: IdentityPublic;
|
||||
private rxKey: Uint8Array;
|
||||
private txKey: Uint8Array;
|
||||
private rxSeq = 0n;
|
||||
private txSeq = 0n;
|
||||
private rxOverflow: Uint8Array | null = null;
|
||||
|
||||
constructor(inner: DuplexStream, out: HandshakeOutput, initiator: boolean) {
|
||||
this.inner = inner;
|
||||
this.remote = out.remote;
|
||||
if (initiator) {
|
||||
this.txKey = out.keyI2R;
|
||||
this.rxKey = out.keyR2I;
|
||||
} else {
|
||||
this.txKey = out.keyR2I;
|
||||
this.rxKey = out.keyI2R;
|
||||
}
|
||||
// Best-effort wipe of the handshake-side keys.
|
||||
out.shared.fill(0);
|
||||
// Note: keyI2R / keyR2I have been copied into rxKey / txKey above
|
||||
// by reference — wiping them is the responsibility of the caller
|
||||
// since we don't own the original buffers exclusively.
|
||||
}
|
||||
|
||||
/** Encrypt and send a single record. Splits if larger than MAX. */
|
||||
async send(plaintext: Uint8Array): Promise<void> {
|
||||
const maxPlain = MAX_FRAME_SIZE - CHACHA_OVERHEAD;
|
||||
let written = 0;
|
||||
while (written < plaintext.length) {
|
||||
const end = Math.min(written + maxPlain, plaintext.length);
|
||||
const chunk = plaintext.slice(written, end);
|
||||
if (this.txSeq === 0xffffffffffffffffn) {
|
||||
throw new Error("zwing: AEAD sequence number exhausted");
|
||||
}
|
||||
const nonce = nonceFor(this.txSeq);
|
||||
const ct = chacha20poly1305(this.txKey, nonce).encrypt(chunk);
|
||||
this.txSeq += 1n;
|
||||
await writeFrame(this.inner, ct);
|
||||
written = end;
|
||||
}
|
||||
}
|
||||
|
||||
/** Receive one full record. */
|
||||
async recv(): Promise<Uint8Array> {
|
||||
if (this.rxOverflow) {
|
||||
const buf = this.rxOverflow;
|
||||
this.rxOverflow = null;
|
||||
return buf;
|
||||
}
|
||||
const frame = await readFrame(this.inner);
|
||||
if (this.rxSeq === 0xffffffffffffffffn) {
|
||||
throw new Error("zwing: AEAD sequence number exhausted");
|
||||
}
|
||||
const nonce = nonceFor(this.rxSeq);
|
||||
const pt = chacha20poly1305(this.rxKey, nonce).decrypt(frame);
|
||||
this.rxSeq += 1n;
|
||||
return pt;
|
||||
}
|
||||
}
|
||||
+425
@@ -0,0 +1,425 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Channel, nonceFor } from "./channel.js";
|
||||
import {
|
||||
HandshakeOutput,
|
||||
MAX_FRAME_SIZE,
|
||||
readFrame,
|
||||
runInitiator,
|
||||
runResponder,
|
||||
writeFrame,
|
||||
} from "./handshake.js";
|
||||
import type { DuplexStream } from "./handshake.js";
|
||||
import {
|
||||
generateIdentity,
|
||||
identityEquals,
|
||||
marshalIdentityPublic,
|
||||
parseIdentityPublic,
|
||||
publicOf,
|
||||
signIdentity,
|
||||
verifyIdentity,
|
||||
} from "./identity.js";
|
||||
import { xwingDecapsulate, xwingEncapsulate } from "./kem.js";
|
||||
|
||||
/**
|
||||
* Async in-memory pipe pair so two sides of a handshake can run in
|
||||
* parallel via `Promise.all`.
|
||||
*/
|
||||
function pipe(): [DuplexStream, DuplexStream] {
|
||||
type Buf = { data: Uint8Array; resolve: (b: Uint8Array) => void };
|
||||
const queueAB: Uint8Array[] = [];
|
||||
const queueBA: Uint8Array[] = [];
|
||||
const waitersAB: ((b: Uint8Array) => void)[] = [];
|
||||
const waitersBA: ((b: Uint8Array) => void)[] = [];
|
||||
|
||||
function makeReader(queue: Uint8Array[], waiters: ((b: Uint8Array) => void)[]) {
|
||||
let buf = new Uint8Array(0);
|
||||
return async function read(n: number): Promise<Uint8Array> {
|
||||
while (buf.length < n) {
|
||||
let chunk: Uint8Array;
|
||||
if (queue.length > 0) {
|
||||
chunk = queue.shift()!;
|
||||
} else {
|
||||
chunk = await new Promise<Uint8Array>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
}
|
||||
const merged = new Uint8Array(buf.length + chunk.length);
|
||||
merged.set(buf);
|
||||
merged.set(chunk, buf.length);
|
||||
buf = merged;
|
||||
}
|
||||
const out = buf.slice(0, n);
|
||||
buf = buf.slice(n);
|
||||
return out;
|
||||
};
|
||||
}
|
||||
|
||||
function makeWriter(queue: Uint8Array[], waiters: ((b: Uint8Array) => void)[]) {
|
||||
return async function write(data: Uint8Array): Promise<void> {
|
||||
const copy = new Uint8Array(data);
|
||||
if (waiters.length > 0) {
|
||||
const resolve = waiters.shift()!;
|
||||
resolve(copy);
|
||||
} else {
|
||||
queue.push(copy);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const a: DuplexStream = {
|
||||
read: makeReader(queueBA, waitersBA),
|
||||
write: makeWriter(queueAB, waitersAB),
|
||||
};
|
||||
const b: DuplexStream = {
|
||||
read: makeReader(queueAB, waitersAB),
|
||||
write: makeWriter(queueBA, waitersBA),
|
||||
};
|
||||
return [a, b];
|
||||
}
|
||||
|
||||
describe("Z-Wing full e2e (TS)", () => {
|
||||
it("identity sign + verify round trip", () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
|
||||
expect(() =>
|
||||
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"), sig),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("verify rejects wrong message", () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
|
||||
expect(() =>
|
||||
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("bye"), sig),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("verify rejects wrong context", () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
|
||||
expect(() =>
|
||||
verifyIdentity(pub, new TextEncoder().encode("other"), new TextEncoder().encode("hi"), sig),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("verify rejects wrong-size signature", () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
expect(() =>
|
||||
verifyIdentity(
|
||||
pub,
|
||||
new TextEncoder().encode("ctx"),
|
||||
new TextEncoder().encode("hi"),
|
||||
new Uint8Array([1, 2, 3]),
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("identity public marshal round trip", () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
const wire = marshalIdentityPublic(pub);
|
||||
const parsed = parseIdentityPublic(wire);
|
||||
expect(identityEquals(parsed, pub)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects wrong-size identity public", () => {
|
||||
expect(() => parseIdentityPublic(new Uint8Array(10))).toThrow();
|
||||
});
|
||||
|
||||
it("two distinct identities differ", () => {
|
||||
const a = publicOf(generateIdentity());
|
||||
const b = publicOf(generateIdentity());
|
||||
expect(identityEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("xwing encap/decap round trip", () => {
|
||||
const recipient = generateIdentity();
|
||||
const { ciphertext, shared } = xwingEncapsulate(publicOf(recipient));
|
||||
const out = xwingDecapsulate(recipient, ciphertext);
|
||||
expect(out).toEqual(shared);
|
||||
});
|
||||
|
||||
it("xwing decap rejects wrong size", () => {
|
||||
const id = generateIdentity();
|
||||
expect(() => xwingDecapsulate(id, new Uint8Array(10))).toThrow();
|
||||
});
|
||||
|
||||
it("full handshake round trip via in-memory pipe", async () => {
|
||||
const client = generateIdentity();
|
||||
const server = generateIdentity();
|
||||
const [a, b] = pipe();
|
||||
const [cOut, sOut] = await Promise.all([
|
||||
runInitiator(a, client, publicOf(server)),
|
||||
runResponder(b, server, publicOf(client)),
|
||||
]);
|
||||
expect(cOut.keyI2R).toEqual(sOut.keyI2R);
|
||||
expect(cOut.keyR2I).toEqual(sOut.keyR2I);
|
||||
expect(identityEquals(cOut.remote, publicOf(server))).toBe(true);
|
||||
expect(identityEquals(sOut.remote, publicOf(client))).toBe(true);
|
||||
});
|
||||
|
||||
it("pinned remote mismatch rejected", async () => {
|
||||
const client = generateIdentity();
|
||||
const server = generateIdentity();
|
||||
const other = generateIdentity();
|
||||
const [a, b] = pipe();
|
||||
await expect(
|
||||
Promise.all([runInitiator(a, client, publicOf(other)), runResponder(b, server)]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("e2e channel echo", async () => {
|
||||
const client = generateIdentity();
|
||||
const server = generateIdentity();
|
||||
const [a, b] = pipe();
|
||||
const [cOut, sOut] = await Promise.all([
|
||||
runInitiator(a, client, publicOf(server)),
|
||||
runResponder(b, server, publicOf(client)),
|
||||
]);
|
||||
const cChan = new Channel(a, cOut, true);
|
||||
const sChan = new Channel(b, sOut, false);
|
||||
|
||||
await cChan.send(new TextEncoder().encode("z-wing ts e2e"));
|
||||
const got = await sChan.recv();
|
||||
expect(new TextDecoder().decode(got)).toBe("z-wing ts e2e");
|
||||
|
||||
await sChan.send(new TextEncoder().encode("ack"));
|
||||
const back = await cChan.recv();
|
||||
expect(new TextDecoder().decode(back)).toBe("ack");
|
||||
});
|
||||
|
||||
it("write frame oversize rejected", async () => {
|
||||
const sink: DuplexStream = {
|
||||
read: async () => new Uint8Array(0),
|
||||
write: async () => {},
|
||||
};
|
||||
await expect(writeFrame(sink, new Uint8Array(MAX_FRAME_SIZE + 1))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("read frame zero length rejected", async () => {
|
||||
let i = 0;
|
||||
const conn: DuplexStream = {
|
||||
read: async (n: number) => {
|
||||
if (i++ === 0) return new Uint8Array([0, 0, 0, 0]);
|
||||
return new Uint8Array(n);
|
||||
},
|
||||
write: async () => {},
|
||||
};
|
||||
await expect(readFrame(conn)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("read frame oversize rejected", async () => {
|
||||
const conn: DuplexStream = {
|
||||
read: async () => new Uint8Array([0xff, 0xff, 0xff, 0xff]),
|
||||
write: async () => {},
|
||||
};
|
||||
await expect(readFrame(conn)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("nonce_for has BE counter in low bytes", () => {
|
||||
expect(nonceFor(0n)).toEqual(new Uint8Array(12));
|
||||
const n1 = nonceFor(1n);
|
||||
expect(n1[11]).toBe(1);
|
||||
const nMax = nonceFor(0xffffffffffffffffn);
|
||||
for (let i = 4; i < 12; i++) expect(nMax[i]).toBe(0xff);
|
||||
});
|
||||
|
||||
it("identityEquals length mismatch returns false", async () => {
|
||||
const { identityEquals } = await import("./identity.js");
|
||||
const a = {
|
||||
edPk: new Uint8Array(32),
|
||||
mlPk: new Uint8Array(1952),
|
||||
xwingMlkemPk: new Uint8Array(1184),
|
||||
xwingX25519Pk: new Uint8Array(32),
|
||||
};
|
||||
const b = {
|
||||
edPk: new Uint8Array(16), // wrong length
|
||||
mlPk: new Uint8Array(1952),
|
||||
xwingMlkemPk: new Uint8Array(1184),
|
||||
xwingX25519Pk: new Uint8Array(32),
|
||||
};
|
||||
expect(identityEquals(a, b)).toBe(false);
|
||||
});
|
||||
|
||||
it("Channel rx overflow path", async () => {
|
||||
const sink: DuplexStream = {
|
||||
read: async () => new Uint8Array(0),
|
||||
write: async () => {},
|
||||
};
|
||||
const id = generateIdentity();
|
||||
const out: HandshakeOutput = {
|
||||
shared: new Uint8Array(32),
|
||||
remote: publicOf(id),
|
||||
keyI2R: new Uint8Array(32),
|
||||
keyR2I: new Uint8Array(32),
|
||||
};
|
||||
const chan = new Channel(sink, out, true);
|
||||
// Inject overflow buffer; recv must return it without touching the wire.
|
||||
(chan as any).rxOverflow = new TextEncoder().encode("hello-overflow");
|
||||
const got = await chan.recv();
|
||||
expect(new TextDecoder().decode(got)).toBe("hello-overflow");
|
||||
});
|
||||
|
||||
it("Channel tx seq exhausted", async () => {
|
||||
const sink: DuplexStream = {
|
||||
read: async () => new Uint8Array(0),
|
||||
write: async () => {},
|
||||
};
|
||||
const id = generateIdentity();
|
||||
const out: HandshakeOutput = {
|
||||
shared: new Uint8Array(32),
|
||||
remote: publicOf(id),
|
||||
keyI2R: new Uint8Array(32),
|
||||
keyR2I: new Uint8Array(32),
|
||||
};
|
||||
const chan = new Channel(sink, out, true);
|
||||
(chan as any).txSeq = 0xffffffffffffffffn;
|
||||
await expect(chan.send(new Uint8Array([1]))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("Channel rx seq exhausted", async () => {
|
||||
let frameRead = false;
|
||||
const stream: DuplexStream = {
|
||||
read: async (n) => {
|
||||
if (!frameRead) {
|
||||
frameRead = true;
|
||||
// Return a valid header for a 1-byte frame...
|
||||
if (n === 4) return new Uint8Array([0, 0, 0, 1]);
|
||||
}
|
||||
if (n === 1) return new Uint8Array([0]);
|
||||
return new Uint8Array(n);
|
||||
},
|
||||
write: async () => {},
|
||||
};
|
||||
const id = generateIdentity();
|
||||
const out: HandshakeOutput = {
|
||||
shared: new Uint8Array(32),
|
||||
remote: publicOf(id),
|
||||
keyI2R: new Uint8Array(32),
|
||||
keyR2I: new Uint8Array(32),
|
||||
};
|
||||
const chan = new Channel(stream, out, true);
|
||||
(chan as any).rxSeq = 0xffffffffffffffffn;
|
||||
await expect(chan.recv()).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("zwing combineXWing rejects every wrong-size input", async () => {
|
||||
const { combineXWing } = await import("./zwing.js");
|
||||
const ok = new Uint8Array(32);
|
||||
const short = new Uint8Array(31);
|
||||
expect(() => combineXWing(short, ok, ok, ok)).toThrow();
|
||||
expect(() => combineXWing(ok, short, ok, ok)).toThrow();
|
||||
expect(() => combineXWing(ok, ok, short, ok)).toThrow();
|
||||
expect(() => combineXWing(ok, ok, ok, short)).toThrow();
|
||||
});
|
||||
|
||||
it("xwing wire decode errors", async () => {
|
||||
// Full wire round-trip already tested. Here we drive the few pure
|
||||
// decode error branches.
|
||||
const conn: DuplexStream = {
|
||||
read: async (n) => {
|
||||
// Fast-fail header read with an oversize length.
|
||||
if (n === 4) return new Uint8Array([0xff, 0xff, 0xff, 0xff]);
|
||||
return new Uint8Array(n);
|
||||
},
|
||||
write: async () => {},
|
||||
};
|
||||
await expect(readFrame(conn)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("identity sign verify rejects corrupt mldsa half", async () => {
|
||||
const id = generateIdentity();
|
||||
const pub = publicOf(id);
|
||||
const sig = signIdentity(id, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"));
|
||||
sig[200] ^= 0xff; // mutate inside the ML-DSA portion
|
||||
expect(() =>
|
||||
verifyIdentity(pub, new TextEncoder().encode("ctx"), new TextEncoder().encode("hi"), sig),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("decodeHandshakeResponse short enc body rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
const { XWING_CIPHERTEXT_SIZE } = await import("./zwing.js");
|
||||
// Right-size ct, encLen=0xFFFF, no body.
|
||||
const buf = new Uint8Array(XWING_CIPHERTEXT_SIZE + 2);
|
||||
buf[XWING_CIPHERTEXT_SIZE] = 0xff;
|
||||
buf[XWING_CIPHERTEXT_SIZE + 1] = 0xff;
|
||||
expect(() => __internal.decodeHandshakeResponse(buf)).toThrow();
|
||||
});
|
||||
|
||||
it("decodeHandshakeResponse trailing garbage rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
const { XWING_CIPHERTEXT_SIZE } = await import("./zwing.js");
|
||||
// Right-size ct, encLen=0, plus trailing byte.
|
||||
const buf = new Uint8Array(XWING_CIPHERTEXT_SIZE + 3);
|
||||
expect(() => __internal.decodeHandshakeResponse(buf)).toThrow();
|
||||
});
|
||||
|
||||
it("splitRespIDPayload short sig rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
// idLen=0, sigLen=0xFFFF, no body.
|
||||
expect(() => __internal.splitRespIDPayload(new Uint8Array([0, 0, 0xff, 0xff]))).toThrow();
|
||||
});
|
||||
|
||||
it("splitRespIDPayload trailing garbage rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
// idLen=0, sigLen=0, then extra byte.
|
||||
expect(() =>
|
||||
__internal.splitRespIDPayload(new Uint8Array([0, 0, 0, 0, 0x99])),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("responder pinned remote mismatch rejected", async () => {
|
||||
const client = generateIdentity();
|
||||
const server = generateIdentity();
|
||||
const other = generateIdentity();
|
||||
const [a, b] = pipe();
|
||||
await expect(
|
||||
Promise.all([runInitiator(a, client), runResponder(b, server, publicOf(other))]),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("splitRespIDPayload short pre-idLen rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
expect(() => __internal.splitRespIDPayload(new Uint8Array(0))).toThrow();
|
||||
expect(() => __internal.splitRespIDPayload(new Uint8Array([0]))).toThrow();
|
||||
});
|
||||
|
||||
it("splitRespIDPayload short id rejected", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
// idLen=10 but buffer only 2 bytes long.
|
||||
expect(() =>
|
||||
__internal.splitRespIDPayload(new Uint8Array([0, 10])),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("decodeHandshakeResponse rejects pre-XWING-CT-length buffer", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
expect(() => __internal.decodeHandshakeResponse(new Uint8Array(50))).toThrow();
|
||||
});
|
||||
|
||||
it("decodeHandshakeInit short reads at every boundary", async () => {
|
||||
const { __internal } = await import("./handshake.js");
|
||||
expect(() => __internal.decodeHandshakeInit(new Uint8Array(0))).toThrow();
|
||||
expect(() => __internal.decodeHandshakeInit(new Uint8Array([0]))).toThrow();
|
||||
// idLen=1, no id body.
|
||||
expect(() => __internal.decodeHandshakeInit(new Uint8Array([0, 1]))).toThrow();
|
||||
// idLen=1, id="A", no sigLen.
|
||||
expect(() =>
|
||||
__internal.decodeHandshakeInit(new Uint8Array([0, 1, 0x41])),
|
||||
).toThrow();
|
||||
// idLen=0, sigLen=0xFFFF, no body.
|
||||
expect(() =>
|
||||
__internal.decodeHandshakeInit(new Uint8Array([0, 0, 0xff, 0xff])),
|
||||
).toThrow();
|
||||
// idLen=0, sigLen=0, trailing garbage.
|
||||
expect(() =>
|
||||
__internal.decodeHandshakeInit(new Uint8Array([0, 0, 0, 0, 0x99])),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
+325
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Z-Wing 1-RTT mutual-auth handshake (TypeScript).
|
||||
*
|
||||
* Bit-for-bit compatible with the Go and Rust implementations. All
|
||||
* HKDF / transcript / AEAD labels are identical.
|
||||
*/
|
||||
|
||||
import { hkdf } from "@noble/hashes/hkdf.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
import { chacha20poly1305 } from "@noble/ciphers/chacha.js";
|
||||
|
||||
import {
|
||||
Identity,
|
||||
IdentityPublic,
|
||||
identityEquals,
|
||||
marshalIdentityPublic,
|
||||
parseIdentityPublic,
|
||||
signIdentity,
|
||||
verifyIdentity,
|
||||
} from "./identity.js";
|
||||
import { xwingDecapsulate, xwingEncapsulate } from "./kem.js";
|
||||
import { XWING_CIPHERTEXT_SIZE } from "./zwing.js";
|
||||
|
||||
export const MAX_FRAME_SIZE = 1 << 20; // 1 MiB
|
||||
export const HS_LABEL_INIT = new TextEncoder().encode(
|
||||
"lux.zwing.v1/handshake-init",
|
||||
);
|
||||
export const HS_LABEL_RESPONSE = new TextEncoder().encode(
|
||||
"lux.zwing.v1/handshake-response",
|
||||
);
|
||||
export const CHANNEL_KEY_LABEL_I2R = new TextEncoder().encode(
|
||||
"lux.zwing.v1/i2r",
|
||||
);
|
||||
export const CHANNEL_KEY_LABEL_R2I = new TextEncoder().encode(
|
||||
"lux.zwing.v1/r2i",
|
||||
);
|
||||
export const RESP_ID_HKDF_LABEL = new TextEncoder().encode(
|
||||
"lux.zwing.v1/resp-id",
|
||||
);
|
||||
export const RESP_ID_NONCE_LABEL = new TextEncoder().encode("zwing-resp-id");
|
||||
export const TRANSCRIPT_LABEL = new TextEncoder().encode(
|
||||
"lux.zwing.v1/transcript",
|
||||
);
|
||||
|
||||
/** Outcome of a successful handshake. */
|
||||
export interface HandshakeOutput {
|
||||
shared: Uint8Array;
|
||||
remote: IdentityPublic;
|
||||
keyI2R: Uint8Array;
|
||||
keyR2I: Uint8Array;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous full-duplex byte stream interface — abstracts away
|
||||
* network/socket specifics so the handshake works over any duplex
|
||||
* medium (TCP, in-memory pipe, Web socket, RNS link).
|
||||
*/
|
||||
export interface DuplexStream {
|
||||
read(n: number): Promise<Uint8Array>;
|
||||
write(data: Uint8Array): Promise<void>;
|
||||
}
|
||||
|
||||
/** Drive the initiator side of the handshake. */
|
||||
export async function runInitiator(
|
||||
conn: DuplexStream,
|
||||
local: Identity,
|
||||
expectedRemote?: IdentityPublic,
|
||||
): Promise<HandshakeOutput> {
|
||||
// 1. Send HandshakeInit.
|
||||
const idPub = marshalIdentityPublic({
|
||||
edPk: local.edPk,
|
||||
mlPk: local.mlPk,
|
||||
xwingMlkemPk: local.xwingMlkemPk,
|
||||
xwingX25519Pk: local.xwingX25519Pk,
|
||||
});
|
||||
const sig = signIdentity(local, HS_LABEL_INIT, idPub);
|
||||
await writeFrame(conn, encodeHandshakeInit(idPub, sig));
|
||||
|
||||
// 2. Receive HandshakeResponse.
|
||||
const respFrame = await readFrame(conn);
|
||||
const { ct, encrypted } = decodeHandshakeResponse(respFrame);
|
||||
|
||||
// 3. Decapsulate.
|
||||
const shared = xwingDecapsulate(local, ct);
|
||||
|
||||
// 4. AEAD-open responder identity payload.
|
||||
const idKey = deriveKey(shared, RESP_ID_HKDF_LABEL);
|
||||
const plaintext = aeadOpen(idKey, RESP_ID_NONCE_LABEL, ct, encrypted);
|
||||
|
||||
const { id: remoteIdBytes, sig: remoteSig } = splitRespIDPayload(plaintext);
|
||||
const remote = parseIdentityPublic(remoteIdBytes);
|
||||
|
||||
// 5. Verify responder sig over transcript.
|
||||
const transcript = transcriptHash(idPub, ct);
|
||||
verifyIdentity(remote, HS_LABEL_RESPONSE, transcript, remoteSig);
|
||||
|
||||
// 6. Optional pinning.
|
||||
if (expectedRemote && !identityEquals(remote, expectedRemote)) {
|
||||
throw new Error("zwing: remote identity does not match expected");
|
||||
}
|
||||
|
||||
return {
|
||||
shared,
|
||||
remote,
|
||||
keyI2R: deriveKey(shared, CHANNEL_KEY_LABEL_I2R),
|
||||
keyR2I: deriveKey(shared, CHANNEL_KEY_LABEL_R2I),
|
||||
};
|
||||
}
|
||||
|
||||
/** Drive the responder side of the handshake. */
|
||||
export async function runResponder(
|
||||
conn: DuplexStream,
|
||||
local: Identity,
|
||||
expectedRemote?: IdentityPublic,
|
||||
): Promise<HandshakeOutput> {
|
||||
// 1. Read HandshakeInit.
|
||||
const initFrame = await readFrame(conn);
|
||||
const { idPub, sig: initSig } = decodeHandshakeInit(initFrame);
|
||||
const remote = parseIdentityPublic(idPub);
|
||||
verifyIdentity(remote, HS_LABEL_INIT, idPub, initSig);
|
||||
|
||||
if (expectedRemote && !identityEquals(remote, expectedRemote)) {
|
||||
throw new Error("zwing: remote identity does not match expected");
|
||||
}
|
||||
|
||||
// 2. Encapsulate to initiator's static X-Wing.
|
||||
const { ciphertext: ct, shared } = xwingEncapsulate(remote);
|
||||
|
||||
// 3. Sign transcript and seal.
|
||||
const localIdPub = marshalIdentityPublic({
|
||||
edPk: local.edPk,
|
||||
mlPk: local.mlPk,
|
||||
xwingMlkemPk: local.xwingMlkemPk,
|
||||
xwingX25519Pk: local.xwingX25519Pk,
|
||||
});
|
||||
const transcript = transcriptHash(idPub, ct);
|
||||
const sig = signIdentity(local, HS_LABEL_RESPONSE, transcript);
|
||||
const plaintext = buildRespIDPayload(localIdPub, sig);
|
||||
const idKey = deriveKey(shared, RESP_ID_HKDF_LABEL);
|
||||
const encrypted = aeadSeal(idKey, RESP_ID_NONCE_LABEL, ct, plaintext);
|
||||
|
||||
await writeFrame(conn, encodeHandshakeResponse(ct, encrypted));
|
||||
|
||||
return {
|
||||
shared,
|
||||
remote,
|
||||
keyI2R: deriveKey(shared, CHANNEL_KEY_LABEL_I2R),
|
||||
keyR2I: deriveKey(shared, CHANNEL_KEY_LABEL_R2I),
|
||||
};
|
||||
}
|
||||
|
||||
// ─── wire helpers ──────────────────────────────────────────────────
|
||||
|
||||
export async function writeFrame(
|
||||
conn: DuplexStream,
|
||||
payload: Uint8Array,
|
||||
): Promise<void> {
|
||||
if (payload.length > MAX_FRAME_SIZE) {
|
||||
throw new Error("zwing: message exceeds maximum size");
|
||||
}
|
||||
const hdr = new Uint8Array(4);
|
||||
const dv = new DataView(hdr.buffer);
|
||||
dv.setUint32(0, payload.length, false);
|
||||
await conn.write(hdr);
|
||||
await conn.write(payload);
|
||||
}
|
||||
|
||||
export async function readFrame(conn: DuplexStream): Promise<Uint8Array> {
|
||||
const hdr = await conn.read(4);
|
||||
const dv = new DataView(hdr.buffer, hdr.byteOffset, 4);
|
||||
const n = dv.getUint32(0, false);
|
||||
if (n > MAX_FRAME_SIZE) {
|
||||
throw new Error("zwing: message exceeds maximum size");
|
||||
}
|
||||
if (n === 0) {
|
||||
throw new Error("zwing: invalid wire format");
|
||||
}
|
||||
return await conn.read(n);
|
||||
}
|
||||
|
||||
function encodeHandshakeInit(idPub: Uint8Array, sig: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(4 + idPub.length + sig.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint16(0, idPub.length, false);
|
||||
out.set(idPub, 2);
|
||||
dv.setUint16(2 + idPub.length, sig.length, false);
|
||||
out.set(sig, 4 + idPub.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeHandshakeInit(data: Uint8Array): {
|
||||
idPub: Uint8Array;
|
||||
sig: Uint8Array;
|
||||
} {
|
||||
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
if (data.length < 2) throw new Error("zwing: short read");
|
||||
const idLen = dv.getUint16(0, false);
|
||||
if (data.length < 2 + idLen + 2) throw new Error("zwing: short read");
|
||||
const idPub = data.slice(2, 2 + idLen);
|
||||
const sigLen = dv.getUint16(2 + idLen, false);
|
||||
const sigStart = 4 + idLen;
|
||||
if (data.length < sigStart + sigLen) throw new Error("zwing: short read");
|
||||
const sig = data.slice(sigStart, sigStart + sigLen);
|
||||
if (data.length !== sigStart + sigLen) {
|
||||
throw new Error("zwing: invalid wire format");
|
||||
}
|
||||
return { idPub, sig };
|
||||
}
|
||||
|
||||
function encodeHandshakeResponse(ct: Uint8Array, encrypted: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(ct.length + 2 + encrypted.length);
|
||||
out.set(ct, 0);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint16(ct.length, encrypted.length, false);
|
||||
out.set(encrypted, ct.length + 2);
|
||||
return out;
|
||||
}
|
||||
|
||||
function decodeHandshakeResponse(data: Uint8Array): {
|
||||
ct: Uint8Array;
|
||||
encrypted: Uint8Array;
|
||||
} {
|
||||
if (data.length < XWING_CIPHERTEXT_SIZE + 2) {
|
||||
throw new Error("zwing: invalid wire format");
|
||||
}
|
||||
const ct = data.slice(0, XWING_CIPHERTEXT_SIZE);
|
||||
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const encLen = dv.getUint16(XWING_CIPHERTEXT_SIZE, false);
|
||||
const encStart = XWING_CIPHERTEXT_SIZE + 2;
|
||||
if (data.length < encStart + encLen) {
|
||||
throw new Error("zwing: short read");
|
||||
}
|
||||
const encrypted = data.slice(encStart, encStart + encLen);
|
||||
if (data.length !== encStart + encLen) {
|
||||
throw new Error("zwing: invalid wire format");
|
||||
}
|
||||
return { ct, encrypted };
|
||||
}
|
||||
|
||||
function buildRespIDPayload(id: Uint8Array, sig: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(4 + id.length + sig.length);
|
||||
const dv = new DataView(out.buffer);
|
||||
dv.setUint16(0, id.length, false);
|
||||
out.set(id, 2);
|
||||
dv.setUint16(2 + id.length, sig.length, false);
|
||||
out.set(sig, 4 + id.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitRespIDPayload(data: Uint8Array): {
|
||||
id: Uint8Array;
|
||||
sig: Uint8Array;
|
||||
} {
|
||||
if (data.length < 2) throw new Error("zwing: short read");
|
||||
const dv = new DataView(data.buffer, data.byteOffset, data.byteLength);
|
||||
const idLen = dv.getUint16(0, false);
|
||||
if (data.length < 2 + idLen + 2) throw new Error("zwing: short read");
|
||||
const id = data.slice(2, 2 + idLen);
|
||||
const sigLen = dv.getUint16(2 + idLen, false);
|
||||
const sigStart = 4 + idLen;
|
||||
if (data.length < sigStart + sigLen) throw new Error("zwing: short read");
|
||||
const sig = data.slice(sigStart, sigStart + sigLen);
|
||||
if (data.length !== sigStart + sigLen) {
|
||||
throw new Error("zwing: invalid wire format");
|
||||
}
|
||||
return { id, sig };
|
||||
}
|
||||
|
||||
// Internal helpers exported only for branch-coverage tests. Not part
|
||||
// of the public API.
|
||||
export const __internal = {
|
||||
encodeHandshakeInit,
|
||||
decodeHandshakeInit,
|
||||
encodeHandshakeResponse,
|
||||
decodeHandshakeResponse,
|
||||
buildRespIDPayload,
|
||||
splitRespIDPayload,
|
||||
};
|
||||
|
||||
// ─── KDF / transcript / AEAD ──────────────────────────────────────
|
||||
|
||||
export function deriveKey(secret: Uint8Array, label: Uint8Array): Uint8Array {
|
||||
return hkdf(sha256, secret, undefined, label, 32);
|
||||
}
|
||||
|
||||
function transcriptHash(initPub: Uint8Array, ct: Uint8Array): Uint8Array {
|
||||
const h = sha256.create();
|
||||
h.update(TRANSCRIPT_LABEL);
|
||||
const lenBuf = new Uint8Array(4);
|
||||
const dv = new DataView(lenBuf.buffer);
|
||||
dv.setUint32(0, initPub.length, false);
|
||||
h.update(lenBuf);
|
||||
h.update(initPub);
|
||||
dv.setUint32(0, ct.length, false);
|
||||
h.update(lenBuf);
|
||||
h.update(ct);
|
||||
return h.digest();
|
||||
}
|
||||
|
||||
function aeadSeal(
|
||||
key: Uint8Array,
|
||||
nonceLabel: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
plaintext: Uint8Array,
|
||||
): Uint8Array {
|
||||
const nonce = handshakeNonce(nonceLabel);
|
||||
return chacha20poly1305(key, nonce, aad).encrypt(plaintext);
|
||||
}
|
||||
|
||||
function aeadOpen(
|
||||
key: Uint8Array,
|
||||
nonceLabel: Uint8Array,
|
||||
aad: Uint8Array,
|
||||
ciphertext: Uint8Array,
|
||||
): Uint8Array {
|
||||
const nonce = handshakeNonce(nonceLabel);
|
||||
return chacha20poly1305(key, nonce, aad).decrypt(ciphertext);
|
||||
}
|
||||
|
||||
function handshakeNonce(label: Uint8Array): Uint8Array {
|
||||
const n = new Uint8Array(12);
|
||||
const copy = Math.min(label.length, 12);
|
||||
n.set(label.slice(0, copy));
|
||||
return n;
|
||||
}
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Hybrid Z-Wing identity: Ed25519 + ML-DSA-65 + X-Wing static keypair.
|
||||
*
|
||||
* Wire format mirrors the Go and Rust implementations:
|
||||
*
|
||||
* [Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216] = 3200 bytes
|
||||
*
|
||||
* Signature wire = [Ed25519 sig: 64][ML-DSA-65 sig: 3309] = 3373 bytes.
|
||||
*/
|
||||
|
||||
import { ml_dsa65 } from "@noble/post-quantum/ml-dsa.js";
|
||||
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
||||
import { ed25519, x25519 } from "@noble/curves/ed25519.js";
|
||||
import { sha256 } from "@noble/hashes/sha2.js";
|
||||
|
||||
import { XWING_PUBLIC_KEY_SIZE, X25519_POINT_SIZE } from "./zwing.js";
|
||||
|
||||
/** Ed25519 public key length. */
|
||||
export const ED25519_PUBLIC_KEY_SIZE = 32;
|
||||
/** Ed25519 signature length. */
|
||||
export const ED25519_SIGNATURE_SIZE = 64;
|
||||
/** ML-DSA-65 public key length (FIPS 204). */
|
||||
export const MLDSA65_PUBLIC_KEY_SIZE = 1952;
|
||||
/** ML-DSA-65 signature length (FIPS 204). */
|
||||
export const MLDSA65_SIGNATURE_SIZE = 3309;
|
||||
/** ML-KEM-768 public key length. */
|
||||
export const MLKEM768_PUBLIC_KEY_SIZE = 1184;
|
||||
|
||||
/** Total wire size of an `IdentityPublic`. */
|
||||
export const IDENTITY_PUBLIC_SIZE =
|
||||
ED25519_PUBLIC_KEY_SIZE + MLDSA65_PUBLIC_KEY_SIZE + XWING_PUBLIC_KEY_SIZE;
|
||||
|
||||
/** Total wire size of an identity signature. */
|
||||
export const SIGNATURE_SIZE =
|
||||
ED25519_SIGNATURE_SIZE + MLDSA65_SIGNATURE_SIZE;
|
||||
|
||||
/** Z-Wing protocol context bound into every identity signature. */
|
||||
export const ZWING_DOMAIN = new TextEncoder().encode("lux.zwing.v1");
|
||||
|
||||
/** Long-term Z-Wing identity. */
|
||||
export interface Identity {
|
||||
edSk: Uint8Array; // 32-byte Ed25519 seed
|
||||
edPk: Uint8Array; // 32-byte Ed25519 public key
|
||||
mlSk: Uint8Array; // ML-DSA-65 secret
|
||||
mlPk: Uint8Array; // ML-DSA-65 public
|
||||
xwingMlkemSk: Uint8Array; // ML-KEM-768 secret
|
||||
xwingMlkemPk: Uint8Array; // ML-KEM-768 public
|
||||
xwingX25519Sk: Uint8Array; // X25519 32-byte secret
|
||||
xwingX25519Pk: Uint8Array; // X25519 32-byte public
|
||||
}
|
||||
|
||||
/** Public half of an Identity. */
|
||||
export interface IdentityPublic {
|
||||
edPk: Uint8Array;
|
||||
mlPk: Uint8Array;
|
||||
xwingMlkemPk: Uint8Array;
|
||||
xwingX25519Pk: Uint8Array;
|
||||
}
|
||||
|
||||
/** Generate a fresh identity using a CSPRNG. */
|
||||
export function generateIdentity(): Identity {
|
||||
const edSeed = crypto.getRandomValues(new Uint8Array(32));
|
||||
const edPk = ed25519.getPublicKey(edSeed);
|
||||
|
||||
const mlKp = ml_dsa65.keygen();
|
||||
const mlSk = mlKp.secretKey;
|
||||
const mlPk = mlKp.publicKey;
|
||||
|
||||
const mlkemKp = ml_kem768.keygen();
|
||||
const xwingMlkemSk = mlkemKp.secretKey;
|
||||
const xwingMlkemPk = mlkemKp.publicKey;
|
||||
|
||||
const xwingX25519Sk = crypto.getRandomValues(new Uint8Array(32));
|
||||
const xwingX25519Pk = x25519.getPublicKey(xwingX25519Sk);
|
||||
|
||||
return {
|
||||
edSk: edSeed,
|
||||
edPk,
|
||||
mlSk,
|
||||
mlPk,
|
||||
xwingMlkemSk,
|
||||
xwingMlkemPk,
|
||||
xwingX25519Sk,
|
||||
xwingX25519Pk,
|
||||
};
|
||||
}
|
||||
|
||||
/** Public half of `id`. */
|
||||
export function publicOf(id: Identity): IdentityPublic {
|
||||
return {
|
||||
edPk: id.edPk,
|
||||
mlPk: id.mlPk,
|
||||
xwingMlkemPk: id.xwingMlkemPk,
|
||||
xwingX25519Pk: id.xwingX25519Pk,
|
||||
};
|
||||
}
|
||||
|
||||
/** Marshal a public identity to wire bytes. */
|
||||
export function marshalIdentityPublic(pub: IdentityPublic): Uint8Array {
|
||||
const out = new Uint8Array(IDENTITY_PUBLIC_SIZE);
|
||||
let off = 0;
|
||||
out.set(pub.edPk, off);
|
||||
off += ED25519_PUBLIC_KEY_SIZE;
|
||||
out.set(pub.mlPk, off);
|
||||
off += MLDSA65_PUBLIC_KEY_SIZE;
|
||||
out.set(pub.xwingMlkemPk, off);
|
||||
off += MLKEM768_PUBLIC_KEY_SIZE;
|
||||
out.set(pub.xwingX25519Pk, off);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Parse a public identity from wire bytes. Throws on size mismatch. */
|
||||
export function parseIdentityPublic(data: Uint8Array): IdentityPublic {
|
||||
if (data.length !== IDENTITY_PUBLIC_SIZE) {
|
||||
throw new Error(
|
||||
`zwing: invalid identity public size (got ${data.length}, want ${IDENTITY_PUBLIC_SIZE})`,
|
||||
);
|
||||
}
|
||||
let off = 0;
|
||||
const edPk = data.slice(off, off + ED25519_PUBLIC_KEY_SIZE);
|
||||
off += ED25519_PUBLIC_KEY_SIZE;
|
||||
const mlPk = data.slice(off, off + MLDSA65_PUBLIC_KEY_SIZE);
|
||||
off += MLDSA65_PUBLIC_KEY_SIZE;
|
||||
const xwingMlkemPk = data.slice(off, off + MLKEM768_PUBLIC_KEY_SIZE);
|
||||
off += MLKEM768_PUBLIC_KEY_SIZE;
|
||||
const xwingX25519Pk = data.slice(off, off + X25519_POINT_SIZE);
|
||||
return { edPk, mlPk, xwingMlkemPk, xwingX25519Pk };
|
||||
}
|
||||
|
||||
/** Bind (ZWING_DOMAIN || len(ctx) || ctx || message) into a SHA-256 digest. */
|
||||
export function identityDigest(ctx: Uint8Array, message: Uint8Array): Uint8Array {
|
||||
const h = sha256.create();
|
||||
h.update(ZWING_DOMAIN);
|
||||
h.update(new Uint8Array([ctx.length & 0xff]));
|
||||
h.update(ctx);
|
||||
h.update(message);
|
||||
return h.digest();
|
||||
}
|
||||
|
||||
/** Sign `(ctx, message)` with both Ed25519 and ML-DSA-65. */
|
||||
export function signIdentity(
|
||||
id: Identity,
|
||||
ctx: Uint8Array,
|
||||
message: Uint8Array,
|
||||
): Uint8Array {
|
||||
const digest = identityDigest(ctx, message);
|
||||
const edSig = ed25519.sign(digest, id.edSk);
|
||||
// Noble ml-dsa API: sign(msg, secretKey).
|
||||
const mlSig = ml_dsa65.sign(digest, id.mlSk);
|
||||
const out = new Uint8Array(SIGNATURE_SIZE);
|
||||
out.set(edSig, 0);
|
||||
out.set(mlSig, ED25519_SIGNATURE_SIZE);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Verify a hybrid Z-Wing signature. Throws on any failure. */
|
||||
export function verifyIdentity(
|
||||
pub: IdentityPublic,
|
||||
ctx: Uint8Array,
|
||||
message: Uint8Array,
|
||||
signature: Uint8Array,
|
||||
): void {
|
||||
if (signature.length !== SIGNATURE_SIZE) {
|
||||
throw new Error("zwing: signature size mismatch");
|
||||
}
|
||||
const digest = identityDigest(ctx, message);
|
||||
const edSig = signature.slice(0, ED25519_SIGNATURE_SIZE);
|
||||
const mlSig = signature.slice(ED25519_SIGNATURE_SIZE);
|
||||
if (!ed25519.verify(edSig, digest, pub.edPk)) {
|
||||
throw new Error("zwing: ed25519 verify failed");
|
||||
}
|
||||
// Noble ml-dsa API: verify(sig, msg, pubKey).
|
||||
if (!ml_dsa65.verify(mlSig, digest, pub.mlPk)) {
|
||||
throw new Error("zwing: ml-dsa-65 verify failed");
|
||||
}
|
||||
}
|
||||
|
||||
/** Constant-time equality of two public identities. */
|
||||
export function identityEquals(a: IdentityPublic, b: IdentityPublic): boolean {
|
||||
return (
|
||||
constantTimeEq(a.edPk, b.edPk) &&
|
||||
constantTimeEq(a.mlPk, b.mlPk) &&
|
||||
constantTimeEq(a.xwingMlkemPk, b.xwingMlkemPk) &&
|
||||
constantTimeEq(a.xwingX25519Pk, b.xwingX25519Pk)
|
||||
);
|
||||
}
|
||||
|
||||
function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
||||
return diff === 0;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* IETF X-Wing KEM — encapsulate / decapsulate built on ML-KEM-768 +
|
||||
* X25519 with the SHA3-256 combiner. Wire bytes match the Go and Rust
|
||||
* implementations.
|
||||
*/
|
||||
|
||||
import { ml_kem768 } from "@noble/post-quantum/ml-kem.js";
|
||||
import { x25519 } from "@noble/curves/ed25519.js";
|
||||
|
||||
import {
|
||||
combineXWing,
|
||||
MLKEM768_CIPHERTEXT_SIZE,
|
||||
X25519_POINT_SIZE,
|
||||
XWING_CIPHERTEXT_SIZE,
|
||||
XWING_SHARED_SIZE,
|
||||
} from "./zwing.js";
|
||||
import type { Identity, IdentityPublic } from "./identity.js";
|
||||
|
||||
/**
|
||||
* Encapsulate a fresh X-Wing shared secret to a recipient. Returns the
|
||||
* combined wire ciphertext (ML-KEM-768 ct || X25519 ephemeral pk) and
|
||||
* the 32-byte shared secret.
|
||||
*/
|
||||
export function xwingEncapsulate(recipient: IdentityPublic): {
|
||||
ciphertext: Uint8Array;
|
||||
shared: Uint8Array;
|
||||
} {
|
||||
const { sharedSecret: ssM, cipherText: ctM } = ml_kem768.encapsulate(
|
||||
recipient.xwingMlkemPk,
|
||||
);
|
||||
|
||||
const ephSk = crypto.getRandomValues(new Uint8Array(32));
|
||||
const ephPk = x25519.getPublicKey(ephSk);
|
||||
const ssX = x25519.getSharedSecret(ephSk, recipient.xwingX25519Pk);
|
||||
|
||||
const ssM32: Uint8Array = ssM.slice(0, 32);
|
||||
const ssX32: Uint8Array = ssX.slice(0, 32);
|
||||
const shared = combineXWing(ssM32, ssX32, ephPk, recipient.xwingX25519Pk);
|
||||
|
||||
const wire = new Uint8Array(XWING_CIPHERTEXT_SIZE);
|
||||
wire.set(ctM, 0);
|
||||
wire.set(ephPk, MLKEM768_CIPHERTEXT_SIZE);
|
||||
|
||||
// Best-effort wipe of intermediate secrets.
|
||||
ephSk.fill(0);
|
||||
ssM.fill(0);
|
||||
ssX.fill(0);
|
||||
|
||||
return { ciphertext: wire, shared };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decapsulate an X-Wing wire ciphertext using `id`'s static keypair.
|
||||
* Throws on size mismatch or low-order ephemeral.
|
||||
*/
|
||||
export function xwingDecapsulate(id: Identity, ciphertext: Uint8Array): Uint8Array {
|
||||
if (ciphertext.length !== XWING_CIPHERTEXT_SIZE) {
|
||||
throw new Error(
|
||||
`zwing: invalid X-Wing ciphertext size (got ${ciphertext.length})`,
|
||||
);
|
||||
}
|
||||
const ctM = ciphertext.slice(0, MLKEM768_CIPHERTEXT_SIZE);
|
||||
const ephPk = ciphertext.slice(MLKEM768_CIPHERTEXT_SIZE);
|
||||
|
||||
const ssM = ml_kem768.decapsulate(ctM, id.xwingMlkemSk);
|
||||
const ssX = x25519.getSharedSecret(id.xwingX25519Sk, ephPk);
|
||||
|
||||
const shared = combineXWing(
|
||||
ssM.slice(0, 32),
|
||||
ssX.slice(0, 32),
|
||||
ephPk,
|
||||
id.xwingX25519Pk,
|
||||
);
|
||||
|
||||
ssM.fill(0);
|
||||
ssX.fill(0);
|
||||
return shared;
|
||||
}
|
||||
|
||||
export { XWING_CIPHERTEXT_SIZE, XWING_SHARED_SIZE, X25519_POINT_SIZE };
|
||||
+5
-1
@@ -41,9 +41,13 @@
|
||||
"homepage": "https://github.com/luxfi/zwing/tree/main/js#readme",
|
||||
"bugs": "https://github.com/luxfi/zwing/issues",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0"
|
||||
"@noble/ciphers": "^2.2.0",
|
||||
"@noble/curves": "^2.2.0",
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@noble/post-quantum": "^0.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^2.1.9",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.0.0"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
__pycache__/
|
||||
.coverage
|
||||
htmlcov/
|
||||
*.pyc
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Z-Wing post-handshake encrypted channel (Python).
|
||||
|
||||
ChaCha20-Poly1305 over length-prefixed frames with sequence-numbered
|
||||
nonces. Wire format identical to Go and Rust.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
|
||||
from handshake import DuplexStream, HandshakeOutput, MAX_FRAME_SIZE, read_frame, write_frame
|
||||
from identity import IdentityPublic
|
||||
|
||||
CHACHA_OVERHEAD = 16
|
||||
NONCE_LEN = 12
|
||||
|
||||
|
||||
def nonce_for(seq: int) -> bytes:
|
||||
"""12-byte nonce: high 4 bytes zero, low 8 bytes BE counter."""
|
||||
n = bytearray(NONCE_LEN)
|
||||
n[NONCE_LEN - 8 :] = struct.pack(">Q", seq)
|
||||
return bytes(n)
|
||||
|
||||
|
||||
class Channel:
|
||||
"""Post-handshake Z-Wing secure channel."""
|
||||
|
||||
def __init__(
|
||||
self, inner: DuplexStream, out: HandshakeOutput, initiator: bool
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self.remote: IdentityPublic = out.remote
|
||||
if initiator:
|
||||
tx_key = out.key_i2r
|
||||
rx_key = out.key_r2i
|
||||
else:
|
||||
tx_key = out.key_r2i
|
||||
rx_key = out.key_i2r
|
||||
self._tx = ChaCha20Poly1305(tx_key)
|
||||
self._rx = ChaCha20Poly1305(rx_key)
|
||||
self._tx_seq = 0
|
||||
self._rx_seq = 0
|
||||
self._rx_overflow: Optional[bytes] = None
|
||||
|
||||
def send(self, plaintext: bytes) -> None:
|
||||
max_plain = MAX_FRAME_SIZE - CHACHA_OVERHEAD
|
||||
written = 0
|
||||
while written < len(plaintext):
|
||||
end = min(written + max_plain, len(plaintext))
|
||||
chunk = plaintext[written:end]
|
||||
if self._tx_seq >= (1 << 64) - 1:
|
||||
raise ValueError("zwing: AEAD sequence number exhausted")
|
||||
nonce = nonce_for(self._tx_seq)
|
||||
ct = self._tx.encrypt(nonce, chunk, None)
|
||||
self._tx_seq += 1
|
||||
write_frame(self._inner, ct)
|
||||
written = end
|
||||
|
||||
def recv(self) -> bytes:
|
||||
if self._rx_overflow is not None:
|
||||
buf = self._rx_overflow
|
||||
self._rx_overflow = None
|
||||
return buf
|
||||
frame = read_frame(self._inner)
|
||||
if self._rx_seq >= (1 << 64) - 1:
|
||||
raise ValueError("zwing: AEAD sequence number exhausted")
|
||||
nonce = nonce_for(self._rx_seq)
|
||||
pt = self._rx.decrypt(nonce, frame, None)
|
||||
self._rx_seq += 1
|
||||
return pt
|
||||
+284
@@ -0,0 +1,284 @@
|
||||
"""Z-Wing 1-RTT mutual-auth handshake (Python).
|
||||
|
||||
Bit-for-bit compatible with the Go and Rust ports. Uses sync I/O over
|
||||
any object with ``read(n)`` / ``write(data)`` methods.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import struct
|
||||
from typing import Optional, Protocol
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||
|
||||
from zwing import XWING_CIPHERTEXT_SIZE
|
||||
from identity import (
|
||||
Identity,
|
||||
IdentityPublic,
|
||||
identity_equals,
|
||||
marshal_identity_public,
|
||||
parse_identity_public,
|
||||
sign_identity,
|
||||
verify_identity,
|
||||
)
|
||||
from kem import xwing_decapsulate, xwing_encapsulate
|
||||
|
||||
MAX_FRAME_SIZE = 1 << 20 # 1 MiB
|
||||
|
||||
HS_LABEL_INIT = b"lux.zwing.v1/handshake-init"
|
||||
HS_LABEL_RESPONSE = b"lux.zwing.v1/handshake-response"
|
||||
CHANNEL_KEY_LABEL_I2R = b"lux.zwing.v1/i2r"
|
||||
CHANNEL_KEY_LABEL_R2I = b"lux.zwing.v1/r2i"
|
||||
RESP_ID_HKDF_LABEL = b"lux.zwing.v1/resp-id"
|
||||
RESP_ID_NONCE_LABEL = b"zwing-resp-id"
|
||||
TRANSCRIPT_LABEL = b"lux.zwing.v1/transcript"
|
||||
|
||||
|
||||
class DuplexStream(Protocol):
|
||||
def read(self, n: int) -> bytes: ...
|
||||
def write(self, data: bytes) -> None: ...
|
||||
|
||||
|
||||
class HandshakeOutput:
|
||||
"""Both peers' shared post-handshake state."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shared: bytes,
|
||||
remote: IdentityPublic,
|
||||
key_i2r: bytes,
|
||||
key_r2i: bytes,
|
||||
) -> None:
|
||||
self.shared = shared
|
||||
self.remote = remote
|
||||
self.key_i2r = key_i2r
|
||||
self.key_r2i = key_r2i
|
||||
|
||||
|
||||
def run_initiator(
|
||||
conn: DuplexStream,
|
||||
local: Identity,
|
||||
expected_remote: Optional[IdentityPublic] = None,
|
||||
) -> HandshakeOutput:
|
||||
# 1. Send HandshakeInit.
|
||||
from identity import public_of # avoid cycle at import time
|
||||
|
||||
id_pub = marshal_identity_public(public_of(local))
|
||||
sig = sign_identity(local, HS_LABEL_INIT, id_pub)
|
||||
write_frame(conn, _encode_handshake_init(id_pub, sig))
|
||||
|
||||
# 2. Receive HandshakeResponse.
|
||||
resp_frame = read_frame(conn)
|
||||
ct, encrypted = _decode_handshake_response(resp_frame)
|
||||
|
||||
# 3. Decapsulate.
|
||||
shared = xwing_decapsulate(local, ct)
|
||||
|
||||
# 4. AEAD-open responder identity payload.
|
||||
id_key = derive_key(shared, RESP_ID_HKDF_LABEL)
|
||||
plaintext = _aead_open(id_key, RESP_ID_NONCE_LABEL, ct, encrypted)
|
||||
|
||||
remote_id_bytes, remote_sig = _split_resp_id_payload(plaintext)
|
||||
remote = parse_identity_public(remote_id_bytes)
|
||||
|
||||
# 5. Verify responder sig over transcript.
|
||||
transcript = _transcript_hash(id_pub, ct)
|
||||
verify_identity(remote, HS_LABEL_RESPONSE, transcript, remote_sig)
|
||||
|
||||
# 6. Optional pinning.
|
||||
if expected_remote is not None and not identity_equals(remote, expected_remote):
|
||||
raise ValueError("zwing: remote identity does not match expected")
|
||||
|
||||
return HandshakeOutput(
|
||||
shared=shared,
|
||||
remote=remote,
|
||||
key_i2r=derive_key(shared, CHANNEL_KEY_LABEL_I2R),
|
||||
key_r2i=derive_key(shared, CHANNEL_KEY_LABEL_R2I),
|
||||
)
|
||||
|
||||
|
||||
def run_responder(
|
||||
conn: DuplexStream,
|
||||
local: Identity,
|
||||
expected_remote: Optional[IdentityPublic] = None,
|
||||
) -> HandshakeOutput:
|
||||
# 1. Read HandshakeInit.
|
||||
init_frame = read_frame(conn)
|
||||
id_pub, init_sig = _decode_handshake_init(init_frame)
|
||||
remote = parse_identity_public(id_pub)
|
||||
verify_identity(remote, HS_LABEL_INIT, id_pub, init_sig)
|
||||
|
||||
if expected_remote is not None and not identity_equals(remote, expected_remote):
|
||||
raise ValueError("zwing: remote identity does not match expected")
|
||||
|
||||
# 2. Encapsulate to initiator's static X-Wing.
|
||||
ct, shared = xwing_encapsulate(remote)
|
||||
|
||||
# 3. Sign transcript and seal.
|
||||
from identity import public_of
|
||||
|
||||
local_id_pub = marshal_identity_public(public_of(local))
|
||||
transcript = _transcript_hash(id_pub, ct)
|
||||
sig = sign_identity(local, HS_LABEL_RESPONSE, transcript)
|
||||
plaintext = _build_resp_id_payload(local_id_pub, sig)
|
||||
id_key = derive_key(shared, RESP_ID_HKDF_LABEL)
|
||||
encrypted = _aead_seal(id_key, RESP_ID_NONCE_LABEL, ct, plaintext)
|
||||
|
||||
write_frame(conn, _encode_handshake_response(ct, encrypted))
|
||||
|
||||
return HandshakeOutput(
|
||||
shared=shared,
|
||||
remote=remote,
|
||||
key_i2r=derive_key(shared, CHANNEL_KEY_LABEL_I2R),
|
||||
key_r2i=derive_key(shared, CHANNEL_KEY_LABEL_R2I),
|
||||
)
|
||||
|
||||
|
||||
# ─── wire helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def write_frame(conn: DuplexStream, payload: bytes) -> None:
|
||||
if len(payload) > MAX_FRAME_SIZE:
|
||||
raise ValueError("zwing: message exceeds maximum size")
|
||||
hdr = struct.pack(">I", len(payload))
|
||||
conn.write(hdr)
|
||||
conn.write(payload)
|
||||
|
||||
|
||||
def read_frame(conn: DuplexStream) -> bytes:
|
||||
hdr = _read_exact(conn, 4)
|
||||
(n,) = struct.unpack(">I", hdr)
|
||||
if n > MAX_FRAME_SIZE:
|
||||
raise ValueError("zwing: message exceeds maximum size")
|
||||
if n == 0:
|
||||
raise ValueError("zwing: invalid wire format")
|
||||
return _read_exact(conn, n)
|
||||
|
||||
|
||||
def _read_exact(conn: DuplexStream, n: int) -> bytes:
|
||||
buf = b""
|
||||
while len(buf) < n:
|
||||
chunk = conn.read(n - len(buf))
|
||||
if not chunk:
|
||||
raise ValueError("zwing: short read")
|
||||
buf += chunk
|
||||
return buf
|
||||
|
||||
|
||||
def _encode_handshake_init(id_pub: bytes, sig: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">H", len(id_pub))
|
||||
+ id_pub
|
||||
+ struct.pack(">H", len(sig))
|
||||
+ sig
|
||||
)
|
||||
|
||||
|
||||
def _decode_handshake_init(data: bytes) -> tuple[bytes, bytes]:
|
||||
if len(data) < 2:
|
||||
raise ValueError("zwing: short read")
|
||||
(id_len,) = struct.unpack(">H", data[:2])
|
||||
if len(data) < 2 + id_len + 2:
|
||||
raise ValueError("zwing: short read")
|
||||
id_pub = data[2 : 2 + id_len]
|
||||
(sig_len,) = struct.unpack(">H", data[2 + id_len : 4 + id_len])
|
||||
sig_start = 4 + id_len
|
||||
if len(data) < sig_start + sig_len:
|
||||
raise ValueError("zwing: short read")
|
||||
sig = data[sig_start : sig_start + sig_len]
|
||||
if len(data) != sig_start + sig_len:
|
||||
raise ValueError("zwing: invalid wire format")
|
||||
return id_pub, sig
|
||||
|
||||
|
||||
def _encode_handshake_response(ct: bytes, encrypted: bytes) -> bytes:
|
||||
return ct + struct.pack(">H", len(encrypted)) + encrypted
|
||||
|
||||
|
||||
def _decode_handshake_response(data: bytes) -> tuple[bytes, bytes]:
|
||||
if len(data) < XWING_CIPHERTEXT_SIZE + 2:
|
||||
raise ValueError("zwing: invalid wire format")
|
||||
ct = data[:XWING_CIPHERTEXT_SIZE]
|
||||
(enc_len,) = struct.unpack(">H", data[XWING_CIPHERTEXT_SIZE : XWING_CIPHERTEXT_SIZE + 2])
|
||||
enc_start = XWING_CIPHERTEXT_SIZE + 2
|
||||
if len(data) < enc_start + enc_len:
|
||||
raise ValueError("zwing: short read")
|
||||
encrypted = data[enc_start : enc_start + enc_len]
|
||||
if len(data) != enc_start + enc_len:
|
||||
raise ValueError("zwing: invalid wire format")
|
||||
return ct, encrypted
|
||||
|
||||
|
||||
def _build_resp_id_payload(id_bytes: bytes, sig: bytes) -> bytes:
|
||||
return (
|
||||
struct.pack(">H", len(id_bytes))
|
||||
+ id_bytes
|
||||
+ struct.pack(">H", len(sig))
|
||||
+ sig
|
||||
)
|
||||
|
||||
|
||||
def _split_resp_id_payload(data: bytes) -> tuple[bytes, bytes]:
|
||||
if len(data) < 2:
|
||||
raise ValueError("zwing: short read")
|
||||
(id_len,) = struct.unpack(">H", data[:2])
|
||||
if len(data) < 2 + id_len + 2:
|
||||
raise ValueError("zwing: short read")
|
||||
id_bytes = data[2 : 2 + id_len]
|
||||
(sig_len,) = struct.unpack(">H", data[2 + id_len : 4 + id_len])
|
||||
sig_start = 4 + id_len
|
||||
if len(data) < sig_start + sig_len:
|
||||
raise ValueError("zwing: short read")
|
||||
sig = data[sig_start : sig_start + sig_len]
|
||||
if len(data) != sig_start + sig_len:
|
||||
raise ValueError("zwing: invalid wire format")
|
||||
return id_bytes, sig
|
||||
|
||||
|
||||
# ─── KDF / transcript / AEAD ──────────────────────────────────────
|
||||
|
||||
|
||||
def derive_key(secret: bytes, label: bytes, length: int = 32) -> bytes:
|
||||
"""HKDF-SHA256(secret, salt='', info=label) → 32 bytes."""
|
||||
# Extract.
|
||||
prk = hmac.new(b"", secret, hashlib.sha256).digest()
|
||||
# Expand.
|
||||
out = b""
|
||||
t = b""
|
||||
counter = 1
|
||||
while len(out) < length:
|
||||
t = hmac.new(prk, t + label + bytes([counter]), hashlib.sha256).digest()
|
||||
out += t
|
||||
counter += 1
|
||||
return out[:length]
|
||||
|
||||
|
||||
def _transcript_hash(init_pub: bytes, ct: bytes) -> bytes:
|
||||
h = hashlib.sha256()
|
||||
h.update(TRANSCRIPT_LABEL)
|
||||
h.update(struct.pack(">I", len(init_pub)))
|
||||
h.update(init_pub)
|
||||
h.update(struct.pack(">I", len(ct)))
|
||||
h.update(ct)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def _aead_seal(key: bytes, nonce_label: bytes, aad: bytes, plaintext: bytes) -> bytes:
|
||||
aead = ChaCha20Poly1305(key)
|
||||
nonce = _handshake_nonce(nonce_label)
|
||||
return aead.encrypt(nonce, plaintext, aad)
|
||||
|
||||
|
||||
def _aead_open(key: bytes, nonce_label: bytes, aad: bytes, ciphertext: bytes) -> bytes:
|
||||
aead = ChaCha20Poly1305(key)
|
||||
nonce = _handshake_nonce(nonce_label)
|
||||
return aead.decrypt(nonce, ciphertext, aad)
|
||||
|
||||
|
||||
def _handshake_nonce(label: bytes) -> bytes:
|
||||
n = bytearray(12)
|
||||
copy = min(len(label), 12)
|
||||
n[:copy] = label[:copy]
|
||||
return bytes(n)
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"""Hybrid Z-Wing identity (Python) — Ed25519 + ML-DSA-65 + X-Wing.
|
||||
|
||||
Wire format mirrors the Go and Rust ports byte-for-byte.
|
||||
|
||||
IdentityPublic = [Ed25519 pk: 32][ML-DSA-65 pk: 1952][X-Wing pk: 1216]
|
||||
Signature = [Ed25519 sig: 64][ML-DSA-65 sig: 3309]
|
||||
|
||||
Pure-Python crypto via ``kyber_py``, ``dilithium_py`` and the standard
|
||||
``cryptography`` library. No native deps.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519, x25519
|
||||
from cryptography.hazmat.primitives.serialization import (
|
||||
Encoding,
|
||||
NoEncryption,
|
||||
PrivateFormat,
|
||||
PublicFormat,
|
||||
)
|
||||
from dilithium_py.ml_dsa import ML_DSA_65
|
||||
from kyber_py.ml_kem import ML_KEM_768
|
||||
|
||||
from zwing import (
|
||||
XWING_PUBLIC_KEY_SIZE,
|
||||
X25519_POINT_SIZE,
|
||||
)
|
||||
|
||||
ED25519_PUBLIC_KEY_SIZE = 32
|
||||
ED25519_SIGNATURE_SIZE = 64
|
||||
MLDSA65_PUBLIC_KEY_SIZE = 1952
|
||||
MLDSA65_SIGNATURE_SIZE = 3309
|
||||
MLKEM768_PUBLIC_KEY_SIZE = 1184
|
||||
|
||||
IDENTITY_PUBLIC_SIZE = (
|
||||
ED25519_PUBLIC_KEY_SIZE + MLDSA65_PUBLIC_KEY_SIZE + XWING_PUBLIC_KEY_SIZE
|
||||
)
|
||||
SIGNATURE_SIZE = ED25519_SIGNATURE_SIZE + MLDSA65_SIGNATURE_SIZE
|
||||
|
||||
ZWING_DOMAIN = b"lux.zwing.v1"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Identity:
|
||||
"""Long-term Z-Wing identity (private)."""
|
||||
|
||||
ed_sk: ed25519.Ed25519PrivateKey
|
||||
ed_pk_bytes: bytes
|
||||
ml_sk: bytes
|
||||
ml_pk: bytes
|
||||
xwing_mlkem_sk: bytes
|
||||
xwing_mlkem_pk: bytes
|
||||
xwing_x25519_sk: x25519.X25519PrivateKey
|
||||
xwing_x25519_pk_bytes: bytes
|
||||
|
||||
|
||||
@dataclass
|
||||
class IdentityPublic:
|
||||
"""Wire-serialisable public half of an Identity."""
|
||||
|
||||
ed_pk: bytes # 32
|
||||
ml_pk: bytes # 1952
|
||||
xwing_mlkem_pk: bytes # 1184
|
||||
xwing_x25519_pk: bytes # 32
|
||||
|
||||
|
||||
def generate_identity() -> Identity:
|
||||
"""Generate a fresh identity using ``os.urandom`` and per-primitive RNG."""
|
||||
ed_sk = ed25519.Ed25519PrivateKey.generate()
|
||||
ed_pk_bytes = ed_sk.public_key().public_bytes(
|
||||
encoding=Encoding.Raw, format=PublicFormat.Raw
|
||||
)
|
||||
|
||||
ml_pk, ml_sk = ML_DSA_65.keygen()
|
||||
mlkem_pk, mlkem_sk = ML_KEM_768.keygen()
|
||||
|
||||
xwing_x25519_sk = x25519.X25519PrivateKey.generate()
|
||||
xwing_x25519_pk_bytes = xwing_x25519_sk.public_key().public_bytes(
|
||||
encoding=Encoding.Raw, format=PublicFormat.Raw
|
||||
)
|
||||
|
||||
return Identity(
|
||||
ed_sk=ed_sk,
|
||||
ed_pk_bytes=ed_pk_bytes,
|
||||
ml_sk=ml_sk,
|
||||
ml_pk=ml_pk,
|
||||
xwing_mlkem_sk=mlkem_sk,
|
||||
xwing_mlkem_pk=mlkem_pk,
|
||||
xwing_x25519_sk=xwing_x25519_sk,
|
||||
xwing_x25519_pk_bytes=xwing_x25519_pk_bytes,
|
||||
)
|
||||
|
||||
|
||||
def public_of(identity: Identity) -> IdentityPublic:
|
||||
return IdentityPublic(
|
||||
ed_pk=identity.ed_pk_bytes,
|
||||
ml_pk=identity.ml_pk,
|
||||
xwing_mlkem_pk=identity.xwing_mlkem_pk,
|
||||
xwing_x25519_pk=identity.xwing_x25519_pk_bytes,
|
||||
)
|
||||
|
||||
|
||||
def marshal_identity_public(pub: IdentityPublic) -> bytes:
|
||||
return pub.ed_pk + pub.ml_pk + pub.xwing_mlkem_pk + pub.xwing_x25519_pk
|
||||
|
||||
|
||||
def parse_identity_public(data: bytes) -> IdentityPublic:
|
||||
if len(data) != IDENTITY_PUBLIC_SIZE:
|
||||
raise ValueError(
|
||||
f"zwing: invalid identity public size (got {len(data)}, want {IDENTITY_PUBLIC_SIZE})"
|
||||
)
|
||||
off = 0
|
||||
ed_pk = data[off : off + ED25519_PUBLIC_KEY_SIZE]
|
||||
off += ED25519_PUBLIC_KEY_SIZE
|
||||
ml_pk = data[off : off + MLDSA65_PUBLIC_KEY_SIZE]
|
||||
off += MLDSA65_PUBLIC_KEY_SIZE
|
||||
xwing_mlkem_pk = data[off : off + MLKEM768_PUBLIC_KEY_SIZE]
|
||||
off += MLKEM768_PUBLIC_KEY_SIZE
|
||||
xwing_x25519_pk = data[off : off + X25519_POINT_SIZE]
|
||||
return IdentityPublic(
|
||||
ed_pk=ed_pk,
|
||||
ml_pk=ml_pk,
|
||||
xwing_mlkem_pk=xwing_mlkem_pk,
|
||||
xwing_x25519_pk=xwing_x25519_pk,
|
||||
)
|
||||
|
||||
|
||||
def identity_digest(ctx: bytes, message: bytes) -> bytes:
|
||||
"""Bind ``ZWING_DOMAIN || len(ctx) || ctx || message`` into SHA-256."""
|
||||
h = hashlib.sha256()
|
||||
h.update(ZWING_DOMAIN)
|
||||
h.update(bytes([len(ctx) & 0xFF]))
|
||||
h.update(ctx)
|
||||
h.update(message)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def sign_identity(identity: Identity, ctx: bytes, message: bytes) -> bytes:
|
||||
"""Concatenate Ed25519 and ML-DSA-65 detached signatures over the digest."""
|
||||
digest = identity_digest(ctx, message)
|
||||
ed_sig = identity.ed_sk.sign(digest)
|
||||
ml_sig = ML_DSA_65.sign(identity.ml_sk, digest)
|
||||
return ed_sig + ml_sig
|
||||
|
||||
|
||||
def verify_identity(
|
||||
pub: IdentityPublic, ctx: bytes, message: bytes, signature: bytes
|
||||
) -> None:
|
||||
"""Verify a hybrid Z-Wing signature. Raises on any failure."""
|
||||
if len(signature) != SIGNATURE_SIZE:
|
||||
raise ValueError("zwing: signature size mismatch")
|
||||
digest = identity_digest(ctx, message)
|
||||
ed_sig = signature[:ED25519_SIGNATURE_SIZE]
|
||||
ml_sig = signature[ED25519_SIGNATURE_SIZE:]
|
||||
|
||||
ed_pub = ed25519.Ed25519PublicKey.from_public_bytes(pub.ed_pk)
|
||||
try:
|
||||
ed_pub.verify(ed_sig, digest)
|
||||
except Exception as e: # cryptography raises InvalidSignature
|
||||
raise ValueError("zwing: ed25519 verify failed") from e
|
||||
|
||||
if not ML_DSA_65.verify(pub.ml_pk, digest, ml_sig):
|
||||
raise ValueError("zwing: ml-dsa-65 verify failed")
|
||||
|
||||
|
||||
def identity_equals(a: IdentityPublic, b: IdentityPublic) -> bool:
|
||||
"""Constant-time equality."""
|
||||
fields = [
|
||||
(a.ed_pk, b.ed_pk),
|
||||
(a.ml_pk, b.ml_pk),
|
||||
(a.xwing_mlkem_pk, b.xwing_mlkem_pk),
|
||||
(a.xwing_x25519_pk, b.xwing_x25519_pk),
|
||||
]
|
||||
diff = 0
|
||||
for x, y in fields:
|
||||
if len(x) != len(y):
|
||||
return False
|
||||
for bx, by in zip(x, y):
|
||||
diff |= bx ^ by
|
||||
return diff == 0
|
||||
@@ -0,0 +1,54 @@
|
||||
"""IETF X-Wing KEM (Python) — encap/decap matching the Go and Rust ports."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric import x25519
|
||||
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
|
||||
from kyber_py.ml_kem import ML_KEM_768
|
||||
|
||||
from zwing import (
|
||||
MLKEM768_CIPHERTEXT_SIZE,
|
||||
XWING_CIPHERTEXT_SIZE,
|
||||
XWING_SHARED_SIZE,
|
||||
X25519_POINT_SIZE,
|
||||
combine_xwing,
|
||||
)
|
||||
from identity import Identity, IdentityPublic
|
||||
|
||||
|
||||
def xwing_encapsulate(recipient: IdentityPublic) -> tuple[bytes, bytes]:
|
||||
"""Encapsulate to a recipient. Returns (wire ciphertext, 32-byte shared)."""
|
||||
ss_m, ct_m = ML_KEM_768.encaps(recipient.xwing_mlkem_pk)
|
||||
|
||||
eph_sk = x25519.X25519PrivateKey.generate()
|
||||
eph_pk = eph_sk.public_key().public_bytes(
|
||||
encoding=Encoding.Raw, format=PublicFormat.Raw
|
||||
)
|
||||
recipient_x25519 = x25519.X25519PublicKey.from_public_bytes(
|
||||
recipient.xwing_x25519_pk
|
||||
)
|
||||
ss_x = eph_sk.exchange(recipient_x25519)
|
||||
|
||||
shared = combine_xwing(ss_m, ss_x, eph_pk, recipient.xwing_x25519_pk)
|
||||
|
||||
wire = bytearray(XWING_CIPHERTEXT_SIZE)
|
||||
wire[:MLKEM768_CIPHERTEXT_SIZE] = ct_m
|
||||
wire[MLKEM768_CIPHERTEXT_SIZE:] = eph_pk
|
||||
|
||||
return bytes(wire), shared
|
||||
|
||||
|
||||
def xwing_decapsulate(identity: Identity, ciphertext: bytes) -> bytes:
|
||||
"""Decapsulate using the identity's static X-Wing key."""
|
||||
if len(ciphertext) != XWING_CIPHERTEXT_SIZE:
|
||||
raise ValueError(
|
||||
f"zwing: invalid X-Wing ciphertext size (got {len(ciphertext)})"
|
||||
)
|
||||
ct_m = ciphertext[:MLKEM768_CIPHERTEXT_SIZE]
|
||||
eph_pk = ciphertext[MLKEM768_CIPHERTEXT_SIZE:]
|
||||
|
||||
ss_m = ML_KEM_768.decaps(identity.xwing_mlkem_sk, ct_m)
|
||||
eph_pub = x25519.X25519PublicKey.from_public_bytes(eph_pk)
|
||||
ss_x = identity.xwing_x25519_sk.exchange(eph_pub)
|
||||
|
||||
return combine_xwing(ss_m, ss_x, eph_pk, identity.xwing_x25519_pk_bytes)
|
||||
+526
@@ -0,0 +1,526 @@
|
||||
"""End-to-end Python Z-Wing tests.
|
||||
|
||||
Run with::
|
||||
|
||||
cd py && python3 test_full.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import unittest
|
||||
from queue import Queue
|
||||
|
||||
from channel import Channel, nonce_for
|
||||
from handshake import (
|
||||
HandshakeOutput,
|
||||
MAX_FRAME_SIZE,
|
||||
read_frame,
|
||||
run_initiator,
|
||||
run_responder,
|
||||
write_frame,
|
||||
)
|
||||
from identity import (
|
||||
SIGNATURE_SIZE,
|
||||
Identity,
|
||||
IDENTITY_PUBLIC_SIZE,
|
||||
generate_identity,
|
||||
identity_equals,
|
||||
marshal_identity_public,
|
||||
parse_identity_public,
|
||||
public_of,
|
||||
sign_identity,
|
||||
verify_identity,
|
||||
)
|
||||
from kem import xwing_decapsulate, xwing_encapsulate
|
||||
|
||||
|
||||
class _Pipe:
|
||||
"""Tiny in-memory full-duplex pipe."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.q1: "Queue[bytes]" = Queue()
|
||||
self.q2: "Queue[bytes]" = Queue()
|
||||
self.buf1 = bytearray()
|
||||
self.buf2 = bytearray()
|
||||
|
||||
|
||||
class _End:
|
||||
def __init__(self, pipe: _Pipe, side: int) -> None:
|
||||
self._pipe = pipe
|
||||
self._side = side
|
||||
|
||||
def read(self, n: int) -> bytes:
|
||||
if self._side == 0:
|
||||
buf = self._pipe.buf1
|
||||
q = self._pipe.q1
|
||||
else:
|
||||
buf = self._pipe.buf2
|
||||
q = self._pipe.q2
|
||||
while len(buf) < n:
|
||||
buf += q.get()
|
||||
out = bytes(buf[:n])
|
||||
del buf[:n]
|
||||
return out
|
||||
|
||||
def write(self, data: bytes) -> None:
|
||||
if self._side == 0:
|
||||
self._pipe.q2.put(data)
|
||||
else:
|
||||
self._pipe.q1.put(data)
|
||||
|
||||
|
||||
def _make_pipe():
|
||||
p = _Pipe()
|
||||
return _End(p, 0), _End(p, 1)
|
||||
|
||||
|
||||
class IdentityTests(unittest.TestCase):
|
||||
def test_sign_verify_round_trip(self):
|
||||
id_ = generate_identity()
|
||||
pub = public_of(id_)
|
||||
sig = sign_identity(id_, b"ctx", b"hello")
|
||||
self.assertEqual(len(sig), SIGNATURE_SIZE)
|
||||
verify_identity(pub, b"ctx", b"hello", sig)
|
||||
|
||||
def test_verify_rejects_wrong_message(self):
|
||||
id_ = generate_identity()
|
||||
sig = sign_identity(id_, b"ctx", b"hello")
|
||||
with self.assertRaises(ValueError):
|
||||
verify_identity(public_of(id_), b"ctx", b"goodbye", sig)
|
||||
|
||||
def test_verify_rejects_wrong_ctx(self):
|
||||
id_ = generate_identity()
|
||||
sig = sign_identity(id_, b"ctx", b"hello")
|
||||
with self.assertRaises(ValueError):
|
||||
verify_identity(public_of(id_), b"other", b"hello", sig)
|
||||
|
||||
def test_verify_rejects_short_sig(self):
|
||||
id_ = generate_identity()
|
||||
with self.assertRaises(ValueError):
|
||||
verify_identity(public_of(id_), b"ctx", b"hello", b"short")
|
||||
|
||||
def test_marshal_round_trip(self):
|
||||
id_ = generate_identity()
|
||||
pub = public_of(id_)
|
||||
wire = marshal_identity_public(pub)
|
||||
self.assertEqual(len(wire), IDENTITY_PUBLIC_SIZE)
|
||||
parsed = parse_identity_public(wire)
|
||||
self.assertTrue(identity_equals(parsed, pub))
|
||||
|
||||
def test_parse_rejects_wrong_size(self):
|
||||
with self.assertRaises(ValueError):
|
||||
parse_identity_public(b"\x00" * 10)
|
||||
|
||||
def test_distinct_identities_differ(self):
|
||||
a = public_of(generate_identity())
|
||||
b = public_of(generate_identity())
|
||||
self.assertFalse(identity_equals(a, b))
|
||||
|
||||
|
||||
class KEMTests(unittest.TestCase):
|
||||
def test_xwing_round_trip(self):
|
||||
id_ = generate_identity()
|
||||
ct, ss_a = xwing_encapsulate(public_of(id_))
|
||||
ss_b = xwing_decapsulate(id_, ct)
|
||||
self.assertEqual(ss_a, ss_b)
|
||||
|
||||
def test_xwing_decap_rejects_wrong_size(self):
|
||||
id_ = generate_identity()
|
||||
with self.assertRaises(ValueError):
|
||||
xwing_decapsulate(id_, b"\x00" * 10)
|
||||
|
||||
|
||||
class WireTests(unittest.TestCase):
|
||||
def test_write_frame_oversize_rejected(self):
|
||||
class Sink:
|
||||
def read(self, n):
|
||||
return b""
|
||||
|
||||
def write(self, b):
|
||||
pass
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
write_frame(Sink(), b"x" * (MAX_FRAME_SIZE + 1))
|
||||
|
||||
def test_read_frame_zero_length_rejected(self):
|
||||
class Src:
|
||||
def __init__(self):
|
||||
self.b = bytearray(b"\x00\x00\x00\x00")
|
||||
|
||||
def read(self, n):
|
||||
out = bytes(self.b[:n])
|
||||
del self.b[:n]
|
||||
return out
|
||||
|
||||
def write(self, b):
|
||||
pass
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
read_frame(Src())
|
||||
|
||||
def test_read_frame_oversize_rejected(self):
|
||||
class Src:
|
||||
def __init__(self):
|
||||
self.b = bytearray(b"\xff\xff\xff\xff")
|
||||
|
||||
def read(self, n):
|
||||
out = bytes(self.b[:n])
|
||||
del self.b[:n]
|
||||
return out
|
||||
|
||||
def write(self, b):
|
||||
pass
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
read_frame(Src())
|
||||
|
||||
|
||||
class HandshakeTests(unittest.TestCase):
|
||||
def test_full_handshake_round_trip(self):
|
||||
client = generate_identity()
|
||||
server = generate_identity()
|
||||
a, b = _make_pipe()
|
||||
|
||||
results: dict[str, HandshakeOutput] = {}
|
||||
err: dict[str, Exception] = {}
|
||||
|
||||
def server_thread():
|
||||
try:
|
||||
results["s"] = run_responder(b, server, public_of(client))
|
||||
except Exception as e:
|
||||
err["s"] = e
|
||||
|
||||
t = threading.Thread(target=server_thread)
|
||||
t.start()
|
||||
try:
|
||||
results["c"] = run_initiator(a, client, public_of(server))
|
||||
t.join(timeout=10)
|
||||
finally:
|
||||
if t.is_alive():
|
||||
t.join(timeout=1)
|
||||
|
||||
if err:
|
||||
raise AssertionError(f"server: {err['s']}")
|
||||
|
||||
c_out = results["c"]
|
||||
s_out = results["s"]
|
||||
self.assertEqual(c_out.key_i2r, s_out.key_i2r)
|
||||
self.assertEqual(c_out.key_r2i, s_out.key_r2i)
|
||||
self.assertTrue(identity_equals(c_out.remote, public_of(server)))
|
||||
self.assertTrue(identity_equals(s_out.remote, public_of(client)))
|
||||
|
||||
def test_pinned_remote_mismatch_rejected(self):
|
||||
client = generate_identity()
|
||||
server = generate_identity()
|
||||
other = generate_identity()
|
||||
a, b = _make_pipe()
|
||||
|
||||
err = {}
|
||||
|
||||
def server_thread():
|
||||
try:
|
||||
run_responder(b, server)
|
||||
except Exception as e:
|
||||
err["s"] = e
|
||||
|
||||
t = threading.Thread(target=server_thread)
|
||||
t.start()
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
run_initiator(a, client, public_of(other))
|
||||
finally:
|
||||
t.join(timeout=2)
|
||||
|
||||
|
||||
class CombinerSizeValidationTests(unittest.TestCase):
|
||||
"""zwing.py combine_xwing rejects wrong-size inputs (3 separate branches)."""
|
||||
|
||||
def test_rejects_wrong_ss_m(self):
|
||||
from zwing import combine_xwing
|
||||
with self.assertRaises(ValueError):
|
||||
combine_xwing(b"\x00" * 31, b"\x00" * 32, b"\x00" * 32, b"\x00" * 32)
|
||||
|
||||
def test_rejects_wrong_ss_x(self):
|
||||
from zwing import combine_xwing
|
||||
with self.assertRaises(ValueError):
|
||||
combine_xwing(b"\x00" * 32, b"\x00" * 31, b"\x00" * 32, b"\x00" * 32)
|
||||
|
||||
def test_rejects_wrong_ct_x(self):
|
||||
from zwing import combine_xwing
|
||||
with self.assertRaises(ValueError):
|
||||
combine_xwing(b"\x00" * 32, b"\x00" * 32, b"\x00" * 31, b"\x00" * 32)
|
||||
|
||||
def test_rejects_wrong_pk_x(self):
|
||||
from zwing import combine_xwing
|
||||
with self.assertRaises(ValueError):
|
||||
combine_xwing(b"\x00" * 32, b"\x00" * 32, b"\x00" * 32, b"\x00" * 31)
|
||||
|
||||
|
||||
class IdentityEqualLengthTests(unittest.TestCase):
|
||||
"""Force identity_equals to compare differently-shaped pubkeys."""
|
||||
|
||||
def test_identity_equals_short_field_returns_false(self):
|
||||
from identity import IdentityPublic, identity_equals
|
||||
a = IdentityPublic(
|
||||
ed_pk=b"\x00" * 32,
|
||||
ml_pk=b"\x00" * 1952,
|
||||
xwing_mlkem_pk=b"\x00" * 1184,
|
||||
xwing_x25519_pk=b"\x00" * 32,
|
||||
)
|
||||
# Build b with a shorter ed_pk; identity_equals must short-circuit
|
||||
# to False on the length mismatch path.
|
||||
b = IdentityPublic(
|
||||
ed_pk=b"\x00" * 16, # wrong length
|
||||
ml_pk=b"\x00" * 1952,
|
||||
xwing_mlkem_pk=b"\x00" * 1184,
|
||||
xwing_x25519_pk=b"\x00" * 32,
|
||||
)
|
||||
self.assertFalse(identity_equals(a, b))
|
||||
|
||||
|
||||
class HandshakeWireDecodeTests(unittest.TestCase):
|
||||
"""Drive every wire-decode error branch in handshake.py."""
|
||||
|
||||
def test_decode_handshake_init_short_idlen(self):
|
||||
from handshake import _decode_handshake_init
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_init(b"\x00") # missing idLen byte
|
||||
|
||||
def test_decode_handshake_init_short_id(self):
|
||||
from handshake import _decode_handshake_init
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_init(b"\x00\xff") # idLen=0x00FF, no body
|
||||
|
||||
def test_decode_handshake_init_short_siglen(self):
|
||||
from handshake import _decode_handshake_init
|
||||
# idLen=1, id="A", then no bytes for sigLen.
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_init(b"\x00\x01A")
|
||||
|
||||
def test_decode_handshake_init_short_sig(self):
|
||||
from handshake import _decode_handshake_init
|
||||
# idLen=0, sigLen=0xFFFF, no body.
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_init(b"\x00\x00\xff\xff")
|
||||
|
||||
def test_decode_handshake_init_trailing_garbage(self):
|
||||
from handshake import _decode_handshake_init
|
||||
# Valid idLen=0, sigLen=0, then extra byte.
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_init(b"\x00\x00\x00\x00\x99")
|
||||
|
||||
def test_decode_handshake_response_too_short(self):
|
||||
from handshake import _decode_handshake_response
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_response(b"\x00" * 100)
|
||||
|
||||
def test_decode_handshake_response_short_enc_body(self):
|
||||
from handshake import _decode_handshake_response
|
||||
from zwing import XWING_CIPHERTEXT_SIZE
|
||||
# Right-size ct, encLen=0xFFFF, no body.
|
||||
buf = b"\x00" * XWING_CIPHERTEXT_SIZE + b"\xff\xff"
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_response(buf)
|
||||
|
||||
def test_decode_handshake_response_trailing_garbage(self):
|
||||
from handshake import _decode_handshake_response
|
||||
from zwing import XWING_CIPHERTEXT_SIZE
|
||||
# Right-size ct, encLen=0, plus trailing byte.
|
||||
buf = b"\x00" * XWING_CIPHERTEXT_SIZE + b"\x00\x00\x99"
|
||||
with self.assertRaises(ValueError):
|
||||
_decode_handshake_response(buf)
|
||||
|
||||
def test_split_resp_id_short_idlen(self):
|
||||
from handshake import _split_resp_id_payload
|
||||
with self.assertRaises(ValueError):
|
||||
_split_resp_id_payload(b"\x00")
|
||||
|
||||
def test_split_resp_id_short_id(self):
|
||||
from handshake import _split_resp_id_payload
|
||||
with self.assertRaises(ValueError):
|
||||
_split_resp_id_payload(b"\x00\xff")
|
||||
|
||||
def test_split_resp_id_short_sig(self):
|
||||
from handshake import _split_resp_id_payload
|
||||
# idLen=0, sigLen=0xFFFF, no body.
|
||||
with self.assertRaises(ValueError):
|
||||
_split_resp_id_payload(b"\x00\x00\xff\xff")
|
||||
|
||||
def test_split_resp_id_trailing_garbage(self):
|
||||
from handshake import _split_resp_id_payload
|
||||
with self.assertRaises(ValueError):
|
||||
_split_resp_id_payload(b"\x00\x00\x00\x00\x99")
|
||||
|
||||
def test_read_exact_short_read(self):
|
||||
"""_read_exact must raise if conn returns empty."""
|
||||
from handshake import _read_exact
|
||||
|
||||
class Empty:
|
||||
def read(self, n):
|
||||
return b""
|
||||
|
||||
def write(self, b):
|
||||
pass
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
_read_exact(Empty(), 4)
|
||||
|
||||
|
||||
class ChannelExhaustionTests(unittest.TestCase):
|
||||
"""Drive sequence-exhaustion error branches via direct field manipulation."""
|
||||
|
||||
def _make_pair(self):
|
||||
client = generate_identity()
|
||||
server = generate_identity()
|
||||
a, b = _make_pipe()
|
||||
|
||||
results = {}
|
||||
err = {}
|
||||
|
||||
def server_thread():
|
||||
try:
|
||||
results["s"] = run_responder(b, server, public_of(client))
|
||||
except Exception as e:
|
||||
err["s"] = e
|
||||
|
||||
t = threading.Thread(target=server_thread)
|
||||
t.start()
|
||||
try:
|
||||
results["c"] = run_initiator(a, client, public_of(server))
|
||||
finally:
|
||||
t.join(timeout=5)
|
||||
return Channel(a, results["c"], True), Channel(b, results["s"], False)
|
||||
|
||||
def test_tx_seq_exhausted(self):
|
||||
c, _ = self._make_pair()
|
||||
c._tx_seq = (1 << 64) - 1
|
||||
with self.assertRaises(ValueError):
|
||||
c.send(b"x")
|
||||
|
||||
def test_rx_seq_exhausted(self):
|
||||
c, s = self._make_pair()
|
||||
# Server pushes one record so receiver can read; force seq=MAX so the
|
||||
# check trips before decrypt.
|
||||
s.send(b"x")
|
||||
c._rx_seq = (1 << 64) - 1
|
||||
with self.assertRaises(ValueError):
|
||||
c.recv()
|
||||
|
||||
|
||||
class FinalCoverageTests(unittest.TestCase):
|
||||
"""Targeted tests to drive the last few error/edge branches."""
|
||||
|
||||
def test_channel_rx_overflow_path(self):
|
||||
"""Channel.recv must serve _rx_overflow before reading the next frame."""
|
||||
from channel import Channel
|
||||
from handshake import HandshakeOutput
|
||||
|
||||
# Build a Channel with a preset overflow buffer; recv() must return it.
|
||||
out = HandshakeOutput(
|
||||
shared=b"\x00" * 32,
|
||||
remote=parse_identity_public(
|
||||
marshal_identity_public(public_of(generate_identity()))
|
||||
),
|
||||
key_i2r=b"\x00" * 32,
|
||||
key_r2i=b"\x00" * 32,
|
||||
)
|
||||
|
||||
class Sink:
|
||||
def read(self, n):
|
||||
return b""
|
||||
|
||||
def write(self, b):
|
||||
pass
|
||||
|
||||
chan = Channel(Sink(), out, initiator=True)
|
||||
chan._rx_overflow = b"hello-overflow"
|
||||
got = chan.recv()
|
||||
self.assertEqual(got, b"hello-overflow")
|
||||
self.assertIsNone(chan._rx_overflow)
|
||||
|
||||
def test_responder_pinned_remote_mismatch(self):
|
||||
"""Server-side pinning rejection — handshake.py:114."""
|
||||
client = generate_identity()
|
||||
server = generate_identity()
|
||||
other = generate_identity()
|
||||
a, b = _make_pipe()
|
||||
|
||||
err = {}
|
||||
|
||||
def initiator_thread():
|
||||
try:
|
||||
run_initiator(a, client)
|
||||
except Exception as e:
|
||||
err["c"] = e
|
||||
|
||||
t = threading.Thread(target=initiator_thread)
|
||||
t.start()
|
||||
try:
|
||||
with self.assertRaises(ValueError):
|
||||
# Responder pins a different identity than what client sends.
|
||||
run_responder(b, server, public_of(other))
|
||||
finally:
|
||||
t.join(timeout=2)
|
||||
|
||||
def test_verify_rejects_corrupt_mldsa_sig(self):
|
||||
"""Drive identity.py:167 — ML-DSA-65 verify failure."""
|
||||
from identity import (
|
||||
ED25519_SIGNATURE_SIZE,
|
||||
sign_identity,
|
||||
verify_identity,
|
||||
)
|
||||
|
||||
id_ = generate_identity()
|
||||
pub = public_of(id_)
|
||||
sig = sign_identity(id_, b"ctx", b"hi")
|
||||
# Flip a byte in the ML-DSA portion (after first 64 ed25519 bytes).
|
||||
bad = bytearray(sig)
|
||||
bad[ED25519_SIGNATURE_SIZE + 200] ^= 0xFF
|
||||
with self.assertRaises(ValueError):
|
||||
verify_identity(pub, b"ctx", b"hi", bytes(bad))
|
||||
|
||||
|
||||
class ChannelTests(unittest.TestCase):
|
||||
def test_e2e_channel_echo(self):
|
||||
client = generate_identity()
|
||||
server = generate_identity()
|
||||
a, b = _make_pipe()
|
||||
|
||||
results = {}
|
||||
err = {}
|
||||
|
||||
def server_thread():
|
||||
try:
|
||||
s_out = run_responder(b, server, public_of(client))
|
||||
s_chan = Channel(b, s_out, initiator=False)
|
||||
msg = s_chan.recv()
|
||||
s_chan.send(msg)
|
||||
results["s_chan"] = s_chan
|
||||
except Exception as e:
|
||||
err["s"] = e
|
||||
|
||||
t = threading.Thread(target=server_thread)
|
||||
t.start()
|
||||
try:
|
||||
c_out = run_initiator(a, client, public_of(server))
|
||||
c_chan = Channel(a, c_out, initiator=True)
|
||||
payload = b"z-wing python e2e"
|
||||
c_chan.send(payload)
|
||||
got = c_chan.recv()
|
||||
self.assertEqual(got, payload)
|
||||
finally:
|
||||
t.join(timeout=5)
|
||||
if err:
|
||||
raise AssertionError(f"server: {err['s']}")
|
||||
|
||||
def test_nonce_for_low_byte_carries_counter(self):
|
||||
self.assertEqual(nonce_for(0), b"\x00" * 12)
|
||||
self.assertEqual(nonce_for(1)[-1], 1)
|
||||
n_max = nonce_for((1 << 64) - 1)
|
||||
for i in range(4, 12):
|
||||
self.assertEqual(n_max[i], 0xFF)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user