diff --git a/ts/packages/ceremony/src/README.md b/ts/packages/ceremony/src/README.md new file mode 100644 index 00000000..6f112d7e --- /dev/null +++ b/ts/packages/ceremony/src/README.md @@ -0,0 +1,59 @@ +# Preserved, not wired up + +**This directory does not compile, is not a package, and is not built, tested +or published.** It has no `package.json`, so `pnpm -r build|test|typecheck` +skips it entirely. + +It exists for one reason: these 1,616 lines are the only TypeScript +implementation of the ceremony wire constructions that exists anywhere, and +they were about to be deleted from a contracts pull request where they did not +belong. This is the copy, kept so the work is not lost. + +## Where it came from + +`libid-org/libid-contracts#13`, `ts/packages/contracts/src/ceremony/`. + +They were removed from there because that repository owns the contracts and +the TypeScript wrappers around them — typed ABIs, calldata, the concrete types +a caller needs to invoke a function. These files are none of those. They are +browser runtime protocol code: nothing here calls a contract, and nothing here +is needed in order to call one. + +## What is in it + +| file | what it implements | +|---|---| +| `authorization.ts` | ceremony-common §5 Authorization Digest, §7 derived PKCE `code_verifier` | +| `attestation.ts` | ceremony-common §9.1 attestation format, plus coverage, bearer-framing and uniqueness checks | +| `profile.ts` | pinned platform profiles and protocol parameters, and the GitHub Token-Exchange Service HTTP contract | +| `*.test.ts` | 686 lines of tests, passing at the commit they were taken from | + +## Why it does not compile here + +`profile.ts` imports `../identity/handleVectors.js`, which is generated in +libid-contracts from `solidity/contracts/identity/handles.json` and does not +exist in this repository. That import is the only structural break; everything +else is self-contained apart from `viem` and `vitest`. + +## What still has to be decided + +Nothing here should be taken as settled placement. + +- `@libid/ceremony` does not exist yet. Its architecture and module layout are + specified in #13, which is documentation only and unmerged. When that lands, + these files should be reorganized to match it rather than kept as they are. +- `profile.ts` mixes two things. The pinned platform constants mirror + `CeremonyProfile.sol`. The token-exchange half — `TOKEN_EXCHANGE_ROUTE`, + the size caps, `TokenExchangeRequestV1`/`ResponseV1` and their validators — + is the HTTP contract of a server, and it currently disagrees with + `ts/packages/ceremony/SERVER.md` as proposed in #13: that document specifies + `POST /api/v1/ceremony/github-token` carrying no `schema` member, while + merged `specs/platform-ceremonies.md` §6.3 fixes + `/oauth/github/token-exchange` with `schema: 1`. These files implement the + merged specification. One of the two has to move. +- Cross-implementation agreement is currently a hex string hand-copied into + three repositories (here, `CeremonyAttestation.t.sol`, and + `libid-rs/crates/libid-ceremony`). Nothing checks that the three match. + Generating conformance vectors from one source, the way libid-contracts + already generates its handle vectors, would make that a guarantee instead of + a convention. diff --git a/ts/packages/ceremony/src/attestation.test.ts b/ts/packages/ceremony/src/attestation.test.ts new file mode 100644 index 00000000..5f5341d8 --- /dev/null +++ b/ts/packages/ceremony/src/attestation.test.ts @@ -0,0 +1,330 @@ +import { describe, expect, it } from 'vitest' +import { + AttestationError, + type AttestedData, + attestationDigest, + decodeAttestedData, + encodeAttestedData, + HEADER_LEN, + requireBearerHeaderRequest, + requireFramedCommitment, + requireExactCoverage, + tag, +} from './attestation.js' + +/// The exact bytes `libid-rs/crates/libid-ceremony` encodes and +/// `solidity/contracts/ceremony/test/CeremonyAttestation.t.sol` decodes. All +/// three carry this fixture: a divergence would have the notary sign a +/// preimage the chain rebuilds differently, deriving a key nobody trusts and +/// rejecting every genuine attestation. +const FIXTURE = + '4930142f5283d4a8eab0d24c588f00b21213ae2a47e7ed6c1dc6a57044f1655d' + + '0000000069800e800000003c0000002800000000000000020000000000000000' + + '0000001461616161616161616161616161616161616161610000002800000000' + + '0000001462626262626262626262626262626262626262620000000000000001' + + '0000001400000028070707070707070707070707070707070707070707070707' + + '0707070707070707000000000000000100000000000000000000000a63636363' + + '63636363636300000000000000010000000a0000002809090909090909090909' + + '09090909090909090909090909090909090909090909' + +const FIXTURE_DIGEST = '0x48162f05bdb27b19b3544bf2aae608745861bf357bb31e07f536b6fb50e95936' + +function bytes(hex: string): Uint8Array { + const out = new Uint8Array(hex.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16) + return out +} + +function sample(): AttestedData { + return { + authorityId: tag('api.x.com'), + createdAt: 1_770_000_000n, + sentTranscriptLength: 60, + recvTranscriptLength: 40, + sent: { + revealed: [ + { start: 0, end: 20, bytes: new Uint8Array(20).fill(0x61) }, + { start: 40, end: 60, bytes: new Uint8Array(20).fill(0x62) }, + ], + commitments: [{ start: 20, end: 40, commitment: new Uint8Array(32).fill(7) }], + }, + received: { + revealed: [{ start: 0, end: 10, bytes: new Uint8Array(10).fill(0x63) }], + commitments: [{ start: 10, end: 40, commitment: new Uint8Array(32).fill(9) }], + }, + } +} + +const hex = (b: Uint8Array) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('') + +describe('attested data', () => { + it('agrees with the Rust encoder and the Solidity decoder', () => { + expect(hex(encodeAttestedData(sample()))).toBe(FIXTURE) + expect(attestationDigest(sample())).toBe(FIXTURE_DIGEST) + }) + + it('round trips', () => { + const decoded = decodeAttestedData(bytes(FIXTURE)) + expect(hex(encodeAttestedData(decoded))).toBe(FIXTURE) + expect(decoded.createdAt).toBe(1_770_000_000n) + expect(decoded.sentTranscriptLength).toBe(60) + expect(decoded.sent.revealed).toHaveLength(2) + expect(decoded.sent.commitments).toHaveLength(1) + expect(new TextDecoder().decode(decoded.received.revealed[0]!.bytes)).toBe('cccccccccc') + }) + + it('lays the header out in 48 bytes', () => { + expect(HEADER_LEN).toBe(48) + }) + + it('separates the two sessions by what the notary observed', () => { + // Nothing in the record labels which session it covers. What separates + // them is the request line, which is a revealed range the notary recorded + // rather than a tag it was handed. + const token = sample() + const first = token.sent.revealed[0] + const line = new TextEncoder().encode('POST /2/oauth2/token ').slice(0, first.bytes.length) + token.sent.revealed[0] = { ...first, bytes: line } + expect(attestationDigest(token)).not.toBe(attestationDigest(sample())) + }) + + // The chain rejects both of these. A dry run that does not is worse than no + // dry run: the runtime spends a second session on a session already lost. + it('refuses a bare line feed in the revealed request', () => { + const head = new TextEncoder().encode( + 'GET /2/users/me HTTP/1.1\r\nhost: api.x.com\nauthorization: Bearer VICTIM\r\nauthorization: Bearer ', + ) + const tail = new TextEncoder().encode('\r\n\r\n') + const start = head.length + const end = start + 16 + expect(() => + requireBearerHeaderRequest( + { + revealed: [ + { start: 0, end: start, bytes: head }, + { start: end, end: end + tail.length, bytes: tail }, + ], + commitments: [{ start, end, commitment: new Uint8Array(32) }], + }, + end + tail.length, + ), + ).toThrow(AttestationError) + }) + + it('refuses a needle split across adjacent revealed ranges', () => { + const enc = new TextEncoder() + const head = enc.encode('GET /2/users/me HTTP/1.1\r\nhost: api.x.com\r\n') + const victim = enc.encode('\r\nauthorization: Bearer VICTIMTOKENVICTIM') + const own = enc.encode('\r\nauthorization: Bearer ') + const tail = enc.encode('\r\nconnection: close\r\n\r\n') + + // The cut falls six bytes into the victim's needle, so neither half of the + // pair holds a whole one. + const a = new Uint8Array([...head, ...victim.slice(0, 6)]) + const b = new Uint8Array([...victim.slice(6), ...own]) + const bearerStart = a.length + b.length + const bearerEnd = bearerStart + 16 + const total = bearerEnd + tail.length + + expect(() => + requireBearerHeaderRequest( + { + revealed: [ + { start: 0, end: a.length, bytes: a }, + { start: a.length, end: bearerStart, bytes: b }, + { start: bearerEnd, end: total, bytes: tail }, + ], + commitments: [{ start: bearerStart, end: bearerEnd, commitment: new Uint8Array(32) }], + }, + total, + ), + ).toThrow(AttestationError) + }) + + it('finds the one commitment framed by the given bytes', () => { + const prefix = new TextEncoder().encode('"access_token":"') + const suffix = new TextEncoder().encode('"') + const start = prefix.length + const end = start + 20 + const block = { + revealed: [ + { start: 0, end: start, bytes: prefix }, + { start: end, end: end + 1, bytes: suffix }, + ], + commitments: [{ start, end, commitment: new Uint8Array(32).fill(7) }], + } + expect(requireFramedCommitment(block, '"access_token":"', '"').start).toBe(start) + expect(() => requireFramedCommitment(block, '"refresh_token":"', '"')).toThrow(AttestationError) + }) + + it('refuses trailing bytes', () => { + expect(() => decodeAttestedData(bytes(FIXTURE + '00'))).toThrow(AttestationError) + }) + + it('refuses every truncation', () => { + const full = bytes(FIXTURE) + for (let cut = 0; cut < full.length; cut++) { + expect(() => decodeAttestedData(full.subarray(0, cut))).toThrow(AttestationError) + } + }) + + it('refuses a count that outruns the buffer', () => { + const tampered = bytes(FIXTURE) + tampered[HEADER_LEN] = 0xff + tampered[HEADER_LEN + 1] = 0xff + expect(() => decodeAttestedData(tampered)).toThrow(AttestationError) + }) + + it('refuses out-of-order ranges', () => { + const a = sample() + a.sent.revealed.reverse() + expect(() => encodeAttestedData(a)).toThrow(/behind the previous end/) + }) + + it('refuses an empty range', () => { + const a = sample() + a.sent.revealed[0] = { start: 0, end: 0, bytes: new Uint8Array(0) } + expect(() => encodeAttestedData(a)).toThrow(/is empty/) + }) + + it('refuses a range past the signed transcript length', () => { + // The signed length is what makes bytes past the last revealed range + // visible at all (REQ-COMMON-36). + const a = { ...sample(), sentTranscriptLength: 50 } + expect(() => encodeAttestedData(a)).toThrow(/past the signed transcript length/) + }) + + it('refuses a commitment overlapping a revealed range', () => { + const a = sample() + a.sent.commitments[0]!.start = 10 + expect(() => encodeAttestedData(a)).toThrow(/overlaps a revealed range/) + }) + + it('refuses a range whose bytes disagree with its offsets', () => { + const a = sample() + a.sent.revealed[0]!.bytes = new Uint8Array(19) + expect(() => encodeAttestedData(a)).toThrow(/carries 19 bytes/) + }) + + it('accepts an exact tiling', () => { + const a = sample() + expect(() => requireExactCoverage(a.sent, 'sent', a.sentTranscriptLength)).not.toThrow() + }) + + it('rejects a gap, which validate accepts on purpose', () => { + // Coverage is conditional under REQ-COMMON-43, so the shape check passes + // and the identity-session verifier is what must refuse this. + const a = sample() + a.sent.commitments[0]!.start = 21 + expect(() => encodeAttestedData(a)).not.toThrow() + expect(() => requireExactCoverage(a.sent, 'sent', a.sentTranscriptLength)).toThrow( + /bytes 20\.\.21 .* covered by nothing/, + ) + }) + + it('rejects a trailing gap', () => { + // Bytes past the last range are invisible without the signed length. + const a = sample() + expect(() => requireExactCoverage(a.sent, 'sent', 80)).toThrow(/bytes 60\.\.80/) + }) +}) + +describe('identity-session request', () => { + const BEARER = 'AAAAbbbbCCCCdddd' + const enc = (s: string) => new TextEncoder().encode(s) + + /// A real `/2/users/me` request: bearer committed, everything else revealed, + /// tiled exactly. + function request(extraHeader: string, bearerPrefix: string) { + const head = `GET /2/users/me HTTP/1.1\r\naccept: application/json\r\nhost: api.x.com\r\n${extraHeader}${bearerPrefix}` + const tail = '\r\nconnection: close\r\n\r\n' + const start = head.length + const end = start + BEARER.length + const length = end + tail.length + const block = { + revealed: [ + { start: 0, end: start, bytes: enc(head) }, + { start: end, end: length, bytes: enc(tail) }, + ], + commitments: [{ start, end, commitment: new Uint8Array(32).fill(5) }], + } + return { block, length } + } + + const honest = () => request('', '\r\nauthorization: Bearer ') + + it('accepts an honest request', () => { + const { block, length } = honest() + expect(requireBearerHeaderRequest(block, length).commitment[0]).toBe(5) + }) + + it('rejects a second authorization header', () => { + const { block, length } = request( + 'authorization: Bearer stolen\r\n', + '\r\nauthorization: Bearer ', + ) + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/2 authorization header lines/) + }) + + it('rejects a case and whitespace evaded second header', () => { + // A literal search over raw bytes would miss this one. + const { block, length } = request( + 'AuThOrIzAtIoN:\tBeArEr stolen\r\n', + '\r\nauthorization: Bearer ', + ) + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/2 authorization header lines/) + }) + + it('rejects an obsolete line fold', () => { + // `authorization:\r\n Bearer x` normalizes to `authorization:\r\nbearer`, + // so the needle never matches and the header is never counted. + const { block, length } = request( + 'authorization:\r\n Bearer stolen\r\n', + '\r\nauthorization: Bearer ', + ) + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/obsolete line fold/) + }) + + it('rejects a request with no authorization header', () => { + const { block, length } = request('', '\r\nx-other: ') + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/0 authorization header lines/) + }) + + it('rejects a gap the scan would never read', () => { + const { block, length } = honest() + block.revealed[0]!.end -= 1 + block.revealed[0]!.bytes = block.revealed[0]!.bytes.subarray( + 0, + block.revealed[0]!.bytes.length - 1, + ) + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/covered by nothing/) + }) + + it('rejects a commitment that is not the header value', () => { + // Framing alone: the header is whole and revealed, coverage is exact, but + // the committed range sits in `host` instead. + const head = + 'GET /2/users/me HTTP/1.1\r\naccept: application/json\r\nauthorization: Bearer TOKEN123\r\nhost: ' + const committed = 'api.' + const tail = 'x.com\r\nconnection: close\r\n\r\n' + const start = head.length + const end = start + committed.length + const length = end + tail.length + const block = { + revealed: [ + { start: 0, end: start, bytes: enc(head) }, + { start: end, end: length, bytes: enc(tail) }, + ], + commitments: [{ start, end, commitment: new Uint8Array(32).fill(5) }], + } + expect(() => requireBearerHeaderRequest(block, length)).toThrow( + /not framed by an authorization header/, + ) + }) + + it('rejects more than one commitment', () => { + const { block, length } = honest() + block.commitments.unshift({ start: 0, end: 1, commitment: new Uint8Array(32).fill(6) }) + expect(() => requireBearerHeaderRequest(block, length)).toThrow(/2 commitments, not one/) + }) +}) diff --git a/ts/packages/ceremony/src/attestation.ts b/ts/packages/ceremony/src/attestation.ts new file mode 100644 index 00000000..17de4d63 --- /dev/null +++ b/ts/packages/ceremony/src/attestation.ts @@ -0,0 +1,463 @@ +/// The attestation format of ceremony-common section 9.1. +/// +/// This mirrors `solidity/contracts/ceremony/CeremonyAttestation.sol` and +/// `libid-rs/crates/libid-ceremony` byte for byte. The browser needs the +/// decoder as much as the chain does: REQ-PLAT-44 has the Canonical Runtime +/// verify the token-exchange attestation locally, against the profile's pinned +/// notary key and this format, before it spends a `/user` session on it. +/// +/// Every boundary is derivable from the bytes before it, so decoding is one +/// forward pass and two different attestations cannot share one preimage by +/// shifting a boundary (REQ-COMMON-48). + +import { type Hex, keccak256, toHex } from 'viem' + +/// The authority, `createdAt`, and the two transcript lengths. +export const HEADER_LEN = 48 + +/// Offsets are zero-based into that direction's complete transcript, `start` +/// inclusive and `end` exclusive. +export interface RevealedRange { + start: number + end: number + bytes: Uint8Array +} + +/// A hidden range, carried as its offsets and a blinded commitment. The +/// plaintext of a committed range never appears in the attested data. +export interface RangeCommitment { + start: number + end: number + commitment: Uint8Array +} + +export interface DirectionBlock { + revealed: RevealedRange[] + commitments: RangeCommitment[] +} + +/// The signed bytes. +/// +/// The attested data describes the observed session and says nothing about +/// where the evidence will be spent: no chain, no verifier identity. +export interface AttestedData { + authorityId: Uint8Array + createdAt: bigint + sentTranscriptLength: number + recvTranscriptLength: number + sent: DirectionBlock + received: DirectionBlock +} + +export class AttestationError extends Error {} + +/// Derive a 32-byte tag from a libID-namespaced ASCII string. +export function tag(namespaced: string): Uint8Array { + return hexToBytes(keccak256(new TextEncoder().encode(namespaced))) +} + +function hexToBytes(value: Hex): Uint8Array { + const body = value.slice(2) + const out = new Uint8Array(body.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16) + return out +} + +class Writer { + private parts: Uint8Array[] = [] + push(bytes: Uint8Array) { + this.parts.push(bytes) + } + uint(value: number | bigint, width: number) { + const out = new Uint8Array(width) + let v = BigInt(value) + for (let i = width - 1; i >= 0; i--) { + out[i] = Number(v & 0xffn) + v >>= 8n + } + if (v !== 0n) throw new AttestationError(`value does not fit ${width} bytes`) + this.parts.push(out) + } + finish(): Uint8Array { + const total = this.parts.reduce((n, p) => n + p.length, 0) + const out = new Uint8Array(total) + let at = 0 + for (const p of this.parts) { + out.set(p, at) + at += p.length + } + return out + } +} + +class Reader { + constructor( + private readonly bytes: Uint8Array, + public at = 0, + ) {} + take(n: number, field: string): Uint8Array { + if (n < 0 || this.at + n > this.bytes.length) { + throw new AttestationError(`attested data ends inside the ${field} field`) + } + const out = this.bytes.subarray(this.at, this.at + n) + this.at += n + return out + } + uint(width: number, field: string): number { + const raw = this.take(width, field) + let out = 0 + for (const b of raw) out = out * 256 + b + return out + } + bigUint(width: number, field: string): bigint { + const raw = this.take(width, field) + let out = 0n + for (const b of raw) out = (out << 8n) | BigInt(b) + return out + } +} + +function checkSpan( + direction: string, + kind: string, + start: number, + end: number, + length: number, + previousEnd: number, +): void { + if (end <= start) + throw new AttestationError(`${kind} of the ${direction} direction is empty at ${start}`) + if (start < previousEnd) { + throw new AttestationError( + `${kind} of the ${direction} direction starts at ${start}, behind the previous end ${previousEnd}`, + ) + } + if (end > length) { + throw new AttestationError( + `${kind} of the ${direction} direction ends at ${end}, past the signed transcript length ${length}`, + ) + } +} + +/// Reject a shape the Platform Verifier must refuse: ranges out of order, +/// overlapping, empty, or ending past the signed transcript length +/// (REQ-COMMON-59, REQ-COMMON-60). +function validateDirection(block: DirectionBlock, direction: string, length: number): void { + let previousEnd = 0 + for (const range of block.revealed) { + checkSpan(direction, 'revealed range', range.start, range.end, length, previousEnd) + if (range.bytes.length !== range.end - range.start) { + throw new AttestationError( + `revealed range ${range.start}..${range.end} of the ${direction} direction carries ${range.bytes.length} bytes`, + ) + } + previousEnd = range.end + } + + previousEnd = 0 + for (const commitment of block.commitments) { + checkSpan(direction, 'commitment', commitment.start, commitment.end, length, previousEnd) + previousEnd = commitment.end + if (commitment.commitment.length !== 32) { + throw new AttestationError(`a commitment of the ${direction} direction is not 32 bytes`) + } + for (const range of block.revealed) { + if (commitment.start < range.end && range.start < commitment.end) { + throw new AttestationError( + `a commitment of the ${direction} direction overlaps a revealed range at ${commitment.start}..${commitment.end}`, + ) + } + } + } +} + +/// Require the revealed ranges and commitments of one direction to tile +/// `[0, length)` exactly, with no gap and no overlap (REQ-COMMON-35). +/// +/// Only for a direction whose profile demands exact coverage. The rule is +/// conditional: REQ-COMMON-43 withholds it from a credential committed in a +/// request body, which is GitHub's `client_secret`, so `validate` does not +/// apply it and a caller asks for it where the profile does. +/// +/// A gap is where a prover hides bytes. Exact coverage leaves the committed +/// range as the only region the verifier cannot read. +export function requireExactCoverage( + block: DirectionBlock, + direction: string, + length: number, +): void { + const spans: Array<[number, number]> = [ + ...block.revealed.map((r): [number, number] => [r.start, r.end]), + ...block.commitments.map((c): [number, number] => [c.start, c.end]), + ].sort((a, b) => a[0] - b[0]) + + let at = 0 + for (const [start, end] of spans) { + // Overlap is `validate`'s job, but a caller may build a block by hand. + if (start < at) { + throw new AttestationError(`spans of the ${direction} direction overlap at ${start}`) + } + if (start !== at) { + throw new AttestationError( + `transcript bytes ${at}..${start} of the ${direction} direction are covered by nothing`, + ) + } + at = end + } + if (at !== length) { + throw new AttestationError( + `transcript bytes ${at}..${length} of the ${direction} direction are covered by nothing`, + ) + } +} + +/// `\r\nauthorization: Bearer ` — the raw bytes REQ-COMMON-40 requires +/// immediately before the committed range. +export const BEARER_PREFIX = '\r\nauthorization: Bearer ' +/// And immediately after it. +export const BEARER_SUFFIX = '\r\n' +/// The normalized, line-anchored needle REQ-COMMON-39 counts. +export const AUTHORIZATION_NEEDLE = '\r\nauthorization:bearer' + +const ascii = (b: Uint8Array) => Array.from(b, (c) => String.fromCharCode(c)).join('') + +/// Lowercase ASCII and drop every space and horizontal tab, keeping CR and LF +/// (REQ-COMMON-39). Field names and the scheme token are case-insensitive and +/// the colon admits whitespace, so a literal search over raw bytes is evadable. +function normalizeHeaderBytes(raw: Uint8Array): string { + let out = '' + for (const b of raw) { + if (b === 0x20 || b === 0x09) continue + out += String.fromCharCode(b >= 0x41 && b <= 0x5a ? b + 0x20 : b) + } + return out +} + +function countNeedle(haystack: string): number { + let count = 0 + for ( + let i = haystack.indexOf(AUTHORIZATION_NEEDLE); + i !== -1; + i = haystack.indexOf(AUTHORIZATION_NEEDLE, i + 1) + ) { + count++ + } + return count +} + +/// Read `[from, to)` of the transcript out of the revealed ranges, or `null` if +/// any byte of it is not revealed. +function revealedSlice(block: DirectionBlock, from: number, to: number): string | null { + let out = '' + let at = from + while (at < to) { + const range = block.revealed.find((r) => r.start <= at && at < r.end) + if (!range) return null + const offset = at - range.start + const take = Math.min(range.bytes.length - offset, to - at) + out += ascii(range.bytes.subarray(offset, offset + take)) + at += take + } + return out +} + +/// Every check REQ-COMMON-35, -39 and -40 require of an identity-session +/// request that commits a credential in an HTTP `Authorization` header. +/// +/// At launch that is X's `/2/users/me` and GitHub's `/user`, and nothing else: +/// REQ-COMMON-43 forbids applying these to a credential committed in a request +/// body, which is GitHub's `client_secret`. +/// +/// The three are one call because they are one property, and two are worthless +/// alone. The uniqueness scan counts the needle across REVEALED bytes only, so +/// a byte covered by nothing is a byte it never reads: without coverage a +/// prover hides a second authorization header in a gap and the count stays at +/// one. +/// +/// The chain runs this too. Here it lets the runtime fail before it spends a +/// submission, and nothing on chain depends on that repeat. +export function requireBearerHeaderRequest(block: DirectionBlock, length: number): RangeCommitment { + if (block.commitments.length !== 1) { + throw new AttestationError( + `the direction holds ${block.commitments.length} commitments, not one`, + ) + } + const commitment = block.commitments[0]! + + requireExactCoverage(block, 'sent', length) + + // Obsolete line folding is illegal in HTTP/1.1 and defeats the needle: + // `authorization:\r\n Bearer x` normalizes to `authorization:\r\nbearer`, + // because normalization strips the space but keeps the CRLF the fold added. + const revealed = block.revealed.map((r) => ascii(r.bytes)).join('') + for (let i = 0; i + 2 < revealed.length; i++) { + if ( + revealed[i] === '\r' && + revealed[i + 1] === '\n' && + (revealed[i + 2] === ' ' || revealed[i + 2] === '\t') + ) { + throw new AttestationError(`the revealed bytes carry an obsolete line fold at ${i}`) + } + } + + // Every LF must be part of a CRLF. Otherwise + // `...\nauthorization: Bearer \r\n` starts a header line the + // CRLF-anchored needle never counts, while a lenient platform parser honours + // it. + for (let i = 0; i < revealed.length; i++) { + if (revealed[i] === '\n' && (i === 0 || revealed[i - 1] !== '\r')) { + throw new AttestationError(`the revealed bytes carry a bare line feed at ${i}`) + } + } + + // Counted over the CONCATENATION, not per range. Per range was wrong in the + // unsafe direction: the prover picks where the reveals are cut, so cutting + // one through a second `\r\nauthorization:` makes neither half contain the + // needle. A seam can only over-count, which fails closed. + const joined = new Uint8Array(block.revealed.reduce((n, r) => n + r.bytes.length, 0)) + let at = 0 + for (const r of block.revealed) { + joined.set(r.bytes, at) + at += r.bytes.length + } + const count = countNeedle(normalizeHeaderBytes(joined)) + if (count !== 1) { + throw new AttestationError( + `the revealed bytes hold ${count} authorization header lines, not one`, + ) + } + + // Framing, on RAW bytes at known offsets. + const before = + commitment.start >= BEARER_PREFIX.length + ? revealedSlice(block, commitment.start - BEARER_PREFIX.length, commitment.start) + : null + const after = revealedSlice(block, commitment.end, commitment.end + BEARER_SUFFIX.length) + if (before !== BEARER_PREFIX || after !== BEARER_SUFFIX) { + throw new AttestationError('the committed range is not framed by an authorization header line') + } + + return commitment +} + +/// The one commitment framed by exactly these revealed bytes. +/// +/// For a direction that is NOT exactly covered, where several ranges are hidden +/// and only the anchors around one of them are revealed. The token response is +/// that case: the bearer is committed and every other byte is too, so without +/// the anchors the committed range is indistinguishable from a `refresh_token` +/// value (REQ-PLAT-57, REQ-PLAT-58). +/// +/// The chain runs this in `_tokenSession`. A runtime that skips it locally +/// finds out by losing two Notary Fees. +export function requireFramedCommitment( + block: DirectionBlock, + prefix: string, + suffix: string, +): RangeCommitment { + let found: RangeCommitment | null = null + for (const commitment of block.commitments) { + const before = + commitment.start >= prefix.length + ? revealedSlice(block, commitment.start - prefix.length, commitment.start) + : null + if (before !== prefix) continue + if (revealedSlice(block, commitment.end, commitment.end + suffix.length) !== suffix) continue + // Two framed commitments frame nothing: the anchors must name one range. + if (found !== null) { + throw new AttestationError('more than one commitment is framed by those bytes') + } + found = commitment + } + if (found === null) { + throw new AttestationError('no commitment is framed by those bytes') + } + return found +} + +export function validate(attested: AttestedData): void { + validateDirection(attested.sent, 'sent', attested.sentTranscriptLength) + validateDirection(attested.received, 'received', attested.recvTranscriptLength) +} + +function requireTag(value: Uint8Array, field: string): Uint8Array { + if (value.length !== 32) throw new AttestationError(`${field} must be 32 bytes`) + return value +} + +/// Serialize exactly the byte concatenation of section 9.1. +export function encodeAttestedData(attested: AttestedData): Uint8Array { + validate(attested) + const w = new Writer() + w.push(requireTag(attested.authorityId, 'authorityId')) + w.uint(attested.createdAt, 8) + w.uint(attested.sentTranscriptLength, 4) + w.uint(attested.recvTranscriptLength, 4) + for (const block of [attested.sent, attested.received]) { + w.uint(block.revealed.length, 8) + for (const range of block.revealed) { + w.uint(range.start, 4) + // The range's length is its bytes; `end` is arithmetic, never encoded. + w.uint(range.bytes.length, 8) + w.push(range.bytes) + } + w.uint(block.commitments.length, 8) + for (const commitment of block.commitments) { + w.uint(commitment.start, 4) + w.uint(commitment.end, 4) + w.push(commitment.commitment) + } + } + return w.finish() +} + +function decodeDirection(r: Reader): DirectionBlock { + const revealedCount = r.uint(8, 'revealed range count') + const revealed: RevealedRange[] = [] + for (let i = 0; i < revealedCount; i++) { + const start = r.uint(4, 'revealed range start') + // The range's length is its bytes. There is no separate `end` to disagree + // with it, so `end` here is arithmetic rather than a claim. + const len = r.uint(8, 'revealed range length') + revealed.push({ start, end: start + len, bytes: r.take(len, 'revealed range bytes') }) + } + + const commitmentCount = r.uint(8, 'commitment count') + const commitments: RangeCommitment[] = [] + for (let i = 0; i < commitmentCount; i++) { + commitments.push({ + start: r.uint(4, 'commitment start'), + end: r.uint(4, 'commitment end'), + commitment: r.take(32, 'commitment value'), + }) + } + return { revealed, commitments } +} + +/// Parse and validate. Trailing bytes are refused: the layout accounts for +/// every byte, so a suffix is a second message hiding behind the first. +export function decodeAttestedData(bytes: Uint8Array): AttestedData { + const r = new Reader(bytes) + const attested: AttestedData = { + authorityId: r.take(32, 'authorityId'), + createdAt: r.bigUint(8, 'createdAt'), + sentTranscriptLength: r.uint(4, 'sentTranscriptLength'), + recvTranscriptLength: r.uint(4, 'recvTranscriptLength'), + sent: { revealed: [], commitments: [] }, + received: { revealed: [], commitments: [] }, + } + attested.sent = decodeDirection(r) + attested.received = decodeDirection(r) + if (r.at !== bytes.length) { + throw new AttestationError( + `${bytes.length - r.at} bytes remain after the received direction block`, + ) + } + validate(attested) + return attested +} + +/// `keccak256(attestedData)` — the only preimage the notary signs. +export function attestationDigest(attested: AttestedData): Hex { + return keccak256(toHex(encodeAttestedData(attested))) +} diff --git a/ts/packages/ceremony/src/authorization.test.ts b/ts/packages/ceremony/src/authorization.test.ts new file mode 100644 index 00000000..83225d27 --- /dev/null +++ b/ts/packages/ceremony/src/authorization.test.ts @@ -0,0 +1,155 @@ +import { keccak256 } from 'viem' +import { describe, expect, it } from 'vitest' +import { + type AuthorizationPreimage, + authorizationDigest, + authorizationPreimage, + base64UrlNoPad, + chainId, + evmChainId, + codeChallenge, + codeVerifier, + operationDomain, + PKCE_LEN, + PREIMAGE_FIXED_LEN, + pkceDomain, + verifierHash, +} from './authorization.js' + +/// Every expected value is transcribed from ceremony-common, not produced by +/// this module. A test that rebuilt them here would agree with any +/// implementation, including a wrong one. +const DIGEST = '0xb318fb559e16a179b853ed2853576cda16032d93b0839bb81a55135d334c0af5' +const PKCE_NONCE = `0x${'44'.repeat(32)}` as const + +const VECTOR: AuthorizationPreimage = { + operationDomain: operationDomain('libid.claim-identity'), + platformVerifierVersion: 1, + chainId: chainId(new TextEncoder().encode('example:1')), + authorizationNonce: `0x${'55'.repeat(32)}`, + transactionData: '0x00010203', +} + +describe('authorization digest', () => { + it('derives the constants the specification publishes', () => { + expect(operationDomain('libid.claim-identity')).toBe( + '0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b', + ) + expect(chainId(new TextEncoder().encode('example:1'))).toBe( + '0x38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066', + ) + expect(pkceDomain()).toBe('0x3961dfe56cd0f2d94e72a15b96df889fbb46968cdb37518830fc0077b0730a01') + }) + + /// @dev The one form an EVM chain's verifier recomputes. Deriving it any + /// other way builds a digest the chain disagrees with, and the + /// submission dies on `code_verifier` after both fees are charged. + it('mirrors CeremonyProofVerifier.chainId for an EVM chain', () => { + // keccak256(abi.encode(uint256(1))) -- 32 bytes, big-endian, left-padded. + expect(evmChainId(1)).toBe(keccak256(`0x${'00'.repeat(31)}01`)) + expect(evmChainId(1n)).toBe(evmChainId(1)) + expect(() => evmChainId(-1)).toThrow(/uint256/) + }) + + it('reproduces the published preimage', () => { + expect(authorizationPreimage(VECTOR)).toBe( + '0xcb29bed0428519ef88a3d670e8203db76e06f41aca3e684e2c63b516c9b93e1b' + + '0001' + + '38064d82f31db40935cc75f2a0d07dcfb448d7c08e7484fc30f5de95484a4066' + + '5555555555555555555555555555555555555555555555555555555555555555' + + '00000004' + + '00010203', + ) + }) + + it('reproduces the published digest', () => { + expect(authorizationDigest(VECTOR)).toBe(DIGEST) + }) + + it('lays the fixed part out in 102 bytes', () => { + const empty = authorizationPreimage({ ...VECTOR, transactionData: '0x' }) + expect((empty.length - 2) / 2).toBe(PREIMAGE_FIXED_LEN) + }) + + it('binds every field', () => { + const base = authorizationDigest(VECTOR) + expect(authorizationDigest({ ...VECTOR, platformVerifierVersion: 2 })).not.toBe(base) + expect(authorizationDigest({ ...VECTOR, transactionData: '0x0001020304' })).not.toBe(base) + expect(authorizationDigest({ ...VECTOR, authorizationNonce: `0x${'56'.repeat(32)}` })).not.toBe( + base, + ) + expect( + authorizationDigest({ ...VECTOR, chainId: chainId(new TextEncoder().encode('example:2')) }), + ).not.toBe(base) + expect( + authorizationDigest({ ...VECTOR, operationDomain: operationDomain('libid.other') }), + ).not.toBe(base) + }) + + it('separates a shifted boundary with the length prefix', () => { + expect(authorizationDigest({ ...VECTOR, transactionData: '0x0001' })).not.toBe( + authorizationDigest({ ...VECTOR, transactionData: '0x00010000' }), + ) + }) + + it('refuses a version that does not fit two bytes', () => { + expect(() => authorizationPreimage({ ...VECTOR, platformVerifierVersion: 0x10000 })).toThrow( + /two bytes/, + ) + }) + + it('refuses a mis-sized fixed field', () => { + expect(() => authorizationPreimage({ ...VECTOR, chainId: '0x1234' })).toThrow(/32 bytes/) + }) +}) + +describe('pkce', () => { + it('reproduces the published triple', () => { + expect(verifierHash(DIGEST, PKCE_NONCE)).toBe( + '0x88c493361ea0424467046958d5cd0c50eb03ecc08ee06f02ee9875fe0219b392', + ) + const verifier = codeVerifier(DIGEST, PKCE_NONCE) + expect(verifier).toBe('iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I') + expect(codeChallenge(verifier)).toBe('BhFqYIY1YnHafYOrrblUswFnjxFF97UvGjSgqugPQvA') + }) + + it('produces 43 unpadded base64url characters', () => { + const verifier = codeVerifier(DIGEST, PKCE_NONCE) + const challenge = codeChallenge(verifier) + for (const value of [verifier, challenge]) { + expect(value).toHaveLength(PKCE_LEN) + expect(value).toMatch(/^[A-Za-z0-9_-]{43}$/) + } + }) + + it('changes with the digest, which is the whole binding', () => { + const other = `0x${'00'.repeat(32)}` as const + expect(codeVerifier(DIGEST, PKCE_NONCE)).not.toBe(codeVerifier(other, PKCE_NONCE)) + }) + + it('changes with the nonce, so a retry is unpredictable', () => { + const other = `0x${'45'.repeat(32)}` as const + expect(codeVerifier(DIGEST, PKCE_NONCE)).not.toBe(codeVerifier(DIGEST, other)) + }) +}) + +describe('base64url', () => { + /// RFC 4648 section 10, with `+` and `/` substituted. Covers all three tail + /// lengths, which is where a padded encoder differs. + it.each([ + ['', ''], + ['f', 'Zg'], + ['fo', 'Zm8'], + ['foo', 'Zm9v'], + ['foob', 'Zm9vYg'], + ['fooba', 'Zm9vYmE'], + ['foobar', 'Zm9vYmFy'], + ])('encodes %o as %o', (input, expected) => { + expect(base64UrlNoPad(new TextEncoder().encode(input))).toBe(expected) + }) + + it('uses the url alphabet, never + or /', () => { + // 0xfb 0xff reaches index 62 and 63, which are `-` and `_` here. + expect(base64UrlNoPad(new Uint8Array([0xfb, 0xff, 0xfe]))).toBe('-__-') + }) +}) diff --git a/ts/packages/ceremony/src/authorization.ts b/ts/packages/ceremony/src/authorization.ts new file mode 100644 index 00000000..bcdf8ffe --- /dev/null +++ b/ts/packages/ceremony/src/authorization.ts @@ -0,0 +1,178 @@ +/// The Authorization Digest of ceremony-common section 5, and the PKCE +/// construction of section 7 that carries it. +/// +/// This mirrors `solidity/contracts/ceremony/CeremonyAuthorization.sol` and +/// `libid-rs/crates/libid-ceremony` byte for byte. The runtime builds the +/// digest before the ceremony starts and the Proof Verifier rebuilds it from +/// the submission; the two must agree or nothing verifies, so the same +/// published vectors pin all three implementations. + +import { concat, type Hex, keccak256, numberToBytes, sha256, toHex } from 'viem' + +/// Fixed part of the preimage: 32 + 2 + 32 + 32 + 4. +export const PREIMAGE_FIXED_LEN = 102 + +/// Both the verifier and the challenge are this many unpadded base64url +/// characters. +export const PKCE_LEN = 43 + +/// Carries no version of its own: the digest already binds +/// `platformVerifierVersion`, and a change to this construction changes the +/// proof statement, which bumps that version (REQ-COMMON-12). +export const PKCE_DOMAIN_STRING = 'libid.identity.pkce' + +export interface AuthorizationPreimage { + /// `keccak256` of the Consumer's libID-namespaced operation-domain string. + operationDomain: Hex + platformVerifierVersion: number + /// `keccak256` of the bytes the chain's own identifier contributes, never + /// the identifier itself: chains name themselves incompatibly, and some too + /// wide for 64 bits (REQ-COMMON-01C). + chainId: Hex + authorizationNonce: Hex + transactionData: Hex +} + +/// Derive an operation domain from its string. +/// +/// The Consumer fixes one libID-namespaced ASCII string per transaction kind. +/// A new operation, or a change to one operation's transaction-data meaning, +/// takes a new string rather than another digest field (REQ-COMMON-01A). +export function operationDomain(domainString: string): Hex { + return keccak256(new TextEncoder().encode(domainString)) +} + +/// Derive a chain id from the exact bytes its Chain Profile fixes. +/// +/// The digest commits a HASH of the chain's identifier rather than the +/// identifier itself, because chains name themselves incompatibly and some too +/// wide for 64 bits (REQ-COMMON-01C). +export function chainId(identifierBytes: Uint8Array | Hex): Hex { + return keccak256(identifierBytes) +} + +/// The chain id of an EVM chain, exactly as `CeremonyProofVerifier.chainId()` +/// computes it. +/// +/// `keccak256(abi.encode(block.chainid))` — the identifier's 32-byte +/// big-endian encoding, hashed. Deriving it any other way builds a digest the +/// chain recomputes differently, and the whole submission then fails on the +/// `code_verifier` comparison after both Notary Fees have been charged, with +/// nothing in the error saying why. So the one form that matches is spelled +/// out here rather than left to each caller. +export function evmChainId(id: bigint | number): Hex { + const value = BigInt(id) + if (value < 0n || value > 2n ** 256n - 1n) { + throw new Error(`chain id ${id} does not fit a uint256`) + } + return keccak256(numberToBytes(value, { size: 32 })) +} + +/// `PKCE_DOMAIN`. +export function pkceDomain(): Hex { + return keccak256(new TextEncoder().encode(PKCE_DOMAIN_STRING)) +} + +function requireBytes(value: Hex, want: number, field: string): Uint8Array { + const bytes = hexToBytes(value) + if (bytes.length !== want) { + throw new Error(`${field} must be ${want} bytes, got ${bytes.length}`) + } + return bytes +} + +function hexToBytes(value: Hex): Uint8Array { + const body = value.startsWith('0x') ? value.slice(2) : value + if (body.length % 2 !== 0) throw new Error(`odd-length hex: ${value}`) + const out = new Uint8Array(body.length / 2) + for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(body.slice(i * 2, i * 2 + 2), 16) + return out +} + +/// Build the preimage of section 5, exactly. +/// +/// Only the transaction data varies in length, so every other field sits at a +/// fixed offset and no boundary can be shifted to reinterpret one +/// authorization as another (REQ-COMMON-01). +export function authorizationPreimage(input: AuthorizationPreimage): Hex { + const { platformVerifierVersion: version } = input + if (!Number.isInteger(version) || version < 0 || version > 0xffff) { + throw new Error(`platformVerifierVersion ${version} does not fit two bytes`) + } + const transactionData = hexToBytes(input.transactionData) + if (transactionData.length > 0xffffffff) { + throw new Error(`transaction data does not fit the four-byte length field`) + } + + return concat([ + toHex(requireBytes(input.operationDomain, 32, 'operationDomain')), + toHex(version, { size: 2 }), + toHex(requireBytes(input.chainId, 32, 'chainId')), + toHex(requireBytes(input.authorizationNonce, 32, 'authorizationNonce')), + toHex(transactionData.length, { size: 4 }), + toHex(transactionData), + ]) +} + +/// The Authorization Digest. +export function authorizationDigest(input: AuthorizationPreimage): Hex { + return keccak256(authorizationPreimage(input)) +} + +/// `SHA256(PKCE_DOMAIN || authorizationDigest || pkceNonce)`. +export function verifierHash(digest: Hex, pkceNonce: Hex): Hex { + return sha256( + concat([ + pkceDomain(), + toHex(requireBytes(digest, 32, 'authorizationDigest')), + toHex(requireBytes(pkceNonce, 32, 'pkceNonce')), + ]), + ) +} + +/// `BASE64URL_NOPAD(verifierHash)` — the 43 ASCII bytes the token request +/// reveals, which REQ-COMMON-15A has the Platform Verifier recompute and +/// compare byte for byte. +/// +/// `pkceNonce` is drawn freshly per authorization attempt: it becomes public +/// at submission, so reusing one across attempts of a single digest publishes +/// the verifier of an earlier attempt whose code may still be live +/// (REQ-COMMON-13). +export function codeVerifier(digest: Hex, pkceNonce: Hex): string { + return base64UrlNoPad(hexToBytes(verifierHash(digest, pkceNonce))) +} + +/// `BASE64URL_NOPAD(SHA256(ASCII(codeVerifier)))` — the S256 challenge. +export function codeChallenge(verifier: string): string { + return base64UrlNoPad(hexToBytes(sha256(toHex(new TextEncoder().encode(verifier))))) +} + +const B64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +/// Encode bytes as unpadded base64url. +/// +/// Written out rather than routed through `btoa`, which is padded, base64 not +/// base64url, and absent from some runtimes this package must run in. +export function base64UrlNoPad(bytes: Uint8Array): string { + let out = '' + let i = 0 + for (; i + 3 <= bytes.length; i += 3) { + const [b0, b1, b2] = [bytes[i]!, bytes[i + 1]!, bytes[i + 2]!] + out += B64URL_ALPHABET[b0 >> 2] + out += B64URL_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)] + out += B64URL_ALPHABET[((b1 & 15) << 2) | (b2 >> 6)] + out += B64URL_ALPHABET[b2 & 63] + } + const rest = bytes.length - i + if (rest === 1) { + const b0 = bytes[i]! + out += B64URL_ALPHABET[b0 >> 2] + out += B64URL_ALPHABET[(b0 & 3) << 4] + } else if (rest === 2) { + const [b0, b1] = [bytes[i]!, bytes[i + 1]!] + out += B64URL_ALPHABET[b0 >> 2] + out += B64URL_ALPHABET[((b0 & 3) << 4) | (b1 >> 4)] + out += B64URL_ALPHABET[(b1 & 15) << 2] + } + return out +} diff --git a/ts/packages/ceremony/src/index.ts b/ts/packages/ceremony/src/index.ts new file mode 100644 index 00000000..bee76883 --- /dev/null +++ b/ts/packages/ceremony/src/index.ts @@ -0,0 +1,10 @@ +/// The wire constructions of the libID identity ceremony. +/// +/// One implementation of each construction the specification fixes, mirroring +/// `libid-rs/crates/libid-ceremony` and `solidity/contracts/ceremony/` so the +/// runtime, the notary and the chain cannot disagree about bytes. The +/// published conformance vectors pin all three. + +export * from './attestation.js' +export * from './authorization.js' +export * from './profile.js' diff --git a/ts/packages/ceremony/src/profile.test.ts b/ts/packages/ceremony/src/profile.test.ts new file mode 100644 index 00000000..58c0bd1f --- /dev/null +++ b/ts/packages/ceremony/src/profile.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from 'vitest' +import { + attestationCount, + GITHUB_V1, + GOOGLE_V1, + LAUNCH_PARAMETERS, + LAUNCH_PROFILES, + TokenExchangeError, + type TokenExchangeRequestV1, + validateTokenExchangeRequest, + validateTokenExchangeResponse, + X_V1, +} from './profile.js' + +/// Expected values computed with `cast keccak`, independently of this module. +/// The same strings live in the Rust and Solidity profiles; a disagreement +/// rejects every genuine attestation with no error that says why. +describe('profile tags', () => { + it('pins the platform ids, and they are distinct', () => { + expect(GOOGLE_V1.platformId).toBe( + '0x8f2f90d8304f6eb382d037c47a041d8c8b4d18bdd8b082fa32828e016a584ca7', + ) + expect(X_V1.platformId).toBe( + '0x7521d1cadbcfa91eec65aa16715b94ffc1c9654ba57ea2ef1a2127bca1127a83', + ) + expect(GITHUB_V1.platformId).toBe( + '0x07a17bd3c7c8d7b88e93a4d9007e3bc230b0a586a434de0bed6500e9f343deb7', + ) + expect(new Set(LAUNCH_PROFILES.map((p) => p.platformId)).size).toBe(3) + }) +}) + +describe('profiles', () => { + it('counts attestations from the session list', () => { + // Google's path stops at the Platform Verifier and costs nothing; X and + // GitHub each pay two Notary Fees per submission. + expect(attestationCount(GOOGLE_V1)).toBe(0) + expect(attestationCount(X_V1)).toBe(2) + expect(attestationCount(GITHUB_V1)).toBe(2) + }) + + it('binds the digest by exactly one method per profile', () => { + expect(GOOGLE_V1.digestBinding).toBe('public-proof-input') + expect(X_V1.digestBinding).toBe('revealed-code-verifier') + expect(GITHUB_V1.digestBinding).toBe('revealed-code-verifier') + }) + + it('gives GitHub two different authorities', () => { + // The exchange is served by github.com, the identity read by + // api.github.com. One pinned authority per profile would be wrong. + expect(GITHUB_V1.tokenSession?.authority).toBe('github.com') + expect(GITHUB_V1.identitySession?.authority).toBe('api.github.com') + }) + + it('keeps every authority and path canonical', () => { + for (const profile of LAUNCH_PROFILES) { + for (const session of [profile.tokenSession, profile.identitySession]) { + if (!session) continue + expect(session.authority).toBe(session.authority.toLowerCase()) + expect(session.authority.endsWith('.')).toBe(false) + expect(session.authority).not.toMatch(/[/:]/) + expect(session.path.startsWith('/')).toBe(true) + expect(session.path).not.toContain('?') + } + } + }) + + it('carries the published launch parameters', () => { + expect(LAUNCH_PARAMETERS).toEqual({ + proofLifetimeX: 3600n, + proofLifetimeGithub: 3600n, + maxFutureAttestationSkew: 300n, + }) + }) + + it('matches the published handle parameter table', () => { + expect(GOOGLE_V1.handle).toEqual({ + maxLength: 62, + stripLeadingAt: false, + isEmail: true, + allowUnderscore: false, + allowHyphen: false, + }) + expect(X_V1.handle.maxLength).toBe(15) + expect(X_V1.handle.allowUnderscore).toBe(true) + expect(GITHUB_V1.handle.maxLength).toBe(39) + expect(GITHUB_V1.handle.allowHyphen).toBe(true) + }) +}) + +describe('token exchange', () => { + const request = (): TokenExchangeRequestV1 => ({ + schema: 1, + code: 'abc123', + codeVerifier: 'iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5I', + }) + + it('accepts a well-formed request', () => { + expect(() => validateTokenExchangeRequest(request())).not.toThrow() + }) + + it('accepts the verifier the specification itself publishes', () => { + // If it did not, the service would refuse a value §7 produces. + expect(request().codeVerifier).toHaveLength(43) + }) + + it.each([ + ['', /empty/], + ['a'.repeat(1025), /over the bound/], + ['ab cd', /printable ASCII/], + ['ab\tcd', /printable ASCII/], + ['ab\ncd', /printable ASCII/], + ])('refuses code %o', (code, message) => { + expect(() => validateTokenExchangeRequest({ ...request(), code })).toThrow(message) + }) + + it.each([ + 'short', + 'iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1_gIZs5', // 42 + 'iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1+gIZs5I', // base64, not base64url + 'iMSTNh6gQkRnBGlY1c0MUOsD7MCO4G8C7ph1/gIZs5I', + ])('refuses codeVerifier %o', (codeVerifier) => { + expect(() => validateTokenExchangeRequest({ ...request(), codeVerifier })).toThrow( + TokenExchangeError, + ) + }) + + it('bounds the response on decoded lengths, not encoded ones', () => { + // base64url expands by 4/3, so bounding the string would bound the wrong + // number and admit an oversized opening. + const opening = 'A'.repeat(Math.ceil((257 * 4) / 3)) + expect(() => + validateTokenExchangeResponse({ + schema: 1, + accessToken: 'token', + tokenAttestation: 'AAAA', + bearerOpening: opening, + }), + ).toThrow(/bearerOpening/) + }) + + it('accepts a 256-byte opening, which is the bound', () => { + // 342 characters is what 256 bytes encodes to: 85 whole groups and a + // remainder of two, which carries one byte. + const opening = 'A'.repeat(342) + expect(() => + validateTokenExchangeResponse({ + schema: 1, + accessToken: 'token', + tokenAttestation: 'AAAA', + bearerOpening: opening, + }), + ).not.toThrow() + }) + + // The response arrives from the network and is checked as strictly as the + // request. Every one of these failed much later before -- inside the prover, + // or inside a verifier decoding the attestation -- where the reason is gone. + const goodResponse = { + schema: 1, + accessToken: 'gho_16C7e42F292c6912E7710c838347Ae178B4a', + tokenAttestation: 'AAAA', + bearerOpening: 'AAAA', + } as const + + it('rejects an empty accessToken', () => { + expect(() => validateTokenExchangeResponse({ ...goodResponse, accessToken: '' })).toThrow( + /accessToken is empty/, + ) + }) + + it('rejects an accessToken outside printable ASCII', () => { + for (const token of ['gho_ab\r\ncd', 'gho_ab cd', 'gho_ab\u00e9']) { + expect(() => validateTokenExchangeResponse({ ...goodResponse, accessToken: token })).toThrow( + /printable ASCII/, + ) + } + }) + + it('rejects base64url fields outside the alphabet', () => { + expect(() => + validateTokenExchangeResponse({ ...goodResponse, tokenAttestation: 'AA+/' }), + ).toThrow(/tokenAttestation is not unpadded base64url/) + expect(() => validateTokenExchangeResponse({ ...goodResponse, bearerOpening: 'AAA=' })).toThrow( + /bearerOpening is not unpadded base64url/, + ) + }) + + it('rejects a base64url length no encoder produces', () => { + // Four characters carry three bytes, so a remainder of one is unreachable. + expect(() => + validateTokenExchangeResponse({ ...goodResponse, tokenAttestation: 'AAAAA' }), + ).toThrow(/length no encoder produces/) + }) + + it('rejects empty base64url fields', () => { + expect(() => validateTokenExchangeResponse({ ...goodResponse, bearerOpening: '' })).toThrow( + /bearerOpening is empty/, + ) + }) +}) diff --git a/ts/packages/ceremony/src/profile.ts b/ts/packages/ceremony/src/profile.ts new file mode 100644 index 00000000..5c7613f7 --- /dev/null +++ b/ts/packages/ceremony/src/profile.ts @@ -0,0 +1,279 @@ +/// Ceremony profile constants, the protocol parameters governance owns, and +/// the GitHub Token-Exchange Service contract. +/// +/// # The namespaced strings are ours, not the specification's +/// +/// ceremony-common fixes exactly one literal: `libid.identity.pkce`, in +/// section 7. Every other libID-namespaced string — the platform name +/// each Consumer's operation domain (REQ-COMMON-01A) — is required to exist +/// and required to be pinned, but its bytes are left to the profile author. +/// +/// So these are a cross-implementation agreement, not a reading of the +/// specification. A notary emitting one string and a verifier pinning another +/// derives a key nobody trusts and rejects every genuine attestation, with no +/// error that says why. `libid-rs/crates/libid-ceremony/src/profile.rs` and +/// `solidity/contracts/ceremony/CeremonyProfile.sol` carry the same strings. + +import { type Hex, keccak256 } from 'viem' + +import { + ALLOW_HYPHEN_GITHUB, + ALLOW_HYPHEN_GOOGLE, + ALLOW_HYPHEN_X, + ALLOW_UNDERSCORE_GITHUB, + ALLOW_UNDERSCORE_GOOGLE, + ALLOW_UNDERSCORE_X, + IS_EMAIL_GITHUB, + IS_EMAIL_GOOGLE, + IS_EMAIL_X, + MAX_LENGTH_GITHUB, + MAX_LENGTH_GOOGLE, + MAX_LENGTH_X, + STRIP_LEADING_AT_GITHUB, + STRIP_LEADING_AT_GOOGLE, + STRIP_LEADING_AT_X, +} from '../identity/handleVectors.js' + +const tag = (s: string): Hex => keccak256(new TextEncoder().encode(s)) + +/// How a profile binds the Authorization Digest to its evidence — exactly one +/// of the two, never both and never neither (REQ-COMMON-02C). +export type DigestBinding = 'public-proof-input' | 'revealed-code-verifier' + +/// One notarized session of a ceremony. +export interface SessionProfile { + /// The TLS server name the notary authenticated, lowercase ASCII with no + /// trailing dot. It reaches the verifier as `authorityId`, never as a + /// transcript range: the transcript holds it only in a prover-composed + /// `Host` header, which says nothing about which server answered. + authority: string + method: 'GET' | 'POST' + /// Origin-form path, no query. + path: string +} + +/// The five parameters of platform-ceremonies §2.1a. `isEmail` supersedes the +/// two booleans below it (REQ-PLAT-67); they are stated because REQ-PLAT-72 +/// requires a profile to fix all five. +export interface HandleRules { + maxLength: number + stripLeadingAt: boolean + isEmail: boolean + allowUnderscore: boolean + allowHyphen: boolean +} + +export interface PlatformProfile { + /// Preimage of `platformId` (REQ-COMMON-55). + name: string + platformId: Hex + /// Launch profiles use 1 (REQ-PLAT-01). + platformVerifierVersion: number + digestBinding: DigestBinding + tokenSession?: SessionProfile + identitySession?: SessionProfile + handle: HandleRules +} + +/// `google/v1` — authentication-only OIDC. No token exchange, no client +/// secret, no PKCE, no notarized session, and therefore no Notary Fee. +export const GOOGLE_V1: PlatformProfile = { + name: 'google', + platformId: tag('google'), + platformVerifierVersion: 1, + digestBinding: 'public-proof-input', + // Read from the generated table, never restated. `handles.json` is the + // one source; a second copy here would drift and the browser would + // normalize to a node the chain never writes. + handle: { + maxLength: MAX_LENGTH_GOOGLE, + stripLeadingAt: STRIP_LEADING_AT_GOOGLE, + isEmail: IS_EMAIL_GOOGLE, + allowUnderscore: ALLOW_UNDERSCORE_GOOGLE, + allowHyphen: ALLOW_HYPHEN_GOOGLE, + }, +} + +/// `x/v1` — a public client with S256 PKCE and two browser-owned sessions. +export const X_V1: PlatformProfile = { + name: 'x', + platformId: tag('x'), + platformVerifierVersion: 1, + digestBinding: 'revealed-code-verifier', + tokenSession: { + authority: 'api.x.com', + method: 'POST', + path: '/2/oauth2/token', + }, + identitySession: { + authority: 'api.x.com', + method: 'GET', + path: '/2/users/me', + }, + // Read from the generated table, never restated. `handles.json` is the + // one source; a second copy here would drift and the browser would + // normalize to a node the chain never writes. + handle: { + maxLength: MAX_LENGTH_X, + stripLeadingAt: STRIP_LEADING_AT_X, + isEmail: IS_EMAIL_X, + allowUnderscore: ALLOW_UNDERSCORE_X, + allowHyphen: ALLOW_HYPHEN_X, + }, +} + +/// `github/v1` — a confidential client, so the exchange runs in the +/// deployment's Token-Exchange Service and that service is the notarized party +/// for the token session. The two sessions have two different authorities. +export const GITHUB_V1: PlatformProfile = { + name: 'github', + platformId: tag('github'), + platformVerifierVersion: 1, + digestBinding: 'revealed-code-verifier', + tokenSession: { + authority: 'github.com', + method: 'POST', + path: '/login/oauth/access_token', + }, + identitySession: { + authority: 'api.github.com', + method: 'GET', + path: '/user', + }, + // Read from the generated table, never restated. `handles.json` is the + // one source; a second copy here would drift and the browser would + // normalize to a node the chain never writes. + handle: { + maxLength: MAX_LENGTH_GITHUB, + stripLeadingAt: STRIP_LEADING_AT_GITHUB, + isEmail: IS_EMAIL_GITHUB, + allowUnderscore: ALLOW_UNDERSCORE_GITHUB, + allowHyphen: ALLOW_HYPHEN_GITHUB, + }, +} + +export const LAUNCH_PROFILES = [GOOGLE_V1, X_V1, GITHUB_V1] as const + +/// Derived from the session list the profile fixes, never stated beside it +/// (REQ-COMMON-41). Google verifies none and pays nothing; X and GitHub verify +/// two each, so one submission on either path pays two Notary Fees. +export function attestationCount(profile: PlatformProfile): number { + return (profile.tokenSession ? 1 : 0) + (profile.identitySession ? 1 : 0) +} + +/// Governance-owned seconds. The Platform Verifier reads the current value +/// when it verifies; a browser read is advisory only (libid.md, REQ-PARAM-02). +export interface ProtocolParameters { + proofLifetimeX: bigint + proofLifetimeGithub: bigint + maxFutureAttestationSkew: bigint +} + +export const LAUNCH_PARAMETERS: ProtocolParameters = { + proofLifetimeX: 3600n, + proofLifetimeGithub: 3600n, + maxFutureAttestationSkew: 300n, +} + +// --- GitHub Token-Exchange Service (platform-ceremonies §6.3) --------------- + +/// Fixed route on the redirect origin. +export const TOKEN_EXCHANGE_ROUTE = '/oauth/github/token-exchange' + +export const MAX_GITHUB_CODE_BYTES = 1024 +export const GITHUB_CODE_VERIFIER_LEN = 43 +export const MAX_GITHUB_ACCESS_TOKEN_BYTES = 4096 +export const MAX_GITHUB_BEARER_OPENING_BYTES = 256 +export const MAX_GITHUB_TOKEN_ATTESTATION_BYTES = 2 * 1024 * 1024 +/// The whole response body, which a transport bounds before this module ever +/// parses it (REQ-PLAT-39). Exported so a server or a fetch wrapper can apply +/// it; `validateTokenExchangeResponse` bounds the fields, not the envelope. +export const MAX_GITHUB_TOKEN_EXCHANGE_RESPONSE_BYTES = 3 * 1024 * 1024 + +export interface TokenExchangeRequestV1 { + schema: 1 + code: string + codeVerifier: string +} + +export interface TokenExchangeResponseV1 { + schema: 1 + accessToken: string + /// Canonical unpadded base64url of the attested data. + tokenAttestation: string + /// Canonical unpadded base64url of the blinder that opens the committed + /// bearer range of that attestation. + /// + /// Private witness material. It must never enter a submission, a log, or + /// anything leaving the browser: the opening and the commitment together + /// reveal the credential the commitment exists to hide (REQ-PLAT-55). + bearerOpening: string +} + +export class TokenExchangeError extends Error {} + +/// Bounded parsing, per REQ-PLAT-37, REQ-PLAT-38 and REQ-PLAT-40. +export function validateTokenExchangeRequest(request: TokenExchangeRequestV1): void { + if (request.schema !== 1) throw new TokenExchangeError(`unknown schema ${request.schema}`) + if (request.code.length === 0) throw new TokenExchangeError('code is empty') + if (request.code.length > MAX_GITHUB_CODE_BYTES) { + throw new TokenExchangeError(`code is ${request.code.length} bytes, over the bound`) + } + // Printable ASCII excludes whitespace and control characters. + if (!/^[\x21-\x7e]+$/.test(request.code)) { + throw new TokenExchangeError('code carries a byte outside printable ASCII') + } + if (!/^[A-Za-z0-9_-]{43}$/.test(request.codeVerifier)) { + throw new TokenExchangeError('codeVerifier must match [A-Za-z0-9_-]{43}') + } +} + +/// Canonical unpadded base64url: the alphabet, and no length a decoder cannot +/// have produced. Four characters carry three bytes, so a remainder of one is +/// unreachable. +const BASE64URL_NOPAD = /^[A-Za-z0-9_-]+$/ + +/// Bounded parsing, per REQ-PLAT-39. +/// +/// The response is checked as strictly as the request. It arrives from the +/// network, and everything downstream of it -- the bearer that goes into a +/// circuit, the attestation a verifier decodes -- assumes the shape stated +/// here. An empty or non-printable `accessToken` fails much later, inside the +/// prover, where the reason is unrecoverable. +export function validateTokenExchangeResponse(response: TokenExchangeResponseV1): void { + if (response.schema !== 1) throw new TokenExchangeError(`unknown schema ${response.schema}`) + if (response.accessToken.length === 0) throw new TokenExchangeError('accessToken is empty') + if (response.accessToken.length > MAX_GITHUB_ACCESS_TOKEN_BYTES) { + throw new TokenExchangeError('accessToken is over the bound') + } + // Non-empty printable ASCII with no CR and no LF (REQ-PLAT-30, REQ-PLAT-36, + // REQ-COMMON-37). That is also what makes the length above a byte count. + if (!/^[\x21-\x7e]+$/.test(response.accessToken)) { + throw new TokenExchangeError('accessToken carries a byte outside printable ASCII') + } + requireBase64Url(response.bearerOpening, 'bearerOpening') + requireBase64Url(response.tokenAttestation, 'tokenAttestation') + // The bounds are on the DECODED lengths, so base64url expands by 4/3. + if (decodedLength(response.bearerOpening) > MAX_GITHUB_BEARER_OPENING_BYTES) { + throw new TokenExchangeError('bearerOpening is over the bound') + } + if (decodedLength(response.tokenAttestation) > MAX_GITHUB_TOKEN_ATTESTATION_BYTES) { + throw new TokenExchangeError('tokenAttestation is over the bound') + } +} + +function requireBase64Url(value: string, field: string): void { + if (value.length === 0) throw new TokenExchangeError(`${field} is empty`) + if (!BASE64URL_NOPAD.test(value)) { + throw new TokenExchangeError(`${field} is not unpadded base64url`) + } + if (value.length % 4 === 1) { + throw new TokenExchangeError(`${field} has a length no encoder produces`) + } +} + +function decodedLength(base64UrlNoPad: string): number { + const groups = Math.floor(base64UrlNoPad.length / 4) + const rest = base64UrlNoPad.length - groups * 4 + return groups * 3 + (rest === 0 ? 0 : rest - 1) +}