diff --git a/.claude/skills/apply-pr-feedback/SKILL.md b/.claude/skills/apply-pr-feedback/SKILL.md deleted file mode 100644 index 77b186c..0000000 --- a/.claude/skills/apply-pr-feedback/SKILL.md +++ /dev/null @@ -1,176 +0,0 @@ ---- -name: apply-pr-feedback -description: "Automated PR review loop: fetches all PR comments, checks the last one to determine state, evaluates and fixes codex findings, pushes, re-triggers review, and repeats until clean — then squash-merges." -argument-hint: "[pr-number-or-url]" ---- - -# Apply PR Feedback - -Deterministic loop that processes codex review feedback on a PR until clean, then merges. - -## Inputs - -- **PR number or URL** — optional. If not provided, detect from the current Graphite branch (`gt ls` or `gh pr list --head `). - -No manual copy-paste of feedback is needed — the skill reads comments directly from the PR. - -## Setup - -1. Determine the PR number (from argument or current branch). -2. Determine the repo owner/name from `git remote get-url origin`. - -## Step 1: Get All Comments - -Fetch all comments on the PR (both issue comments and PR review comments are visible here): -```bash -gh api repos/{owner}/{repo}/issues/{pr}/comments \ - --jq '[.[] | {id, user: .user.login, body: .body, created_at}] | sort_by(.created_at)' -``` - -Also fetch PR reviews (codex sometimes posts as a PR review instead of an issue comment): -```bash -gh api repos/{owner}/{repo}/pulls/{pr}/reviews \ - --jq '[.[] | {id, user: .user.login, body: .body, submitted_at}] | sort_by(.submitted_at)' -``` - -Combine both lists and sort by timestamp. Identify the **last comment/review** across both. - -## Step 2: Check the Last Comment - -Look at the last comment/review and branch: - -### (a) Last comment contains `@codex review` - -Review is still pending. Sleep 60 seconds, then go back to **Step 1**. - -### (b) Last comment/review is from `chatgpt-codex-connector[bot]` - -Check if it indicates a clean review or has findings: - -- **Clean review** — the body contains "Didn't find any major issues" or similar pass message → go to **Step 8**. -- **Has findings** — codex posted inline review comments → go to **Step 3**. - -To check for inline findings: -```bash -gh api repos/{owner}/{repo}/pulls/{pr}/comments \ - --jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]")]' -``` -Filter to only unresolved comments (check against resolved threads). If there are unresolved codex comments, proceed to **Step 3**. If all are resolved (or there are none), treat as clean → go to **Step 8**. - -### (c) Last comment is something else (e.g., Greptile, a human) - -Ignore it. Look at the comment before it and repeat this check. Walk backwards through comments until you find either `@codex review` or a `chatgpt-codex-connector[bot]` response. - -## Step 3: Evaluate Findings - -For each unresolved codex inline comment: - -1. Read the referenced file and line range with full surrounding context. -2. Evaluate the feedback against the actual code: - - **Valid** — real issue (bug, safety concern, spec violation, missing edge case) - - **Improvement** — better approach, correct but not strictly a bug - - **False positive** — problem does not actually exist in the code - -3. Report assessment: - ```text - [file:lines] — - Reason: <1-2 sentence explanation> - ``` - -4. If **valid** or **improvement**, apply the fix immediately. -5. If **false positive**, reply to the comment with a brief reason explaining why. - -## Step 4: Resolve PR Conversations - -After evaluating ALL comments and applying all fixes, resolve each review thread: - -To resolve a review thread, use the GraphQL API. First fetch thread IDs: -```bash -gh api graphql -f query=' - query { - repository(owner: "", name: "") { - pullRequest(number: ) { - reviewThreads(last: 50) { - nodes { id isResolved comments(first: 1) { nodes { body author { login } } } } - } - } - } - } -' -``` - -Then resolve each unresolved codex thread: -```bash -gh api graphql -f query=' - mutation { - resolveReviewThread(input: {threadId: ""}) { - thread { isResolved } - } - } -' -``` - -**Important:** `resolveReviewThread` requires a `PullRequestReviewThread` ID (starts with `PRRT_`), not a review comment ID. Match threads to comments by the first comment body and author. - -## Step 5: Run Acceptance Checks - -1. Run the repository acceptance criteria: - - Build commands (including no_std if applicable). - - Linter / clippy with warnings as errors. - - Formatter check. - - Full test suite. -2. If any check fails, fix the issue and rerun until passing. - -## Step 6: Push Changes - -Once all checks pass: -```bash -git add -gt modify -gt submit -``` - -Report summary: -```text -| File:Lines | Assessment | Action | -|---|---|---| -| file.rs:10-15 | Valid | Brief description of fix applied | -| file.rs:42-44 | False positive | Reason (no change) | - -Checks passed: -- -- - -Pushed via: gt modify + gt submit -``` - -## Step 7: Trigger Review and Loop - -```bash -gh pr comment {pr-number} --body "@codex review" -sleep 60 -``` - -Go back to **Step 1**. - -## Step 8: Merge PR - -Codex review passed with no issues. Ask the user: "Codex review passed. Shall I squash-merge this PR?" - -If the user accepts: -```bash -gh pr merge {pr-number} --squash --delete-branch -``` - -Report the merge result. - -## Rules - -- Never push before all acceptance checks pass. -- Keep fixes minimal and targeted to the feedback. -- Do not use `git commit` directly — use `gt modify` to amend into the existing branch commit. -- If a fix introduces a test failure elsewhere, investigate and fix before pushing. -- If feedback is ambiguous, ask the user to clarify before making changes. -- Only process comments from `chatgpt-codex-connector[bot]` — ignore other reviewers. -- Push autonomously — no user confirmation needed between loop iterations. -- When waiting for review, poll every 60 seconds — do not busy-loop. diff --git a/.claude/skills/implement-plan-linear/SKILL.md b/.claude/skills/implement-plan-linear/SKILL.md index 0c8350b..a186097 100644 --- a/.claude/skills/implement-plan-linear/SKILL.md +++ b/.claude/skills/implement-plan-linear/SKILL.md @@ -126,9 +126,6 @@ After manual confirmation for a phase, run these steps in order: 3. **Submit and publish the stack:** - `gt submit --publish` — pushes all branches in the stack and creates/updates PRs for each. -4. **Trigger automated review:** - - `gh pr comment --body "@codex review"` - Rules: - Always run `gt sync` before `gt create` — it is safe on stack branches and keeps the stack rebased on latest trunk. diff --git a/.gitignore b/.gitignore index 90f64d2..080eef4 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ build/ # plans thoughts/shared/plans/* +thoughts/shared/research/* diff --git a/packages/pq-key-fingerprint/test-data/test-keys/README.md b/packages/pq-key-fingerprint/test-data/test-keys/README.md new file mode 100644 index 0000000..bcd72b0 --- /dev/null +++ b/packages/pq-key-fingerprint/test-data/test-keys/README.md @@ -0,0 +1,11 @@ +# Test Key Fixtures + +This directory contains package-local test fixtures used by `pq-key-fingerprint` TypeScript tests. + +Source provenance: +- Copied from `packages/pq-key-encoder/test-data/test-keys/` +- Files currently mirrored: + - `ml_kem_512_pub.der` + - `ml_kem_512_pub.pem` + +These fixtures are duplicated intentionally to avoid runtime coupling to another package's test-data layout. diff --git a/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.der b/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.der new file mode 100644 index 0000000..4e48782 Binary files /dev/null and b/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.der differ diff --git a/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.pem b/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.pem new file mode 100644 index 0000000..fdcd3f7 --- /dev/null +++ b/packages/pq-key-fingerprint/test-data/test-keys/ml_kem_512_pub.pem @@ -0,0 +1,20 @@ +-----BEGIN PUBLIC KEY----- +MIIDMjALBglghkgBZQMEBAEDggMhACjwCgBjmsLKiitKIXfMCbMGdCWnRlDUELz4 +H4nGER+Dx2y6jewoTBdRQz6YIsApn1p0Z/uXVHoDn0FAUDdgHv+BuV/RluHjHBsL +iRsYip5TOCNHD4ekkI0UxAuRzkBmFUzCm5yVqCaZvcUnVRWRp56kQ9UcL5sRi33l +WqKHwuGGfJUCwy35Vezps7O5otEJzuuXko5mgXrQunY6yhpLmlwVcUY7x85AkrbX +sPtpFB1WwOCbON6TCdo4Bn42WyFGh1zlIc5oYpArTHYywWSMVwd8bWPoNmFxdFyK +oE/TdumWJGy4fBzWZwooVSpxZuTsxpgJSmMLafMil8Z2A/GzO5WAm/D3W5kBGKfi +QZehG8PovLjTl/+RE1WYsaFTVygVfM98q6wKtXInixYjPtzGfgPiCXLLGUa5Chmr +dK7TUOhYxfbZGCVTelxbuMJ0Hg4GjksWG9Q3L3HRxHVBm2EFi13MSNHjyMzwFovM +zxFsWDo8J94UfWL6FqPCn+bDflJoFsRgFVs3zuXqd1rheWUVXavHONoMTLaCUAlT +nwsWBd48QqehNP+7TGfpM7ErvaOAabdmip3QKE/objK8cRD7LEGzmW5JWYq1H9i8 +rQnQFFF5kBKECNLCZ4qQrqb0g0opCWfqGXcIkQA9WqPEZe/Qdc7aVQURQj+aJVim +U+06ezvWJzT3lNURc9vLt8TlEpEzaa5pfQPWm57FIMDWoB/Uut8YXfOJHxQyead4 +rU5BQUtTDmI8uAxnFEacpavSuGGGqLDGyqWmkyJEklNgVdjEWvdEvufSt8oSGmco +nsQWKQVSuYnTldqRskdiiEZZy9VBwgrkJjJsTsdTFr1Vja43laq1VJO0u3q5wB7A +CxRyFHRKzkMMSQ1spHGVPmxYAf63B/n4WcgDvT4zGb/2kANIAr5GhkITAZaYqrwp +QEsZF4RsK4Bgw2SzXSTAqz2rAxkkw84kBn/WtjgzudSZgH1wZYm0ZfMwgtA0Rgsi +KxQRXUdLWa/CRcEWNc6govJFEtDWepLuxdfD9f5AjiQWykctmlpYvknoSh9fSz9I +uin1mrhx +-----END PUBLIC KEY----- diff --git a/packages/pq-key-fingerprint/ts/README.md b/packages/pq-key-fingerprint/ts/README.md index faa934d..6ec4986 100644 --- a/packages/pq-key-fingerprint/ts/README.md +++ b/packages/pq-key-fingerprint/ts/README.md @@ -89,21 +89,60 @@ interface FingerprintOptions { type FingerprintResult = string | Uint8Array; -function fingerprintPublicKey(input: PublicKeyInput, options?: FingerprintOptions): Promise; +function fingerprintPublicKey( + input: PublicKeyInput, + options: FingerprintOptions & { encoding: 'bytes' }, +): Promise; +function fingerprintPublicKey(input: PublicKeyInput, options?: FingerprintOptions): Promise; +function fingerprintPublicKeyBytes( + bytes: Uint8Array, + alg: AlgorithmName, + options: FingerprintOptions & { encoding: 'bytes' }, +): Promise; function fingerprintPublicKeyBytes( bytes: Uint8Array, alg: AlgorithmName, options?: FingerprintOptions, -): Promise; -function fingerprintSPKI(spki: Uint8Array, options?: FingerprintOptions): Promise; -function fingerprintPEM(pem: string, options?: FingerprintOptions): Promise; -function fingerprintJWK(jwk: PQJwk, options?: FingerprintOptions): Promise; +): Promise; +function fingerprintSPKI( + spki: Uint8Array, + options: FingerprintOptions & { encoding: 'bytes' }, +): Promise; +function fingerprintSPKI(spki: Uint8Array, options?: FingerprintOptions): Promise; +function fingerprintPEM( + pem: string, + options: FingerprintOptions & { encoding: 'bytes' }, +): Promise; +function fingerprintPEM(pem: string, options?: FingerprintOptions): Promise; +function fingerprintJWK( + jwk: PQPublicJwk, + options: FingerprintOptions & { encoding: 'bytes' }, +): Promise; +function fingerprintJWK(jwk: PQPublicJwk, options?: FingerprintOptions): Promise; ``` ## Compatibility Note +Canonical fingerprint identity for interoperability is `SHA-256` with `hex` output (the default). Alternate digest or encoding choices are intended for advanced use-cases where both producer and consumer explicitly agree on format. + All exported fingerprint entrypoints enforce a strict local error boundary by design. Upstream parser/validation failures from `pq-key-encoder` are translated into `pq-key-fingerprint` error classes (subclasses of `FingerprintError`) before they leave this package. This behavior is intentional and part of the package contract. +`options` must be a plain object when provided and only supports `digest` plus `encoding`. Unknown option keys and invalid option values (for example, empty `digest`/`encoding` strings) are rejected rather than silently defaulting. + +The fingerprint preimage format is stable and versioned as: + +`UTF8("pq-key-fingerprint:v1") || 0x00 || UTF8(alg) || 0x00 || publicKeyBytes` + +`alg` uses canonical algorithm names emitted by `pq-key-encoder`; that canonicalization is part of this package contract. Algorithm names containing NUL bytes are rejected. + +Unexpected runtime/internal failures are wrapped as `FingerprintError` with the original failure attached via `cause` when available. + +Fingerprint digests are algorithm-scoped: the digest input is domain-separated and includes both the algorithm name and public key bytes. The same byte sequence under different algorithms yields different fingerprints. + +Runtime requirement: a WebCrypto `subtle.digest` implementation and `TextEncoder` must be available in the current runtime. + +Supported runtime baseline: Node.js 18+, Bun 1+, and modern browsers that expose global WebCrypto plus `TextEncoder`. + ## License MIT diff --git a/packages/pq-key-fingerprint/ts/package.json b/packages/pq-key-fingerprint/ts/package.json index bdd3db3..23a9fae 100644 --- a/packages/pq-key-fingerprint/ts/package.json +++ b/packages/pq-key-fingerprint/ts/package.json @@ -5,6 +5,17 @@ "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "engines": { + "node": ">=18" + }, + "sideEffects": false, "files": [ "dist" ], diff --git a/packages/pq-key-fingerprint/ts/src/errors.ts b/packages/pq-key-fingerprint/ts/src/errors.ts index 4b52664..9564bbb 100644 --- a/packages/pq-key-fingerprint/ts/src/errors.ts +++ b/packages/pq-key-fingerprint/ts/src/errors.ts @@ -1,7 +1,7 @@ /** Base error for pq-key-fingerprint failures. */ export class FingerprintError extends Error { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'FingerprintError'; Object.setPrototypeOf(this, new.target.prototype); } @@ -9,32 +9,32 @@ export class FingerprintError extends Error { /** Error for invalid or missing fingerprint input values. */ export class InvalidFingerprintInputError extends FingerprintError { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'InvalidFingerprintInputError'; } } /** Error for key-type mismatches, such as passing private keys. */ export class InvalidKeyTypeError extends FingerprintError { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'InvalidKeyTypeError'; } } /** Error for unsupported digest algorithm selections. */ export class UnsupportedDigestError extends FingerprintError { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'UnsupportedDigestError'; } } /** Error for missing runtime cryptographic capabilities. */ export class RuntimeCapabilityError extends FingerprintError { - constructor(message: string) { - super(message); + constructor(message: string, options?: ErrorOptions) { + super(message, options); this.name = 'RuntimeCapabilityError'; } } diff --git a/packages/pq-key-fingerprint/ts/src/fingerprint.ts b/packages/pq-key-fingerprint/ts/src/fingerprint.ts index 758bed2..317fd11 100644 --- a/packages/pq-key-fingerprint/ts/src/fingerprint.ts +++ b/packages/pq-key-fingerprint/ts/src/fingerprint.ts @@ -1,13 +1,14 @@ import { + type AlgorithmName, assertKeyData, encodeBase64, encodeBase64Url, fromJWK, fromPEM, fromSPKI, - type AlgorithmName, type KeyData, - type PQJwk, + KeyEncoderError, + type PQPublicJwk, } from 'pq-key-encoder'; import { FingerprintError, @@ -17,38 +18,100 @@ import { UnsupportedDigestError, } from './errors'; import type { + FingerprintBytesOptions, FingerprintDigest, FingerprintEncoding, FingerprintOptions, FingerprintResult, + FingerprintStringOptions, PublicKeyData, PublicKeyInput, } from './types'; const DEFAULT_DIGEST: FingerprintDigest = 'SHA-256'; const DEFAULT_ENCODING: FingerprintEncoding = 'hex'; +const FINGERPRINT_INPUT_DOMAIN = 'pq-key-fingerprint:v1'; +let textEncoder: TextEncoder | undefined; const SUPPORTED_DIGESTS = new Set(['SHA-256', 'SHA-384', 'SHA-512']); const SUPPORTED_ENCODINGS = new Set(['hex', 'base64', 'base64url', 'bytes']); +const ALLOWED_OPTION_KEYS = new Set(['digest', 'encoding']); + +type UnknownRecord = Record; -function resolveDigest(digest?: FingerprintDigest): FingerprintDigest { - if (!digest) { +function isPlainObject(value: unknown): value is UnknownRecord { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; +} + +function hasOwn(record: UnknownRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(record, key); +} + +function resolveDigest(digest: unknown): FingerprintDigest { + if (digest === undefined) { return DEFAULT_DIGEST; } - if (!SUPPORTED_DIGESTS.has(digest)) { + if (!SUPPORTED_DIGESTS.has(digest as FingerprintDigest)) { throw new UnsupportedDigestError(`Unsupported digest: ${String(digest)}.`); } - return digest; + return digest as FingerprintDigest; } -function resolveEncoding(encoding?: FingerprintEncoding): FingerprintEncoding { - if (!encoding) { +function resolveEncoding(encoding: unknown): FingerprintEncoding { + if (encoding === undefined) { return DEFAULT_ENCODING; } - if (!SUPPORTED_ENCODINGS.has(encoding)) { + if (!SUPPORTED_ENCODINGS.has(encoding as FingerprintEncoding)) { throw new InvalidFingerprintInputError(`Unsupported encoding: ${String(encoding)}.`); } - return encoding; + return encoding as FingerprintEncoding; +} + +function normalizeOptions(options: unknown): FingerprintOptions { + if (options === undefined) { + return {}; + } + if (!isPlainObject(options)) { + throw new InvalidFingerprintInputError('options must be a plain object.'); + } + + for (const key of Reflect.ownKeys(options)) { + if (typeof key !== 'string' || !ALLOWED_OPTION_KEYS.has(key as keyof FingerprintOptions)) { + throw new InvalidFingerprintInputError(`Unknown option: ${String(key)}.`); + } + } + + const normalized: FingerprintOptions = {}; + if (hasOwn(options, 'digest')) { + normalized.digest = options.digest as FingerprintDigest | undefined; + } + if (hasOwn(options, 'encoding')) { + normalized.encoding = options.encoding as FingerprintEncoding | undefined; + } + + return normalized; +} + +function getTextEncoder(): TextEncoder { + if (typeof TextEncoder !== 'function') { + throw new RuntimeCapabilityError('TextEncoder is not available in this runtime.'); + } + if (!textEncoder) { + textEncoder = new TextEncoder(); + } + return textEncoder; +} + +function getSubtleCrypto(): SubtleCrypto { + const subtle = globalThis.crypto?.subtle; + if (!subtle || typeof subtle.digest !== 'function') { + throw new RuntimeCapabilityError('WebCrypto subtle.digest is not available in this runtime.'); + } + return subtle; } function bytesToHex(bytes: Uint8Array): string { @@ -73,19 +136,49 @@ function encodeFingerprint(bytes: Uint8Array, encoding: FingerprintEncoding): Fi } async function digestBytes(bytes: Uint8Array, digest: FingerprintDigest): Promise { - const subtle = globalThis.crypto?.subtle; - if (!subtle) { - throw new RuntimeCapabilityError('WebCrypto subtle.digest is not available in this runtime.'); + const subtle = getSubtleCrypto(); + + let digestResult: ArrayBuffer; + try { + digestResult = await subtle.digest(digest, bytes as unknown as BufferSource); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown digest failure.'; + throw new RuntimeCapabilityError(`WebCrypto digest operation failed: ${message}`, { + cause: error, + }); } - const digestInput = bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer; - const digestResult = await subtle.digest(digest, digestInput); return new Uint8Array(digestResult); } +function createDigestInput(keyData: PublicKeyData): Uint8Array { + if (keyData.alg.includes('\0')) { + throw new InvalidFingerprintInputError('Algorithm names must not contain NUL bytes.'); + } + + const encoder = getTextEncoder(); + const domainBytes = encoder.encode(FINGERPRINT_INPUT_DOMAIN); + const algorithmBytes = encoder.encode(keyData.alg); + + const digestInput = new Uint8Array( + domainBytes.length + 1 + algorithmBytes.length + 1 + keyData.bytes.length, + ); + + let offset = 0; + digestInput.set(domainBytes, offset); + offset += domainBytes.length; + digestInput[offset] = 0; + offset += 1; + + digestInput.set(algorithmBytes, offset); + offset += algorithmBytes.length; + digestInput[offset] = 0; + offset += 1; + + digestInput.set(keyData.bytes, offset); + return digestInput; +} + function ensurePublicKeyData(keyData: KeyData): PublicKeyData { if (keyData.type !== 'public') { throw new InvalidKeyTypeError('Only public keys can be fingerprinted.'); @@ -101,34 +194,44 @@ function ensurePublicKeyData(keyData: KeyData): PublicKeyData { } function normalizePublicKeyInput(input: PublicKeyInput): PublicKeyData { - if (typeof input !== 'object' || input === null) { + if (!isPlainObject(input)) { throw new InvalidFingerprintInputError('input must be a public key object.'); } - if ('type' in input) { - return ensurePublicKeyData(input as KeyData); + const inputRecord = input as UnknownRecord; + + if (hasOwn(inputRecord, 'type')) { + if (!hasOwn(inputRecord, 'alg') || !hasOwn(inputRecord, 'bytes')) { + throw new InvalidFingerprintInputError('input must include type, alg, and bytes.'); + } + + const keyData: KeyData = { + alg: inputRecord.alg as AlgorithmName, + type: inputRecord.type as KeyData['type'], + bytes: inputRecord.bytes as Uint8Array, + }; + return ensurePublicKeyData(keyData); } - if (!('alg' in input) || !('bytes' in input)) { + if (!hasOwn(inputRecord, 'alg') || !hasOwn(inputRecord, 'bytes')) { throw new InvalidFingerprintInputError('input must include alg and bytes.'); } const keyData: KeyData = { - alg: input.alg, + alg: inputRecord.alg as AlgorithmName, type: 'public', - bytes: input.bytes, + bytes: inputRecord.bytes as Uint8Array, }; return ensurePublicKeyData(keyData); } -async function fingerprintKeyData( - keyData: KeyData, - options: FingerprintOptions = {}, -): Promise { - const digest = resolveDigest(options.digest); - const encoding = resolveEncoding(options.encoding); +async function fingerprintKeyData(keyData: KeyData, options: unknown): Promise { + const normalizedOptions = normalizeOptions(options); + const digest = resolveDigest(normalizedOptions.digest); + const encoding = resolveEncoding(normalizedOptions.encoding); const publicKeyData = ensurePublicKeyData(keyData); - const digestOutput = await digestBytes(publicKeyData.bytes, digest); + const digestInput = createDigestInput(publicKeyData); + const digestOutput = await digestBytes(digestInput, digest); return encodeFingerprint(digestOutput, encoding); } @@ -137,11 +240,15 @@ function translateError(error: unknown): FingerprintError { return error; } + if (error instanceof KeyEncoderError) { + return new InvalidFingerprintInputError(error.message, { cause: error }); + } + if (error instanceof Error) { - return new InvalidFingerprintInputError(error.message); + return new FingerprintError('Unexpected fingerprint failure.', { cause: error }); } - return new InvalidFingerprintInputError('Fingerprinting failed due to an unknown error.'); + return new FingerprintError('Unexpected fingerprint failure.'); } async function withErrorBoundary(operation: () => Promise): Promise { @@ -152,57 +259,94 @@ async function withErrorBoundary(operation: () => Promise): Promise { } } +async function fingerprintFrom( + keyDataResolver: () => KeyData, + options?: FingerprintOptions, +): Promise { + return withErrorBoundary(async () => fingerprintKeyData(keyDataResolver(), options)); +} + +export async function fingerprintPublicKey( + input: PublicKeyInput, + options: FingerprintBytesOptions, +): Promise; +export async function fingerprintPublicKey( + input: PublicKeyInput, + options?: FingerprintStringOptions, +): Promise; export async function fingerprintPublicKey( input: PublicKeyInput, - options: FingerprintOptions = {}, + options?: FingerprintOptions, ): Promise { - return withErrorBoundary(async () => { - const keyData = normalizePublicKeyInput(input); - return fingerprintKeyData(keyData, options); - }); + return fingerprintFrom(() => normalizePublicKeyInput(input), options); } export async function fingerprintPublicKeyBytes( bytes: Uint8Array, alg: AlgorithmName, - options: FingerprintOptions = {}, + options: FingerprintBytesOptions, +): Promise; +export async function fingerprintPublicKeyBytes( + bytes: Uint8Array, + alg: AlgorithmName, + options?: FingerprintStringOptions, +): Promise; +export async function fingerprintPublicKeyBytes( + bytes: Uint8Array, + alg: AlgorithmName, + options?: FingerprintOptions, ): Promise { - return withErrorBoundary(async () => { - const keyData: KeyData = { + return fingerprintFrom( + () => ({ alg, type: 'public', bytes, - }; - return fingerprintKeyData(keyData, options); - }); + }), + options, + ); } export async function fingerprintSPKI( spki: Uint8Array, - options: FingerprintOptions = {}, + options: FingerprintBytesOptions, +): Promise; +export async function fingerprintSPKI( + spki: Uint8Array, + options?: FingerprintStringOptions, +): Promise; +export async function fingerprintSPKI( + spki: Uint8Array, + options?: FingerprintOptions, ): Promise { - return withErrorBoundary(async () => { - const keyData = fromSPKI(spki); - return fingerprintKeyData(keyData, options); - }); + return fingerprintFrom(() => fromSPKI(spki), options); } export async function fingerprintPEM( pem: string, - options: FingerprintOptions = {}, + options: FingerprintBytesOptions, +): Promise; +export async function fingerprintPEM( + pem: string, + options?: FingerprintStringOptions, +): Promise; +export async function fingerprintPEM( + pem: string, + options?: FingerprintOptions, ): Promise { - return withErrorBoundary(async () => { - const keyData = fromPEM(pem); - return fingerprintKeyData(keyData, options); - }); + return fingerprintFrom(() => fromPEM(pem), options); } export async function fingerprintJWK( - jwk: PQJwk, - options: FingerprintOptions = {}, + jwk: PQPublicJwk, + options: FingerprintBytesOptions, +): Promise; +export async function fingerprintJWK( + jwk: PQPublicJwk, + options?: FingerprintStringOptions, +): Promise; +export async function fingerprintJWK( + jwk: PQPublicJwk, + options?: FingerprintOptions, ): Promise { - return withErrorBoundary(async () => { - const keyData = fromJWK(jwk); - return fingerprintKeyData(keyData, options); - }); + return fingerprintFrom(() => fromJWK(jwk), options); } diff --git a/packages/pq-key-fingerprint/ts/src/types.ts b/packages/pq-key-fingerprint/ts/src/types.ts index dade6b4..25d0156 100644 --- a/packages/pq-key-fingerprint/ts/src/types.ts +++ b/packages/pq-key-fingerprint/ts/src/types.ts @@ -1,4 +1,4 @@ -import type { AlgorithmName as EncoderAlgorithmName, KeyData, PQJwk } from 'pq-key-encoder'; +import type { AlgorithmName as EncoderAlgorithmName, KeyData } from 'pq-key-encoder'; export type { AlgorithmName } from 'pq-key-encoder'; @@ -6,11 +6,21 @@ export type FingerprintDigest = 'SHA-256' | 'SHA-384' | 'SHA-512'; export type FingerprintEncoding = 'hex' | 'base64' | 'base64url' | 'bytes'; +export type FingerprintStringEncoding = Exclude; + export interface FingerprintOptions { digest?: FingerprintDigest; encoding?: FingerprintEncoding; } +export type FingerprintBytesOptions = Omit & { + encoding: 'bytes'; +}; + +export type FingerprintStringOptions = Omit & { + encoding?: FingerprintStringEncoding; +}; + export type PublicKeyData = Omit & { alg: EncoderAlgorithmName; type: 'public'; @@ -18,6 +28,4 @@ export type PublicKeyData = Omit & { export type PublicKeyInput = PublicKeyData | { alg: EncoderAlgorithmName; bytes: Uint8Array }; -export type FingerprintInput = PublicKeyInput | Uint8Array | string | PQJwk; - export type FingerprintResult = string | Uint8Array; diff --git a/packages/pq-key-fingerprint/ts/tests/contract.test.ts b/packages/pq-key-fingerprint/ts/tests/contract.test.ts index 00653a5..50bd0fe 100644 --- a/packages/pq-key-fingerprint/ts/tests/contract.test.ts +++ b/packages/pq-key-fingerprint/ts/tests/contract.test.ts @@ -10,13 +10,16 @@ import { } from '../src'; async function expectTranslatedError(promise: Promise): Promise { + let caught: unknown; try { await promise; - throw new Error('Expected fingerprint API to throw.'); } catch (error) { - expect(error).toBeInstanceOf(FingerprintError); - expect(error).not.toBeInstanceOf(KeyEncoderError); + caught = error; } + + expect(caught, 'Expected fingerprint API to throw.').toBeDefined(); + expect(caught).toBeInstanceOf(FingerprintError); + expect(caught).not.toBeInstanceOf(KeyEncoderError); } describe('fingerprint API contract', () => { diff --git a/packages/pq-key-fingerprint/ts/tests/fingerprint.test.ts b/packages/pq-key-fingerprint/ts/tests/fingerprint.test.ts new file mode 100644 index 0000000..ca6001b --- /dev/null +++ b/packages/pq-key-fingerprint/ts/tests/fingerprint.test.ts @@ -0,0 +1,318 @@ +import { beforeEach, describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { fromSPKI, toJWK } from 'pq-key-encoder'; +import { + FingerprintError, + fingerprintJWK, + fingerprintPEM, + fingerprintPublicKey, + fingerprintPublicKeyBytes, + fingerprintSPKI, + InvalidFingerprintInputError, + InvalidKeyTypeError, + RuntimeCapabilityError, + UnsupportedDigestError, +} from '../src'; + +const FIXTURE_DIR = new URL('../../test-data/test-keys/', import.meta.url); +const VECTOR_BYTES = new Uint8Array(Array.from({ length: 32 }, (_, index) => index)); + +const VECTOR_SHA256_HEX = 'c93cb848642db990e05faf50407802b9dc78358d5e2ebacbd5663405b650715f'; +const VECTOR_SHA256_BASE64 = 'yTy4SGQtuZDgX69QQHgCudx4NY1eLrrL1WY0BbZQcV8='; +const VECTOR_SHA256_BASE64URL = 'yTy4SGQtuZDgX69QQHgCudx4NY1eLrrL1WY0BbZQcV8'; + +const VECTOR_SHA384_HEX = + 'f5616aeb4298f9b13d5d779d1f8219fc2343fe83cd3ab5ef493c6c216c2bd849c555f835966d9cdf0a7459aac991b941'; +const VECTOR_SHA512_HEX = + 'ab5d794d711f6500d759d7dac645a2da327e3875c47d430e53d0cf895dfabf42377094d99e2e44967e68b23bc17c72f74870b278a7a56287d8652204f5d19013'; + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i += 1) { + bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +function readDer(name: string): Uint8Array { + return new Uint8Array(readFileSync(new URL(name, FIXTURE_DIR))); +} + +function readPem(name: string): string { + return readFileSync(new URL(name, FIXTURE_DIR), 'utf8'); +} + +const ORIGINAL_CRYPTO = globalThis.crypto; +const ORIGINAL_TEXT_ENCODER = globalThis.TextEncoder; + +beforeEach(() => { + Object.defineProperty(globalThis, 'crypto', { + value: ORIGINAL_CRYPTO, + configurable: true, + writable: true, + }); + Object.defineProperty(globalThis, 'TextEncoder', { + value: ORIGINAL_TEXT_ENCODER, + configurable: true, + writable: true, + }); +}); + +describe('fingerprint deterministic vectors', () => { + it('uses SHA-256 hex by default', async () => { + const result = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'); + expect(result).toBe(VECTOR_SHA256_HEX); + }); + + it('supports SHA-256 in all encodings', async () => { + expect( + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-256', + encoding: 'hex', + }), + ).toBe(VECTOR_SHA256_HEX); + + expect( + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-256', + encoding: 'base64', + }), + ).toBe(VECTOR_SHA256_BASE64); + + expect( + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-256', + encoding: 'base64url', + }), + ).toBe(VECTOR_SHA256_BASE64URL); + + const bytes = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-256', + encoding: 'bytes', + }); + expect(bytes).toBeInstanceOf(Uint8Array); + expect(Array.from(bytes as Uint8Array)).toEqual(Array.from(hexToBytes(VECTOR_SHA256_HEX))); + }); + + it('produces expected SHA-384 and SHA-512 outputs', async () => { + expect( + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-384', + encoding: 'hex', + }), + ).toBe(VECTOR_SHA384_HEX); + + expect( + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-512', + encoding: 'hex', + }), + ).toBe(VECTOR_SHA512_HEX); + + const sha384Bytes = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-384', + encoding: 'bytes', + }); + expect(sha384Bytes).toBeInstanceOf(Uint8Array); + expect(sha384Bytes.length).toBe(48); + + const sha512Bytes = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-512', + encoding: 'bytes', + }); + expect(sha512Bytes).toBeInstanceOf(Uint8Array); + expect(sha512Bytes.length).toBe(64); + }); + + it('binds algorithm name into fingerprint input', async () => { + const sha2 = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'); + const shake = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHAKE-128s'); + expect(sha2).not.toBe(shake); + }); +}); + +describe('fingerprint input forms', () => { + it('accepts KeyData and bytes+alg', async () => { + const fromObject = await fingerprintPublicKey({ + alg: 'SLH-DSA-SHA2-128s', + type: 'public', + bytes: VECTOR_BYTES, + }); + const fromBytes = await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'); + expect(fromObject).toBe(fromBytes); + }); + + it('accepts SPKI, PEM, and JWK public forms', async () => { + const spki = readDer('ml_kem_512_pub.der'); + const pem = readPem('ml_kem_512_pub.pem'); + const fromSpki = await fingerprintSPKI(spki); + const fromPem = await fingerprintPEM(pem); + expect(fromSpki).toBe(fromPem); + + const jwk = toJWK(fromSPKI(spki)); + const fromJwk = await fingerprintJWK(jwk); + expect(typeof fromJwk).toBe('string'); + expect(fromJwk).toBe(fromSpki); + }); +}); + +describe('fingerprint error behavior', () => { + it('rejects private keys', async () => { + await expect( + fingerprintPublicKey({ + alg: 'SLH-DSA-SHA2-128s', + type: 'private', + bytes: new Uint8Array(64), + } as never), + ).rejects.toBeInstanceOf(InvalidKeyTypeError); + }); + + it('rejects unsupported digest', async () => { + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: 'SHA-1' as never, + }), + ).rejects.toBeInstanceOf(UnsupportedDigestError); + }); + + it('rejects malformed options values', async () => { + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', null as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', [] as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digset: 'SHA-512', + } as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + encoding: 'base64url', + extra: true, + } as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + + await expect( + fingerprintPublicKeyBytes( + VECTOR_BYTES, + 'SLH-DSA-SHA2-128s', + Object.create({ + digest: 'SHA-512', + }) as never, + ), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + }); + + it('rejects non-canonical algorithm names', async () => { + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'slh-dsa-sha2-128s' as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + }); + + it('rejects prototype-derived public key inputs', async () => { + const inheritedInput = Object.create({ + alg: 'SLH-DSA-SHA2-128s', + bytes: VECTOR_BYTES, + }); + + await expect(fingerprintPublicKey(inheritedInput as never)).rejects.toBeInstanceOf( + InvalidFingerprintInputError, + ); + }); + + it('rejects algorithm names with NUL bytes', async () => { + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s\0evil' as never), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + }); + + it('rejects empty digest and encoding values instead of defaulting', async () => { + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + digest: '' as never, + }), + ).rejects.toBeInstanceOf(UnsupportedDigestError); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s', { + encoding: '' as never, + }), + ).rejects.toBeInstanceOf(InvalidFingerprintInputError); + }); + + it('handles invalid input object/string errors', async () => { + await expect(fingerprintPublicKey('bad-input' as never)).rejects.toBeInstanceOf( + InvalidFingerprintInputError, + ); + await expect(fingerprintPEM('not-a-valid-pem')).rejects.toBeInstanceOf( + InvalidFingerprintInputError, + ); + }); + + it('returns RuntimeCapabilityError when crypto.subtle is unavailable', async () => { + Object.defineProperty(globalThis, 'crypto', { + value: {}, + configurable: true, + writable: true, + }); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'), + ).rejects.toBeInstanceOf(RuntimeCapabilityError); + }); + + it('maps subtle.digest runtime failures to RuntimeCapabilityError', async () => { + Object.defineProperty(globalThis, 'crypto', { + value: { + subtle: { + digest: async () => { + throw new TypeError('digest failure'); + }, + }, + }, + configurable: true, + writable: true, + }); + + let caught: unknown; + try { + await fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(RuntimeCapabilityError); + expect((caught as Error).cause).toBeInstanceOf(TypeError); + }); + + it('returns RuntimeCapabilityError when TextEncoder is unavailable', async () => { + Object.defineProperty(globalThis, 'TextEncoder', { + value: undefined, + configurable: true, + writable: true, + }); + + await expect( + fingerprintPublicKeyBytes(VECTOR_BYTES, 'SLH-DSA-SHA2-128s'), + ).rejects.toBeInstanceOf(RuntimeCapabilityError); + }); + + it('enforces error translation contract on all public entrypoints', async () => { + const calls = [ + () => fingerprintPublicKey({ alg: 'ML-KEM-512', type: 'public', bytes: new Uint8Array(1) }), + () => fingerprintPublicKeyBytes(new Uint8Array(0), 'ML-KEM-512'), + () => fingerprintSPKI(new Uint8Array([0x30, 0x00])), + () => fingerprintPEM('invalid-pem'), + () => fingerprintJWK({ kty: 'PQC', alg: 'ML-KEM-512', x: 'AQ' }), + ]; + + for (const call of calls) { + await expect(call()).rejects.toBeInstanceOf(FingerprintError); + } + }); +}); diff --git a/packages/pq-key-fingerprint/ts/tests/integration.test.ts b/packages/pq-key-fingerprint/ts/tests/integration.test.ts new file mode 100644 index 0000000..830c6e0 --- /dev/null +++ b/packages/pq-key-fingerprint/ts/tests/integration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test'; +import { readFileSync } from 'node:fs'; +import { fromPEM, fromSPKI, toJWK } from 'pq-key-encoder'; +import { + fingerprintJWK, + fingerprintPEM, + fingerprintPublicKey, + fingerprintPublicKeyBytes, + fingerprintSPKI, +} from '../src'; + +const FIXTURE_DIR = new URL('../../test-data/test-keys/', import.meta.url); + +function readDer(name: string): Uint8Array { + return new Uint8Array(readFileSync(new URL(name, FIXTURE_DIR))); +} + +function readPem(name: string): string { + return readFileSync(new URL(name, FIXTURE_DIR), 'utf8'); +} + +describe('cross-format integration', () => { + it('produces identical fingerprints across bytes, key object, SPKI, PEM, and JWK', async () => { + const spki = readDer('ml_kem_512_pub.der'); + const pem = readPem('ml_kem_512_pub.pem'); + + const keyFromSpki = fromSPKI(spki); + const keyFromPem = fromPEM(pem); + const jwk = toJWK(keyFromSpki); + + const [fromBytes, fromObject, fromSpki, fromPem, fromJwk] = await Promise.all([ + fingerprintPublicKeyBytes(keyFromSpki.bytes, keyFromSpki.alg), + fingerprintPublicKey({ + alg: keyFromSpki.alg, + type: 'public', + bytes: keyFromSpki.bytes, + }), + fingerprintSPKI(spki), + fingerprintPEM(pem), + fingerprintJWK(jwk), + ]); + + expect(keyFromPem.alg).toBe(keyFromSpki.alg); + expect(Array.from(keyFromPem.bytes)).toEqual(Array.from(keyFromSpki.bytes)); + + expect(fromObject).toBe(fromBytes); + expect(fromSpki).toBe(fromBytes); + expect(fromPem).toBe(fromBytes); + expect(fromJwk).toBe(fromBytes); + }); +});