~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
96 lines · 3.7 KB Raw History
// Secrets sealed in the browser to a runner's key, as knotless::sealed in
// the crate seals them, so the value leaves the page only as ciphertext:
// HPKE (RFC 9180) in base mode, DHKEM(P-256, HKDF-SHA256), HKDF-SHA256 and
// AES-128-GCM, bound as the AEAD's associated data to where it is for.

import { Aes128Gcm, CipherSuite, DhkemP256HkdfSha256, HkdfSha256 } from '@hpke/core';

/** What each sealing is for, as the runner opens it. */
const INFO = new TextEncoder().encode('dev.knotless.secret v1');

/** P-256's field prime and the curve's b, for a compressed key's y. */
const P = 2n ** 256n - 2n ** 224n + 2n ** 192n + 2n ** 96n - 1n;
const B = 0x5ac635d8aa3a93e7b3ebbd55769886bc651d06b0cc53b0f63bce3c3e27d2604bn;

const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';

/** Where a sealed secret is for: the repository's owner and key, the runner, its name and the refs it is limited to. */
export type Bound = { owner: string; repo: string; runner: string; name: string; refs: string[] };

function unbase58(text: string): Uint8Array {
	let n = 0n;
	for (const c of text) {
		const digit = BASE58.indexOf(c);
		if (digit < 0) throw new Error('not base58');
		n = n * 58n + BigInt(digit);
	}
	const bytes: number[] = [];
	for (; n > 0n; n >>= 8n) bytes.unshift(Number(n & 0xffn));
	for (const c of text) {
		if (c !== '1') break;
		bytes.unshift(0);
	}
	return Uint8Array.from(bytes);
}

function power(base: bigint, exp: bigint): bigint {
	let out = 1n;
	for (base %= P; exp > 0n; exp >>= 1n) {
		if (exp & 1n) out = (out * base) % P;
		base = (base * base) % P;
	}
	return out;
}

function bytes32(n: bigint): number[] {
	return Array.from({ length: 32 }, (_, i) => Number((n >> BigInt(8 * (31 - i))) & 0xffn));
}

/** The uncompressed point of `key`, a P-256 `did:key`, as HPKE takes a key. */
export function point(key: string): Uint8Array {
	const raw = key.startsWith('did:key:z')
		? unbase58(key.slice('did:key:z'.length))
		: new Uint8Array();
	// The multicodec p256-pub, 0x1200 as a varint, then the compressed point.
	if (raw.length !== 35 || raw[0] !== 0x80 || raw[1] !== 0x24 || (raw[2] !== 2 && raw[2] !== 3))
		throw new Error(`${key} is not a P-256 did:key`);
	const x = raw.slice(3).reduce((n, b) => (n << 8n) | BigInt(b), 0n);
	const y2 = (((x * x * x - 3n * x + B) % P) + P) % P;
	// P is 3 mod 4, so a square's root is its (P + 1) / 4th power.
	let y = power(y2, (P + 1n) / 4n);
	if ((y * y) % P !== y2) throw new Error(`${key} is no point on P-256`);
	if ((y & 1n) !== BigInt(raw[2] & 1)) y = P - y;
	return Uint8Array.from([4, ...bytes32(x), ...bytes32(y)]);
}

/** Each field of `bound`, its length first, so no two differ only in where one ends. */
export function aad(bound: Bound): Uint8Array {
	const fields = [bound.owner, bound.repo, bound.runner, bound.name, ...bound.refs].map((f) =>
		new TextEncoder().encode(f)
	);
	const out = new Uint8Array(fields.reduce((n, f) => n + 4 + f.length, 0));
	const view = new DataView(out.buffer);
	let at = 0;
	for (const f of fields) {
		view.setUint32(at, f.length);
		out.set(f, at + 4);
		at += 4 + f.length;
	}
	return out;
}

/** `value` sealed to the runner key `key` for `bound`: the encapsulated key, then the ciphertext. */
export async function seal(key: string, bound: Bound, value: string): Promise<Uint8Array> {
	const suite = new CipherSuite({
		kem: new DhkemP256HkdfSha256(),
		kdf: new HkdfSha256(),
		aead: new Aes128Gcm()
	});
	const recipientPublicKey = await suite.kem.deserializePublicKey(point(key).buffer);
	const sealed = await suite.seal(
		{ recipientPublicKey, info: INFO },
		new TextEncoder().encode(value),
		aad(bound)
	);
	return Uint8Array.from([...new Uint8Array(sealed.enc), ...new Uint8Array(sealed.ct)]);
}