@noisytransfer/crypto
v0.2.3
Published
NoisyTransfer crypto (experimental)
Maintainers
Readme
@noisytransfer/crypto — HPKE (Hybrid X25519+Kyber768) channel helpers
A thin, safe wrapper around hpke-js that gives you long‑lived HPKE contexts (one encapsulation, many messages), plus a simple streaming facade. Built for ordered transports (e.g., RTCDataChannel with ordered: true).
What changed?
- We encapsulate once and reuse a sender/recipient context.
- We queue concurrent
seal()/open()calls to respect HPKE’s internal nonce counter.- We pass AAD through consistently.
- All byte outputs are
Uint8Arrayfor ergonomic equality/serialization.mkAeadStreamFromHpke(..., { id })honors caller-supplied IDs verbatim (no auto-prefix).
Install
npm i @noisytransfer/cryptoThis package assumes a modern runtime with WebCrypto and TextEncoder/TextDecoder available.
Quick start
import { suite } from "@noisytransfer/crypto"; // your configured CipherSuite
import { createSenderSession, createReceiverSession, mkAeadStreamFromHpke } from "@noisytransfer/crypto";
// 1) generate recipient keypair & publish public key bytes
const kp = await suite.kem.generateKeyPair();
const pubBytes = await suite.kem.serializePublicKey(kp.publicKey);
// 2) set up HPKE contexts (one encapsulation)
const sender = await createSenderSession(pubBytes);
const receiver = await createReceiverSession(sender.enc, kp.privateKey);
// 3) send multiple messages under the same context
const ct1 = await sender.seal(new TextEncoder().encode("alpha"));
const ct2 = await sender.seal(new TextEncoder().encode("beta"));
console.log(new TextDecoder().decode(await receiver.open(ct1))); // "alpha"
console.log(new TextDecoder().decode(await receiver.open(ct2))); // "beta"Streaming (ordered channels)
const streamId = "my-app/stream/v1:abc123"; // supply your own ID for AAD/telemetry
const send = await mkAeadStreamFromHpke("sender", pubBytes, undefined, { id: streamId });
const recv = await mkAeadStreamFromHpke("receiver", send.enc, kp.privateKey, { id: streamId });
// Encrypt chunks (parallel calls are queued internally)
const chunks = ["alpha","beta","gamma"].map(x => new TextEncoder().encode(x));
const cts = await Promise.all(chunks.map(c => send.seal(c)));
// Decrypt in order (parallel-safe; internally serialized)
for (const ct of cts) {
const pt = await recv.open(ct);
console.log(new TextDecoder().decode(pt));
}API
createSenderSession(receiverPublicKey[, opts]) => Promise<SenderSession>
Creates a long‑lived HPKE sender context and performs one encapsulation.
receiverPublicKey:Uint8Array|ArrayBuffer— bytes of the recipient’s public key (usesuite.kem.serializePublicKey).opts.timeoutMs?: number— default30_000.
Returns:
type SenderSession = {
enc: Uint8Array; // send once to the receiver
seal(pt: Uint8Array|ArrayBuffer, aad?: Uint8Array|ArrayBuffer): Promise<Uint8Array>;
exportSecret?(info: Uint8Array|ArrayBuffer, len: number): Promise<Uint8Array>;
// alias:
hpkeSeal: SenderSession["seal"];
};Notes:
seal()calls are serialized to avoid nonce/counter races.- All outputs are
Uint8Array.
createReceiverSession(enc, recipientPrivateKey[, opts]) => Promise<ReceiverSession>
enc:Uint8Array|ArrayBuffer— sender’s encapsulated key (fromsender.enc).recipientPrivateKey:CryptoKey— the recipient’s HPKE private key.opts.timeoutMs?: number— default30_000.
Returns:
type ReceiverSession = {
open(ct: Uint8Array|ArrayBuffer, aad?: Uint8Array|ArrayBuffer): Promise<Uint8Array>;
exportSecret?(info: Uint8Array|ArrayBuffer, len: number): Promise<Uint8Array>;
// alias:
hpkeOpen: ReceiverSession["open"];
};Notes:
open()calls are also serialized defensively.
mkAeadStreamFromHpke(role, arg1, arg2, opts?)
Streaming-friendly facade around the sessions.
role:"sender"or"receiver".arg1:- sender: recipient public key bytes.
- receiver: the
encvalue from the sender.
arg2:- sender:
undefined. - receiver: recipient private
CryptoKey.
- sender:
opts.id?: string— caller-supplied stream ID, used verbatim on both sides.opts.timeoutMs?: number— default30_000.
Returns (sender):
{
id: string, // === opts.id if provided, else base64url(enc)
enc: Uint8Array, // send with your control message
seal(pt: Uint8Array|ArrayBuffer, aad?: Uint8Array|ArrayBuffer): Promise<Uint8Array>,
open(): never, // throws: sender doesn’t decrypt
}Returns (receiver):
{
id: string, // === opts.id if provided, else base64url(enc)
open(ct: Uint8Array|ArrayBuffer, aad?: Uint8Array|ArrayBuffer): Promise<Uint8Array>,
seal(): never, // throws: receiver doesn’t encrypt
}Design notes
- Context reuse: Using
CipherSuite.createSenderContext/createRecipientContextonce and reusing is far cheaper thansuite.seal()per message (which would re‑encapsulate). - Nonce & ordering: HPKE contexts maintain a monotonic message counter. Calls are serialized internally to preserve the sequence even if user code fires them in parallel.
- AAD passthrough: Whatever AAD you pass into
seal()must be provided verbatim toopen(). - IDs: We do not invent prefixes. If your application has an AAD/telemetry contract, pass
{ id: ... }and we’ll honor it exactly. - Bytes: We normalize to
Uint8Arrayso Node’sassert.deepStrictEqualworks as expected.
Testing
Concurrency safety
import { suite, createSenderSession, createReceiverSession } from "@noisytransfer/crypto";
import { strict as assert } from "node:assert";
test("HPKE context serializes parallel seal/open", async () => {
const kp = await suite.kem.generateKeyPair();
const pub = await suite.kem.serializePublicKey(kp.publicKey);
const sender = await createSenderSession(pub);
const recv = await createReceiverSession(sender.enc, kp.privateKey);
const chunks = Array.from({ length: 8 }, (_, i) => new TextEncoder().encode(`m${i}`));
const cts = await Promise.all(chunks.map(c => sender.seal(c)));
const pts = await Promise.all(cts.map(ct => recv.open(ct)));
assert.equal(new TextDecoder().decode(pts.at(0)), "m0");
assert.equal(new TextDecoder().decode(pts.at(-1)), "m7");
});ID contract
import { mkAeadStreamFromHpke, suite } from "@noisytransfer/crypto";
import { strict as assert } from "node:assert";
test("mkAeadStreamFromHpke honors caller-supplied id", async () => {
const kp = await suite.kem.generateKeyPair();
const pub = await suite.kem.serializePublicKey(kp.publicKey);
const id = "my/app/stream:v1:xyz";
const send = await mkAeadStreamFromHpke("sender", pub, undefined, { id });
const recv = await mkAeadStreamFromHpke("receiver", send.enc, kp.privateKey, { id });
assert.equal(send.id, id);
assert.equal(recv.id, id);
});Run all tests with:
NODE_OPTIONS="--preserve-symlinks --preserve-symlinks-main" node --testSecurity & limits
- Message cap: HPKE contexts have a finite message limit; plan to rekey if you stream large volumes.
- Ordered transports: This module assumes ordered delivery. If your transport can reorder/drop packets, add an outer framing with indices and replay checks, and wire those into AAD.
- Key hygiene: Zeroize exported material when possible, and avoid logging keys/AADs in production.
- Experimental KEM:
@hpke/hybridkem-x25519-kyber768has been experimental/deprecated in upstream notes. Keep an eye on ML‑KEM or newer hybrid KEMs.
License
MIT © NoisyTransfer
