Skip to content

Instantly share code, notes, and snippets.

@martinthomson
Created December 8, 2021 05:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save martinthomson/1f000d3e389b0bf1308e1043e141fbb9 to your computer and use it in GitHub Desktop.
Save martinthomson/1f000d3e389b0bf1308e1043e141fbb9 to your computer and use it in GitHub Desktop.
Test vector script for QUICv2
Run this with an argument of the version number (in hex).
This is a copy of what I used for QUICv1.
#!/bin/sh
':' //; exec "$(command -v nodejs || command -v node)" "$0" "$@"
// This script performs simple encryption and decryption for Initial packets.
// It's crude, but it should be sufficient to generate examples.
'use strict';
require('buffer');
const assert = require('assert');
const crypto = require('crypto');
const SHA256 = 'sha256';
const AES_GCM = 'aes-128-gcm';
const AES_ECB = 'aes-128-ecb';
const V1 = '00000001';
const V2 = 'ff020000';
const VERSION_INFO = {
'00000001': {
initial_salt: Buffer.from('38762cf7f55934b34d179ae6a4c80cadccbb7f0a', 'hex'),
retry_secret: Buffer.from('d9c9943e6101fd200021506bcc02814c73030f25c79d71ce876eca876e6fca8e', 'hex'),
retry_key: Buffer.from('be0c690b9f66575a1d766b54e368c84e', 'hex'),
retry_nonce: Buffer.from('461599d35d632bf2239825bb', 'hex'),
label_prefix: 'quic ',
},
'ff020000': {
initial_salt: Buffer.from('a707c203a59b47184a1d62ca570406ea7ae3e5d3', 'hex'),
retry_secret: Buffer.from('3425c20cf88779df2ff71e8abfa78249891e763bbed2f13c048343d348c060e2', 'hex'),
retry_key: Buffer.from('ba858dc7b43de5dbf87617ff4ab253db', 'hex'),
retry_nonce: Buffer.from('141b99c239b03e785d6a2e9f', 'hex'),
label_prefix: 'quicv2 ',
},
};
var version = V1;
function chunk(s, n) {
return (new Array(Math.ceil(s.length / n)))
.fill()
.map((_, i) => s.slice(i * n, i * n + n));
}
function log(m, k) {
console.log(m + ' [' + k.length + ']: ' + chunk(k.toString('hex'), 32).join(' '));
};
class HMAC {
constructor(hash) {
this.hash = hash;
}
digest(key, input) {
var hmac = crypto.createHmac(this.hash, key);
hmac.update(input);
return hmac.digest();
}
}
/* HKDF as defined in RFC5869, with HKDF-Expand-Label from RFC8446. */
class QHKDF {
constructor(hmac, prk) {
this.hmac = hmac;
this.prk = prk;
}
static extract(hash, salt, ikm) {
var hmac = new HMAC(hash);
return new QHKDF(hmac, hmac.digest(salt, ikm));
}
expand(info, len) {
var output = Buffer.alloc(0);
var T = Buffer.alloc(0);
info = Buffer.from(info, 'ascii');
var counter = 0;
var cbuf = Buffer.alloc(1);
while (output.length < len) {
cbuf.writeUIntBE(++counter, 0, 1);
T = this.hmac.digest(this.prk, Buffer.concat([T, info, cbuf]));
output = Buffer.concat([output, T]);
}
return output.slice(0, len);
}
expand_label(label, len) {
const prefix = "tls13 ";
var info = Buffer.alloc(2 + 1 + prefix.length + label.length + 1);
// Note that Buffer.write returns the number of bytes written, whereas
// Buffer.writeUIntBE returns the end offset of the write. Consistency FTW.
var offset = info.writeUIntBE(len, 0, 2);
offset = info.writeUIntBE(prefix.length + label.length, offset, 1);
offset += info.write(prefix + label, offset);
info.writeUIntBE(0, offset, 1);
log('info for ' + label, info);
return this.expand(info, len);
}
}
// XOR b into a.
function xor(a, b) {
a.forEach((_, i) => {
a[i] ^= b[i];
});
}
function applyNonce(iv, counter) {
var nonce = Buffer.from(iv);
const m = nonce.readUIntBE(nonce.length - 6, 6);
const x = ((m ^ counter) & 0xffffff) +
((((m / 0x1000000) ^ (counter / 0x1000000)) & 0xffffff) * 0x1000000);
nonce.writeUIntBE(x, nonce.length - 6, 6);
return nonce;
}
class InitialProtection {
constructor(label, cid) {
var qhkdf = QHKDF.extract(SHA256, VERSION_INFO[version].initial_salt, cid);
log('initial_secret', qhkdf.prk);
qhkdf = new QHKDF(qhkdf.hmac, qhkdf.expand_label(label, 32));
log(label + ' secret', qhkdf.prk);
this.key = qhkdf.expand_label(VERSION_INFO[version].label_prefix + 'key', 16);
log(label + ' key', this.key);
this.iv = qhkdf.expand_label(VERSION_INFO[version].label_prefix + 'iv', 12);
log(label + ' iv', this.iv);
this.hp = qhkdf.expand_label(VERSION_INFO[version].label_prefix + 'hp', 16);
log(label + ' hp', this.hp);
}
generateNonce(counter) {
return applyNonce(this.iv, counter);
}
// Returns the encrypted data with authentication tag appended. The AAD is
// used, but not added to the output.
encipher(pn, aad, data) {
console.log('encipher pn', pn);
log('encipher aad', aad);
log('encipher data', data);
var nonce = this.generateNonce(pn);
var gcm = crypto.createCipheriv(AES_GCM, this.key, nonce);
gcm.setAAD(aad);
var e = gcm.update(data);
gcm.final();
e = Buffer.concat([e, gcm.getAuthTag()]);
log('enciphered', e);
return e;
}
decipher(pn, aad, data) {
console.log('decipher pn', pn);
log('decipher aad', aad);
log('decipher data', data);
var nonce = this.generateNonce(pn);
var gcm = crypto.createDecipheriv(AES_GCM, this.key, nonce);
gcm.setAAD(aad);
gcm.setAuthTag(data.slice(data.length - 16));
var d = gcm.update(data.slice(0, data.length - 16));
gcm.final();
log('deciphered', d);
return d;
}
// Calculates the header protection mask. Returns 16 bytes of output.
hpMask(sample) {
log('hp sample', sample);
// var ctr = crypto.createCipheriv('aes-128-ctr', this.hp, sample);
// var mask = ctr.update(Buffer.alloc(5));
var ecb = crypto.createCipheriv(AES_ECB, this.hp, Buffer.alloc(0));
var mask = ecb.update(sample);
log('hp mask', mask);
return mask;
}
// hdr is everything before the length field
// hdr[0] has the packet number length already in place
// pn is the packet number
// data is the payload (i.e., encoded frames)
encrypt(hdr, pn, data) {
var pn_len = 1 + (hdr[0] & 0x3);
if (pn_len + data.length < 4) {
throw new Error('insufficient length of packet number and payload');
}
var aad = Buffer.alloc(hdr.length + 2 + pn_len);
var offset = hdr.copy(aad);
// Add a length that covers the packet number encoding and the auth tag.
offset = aad.writeUIntBE(0x4000 | (pn_len + data.length + 16), offset, 2);
var pn_offset = offset;
var pn_mask = 0xffffffff >> (8 * (4 - pn_len));
offset = aad.writeUIntBE(pn & pn_mask, offset, pn_len)
log('header', aad);
var payload = this.encipher(pn, aad, data);
var mask = this.hpMask(payload.slice(4 - pn_len, 20 - pn_len));
aad[0] ^= mask[0] & (0x1f >> (aad[0] >> 7));
xor(aad.slice(pn_offset), mask.slice(1));
log('masked header', aad);
return Buffer.concat([aad, payload]);
}
cidLen(v) {
if (!v) {
return 0;
}
return v + 3;
}
decrypt(data) {
log('decrypt', data);
if (data[0] & 0x40 !== 0x40) {
throw new Error('missing QUIC bit');
}
if (data[0] & 0x80 === 0) {
throw new Error('short header unsupported');
}
var hdr_len = 1 + 4;
hdr_len += 1 + data[hdr_len]; // DCID
hdr_len += 1 + data[hdr_len]; // SCID
if ((data[0] & 0x30) === 0) { // Initial packet: token.
if ((data[hdr_len] & 0xc0) !== 0) {
throw new Error('multi-byte token length unsupported');
}
hdr_len += 1 + data[hdr_len]; // oops: this only handles single octet lengths.
}
// Skip the length.
hdr_len += 1 << (data[hdr_len] >> 6);
// Now we're at the encrypted bit.
var mask = this.hpMask(data.slice(hdr_len + 4, hdr_len + 20));
var octet0 = data[0] ^ (mask[0] & (0x1f >> (data[0] >> 7)));
var pn_len = (octet0 & 3) + 1;
var hdr = Buffer.from(data.slice(0, hdr_len + pn_len));
hdr[0] = octet0;
log('header', hdr);
xor(hdr.slice(hdr_len), mask.slice(1));
log('unmasked header', hdr);
var pn = hdr.readUIntBE(hdr_len, pn_len);
// Important: this doesn't recover PN based on expected value.
// The expectation being that Initial packets won't ever need that.
return this.decipher(pn, hdr, data.slice(hdr.length));
}
}
function pad(hdr, body) {
var pn_len = (hdr[0] & 3) + 1;
var size = 1200 - hdr.length - 2 - pn_len - 16; // Assume 2 byte length.
if (size < 0) {
return body;
}
var padded = Buffer.allocUnsafe(size);
console.log('pad amount', size);
body.copy(padded);
padded.fill(0, body.length);
log('padded', padded);
return padded;
}
function test(role, cid, hdr, pn, body) {
cid = Buffer.from(cid, 'hex');
log('connection ID', cid);
hdr = Buffer.from(hdr, 'hex');
log('header', hdr);
console.log('packet number = ' + pn);
body = Buffer.from(body, 'hex');
log('body', hdr);
if (role === 'client' && (hdr[0] & 0x30) === 0) {
body = pad(hdr, body);
}
var endpoint = new InitialProtection(role + ' in', cid);
var packet = endpoint.encrypt(hdr, pn, body);
log('encrypted packet', packet);
var content = endpoint.decrypt(packet);
log('decrypted content', content);
if (content.compare(body) !== 0) {
throw new Error('decrypted result not the same as the original');
}
}
function hex_cid(cid) {
return '0' + (cid.length / 2).toString(16) + cid;
}
// Verify that the retry keys are correct.
function derive_retry() {
let secret = VERSION_INFO[version].retry_secret;
let qhkdf = new QHKDF(new HMAC(SHA256), secret);
let key = qhkdf.expand_label(VERSION_INFO[version].label_prefix + 'key', 16);
log('retry key', key);
assert.deepStrictEqual(key, VERSION_INFO[version].retry_key);
let nonce = qhkdf.expand_label(VERSION_INFO[version].label_prefix + 'iv', 12);
log('retry nonce', nonce);
assert.deepStrictEqual(nonce, VERSION_INFO[version].retry_nonce);
}
function retry(dcid, scid, odcid) {
var pfx = Buffer.from(hex_cid(odcid), 'hex');
var encoded = Buffer.from('ff' + version + hex_cid(dcid) + hex_cid(scid), 'hex');
var token = Buffer.from('token', 'ascii');
var header = Buffer.concat([encoded, token]);
log('retry header', header);
var aad = Buffer.concat([pfx, header]);
log('retry aad', aad);
var gcm = crypto.createCipheriv(AES_GCM, VERSION_INFO[version].retry_key,
VERSION_INFO[version].retry_nonce);
gcm.setAAD(aad);
gcm.update('');
gcm.final();
log('retry', Buffer.concat([header, gcm.getAuthTag()]));
}
// A simple ChaCha20-Poly1305 packet.
function chacha20(pn, payload) {
log('chacha20poly1305 pn=' + pn.toString(), payload);
let header = Buffer.alloc(4);
header.writeUIntBE(0x42, 0, 1);
header.writeUIntBE(pn & 0xffffff, 1, 3);
log('unprotected header', header);
const key = Buffer.from('c6d98ff3441c3fe1b2182094f69caa2e' +
'd4b716b65488960a7a984979fb23e1c8', 'hex');
const iv = Buffer.from('e0459b3474bdd0e44a41c144', 'hex');
const nonce = applyNonce(iv, pn);
log('nonce', nonce);
let aead = crypto.createCipheriv('ChaCha20-Poly1305', key, nonce, { authTagLength: 16 });
aead.setAAD(header);
const e = aead.update(payload);
aead.final();
let ct = Buffer.concat([e, aead.getAuthTag()]);
log('ciphertext', ct);
const sample = ct.slice(1, 17);
log('sample', sample);
const hp = Buffer.from('25a282b9e82f06f21f488917a4fc8f1b' +
'73573685608597d0efcb076b0ab7a7a4', 'hex');
let chacha = crypto.createCipheriv('ChaCha20', hp, sample);
const mask = chacha.update(Buffer.alloc(5));
log('mask', mask);
let packet = Buffer.concat([header, ct]);
header[0] ^= mask[0] & 0x1f;
xor(header.slice(1), mask.slice(1));
log('header', header);
log('protected packet', Buffer.concat([header, ct]));
}
if (process.argv.length > 2) {
if (VERSION_INFO[process.argv[2]]) {
version = process.argv[2];
} else {
console.warn(`unknown version: ${process.argv[2]}`);
process.exit(2);
}
}
var cid = '8394c8f03e515708';
var ci_hdr = 'c3' + version + hex_cid(cid) + '0000';
// This is a client Initial.
var crypto_frame = '060040f1' +
'010000ed0303ebf8fa56f12939b9584a3896472ec40bb863cfd3e86804fe3a47' +
'f06a2b69484c00000413011302010000c000000010000e00000b6578616d706c' +
'652e636f6dff01000100000a00080006001d0017001800100007000504616c70' +
'6e000500050100000000003300260024001d00209370b2c9caa47fbabaf4559f' +
'edba753de171fa71f50f1ce15d43e994ec74d748002b0003020304000d001000' +
'0e0403050306030203080408050806002d00020101001c000240010039003204' +
'08ffffffffffffffff05048000ffff07048000ffff0801100104800075300901' +
'100f088394c8f03e51570806048000ffff';
test('client', cid, ci_hdr, 2, crypto_frame);
// This should be a valid server Initial.
var frames = '02000000000600405a' +
'020000560303eefce7f7b37ba1d163' +
'2e96677825ddf73988cfc79825df566dc5430b9a04' +
'5a1200130100002e00330024001d00209d3c940d89' +
'690b84d08a60993c144eca684d1081287c834d5311' +
'bcf32bb9da1a002b00020304';
var scid = 'f067a5502a4262b5';
var si_hdr = 'c1' + version + '00' + hex_cid(scid) + '00';
test('server', cid, si_hdr, 1, frames);
derive_retry();
retry('', scid, cid);
chacha20(654360564, Buffer.from('01', 'hex'));
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment