// 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 { 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)]); }