@hpke/ohttp
Version:
Oblivious HTTP (RFC 9458) client and server using HPKE
174 lines (173 loc) • 6.8 kB
JavaScript
import { OhttpError } from "./errors.js";
const REQUEST_INFO_LABEL = new TextEncoder().encode("message/bhttp request");
const RESPONSE_LABEL = new TextEncoder().encode("message/bhttp response");
const LABEL_KEY = new TextEncoder().encode("key");
const LABEL_NONCE = new TextEncoder().encode("nonce");
/**
* Build the info parameter for HPKE SetupBase{S,R}.
*
* info = concat("message/bhttp request", 0x00, hdr)
* hdr = concat(keyId(1), kemId(2), kdfId(2), aeadId(2))
*/
function buildRequestInfo(hdr) {
const info = new Uint8Array(REQUEST_INFO_LABEL.length + 1 + hdr.length);
info.set(REQUEST_INFO_LABEL, 0);
info[REQUEST_INFO_LABEL.length] = 0;
info.set(hdr, REQUEST_INFO_LABEL.length + 1);
return info;
}
/**
* Build the 7-byte header:
* keyId (1) || kemId (2) || kdfId (2) || aeadId (2)
*/
export function buildHeader(keyId, kemId, kdfId, aeadId) {
const hdr = new Uint8Array(7);
const view = new DataView(hdr.buffer);
hdr[0] = keyId;
view.setUint16(1, kemId);
view.setUint16(3, kdfId);
view.setUint16(5, aeadId);
return hdr;
}
/**
* Encapsulate an OHTTP request (RFC 9458 Section 4.3).
*
* Returns the encrypted request and a context for decapsulating the response.
*/
export async function encapsulateRequest(suite, keyId, kemId, kdfId, aeadId, recipientPublicKey, binaryRequest) {
const hdr = buildHeader(keyId, kemId, kdfId, aeadId);
const info = buildRequestInfo(hdr);
const sctx = await suite.createSenderContext({
recipientPublicKey,
info: info.buffer,
});
const enc = new Uint8Array(sctx.enc);
const ct = new Uint8Array(await sctx.seal(binaryRequest, new Uint8Array(0)));
// hdr(7) + enc(Nenc) + ct(variable)
const encRequest = new Uint8Array(hdr.length + enc.length + ct.length);
encRequest.set(hdr, 0);
encRequest.set(enc, hdr.length);
encRequest.set(ct, hdr.length + enc.length);
return {
encRequest,
context: {
enc,
suite,
senderExport: (ctx, len) => sctx.export(ctx, len),
},
};
}
/**
* Decapsulate an OHTTP request on the gateway side (RFC 9458 Section 4.3).
*/
export async function decapsulateRequest(suite, recipientKey, keyId, kemId, encRequest) {
const view = new DataView(encRequest.buffer, encRequest.byteOffset, encRequest.byteLength);
if (encRequest.byteLength < 7) {
throw new OhttpError("Encapsulated request too short");
}
const reqKeyId = encRequest[0];
const reqKemId = view.getUint16(1);
const kdfId = view.getUint16(3);
const aeadId = view.getUint16(5);
if (reqKeyId !== keyId) {
throw new OhttpError(`Key ID mismatch: expected ${keyId}, got ${reqKeyId}`);
}
if (reqKemId !== kemId) {
throw new OhttpError(`KEM ID mismatch: expected 0x${kemId.toString(16)}, got 0x${reqKemId.toString(16)}`);
}
const nenc = suite.kem.encSize;
const hdrLen = 7;
if (encRequest.byteLength < hdrLen + nenc) {
throw new OhttpError("Encapsulated request too short for enc");
}
const enc = encRequest.slice(hdrLen, hdrLen + nenc);
const ct = encRequest.slice(hdrLen + nenc);
const hdr = buildHeader(reqKeyId, reqKemId, kdfId, aeadId);
const info = buildRequestInfo(hdr);
const rctx = await suite.createRecipientContext({
recipientKey,
enc,
info: info.buffer,
});
const binaryRequest = new Uint8Array(await rctx.open(ct, new Uint8Array(0)));
return {
binaryRequest,
context: {
enc,
suite,
recipientExport: (ctx, len) => rctx.export(ctx, len),
},
};
}
/**
* Encapsulate an OHTTP response on the gateway side (RFC 9458 Section 4.4).
*
* Uses the HPKE exporter to derive response key/nonce.
*/
export async function encapsulateResponse(ctx, binaryResponse) {
const nn = ctx.suite.aead.nonceSize;
const nk = ctx.suite.aead.keySize;
const secretLen = Math.max(nn, nk);
const secret = new Uint8Array(await ctx.recipientExport(RESPONSE_LABEL.buffer, secretLen));
const responseNonce = new Uint8Array(secretLen);
crypto.getRandomValues(responseNonce);
const { key, nonce } = await deriveResponseKeyNonce(ctx.suite, ctx.enc, responseNonce, secret);
const aeadCtx = ctx.suite.aead.createEncryptionContext(key);
const ct = new Uint8Array(await aeadCtx.seal(nonce, binaryResponse, new Uint8Array(0)));
const encResponse = new Uint8Array(responseNonce.length + ct.length);
encResponse.set(responseNonce, 0);
encResponse.set(ct, responseNonce.length);
return encResponse;
}
/**
* Decapsulate an OHTTP response on the client side (RFC 9458 Section 4.4).
*/
export async function decapsulateResponse(ctx, encResponse) {
const nn = ctx.suite.aead.nonceSize;
const nk = ctx.suite.aead.keySize;
const secretLen = Math.max(nn, nk);
if (encResponse.byteLength < secretLen) {
throw new OhttpError("Encapsulated response too short");
}
const secret = new Uint8Array(await ctx.senderExport(RESPONSE_LABEL.buffer, secretLen));
const responseNonce = encResponse.slice(0, secretLen);
const ct = encResponse.slice(secretLen);
const { key, nonce } = await deriveResponseKeyNonce(ctx.suite, ctx.enc, responseNonce, secret);
const aeadCtx = ctx.suite.aead.createEncryptionContext(key);
return new Uint8Array(await aeadCtx.open(nonce, ct, new Uint8Array(0)));
}
/**
* Derive response AEAD key and nonce (RFC 9458 Section 4.4).
*
* salt = concat(enc, response_nonce)
* prk = Extract(salt, secret)
* key = Expand(prk, "key", Nk)
* nonce = Expand(prk, "nonce", Nn)
*
* Uses raw HMAC for Extract because the salt length may exceed
* the KDF's hashSize (which the HPKE KDF implementation rejects).
*/
async function deriveResponseKeyNonce(suite, enc, responseNonce, secret) {
const salt = new Uint8Array(enc.length + responseNonce.length);
salt.set(enc, 0);
salt.set(responseNonce, enc.length);
// HKDF-Extract(salt, ikm) = HMAC-Hash(salt, ikm)
const hmacAlg = kdfHashAlgorithm(suite.kdf.id);
const saltKey = await crypto.subtle.importKey("raw", salt, { name: "HMAC", hash: hmacAlg }, false, ["sign"]);
const prk = await crypto.subtle.sign("HMAC", saltKey, secret.buffer);
const key = await suite.kdf.expand(prk, LABEL_KEY, suite.aead.keySize);
const nonce = await suite.kdf.expand(prk, LABEL_NONCE, suite.aead.nonceSize);
return { key, nonce };
}
function kdfHashAlgorithm(kdfId) {
switch (kdfId) {
case 0x0001:
return "SHA-256";
case 0x0002:
return "SHA-384";
case 0x0003:
return "SHA-512";
default:
throw new OhttpError(`Unsupported KDF ID: 0x${kdfId.toString(16)}`);
}
}