Skip to content
9 changes: 9 additions & 0 deletions src/commons/bufutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ export function hex(b) {
.join("");
}

/**
* @param {string} h
* @returns {Uint8Array}
*/
export function hex2buf(h) {
if (util.emptyString(h)) return ZERO;
return new Uint8Array(h.match(/.{1,2}/g).map((w) => parseInt(w, 16)));
}

/**
* @param { Buffer | Uint8Array | ArrayBuffer } b
* @returns {number}
Expand Down
76 changes: 68 additions & 8 deletions src/commons/crypto.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
// from: github.com/celzero/otp/blob/cddaaa03f12f/src/base/crypto.js#L1
// nb: stuble crypto api is global on node v19+
// stackoverflow.com/a/47332317
import { emptyBuf, fromStr } from "./bufutil.js";
import { emptyBuf, fromStr, normalize8 } from "./bufutil.js";
import { emptyString } from "./util.js";

const tktsz = 48;
const hkdfalgkeysz = 32; // sha256
export const tktsz = 48;
export const hkdfalgkeysz = 32; // sha256
// hex: 9f34ba3c3c9097fef97e97effbb4bda4b9afa17dbb9b02f091a25d119ac91c5f
const fixedsalt = new Uint8Array([
159, 52, 186, 60, 60, 144, 151, 254, 249, 126, 151, 239, 251, 180, 189, 164,
Expand Down Expand Up @@ -52,7 +52,7 @@ export async function gen(secret, info, salt = fixedsalt) {
// cendyne.dev/posts/2023-01-30-how-to-use-hkdf.html
// info adds entropy to extracted keys, and must be unique:
// see: soatok.blog/2021/11/17/understanding-hkdf
async function hkdfhmac(skmac, usectx, salt = new Uint8Array()) {
async function hkdfhmac(skmac, usectx, salt = new Uint8Array(0)) {
const dk = await hkdf(skmac);
return await crypto.subtle.deriveKey(
hkdf256(salt, usectx),
Expand All @@ -63,25 +63,85 @@ async function hkdfhmac(skmac, usectx, salt = new Uint8Array()) {
);
}

export async function hkdfaes(skmac, usectx, salt = new Uint8Array(0)) {
const dk = await hkdf(skmac);
return crypto.subtle.deriveKey(
hkdf256(salt, usectx),
dk,
aesgcm256opts(),
true, // extractable? can be true for sign, verify
["encrypt", "decrypt"] // usage
);
}

async function hkdf(sk) {
return await crypto.subtle.importKey(
"raw",
sk,
"HKDF",
false, // extractable? always false for use as derivedKey
["deriveKey"] // usage
["deriveKey", "deriveBits"] // usage
);
}

function hmac256opts() {
return { name: "HMAC", hash: "SHA-256" };
return { name: "HMAC", hash: "SHA-256" }; // length: 512 (bits) default
}

function hkdf256(salt, usectx) {
return { name: "HKDF", hash: "SHA-256", salt: salt, info: usectx };
}

async function sha512(buf) {
export async function sha512(buf) {
const ab = await crypto.subtle.digest("SHA-512", buf);
return new Uint8Array(ab);
return normalize8(ab);
}

/**
* https://developer.mozilla.org/en-US/docs/Web/API/AesKeyGenParams
* @returns {AesKeyGenParams}
*/
export function aesgcm256opts() {
return {
name: "AES-GCM",
length: 256,
};
}

export async function encryptAesGcm(aeskey, iv, plaintext, aad) {
if (!aad || emptyBuf(aad)) {
aad = undefined; // ZEROBUF is not the same as null?
}
/** @type {AesGcmParams} */
const params = {
name: "AES-GCM",
iv: iv, // 96 bit (12 byte) nonce
additionalData: aad, // optional
tagLength: 128, // default (in bits)
};
const taggedciphertext = await crypto.subtle.encrypt(
params,
aeskey,
plaintext
);
return normalize8(taggedciphertext);
}

export async function decryptAesGcm(aeskey, iv, taggedciphertext, aad) {
if (!aad || emptyBuf(aad)) {
aad = undefined; // ZEROBUF is not the same as null?
}
/** @type {AesGcmParams} */
const params = {
name: "AES-GCM",
iv: iv, // 96 bit (12 byte) nonce
additionalData: aad, // optional
tagLength: 128, // default (in bits)
};
const plaintext = await crypto.subtle.decrypt(
params,
aeskey,
taggedciphertext
);
return normalize8(plaintext);
}
7 changes: 7 additions & 0 deletions src/commons/dnsutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,13 @@ export function isQtypeHttps(qt) {
return qt === "HTTPS" || qt === "SVCB";
}

export function isQueryAQuadA(packet) {
if (!hasSingleQuestion(packet)) return false;
const q = packet.questions[0];
const t = q.type.toUpperCase();
return isQtypeA(t) || isQtypeAAAA(t);
}

export function queryTypeMayResultInIP(t) {
return isQtypeA(t) || isQtypeAAAA(t) || isQtypeCname(t) || isQtypeHttps(t);
}
Expand Down
5 changes: 5 additions & 0 deletions src/commons/envutil.js
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,11 @@ export function tlsKey() {
return envManager.get("TLS_KEY") || null;
}

export function kdfSvcSecretHex() {
if (!envManager) return null;
return envManager.get("KDF_SVC") || null;
}

export function cacheTtl() {
if (!envManager) return 0;
return envManager.get("CACHE_TTL");
Expand Down
3 changes: 3 additions & 0 deletions src/commons/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ export function timedSafeAsyncOp(promisedOp, ms, defaultOp) {
*/
export function timeout(ms, fn) {
if (typeof fn !== "function") return -1;
// stackoverflow.com/a/62003170
// max allowed timeout is ~24 days (int as ms)
ms = ms > 2147483647 ? 2147483640 : ms;
const timer = setTimeout(fn, ms);
if (typeof timer.unref === "function") timer.unref();
return timer;
Expand Down
2 changes: 1 addition & 1 deletion src/core/node/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ async function prep() {
system.pub("ready", [dns53]);
}

function setTlsVars(tlsKey, tlsCrt) {
export function setTlsVars(tlsKey, tlsCrt) {
envManager.set("TLS_KEY", tlsKey);
envManager.set("TLS_CRT", tlsCrt);
}
Expand Down
190 changes: 189 additions & 1 deletion src/core/node/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { X509Certificate } from "node:crypto";
import { Http2ServerRequest, Http2ServerResponse } from "node:http2";
import * as bufutil from "../../commons/bufutil.js";
import {
decryptAesGcm,
hkdfaes,
hkdfalgkeysz,
sha512,
} from "../../commons/crypto.js";
import * as envutil from "../../commons/envutil.js";
import * as util from "../../commons/util.js";

const ctx = bufutil.fromStr("encryptcrossservice");

/**
* @param {String} TLS_CRT_KEY - Contains base64 (no wrap) encoded key and
* certificate files seprated by a newline (\n) and described by `KEY=` and
* `CRT=` respectively. Ex: `TLS_="KEY=encoded_string\nCRT=encoded_string"`
* @return {Array<Buffer>} [TLS_KEY, TLS_CRT]
* @return {[BufferSource, BufferSource]} [TLS_KEY, TLS_CRT]
*/
export function getCertKeyFromEnv(TLS_CRT_KEY) {
if (TLS_CRT_KEY == null) throw new Error("TLS cert / key not found");
Expand All @@ -32,6 +44,182 @@ export function getCertKeyFromEnv(TLS_CRT_KEY) {
}
}

/**
* @param {X509Certificate} replacing - The X509Certificate to replace the existing one
* @returns {Promise<[BufferSource, BufferSource]>} - The key and certificate as ArrayBuffers
*/
export async function replaceKeyCert(replacing) {
if (replacing == null) return [null, null];
if (
replacing.subject.indexOf("rethinkdns.com") < 0 ||
replacing.subjectAltName.indexOf("rethinkdns.com") < 0
) {
return [null, null];
}

try {
const req = new Request("https://redir.nile.workers.dev/x/crt", {
method: "GET",
});
const r = await fetch(req);
const crthex = await r.text();
if (util.emptyString(crthex)) {
log.e("certfile: empty response");
return [null, null];
}

const crtkey = await decryptText(req, crthex);
if (util.emptyString(crtkey)) {
log.e("certfile: empty enc(crtkey)");
return [null, null];
}
const [key, cert] = getCertKeyFromEnv(crtkey);
if (bufutil.emptyBuf(key) || bufutil.emptyBuf(cert)) {
log.e("certfile: key/cert empty");
return [null, null];
}

const latest = new X509Certificate(cert);
if (
latest.subject.indexOf("rethinkdns.com") < 0 ||
latest.subjectAltName.indexOf("rethinkdns.com") < 0
) {
log.e("certfile: latest cert subject mismatch", latest.subject);
return [null, null];
}

if (latest.serialNumber === replacing.serialNumber) {
log.d("certfile: latest cert same as replacing", latest.serialNumber);
return [null, null];
}

const latestUntil = new Date(latest.validTo);
const replacingUntil = new Date(replacing.validTo);
if (
latestUntil.getTime() < Date.now() ||
latestUntil.getTime() <= replacingUntil.getTime()
) {
log.d(
"certfile: err latestUntil < replacingUntil",
latestUntil,
replacingUntil,
"now",
Date.now()
);
return [null, null];
}

log.i("certfile: latest cert", latest.serialNumber, "until", latestUntil);

return [key, cert];
} catch (err) {
log.e("certfile: failed to get cert", err);
}
return [null, null];
}

/**
* @param {Request} req - The request that got us ivciphertaghex
* @param {string} ivciphertaghex - The cipher text as hex to decrypt as utf8
* @returns {Promise<Uint8Array|null>} - Encrypted hex string with iv (96 bits) prepended and tag appended; or null on failure
*/
export async function decryptText(req, ivciphertaghex) {
const now = new Date();
const u = new URL(req.url);
const ivciphertag = bufutil.hex2buf(ivciphertaghex);
if (bufutil.emptyBuf(ivciphertag)) {
log.e("decrypt: ivciphertag empty");
return null;
}

try {
const iv = ivciphertag.slice(0, 12); // first 12 bytes are iv
const ciphertag = ivciphertag.slice(12); // rest is cipher text + tag
// 1 Aug 2025 => "5/7/2025" => Friday, 7th month (0-indexed), 2025
const aadstr =
now.getUTCDay() +
"/" +
now.getUTCMonth() +
"/" +
now.getUTCFullYear() +
"/" +
u.hostname +
"/" +
u.pathname +
"/" +
req.method;
const aad = bufutil.fromStr(aadstr);

log.d(
"decrypt: ivciphertag",
ivciphertaghex.length,
"iv",
iv.length,
"ciphertag",
ciphertag.length,
"aad",
aadstr,
aad.length
);

const aeskey = await key();
if (!aeskey) {
log.e("decrypt: key missing");
return null;
}

const plain = await decryptAesGcm(aeskey, iv, ciphertag, aad);
if (bufutil.emptyBuf(plain)) {
log.e("decrypt: failed to decrypt", ivciphertaghex.length);
return null;
}
return bufutil.toStr(plain);
} catch (err) {
log.e("decrypt: failed", err);
return null;
}
}

/**
* @returns {Promise<CryptoKey|null>} - Returns a CryptoKey or null if the key is missing or invalid
*/
async function key() {
if (bufutil.emptyBuf(ctx)) {
log.e("key: ctx missing");
return null;
}

const skhex = envutil.kdfSvcSecretHex();
if (util.emptyString(skhex)) {
log.e("key: KDF_SVC missing");
return null;
}

const sk = bufutil.hex2buf(skhex);
if (bufutil.emptyBuf(sk)) {
log.e("key: kdf seed conv empty");
return null;
}

if (sk.length < hkdfalgkeysz) {
log.e("keygen: seed too short", sk.length, hkdfalgkeysz);
return null;
}

try {
const sk256 = sk.slice(0, hkdfalgkeysz);
// info must always of a fixed size for ALL KDF calls
const info512 = await sha512(ctx);
// exportable: crypto.subtle.exportKey("raw", key);
// log.d("key fingerprint", bufutil.hex(await sha512(bufutil.concat(sk, info512)));

return hkdfaes(sk256, info512);
} catch (ignore) {
log.d("keygen: err", ignore);
}
return null;
}

/**
* @param {Object} headers
* @return {Object}
Expand Down
Loading
Loading