johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
// 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)]);
}