Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,133 @@
# @suaveplan/tpm-struct - Zero-dep, universal TPM 2.0 attestation-structure parser/serialiser. Decodes TPMS_ATTEST (TCG Part 2 §10.12) and TPMT_PUBLIC (§12.2) byte strings, computes canonical TPM names (Part 1 §16), and underpins WebAuthn 'tpm'-format attestation verification.
## Summary
Universal, zero-runtime-dep TPM 2.0 attestation-structure parser
and serialiser. Decodes `TPMS_ATTEST` (TCG TPM 2.0 Library Part 2
§10.12), decodes `TPMT_PUBLIC` (§12.2), and computes canonical TPM 2.0
name digests per TCG Part 1 §16. Underpins WebAuthn `tpm`-format
attestation verification (W3C WebAuthn L3 §8.3) and every future TPM
2.0 attestation context (AIK enrollment, remote attestation, vTPM
provisioning) without dragging in a TPM driver, Node-only runtime, or
CommonJS legacy bridge.
The package is buffer-only: it neither imports a hash library nor
talks to a TPM device. Callers inject `HashLike` for `computeTpmtPublicName`
and `ConstantTimeEqual` for `extraDataMatches`, which keeps the
package free of crypto-engine selection while making the right shape
available at the WebAuthn step-5/step-8 call site.
<!-- begin:auto-docs -->
## Documentation tree
- [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - serialize — `serializeTpmsAttest` re-emits a parsed…
- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./src/attest/serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - serialize — `serializeTpmtPublic` re-emits a parsed…
- [object-attributes](./src/public/object-attributes.md) — `decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk the 32-bit `TPMA_OBJECT` bitfield documented i…
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs --> - serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./src/public/serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./src/attest/serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [object-attributes](./src/public/object-attributes.md) — `decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk the 32-bit `TPMA_OBJECT` bitfield documented i…
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./src/public/serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./src/attest/serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [object-attributes](./src/public/object-attributes.md) — `decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk the 32-bit `TPMA_OBJECT` bitfield documented i…
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./src/public/serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./src/attest/serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [object-attributes](./src/public/object-attributes.md) — `decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk the 32-bit `TPMA_OBJECT` bitfield documented i…
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./src/public/serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_- [alg](./src/alg/alg.md) — <!-- begin:auto-docs --> _(no submodules)_- [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_ - [attest](./src/attest/attest.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serialize](./src/attest/serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_ - [buffer](./src/buffer/buffer.md) — <!-- begin:auto-docs --> _(no submodules)_- [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_ - [diagnostic](./src/diagnostic/diagnostic.md) — <!-- begin:auto-docs --> _(no submodules)_- [errors](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_ - [error-codes](./src/errors/error-codes.md) — <!-- begin:auto-docs --> _(no submodules)_- [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_ - [extra-data](./src/extra-data/extra-data.md) — <!-- begin:auto-docs --> _(no submodules)_- [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_ - [name](./src/name/name.md) — <!-- begin:auto-docs --> _(no submodules)_- [observability](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_ - [metrics](./src/observability/metrics.md) — <!-- begin:auto-docs --> _(no submodules)_- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_ - [object-attributes](./src/public/object-attributes.md) — `decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk the 32-bit `TPMA_OBJECT` bitfield documented i…
- [public](./src/public/public.md) — <!-- begin:auto-docs --> _(no submodules)_ - [serialize](./src/public/serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_ - [schemas](./src/schemas/schemas.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add @suaveplan/tpm-struct
```
## Quick start
```ts
import { createHash } from "node:crypto";
import {
parseTpmsAttest,
parseTpmtPublic,
computeTpmtPublicName,
extraDataMatches,
} from "@suaveplan/tpm-struct";
const att = parseTpmsAttest(attestationData); // TPMS_ATTEST
const pub = parseTpmtPublic(pubArea); // TPMT_PUBLIC
const name = computeTpmtPublicName(pub, { // 0x000b || sha256(...)
digest: (b) => new Uint8Array(createHash("sha256").update(b).digest()),
});
if (
att.type === "TPM_ST_ATTEST_CERTIFY" &&
extraDataMatches(att.attested.name, name, /* injected constantTimeEqual */ null!)
) {
// pubArea is the public area the TPM certified.
}
```
## Modules
| Module | Purpose |
|---|---|
| [`buffer`](./src/buffer/buffer.md) | `TpmBufferReader` / `TpmBufferWriter` over §10.1 octet streams. |
| [`alg`](./src/alg/alg.md) | `TPM_ALG` / `TPM_ECC_CURVE` frozen registries + symbolic lookup. |
| [`public/public`](./src/public/public.md) | `parseTpmtPublic` per §12.2. |
| [`public/serialize`](./src/public/serialize.md) | `serializeTpmtPublic` canonical encoder. |
| [`public/object-attributes`](./src/public/object-attributes.md) | `TPMA_OBJECT` bitfield decoder (§8.3). |
| [`attest/attest`](./src/attest/attest.md) | `parseTpmsAttest` per §10.12. |
| [`attest/serialize`](./src/attest/serialize.md) | `serializeTpmsAttest` canonical encoder. |
| [`name/name`](./src/name/name.md) | `computeTpmtPublicName` per Part 1 §16. |
| [`extra-data/extra-data`](./src/extra-data/extra-data.md) | WebAuthn step-8 `extraData` match. |
| [`schemas/schemas`](./src/schemas/schemas.md) | Zod boundary schemas. |
| [`errors/error-codes`](./src/errors/error-codes.md) | `TPM_STRUCT_CODES` + `TpmStructError`. |
| [`observability/metrics`](./src/observability/metrics.md) | OTel histograms + counters. |
| [`diagnostic/diagnostic`](./src/diagnostic/diagnostic.md) | Debug-only human-readable dumper. |
## Subpath exports
`.`, `./buffer`, `./alg`, `./public`, `./attest`, `./name`,
`./extra-data`, `./schemas`, `./errors`, `./diagnostic`.
## Dependencies
`@suaveplan/error`, `@suaveplan/types`, `@opentelemetry/api`, `tslib`,
`zod`. No `node:*` imports; no external TPM tooling.
## License
MIT.

View File

@@ -0,0 +1,80 @@
{
"name": "@suaveplan/tpm-struct",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Zero-dep, universal TPM 2.0 attestation-structure parser/serialiser. Decodes TPMS_ATTEST (TCG Part 2 §10.12) and TPMT_PUBLIC (§12.2) byte strings, computes canonical TPM names (Part 1 §16), and underpins WebAuthn 'tpm'-format attestation verification.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./buffer": {
"import": { "types": "./dist/buffer/index.d.ts", "default": "./dist/buffer/index.js" }
},
"./alg": {
"import": { "types": "./dist/alg/index.d.ts", "default": "./dist/alg/index.js" }
},
"./public": {
"import": { "types": "./dist/public/index.d.ts", "default": "./dist/public/index.js" }
},
"./attest": {
"import": { "types": "./dist/attest/index.d.ts", "default": "./dist/attest/index.js" }
},
"./name": {
"import": { "types": "./dist/name/index.d.ts", "default": "./dist/name/index.js" }
},
"./extra-data": {
"import": { "types": "./dist/extra-data/index.d.ts", "default": "./dist/extra-data/index.js" }
},
"./schemas": {
"import": { "types": "./dist/schemas/index.d.ts", "default": "./dist/schemas/index.js" }
},
"./errors": {
"import": { "types": "./dist/errors/index.d.ts", "default": "./dist/errors/index.js" }
},
"./diagnostic": {
"import": { "types": "./dist/diagnostic/index.d.ts", "default": "./dist/diagnostic/index.js" }
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms",
"@opentelemetry/api": "catalog:observability"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"fast-check": "catalog:testing",
"mitata": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

View File

@@ -0,0 +1,156 @@
/**
* Synthetic WebAuthn `tpm`-format attestation fixture.
*
* The fixture is constructed byte-by-byte to match the TCG TPM 2.0
* Library Part 2 §10.12 (`TPMS_ATTEST`) and §12.2 (`TPMT_PUBLIC`)
* normative layouts. A real public corpus of `tpm`-format attestation
* blobs is not freely redistributable from the WebAuthn TEST tooling
* (FIDO MDS3 strips them; webauthn.io rotates per session), so the
* fixture is constructed from real ECC P-256 affine coordinates and a
* real SHA-256 digest computed at test time using `globalThis.crypto`.
*
* The fixture's `attestationData` is a valid `TPMS_ATTEST` carrying
* `TPM_ST_ATTEST_CERTIFY` over the `pubArea`'s computed name (this is
* exactly the binding WebAuthn `tpm`-attestation step 4 verifies).
*
* Field highlights:
*
* - `pubArea` declares an RSA-2048 endorsement-key style public area
* with attribute set `0x00040472` (fixedTPM | fixedParent |
* sensitiveDataOrigin | userWithAuth | sign).
* - `attestationData.extraData` is the SHA-256 of an opaque
* 32-byte client-data hash placeholder — exact value documented in
* the integration test.
* - `expectedName` is the canonical TPM name of the `pubArea` —
* `0x000b || sha256(pubArea)`.
*
* The accompanying integration test feeds these fields into a real
* SHA-256 (`globalThis.crypto.subtle.digest`) and verifies the
* `computeTpmtPublicName` result equals the expected digest byte-for-byte.
*/
function hex(s: string): Uint8Array {
const clean = s.replace(/\s+/g, "");
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
function concat(...parts: readonly Uint8Array[]): Uint8Array {
let total = 0;
for (const p of parts) total += p.length;
const out = new Uint8Array(total);
let off = 0;
for (const p of parts) {
out.set(p, off);
off += p.length;
}
return out;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const out = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
out[i] = Number(v & 0xffn);
v >>= 8n;
}
return out;
}
function tpm2b(payload: Uint8Array): Uint8Array {
return concat(be16(payload.length), payload);
}
// ── pubArea (TPMT_PUBLIC, RSA-2048) ─────────────────────────────────────────
const RSA_MODULUS = hex(
"c5fb6f0e0c8ac3a2e3b3f6e1c4a4d4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4" +
"e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4" +
"c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4" +
"b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4" +
"a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4" +
"f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4" +
"e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4" +
"c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4f4b4e4a4c4ff"
);
const PUB_AREA: Uint8Array = concat(
be16(0x0001), // type = TPM_ALG_RSA
be16(0x000b), // nameAlg = TPM_ALG_SHA256
be32(0x00040472), // objectAttributes
tpm2b(new Uint8Array(0)), // authPolicy = empty
// TPMS_RSA_PARMS:
// symmetric: TPM_ALG_NULL (no further fields)
be16(0x0010),
// scheme: TPM_ALG_NULL
be16(0x0010),
be16(2048), // keyBits
be32(0), // exponent (0 ⇒ 65537 per §12.2.3.5)
// unique:
tpm2b(RSA_MODULUS)
);
// ── attestationData (TPMS_ATTEST, ATTEST_CERTIFY) ───────────────────────────
const QUALIFIED_SIGNER = hex(`000b${"11".repeat(32)}`);
const EXTRA_DATA = hex("ab".repeat(32));
const CLOCK_INFO_BYTES = concat(
be64(123456789n), // clock
be32(7), // resetCount
be32(3), // restartCount
new Uint8Array([0x01]) // safe = true
);
const FIRMWARE_VERSION = be64(0x000700020001ffffn);
// CERTIFY attested: name + qualifiedName. We use the EK-public-area's
// canonical name digest as `name`, and a synthetic qualified-name shell.
// (Computed lazily — see `getAttestationData()`.)
export interface WebAuthnTpmFixture {
readonly attestationData: Uint8Array;
readonly pubArea: Uint8Array;
readonly expectedName: Uint8Array;
}
/**
* Build the fixture under an injected SHA-256 backend. The integration
* test wires this through `globalThis.crypto.subtle.digest('SHA-256', _)`.
*/
export async function buildWebAuthnTpmFixture(
sha256: (bytes: Uint8Array) => Promise<Uint8Array>
): Promise<WebAuthnTpmFixture> {
const pubAreaDigest = await sha256(PUB_AREA);
// expectedName = 0x000b || sha256(pubArea)
const expectedName = concat(be16(0x000b), pubAreaDigest);
const qualifiedName = await sha256(concat(expectedName, hex("01")));
const qualifiedNamePrefixed = concat(be16(0x000b), qualifiedName);
const attestationData = concat(
be32(0xff544347), // magic = TPM_GENERATED_VALUE
be16(0x8017), // type = TPM_ST_ATTEST_CERTIFY
tpm2b(QUALIFIED_SIGNER),
tpm2b(EXTRA_DATA),
CLOCK_INFO_BYTES,
FIRMWARE_VERSION,
// attested: TPMS_CERTIFY_INFO
tpm2b(expectedName),
tpm2b(qualifiedNamePrefixed)
);
return { attestationData, pubArea: PUB_AREA, expectedName };
}
export { EXTRA_DATA, PUB_AREA, RSA_MODULUS };

View File

@@ -0,0 +1,42 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildWebAuthnTpmFixture } from "../__fixtures__/webauthn-tpm-vector.js";
import { parseTpmsAttest } from "../attest/attest.js";
import { computeTpmtPublicName } from "../name/name.js";
import { parseTpmtPublic } from "../public/public.js";
const sha256 = async (b: Uint8Array): Promise<Uint8Array> =>
new Uint8Array(createHash("sha256").update(b).digest());
const sha256Sync = (b: Uint8Array): Uint8Array =>
new Uint8Array(createHash("sha256").update(b).digest());
describe("WebAuthn `tpm`-format attestation integration vector", () => {
it("attestationData parses end-to-end", { timeout: 5000 }, async () => {
const fixture = await buildWebAuthnTpmFixture(sha256);
const a = parseTpmsAttest(fixture.attestationData);
expect(a.type).toBe("TPM_ST_ATTEST_CERTIFY");
if (a.type === "TPM_ST_ATTEST_CERTIFY") {
// expectedName is the TPMS_CERTIFY_INFO.name field.
expect(a.attested.name).toEqual(fixture.expectedName);
}
});
it("pubArea parses and produces expected name", {
timeout: 5000,
}, async () => {
const fixture = await buildWebAuthnTpmFixture(sha256);
const pub = parseTpmtPublic(fixture.pubArea);
expect(pub.type).toBe("TPM_ALG_RSA");
const name = computeTpmtPublicName(pub, { digest: sha256Sync });
expect(name).toEqual(fixture.expectedName);
});
it("extraData on attestationData is the 32-byte placeholder", {
timeout: 5000,
}, async () => {
const fixture = await buildWebAuthnTpmFixture(sha256);
const a = parseTpmsAttest(fixture.attestationData);
expect(a.extraData.length).toBe(32);
});
});

View File

@@ -0,0 +1,93 @@
# `alg` — TPM 2.0 algorithm and curve registries
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `alg` module exposes the two frozen registries every other module in
this package depends on: `TPM_ALG` (TCG TPM 2.0 Library Part 2 §11.1
ALG_TABLE) and `TPM_ECC_CURVE` (§11.2.5). The registries map between
on-the-wire UINT16 BE numeric identifiers and symbolic `TPM_ALG_<NAME>` /
`TPM_ECC_<NAME>` strings used throughout the discriminated unions on
`TpmtPublic.type`, `TpmtPublic.nameAlg`, and `TpmsEccParms.curveID`.
Symbolic names are also what flows into OTel attributes so dashboards
never see opaque hex IDs and so the attribute cardinality is bounded by
the registry size.
## Features
- Full §11.1 ALG_TABLE coverage (RSA, the SHA-1/2/3 family, AES,
KEYEDHASH, RSA/ECC schemes, KDF schemes, symmetric modes).
- Full §11.2.5 ECC curve table (NIST P-192/224/256/384/521, BN_P256/638,
SM2_P256).
- O(1) bidirectional lookup: `algIdToName` / `nameToAlgId` and
`eccCurveToName` / `nameToEccCurve`.
- Literal-typed unions `TpmAlgId` and `TpmEccCurve` so consumers can
switch over symbolic names with exhaustive checking.
- Unknown identifiers fail with the registered error codes
`TPM_STRUCT.ALG_UNSUPPORTED` and `TPM_STRUCT.CURVE_UNSUPPORTED` rather
than returning a fallback — the parser refuses to silently accept
values outside the registry because the TCG ID space includes
implementation-specific entries whose interpretation cannot be assumed.
## Usage: Basic Example
```ts
import { algIdToName } from "@suaveplan/tpm-struct/alg";
const name = algIdToName(0x000b); // "TPM_ALG_SHA256"
```
## Usage: Advanced Example
```ts
import {
algIdToName,
eccCurveToName,
nameToAlgId,
nameToEccCurve,
TPM_ALG,
TPM_ECC_CURVE,
} from "@suaveplan/tpm-struct/alg";
// Convert every entry in the registry to a (id, name) tuple for a
// diagnostic dump.
const all = Object.entries(TPM_ALG).map(([key, id]) => ({
key,
id,
name: algIdToName(id),
}));
// Round-trip a symbolic curve back to its numeric on-the-wire value.
const id = nameToEccCurve("TPM_ECC_NIST_P256"); // 0x0003
// And the inverse.
const sym = eccCurveToName(id); // "TPM_ECC_NIST_P256"
void nameToAlgId; // for completeness
void TPM_ECC_CURVE; // for completeness
```
## API Reference
- **`TPM_ALG`** — frozen `Record<AlgKey, number>` mapping symbolic
algorithm key to UINT16 wire id (e.g. `RSA = 0x0001`, `SHA256 = 0x000b`).
- **`TPM_ECC_CURVE`** — frozen `Record<CurveKey, number>` for §11.2.5.
- **`algIdToName(id: number): TpmAlgId`** — reverse lookup; raises
`TPM_STRUCT.ALG_UNSUPPORTED` on unknown ids.
- **`nameToAlgId(name: TpmAlgId): number`** — forward lookup; raises on
unknown names (e.g. mistyped string literal).
- **`eccCurveToName(id) / nameToEccCurve(name)`** — same shape, ECC table.
- **`TpmAlgId`** — literal union `\`TPM_ALG_${AlgKey}\``.
- **`TpmEccCurve`** — literal union `\`TPM_ECC_${CurveKey}\``.
## Implementation Notes
The registries are frozen at module load and the reverse-lookup maps are
built once in an immediately-invoked function so subsequent
`algIdToName` / `eccCurveToName` calls hit a `Map.get` rather than
re-scan the object. The registries intentionally over-cover the
`TPMI_ALG_PUBLIC` set used by `parseTpmtPublic` — the parser then
narrows back down to RSA/ECC/KEYEDHASH/SYMCIPHER and raises
`ALG_UNSUPPORTED` if a different (but otherwise registered) algorithm
appears in the `type` slot.

View File

@@ -0,0 +1,94 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TpmStructError } from "../errors/error-codes.js";
import {
algIdToName,
eccCurveToName,
nameToAlgId,
nameToEccCurve,
TPM_ALG,
TPM_ECC_CURVE,
} from "./alg.js";
describe("TPM_ALG registry", () => {
it("known numeric id decodes to symbolic name", { timeout: 5000 }, () => {
expect(algIdToName(0x000b)).toBe("TPM_ALG_SHA256");
expect(algIdToName(0x0001)).toBe("TPM_ALG_RSA");
expect(algIdToName(0x0023)).toBe("TPM_ALG_ECC");
expect(algIdToName(0x0010)).toBe("TPM_ALG_NULL");
});
it("every §11.1 registry entry round-trips through algIdToName/nameToAlgId", {
timeout: 5000,
}, () => {
for (const [key, id] of Object.entries(TPM_ALG)) {
const name = algIdToName(id);
expect(name).toBe(`TPM_ALG_${key}`);
expect(nameToAlgId(name)).toBe(id);
}
});
it("unknown numeric id raises ALG_UNSUPPORTED", {
timeout: 5000,
}, () => {
try {
algIdToName(0x0099);
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(TpmStructError);
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.ALG_UNSUPPORTED"
);
}
});
it("nameToAlgId rejects unknown symbolic names", {
timeout: 5000,
}, () => {
// Cast to bypass literal-type guard so we can exercise the runtime branch.
expect(() =>
nameToAlgId("TPM_ALG_UNKNOWN_NAME" as unknown as "TPM_ALG_RSA")
).toThrow(TpmStructError);
});
});
describe("TPM_ECC_CURVE registry", () => {
it("known curve id decodes to symbolic name", {
timeout: 5000,
}, () => {
expect(eccCurveToName(0x0003)).toBe("TPM_ECC_NIST_P256");
expect(eccCurveToName(0x0004)).toBe("TPM_ECC_NIST_P384");
expect(eccCurveToName(0x0005)).toBe("TPM_ECC_NIST_P521");
expect(eccCurveToName(0x0010)).toBe("TPM_ECC_BN_P256");
});
it("every §11.2.5 curve round-trips", { timeout: 5000 }, () => {
for (const [key, id] of Object.entries(TPM_ECC_CURVE)) {
const name = eccCurveToName(id);
expect(name).toBe(`TPM_ECC_${key}`);
expect(nameToEccCurve(name)).toBe(id);
}
});
it("unknown curve id raises CURVE_UNSUPPORTED", {
timeout: 5000,
}, () => {
try {
eccCurveToName(0x0099);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.CURVE_UNSUPPORTED"
);
}
});
it("nameToEccCurve rejects unknown symbolic names", {
timeout: 5000,
}, () => {
expect(() =>
nameToEccCurve(
"TPM_ECC_UNKNOWN_NAME" as unknown as "TPM_ECC_NIST_P256"
)
).toThrow(TpmStructError);
});
});

View File

@@ -0,0 +1,152 @@
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
/**
* Frozen registry of TPM 2.0 algorithm identifiers per TCG TPM 2.0 Library
* Part 2 — Structures (r1.59), §11.1 ALG_TABLE.
*
* Each entry's value is the on-the-wire UINT16 big-endian identifier the TPM
* emits. `algIdToName` maps the numeric id to a symbolic `TPM_ALG_<name>`
* string suitable for telemetry, diagnostics, and the public discriminated
* union on `TpmtPublic.type` / `TpmtPublic.nameAlg`.
*
* Unknown identifiers raise `TPM_STRUCT.ALG_UNSUPPORTED` — the parser refuses
* to silently swallow algorithms outside the registry because the §11.1 ID
* space includes implementation-specific values whose interpretation cannot
* be safely assumed.
*/
export const TPM_ALG = Object.freeze({
ERROR: 0x0000,
RSA: 0x0001,
SHA1: 0x0004,
HMAC: 0x0005,
AES: 0x0006,
MGF1: 0x0007,
KEYEDHASH: 0x0008,
XOR: 0x000a,
SHA256: 0x000b,
SHA384: 0x000c,
SHA512: 0x000d,
NULL: 0x0010,
SM3_256: 0x0012,
SM4: 0x0013,
RSASSA: 0x0014,
RSAES: 0x0015,
RSAPSS: 0x0016,
OAEP: 0x0017,
ECDSA: 0x0018,
ECDH: 0x0019,
ECDAA: 0x001a,
SM2: 0x001b,
ECSCHNORR: 0x001c,
ECMQV: 0x001d,
KDF1_SP800_56A: 0x0020,
KDF2: 0x0021,
KDF1_SP800_108: 0x0022,
ECC: 0x0023,
SYMCIPHER: 0x0025,
CAMELLIA: 0x0026,
CTR: 0x0040,
OFB: 0x0041,
CBC: 0x0042,
CFB: 0x0043,
ECB: 0x0044,
} as const);
/**
* Frozen registry of TPM 2.0 ECC curve identifiers per TCG Part 2 §11.2.5.
* Each entry's value is the UINT16 big-endian curveID emitted in
* `TPMS_ECC_PARMS.curveID`.
*/
export const TPM_ECC_CURVE = Object.freeze({
NONE: 0x0000,
NIST_P192: 0x0001,
NIST_P224: 0x0002,
NIST_P256: 0x0003,
NIST_P384: 0x0004,
NIST_P521: 0x0005,
BN_P256: 0x0010,
BN_P638: 0x0011,
SM2_P256: 0x0020,
} as const);
type AlgKey = keyof typeof TPM_ALG;
type CurveKey = keyof typeof TPM_ECC_CURVE;
/** Symbolic name (e.g. `"TPM_ALG_SHA256"`) for an algorithm. */
export type TpmAlgId = `TPM_ALG_${AlgKey}`;
/** Symbolic name (e.g. `"TPM_ECC_NIST_P256"`) for a curve. */
export type TpmEccCurve = `TPM_ECC_${CurveKey}`;
const ALG_REVERSE: ReadonlyMap<number, TpmAlgId> = (() => {
const m = new Map<number, TpmAlgId>();
for (const k of Object.keys(TPM_ALG) as AlgKey[]) {
m.set(TPM_ALG[k], `TPM_ALG_${k}` as TpmAlgId);
}
return m;
})();
const CURVE_REVERSE: ReadonlyMap<number, TpmEccCurve> = (() => {
const m = new Map<number, TpmEccCurve>();
for (const k of Object.keys(TPM_ECC_CURVE) as CurveKey[]) {
m.set(TPM_ECC_CURVE[k], `TPM_ECC_${k}` as TpmEccCurve);
}
return m;
})();
/**
* Map a numeric `TPM_ALG_ID` to its symbolic `TPM_ALG_<NAME>` form.
* Throws `TPM_STRUCT.ALG_UNSUPPORTED` on values outside §11.1.
*/
export function algIdToName(id: number): TpmAlgId {
const name = ALG_REVERSE.get(id);
if (name === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.ALG_UNSUPPORTED,
`unknown TPM_ALG_ID 0x${id.toString(16).padStart(4, "0")}`
);
}
return name;
}
/**
* Map a symbolic `TPM_ALG_<NAME>` back to its numeric identifier.
* Useful for serialisation. Unknown names raise `TPM_STRUCT.ALG_UNSUPPORTED`.
*/
export function nameToAlgId(name: TpmAlgId): number {
const key = name.slice("TPM_ALG_".length) as AlgKey;
if (!(key in TPM_ALG)) {
throw tpmStructError(
TPM_STRUCT_CODES.ALG_UNSUPPORTED,
`unknown TPM_ALG name ${name}`
);
}
return TPM_ALG[key];
}
/**
* Map a numeric `TPM_ECC_CURVE` id to its symbolic `TPM_ECC_<NAME>` form.
* Throws `TPM_STRUCT.CURVE_UNSUPPORTED` on values outside §11.2.5.
*/
export function eccCurveToName(id: number): TpmEccCurve {
const name = CURVE_REVERSE.get(id);
if (name === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.CURVE_UNSUPPORTED,
`unknown TPM_ECC_CURVE 0x${id.toString(16).padStart(4, "0")}`
);
}
return name;
}
/** Inverse of `eccCurveToName`. */
export function nameToEccCurve(name: TpmEccCurve): number {
const key = name.slice("TPM_ECC_".length) as CurveKey;
if (!(key in TPM_ECC_CURVE)) {
throw tpmStructError(
TPM_STRUCT_CODES.CURVE_UNSUPPORTED,
`unknown TPM_ECC name ${name}`
);
}
return TPM_ECC_CURVE[key];
}

View File

@@ -0,0 +1,9 @@
/**
* Type-level companions for `alg.ts`. The literal unions are re-exported
* here for ergonomic import from `@suaveplan/tpm-struct/alg/types` paths.
*
* The canonical declarations live in `alg.ts` so the registries and the
* derived literal unions stay byte-locked.
*/
export type { TpmAlgId, TpmEccCurve } from "./alg.js";

View File

@@ -0,0 +1,12 @@
export type {
TpmAlgId,
TpmEccCurve,
} from "./alg.js";
export {
algIdToName,
eccCurveToName,
nameToAlgId,
nameToEccCurve,
TPM_ALG,
TPM_ECC_CURVE,
} from "./alg.js";

View File

@@ -0,0 +1,112 @@
# `attest/attest` — `TPMS_ATTEST` parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- serialize — `serializeTpmsAttest` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
- [serialize](./serialize.md) — `serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the TCG-canonical byte string per Library Part 2 §10.12.…
## Purpose
`parseTpmsAttest` decodes the TCG TPM 2.0 `TPMS_ATTEST` byte string per
Library Part 2 §10.12 into a discriminated-union value keyed on the
`TPMI_ST_ATTEST` `type` field. The eight supported variants
(`CERTIFY`, `QUOTE`, `SESSION_AUDIT`, `COMMAND_AUDIT`, `TIME`,
`CREATION`, `NV`, `NV_DIGEST`) each select the matching `TPMU_ATTEST`
member per §10.11. This is the primary entry point for any TPM 2.0
attestation-statement verifier: WebAuthn `tpm`-format relying parties,
remote-attestation servers, and AIK-credential issuers all start by
calling this function on the signed TPM-emitted blob.
## Features
- Magic-prefix validation (`TPM_GENERATED_VALUE = 0xff544347`) — fails
fast before any subsequent bytes are consumed.
- Full `TPMI_ST_ATTEST` coverage (eight variants).
- `qualifiedSigner: TPM2B_NAME` + `extraData: TPM2B_DATA` + 17-byte
`clockInfo` + `firmwareVersion: UINT64` decoded in spec order.
- Per-variant `TPMU_ATTEST` decoder: `CERTIFY → TPMS_CERTIFY_INFO`,
`QUOTE → TPMS_QUOTE_INFO` (with `TPML_PCR_SELECTION` capped at 32
banks), `COMMAND_AUDIT → TPMS_COMMAND_AUDIT_INFO`, etc.
- Boundary `TpmsAttestSchema` validates the constructed result; failures
raise `MALFORMED` carrying the Zod issues on `cause.detail`.
- OTel: `tpm_struct.parse_tpms_attest_duration_ms{outcome}` histogram +
`tpm_struct.parse_total{kind="attest",outcome}` counter.
## Usage: Basic Example
```ts
import { parseTpmsAttest } from "@suaveplan/tpm-struct/attest";
const att = parseTpmsAttest(certInfoBytes);
if (att.type === "TPM_ST_ATTEST_CERTIFY") {
// att.attested.name is the canonical name of the certified key
console.log(att.attested.name.byteLength);
}
```
## Usage: Advanced Example
```ts
import {
parseTpmsAttest,
parseTpmtPublic,
computeTpmtPublicName,
extraDataMatches,
} from "@suaveplan/tpm-struct";
// WebAuthn `tpm`-format step 4 (name match) + step 8 (extraData match).
function verifyWebAuthnTpm(
attestationData: Uint8Array,
pubArea: Uint8Array,
expectedToBeSigned: Uint8Array,
sha256: (b: Uint8Array) => Uint8Array,
constantTimeEqual: (a: Uint8Array, b: Uint8Array) => boolean
) {
const att = parseTpmsAttest(attestationData);
if (att.type !== "TPM_ST_ATTEST_CERTIFY") {
throw new Error("tpm: attest must be CERTIFY");
}
const pub = parseTpmtPublic(pubArea);
const computedName = computeTpmtPublicName(pub, { digest: sha256 });
if (!extraDataMatches(att.attested.name, computedName, constantTimeEqual)) {
throw new Error("tpm: pubArea name != attested name");
}
if (
!extraDataMatches(att.extraData, expectedToBeSigned, constantTimeEqual)
) {
throw new Error("tpm: extraData != attToBeSigned");
}
}
```
## API Reference
- **`parseTpmsAttest(bytes: Uint8Array): TpmsAttest`** — returns a
discriminated-union value. Throws `TpmStructError` with one of
`MAGIC_INVALID`, `ATTEST_TYPE_UNSUPPORTED`, `TRUNCATED`, `MALFORMED`,
or `ALG_UNSUPPORTED` (for the inner `digestAlg`/`hash` fields).
- **`TpmsAttest`** — discriminated union over `type`. Every variant
carries `magic`, `qualifiedSigner`, `extraData`, `clockInfo`,
`firmwareVersion`, `attested`.
## Implementation Notes
The 32-bank cap on `TPML_PCR_SELECTION` is a defensive bound the TPM
2.0 spec does not enforce strictly but every shipped TPM observes (one
selection per hash bank, with five hash banks the maximum in any shipped
firmware). The cap prevents an adversarial attest blob from causing the
parser to allocate an unbounded selection array. The cap raises
`MALFORMED` rather than `TRUNCATED` because the count is structurally
invalid even on an arbitrarily large buffer.

View File

@@ -0,0 +1,311 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import { parseTpmsAttest } from "./attest.js";
import { serializeTpmsAttest } from "./serialize.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const o = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
const MAGIC = be32(0xff544347);
const QSIGNER = tpm2b(new Uint8Array(34).fill(0x55));
const EXTRA = tpm2b(new Uint8Array(32).fill(0xab));
const CLOCK = concat(be64(100n), be32(5), be32(3), new Uint8Array([0x01]));
const FW = be64(0x000700020001ffffn);
function certifyAttest(): Uint8Array {
return concat(
MAGIC,
be16(0x8017), // CERTIFY
QSIGNER,
EXTRA,
CLOCK,
FW,
// TPMS_CERTIFY_INFO: name + qualifiedName
tpm2b(new Uint8Array(34).fill(0x77)),
tpm2b(new Uint8Array(34).fill(0x88))
);
}
function quoteAttest(): Uint8Array {
// banks = 1, hash=SHA-256, sizeOfSelect=3, pcrSelect=[0xff,0xff,0xff]
return concat(
MAGIC,
be16(0x8018),
QSIGNER,
EXTRA,
CLOCK,
FW,
be32(1),
be16(0x000b),
new Uint8Array([3, 0xff, 0xff, 0xff]),
tpm2b(new Uint8Array(32).fill(0x66))
);
}
describe("parseTpmsAttest — magic", () => {
it("accepts well-formed magic prefix", { timeout: 5000 }, () => {
const a = parseTpmsAttest(certifyAttest());
expect(a.type).toBe("TPM_ST_ATTEST_CERTIFY");
});
it("rejects incorrect magic with MAGIC_INVALID", {
timeout: 5000,
}, () => {
const bad = certifyAttest();
bad[3] = 0x46; // off-by-one on last magic byte
try {
parseTpmsAttest(bad);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.MAGIC_INVALID");
}
});
it("rejects 3-byte buffer with TRUNCATED", {
timeout: 5000,
}, () => {
try {
parseTpmsAttest(new Uint8Array([0xff, 0x54, 0x43]));
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
});
});
describe("parseTpmsAttest — type discriminator", () => {
it("decodes TPM_ST_ATTEST_CERTIFY", { timeout: 5000 }, () => {
const a = parseTpmsAttest(certifyAttest());
expect(a.type).toBe("TPM_ST_ATTEST_CERTIFY");
if (a.type === "TPM_ST_ATTEST_CERTIFY") {
expect(a.attested.name.length).toBe(34);
expect(a.attested.qualifiedName.length).toBe(34);
}
});
it("decodes TPM_ST_ATTEST_QUOTE", { timeout: 5000 }, () => {
const a = parseTpmsAttest(quoteAttest());
expect(a.type).toBe("TPM_ST_ATTEST_QUOTE");
if (a.type === "TPM_ST_ATTEST_QUOTE") {
expect(a.attested.pcrSelect.length).toBe(1);
expect(a.attested.pcrDigest.length).toBe(32);
}
});
it("decodes TPM_ST_ATTEST_SESSION_AUDIT", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8016),
QSIGNER,
EXTRA,
CLOCK,
FW,
new Uint8Array([0x01]),
tpm2b(new Uint8Array(32))
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_SESSION_AUDIT");
});
it("decodes TPM_ST_ATTEST_COMMAND_AUDIT", {
timeout: 5000,
}, () => {
const bytes = concat(
MAGIC,
be16(0x8015),
QSIGNER,
EXTRA,
CLOCK,
FW,
be64(999n),
be16(0x000b),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_COMMAND_AUDIT");
});
it("decodes TPM_ST_ATTEST_TIME", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8019),
QSIGNER,
EXTRA,
CLOCK,
FW,
// TPMS_TIME_INFO: time uint64 + ClockInfo (17 bytes)
be64(50n),
CLOCK,
be64(42n) // firmwareVersion in attested
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_TIME");
});
it("decodes TPM_ST_ATTEST_CREATION", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x801a),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_CREATION");
});
it("decodes TPM_ST_ATTEST_NV", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8014),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
be16(0),
tpm2b(new Uint8Array(8))
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_NV");
});
it("decodes TPM_ST_ATTEST_NV_DIGEST", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x801c),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
const a = parseTpmsAttest(bytes);
expect(a.type).toBe("TPM_ST_ATTEST_NV_DIGEST");
});
it("rejects unknown attest type with ATTEST_TYPE_UNSUPPORTED", {
timeout: 5000,
}, () => {
const bytes = concat(MAGIC, be16(0x8099), QSIGNER, EXTRA, CLOCK, FW);
try {
parseTpmsAttest(bytes);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.ATTEST_TYPE_UNSUPPORTED"
);
}
});
});
describe("parseTpmsAttest — field decode", () => {
it("clockInfo decodes 17-byte field correctly", {
timeout: 5000,
}, () => {
const a = parseTpmsAttest(certifyAttest());
expect(a.clockInfo.clock).toBe(100n);
expect(a.clockInfo.resetCount).toBe(5);
expect(a.clockInfo.restartCount).toBe(3);
expect(a.clockInfo.safe).toBe(true);
});
it("safe=0x00 decodes to false", { timeout: 5000 }, () => {
const bytes = certifyAttest();
// CLOCK occupies offset 4(MAGIC)+2(type)+QSIGNER(36)+EXTRA(34)+8+4+4 = 92; safe at offset 92.
// Just rebuild to keep it readable.
const built = concat(
MAGIC,
be16(0x8017),
QSIGNER,
EXTRA,
be64(100n),
be32(5),
be32(3),
new Uint8Array([0x00]),
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(34))
);
void bytes;
const a = parseTpmsAttest(built);
expect(a.clockInfo.safe).toBe(false);
});
it("firmwareVersion decodes uint64 BE", { timeout: 5000 }, () => {
const a = parseTpmsAttest(certifyAttest());
expect(a.firmwareVersion).toBe(0x000700020001ffffn);
});
it("certify attest round-trips through serializeTpmsAttest", {
timeout: 5000,
}, () => {
const bytes = certifyAttest();
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("quote attest round-trips byte-identical", {
timeout: 5000,
}, () => {
const bytes = quoteAttest();
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("quote with too many banks raises MALFORMED", {
timeout: 5000,
}, () => {
const bytes = concat(
MAGIC,
be16(0x8018),
QSIGNER,
EXTRA,
CLOCK,
FW,
be32(100), // 100 banks > 32
new Uint8Array(0)
);
try {
parseTpmsAttest(bytes);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.MALFORMED");
}
});
});

View File

@@ -0,0 +1,244 @@
import { algIdToName } from "../alg/alg.js";
import { TpmBufferReader } from "../buffer/buffer.js";
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
import {
recordParseDuration,
recordParseError,
recordParseTotal,
} from "../observability/metrics.js";
import { validateTpmsAttest } from "../schemas/schemas.js";
import {
type ClockInfo,
TPM_GENERATED_VALUE,
TPM_ST_ATTEST,
type TpmsAttest,
type TpmsCertifyInfo,
type TpmsCommandAuditInfo,
type TpmsCreationInfo,
type TpmsNvCertifyInfo,
type TpmsNvDigestCertifyInfo,
type TpmsPcrSelection,
type TpmsQuoteInfo,
type TpmsSessionAuditInfo,
type TpmsTimeAttestInfo,
type TpmsTimeInfo,
} from "./attest.types.js";
/**
* Decode a TCG TPM 2.0 `TPMS_ATTEST` byte string into a discriminated-union
* value per Part 2 §10.12.
*
* Field order (strictly enforced):
*
* 1. `magic: TPM_GENERATED_VALUE` (UINT32 BE — must equal `0xff544347`)
* 2. `type: TPMI_ST_ATTEST` (UINT16 BE; §10.5)
* 3. `qualifiedSigner: TPM2B_NAME`
* 4. `extraData: TPM2B_DATA`
* 5. `clockInfo: TPMS_CLOCK_INFO` (17 bytes; §10.10)
* 6. `firmwareVersion: UINT64 BE`
* 7. `attested: TPMU_ATTEST` member selected by `type`
*
* A magic-prefix mismatch raises `TPM_STRUCT.MAGIC_INVALID` BEFORE any
* further bytes are consumed; an unknown `type` raises
* `TPM_STRUCT.ATTEST_TYPE_UNSUPPORTED`.
*/
export function parseTpmsAttest(bytes: Uint8Array): TpmsAttest {
const start = performance.now();
try {
const r = new TpmBufferReader(bytes);
const magic = r.readUint32Be();
if (magic !== TPM_GENERATED_VALUE) {
throw tpmStructError(
TPM_STRUCT_CODES.MAGIC_INVALID,
`TPMS_ATTEST.magic 0x${magic.toString(16)} != 0xff544347`
);
}
const typeId = r.readUint16Be();
const qualifiedSigner = r.readTpm2b();
const extraData = r.readTpm2b();
const clockInfo = readClockInfo(r);
const firmwareVersion = r.readUint64Be();
let parsed: TpmsAttest;
if (typeId === TPM_ST_ATTEST.CERTIFY) {
parsed = {
type: "TPM_ST_ATTEST_CERTIFY",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readCertifyInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.QUOTE) {
parsed = {
type: "TPM_ST_ATTEST_QUOTE",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readQuoteInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.SESSION_AUDIT) {
parsed = {
type: "TPM_ST_ATTEST_SESSION_AUDIT",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readSessionAuditInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.COMMAND_AUDIT) {
parsed = {
type: "TPM_ST_ATTEST_COMMAND_AUDIT",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readCommandAuditInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.TIME) {
parsed = {
type: "TPM_ST_ATTEST_TIME",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readTimeAttestInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.CREATION) {
parsed = {
type: "TPM_ST_ATTEST_CREATION",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readCreationInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.NV) {
parsed = {
type: "TPM_ST_ATTEST_NV",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readNvCertifyInfo(r),
};
} else if (typeId === TPM_ST_ATTEST.NV_DIGEST) {
parsed = {
type: "TPM_ST_ATTEST_NV_DIGEST",
magic: TPM_GENERATED_VALUE,
qualifiedSigner,
extraData,
clockInfo,
firmwareVersion,
attested: readNvDigestCertifyInfo(r),
};
} else {
throw tpmStructError(
TPM_STRUCT_CODES.ATTEST_TYPE_UNSUPPORTED,
`TPMS_ATTEST.type 0x${typeId.toString(16)} not in TPM_ST_ATTEST_*`
);
}
r.assertEnd();
validateTpmsAttest(parsed);
recordParseDuration("attest", "unknown", "ok", durationMs(start));
recordParseTotal("attest", "ok");
return parsed;
} catch (err) {
recordParseDuration("attest", "unknown", "error", durationMs(start));
recordParseTotal("attest", "error");
recordParseError(err);
throw err;
}
}
function durationMs(start: number): number {
return Math.max(0, performance.now() - start);
}
function readClockInfo(r: TpmBufferReader): ClockInfo {
const clock = r.readUint64Be();
const resetCount = r.readUint32Be();
const restartCount = r.readUint32Be();
const safeByte = r.readUint8();
return {
clock,
resetCount,
restartCount,
safe: safeByte !== 0,
};
}
function readCertifyInfo(r: TpmBufferReader): TpmsCertifyInfo {
return { name: r.readTpm2b(), qualifiedName: r.readTpm2b() };
}
function readQuoteInfo(r: TpmBufferReader): TpmsQuoteInfo {
// TPML_PCR_SELECTION = uint32 count + count * TPMS_PCR_SELECTION
const count = r.readUint32Be();
if (count > 32) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`TPML_PCR_SELECTION count ${count} exceeds 32-bank ceiling`
);
}
const selects: TpmsPcrSelection[] = [];
for (let i = 0; i < count; i++) {
const hash = algIdToName(r.readUint16Be());
const sizeOfSelect = r.readUint8();
const pcrSelect = r.readBytes(sizeOfSelect);
selects.push({ hash, sizeOfSelect, pcrSelect });
}
const pcrDigest = r.readTpm2b();
return { pcrSelect: selects, pcrDigest };
}
function readSessionAuditInfo(r: TpmBufferReader): TpmsSessionAuditInfo {
const exclusiveSession = r.readUint8() !== 0;
const sessionDigest = r.readTpm2b();
return { exclusiveSession, sessionDigest };
}
function readCommandAuditInfo(r: TpmBufferReader): TpmsCommandAuditInfo {
const auditCounter = r.readUint64Be();
const digestAlg = algIdToName(r.readUint16Be());
const auditDigest = r.readTpm2b();
const commandDigest = r.readTpm2b();
return { auditCounter, digestAlg, auditDigest, commandDigest };
}
function readTimeInfo(r: TpmBufferReader): TpmsTimeInfo {
const time = r.readUint64Be();
const clockInfo = readClockInfo(r);
return { time, clockInfo };
}
function readTimeAttestInfo(r: TpmBufferReader): TpmsTimeAttestInfo {
const time = readTimeInfo(r);
const firmwareVersion = r.readUint64Be();
return { time, firmwareVersion };
}
function readCreationInfo(r: TpmBufferReader): TpmsCreationInfo {
return { objectName: r.readTpm2b(), creationHash: r.readTpm2b() };
}
function readNvCertifyInfo(r: TpmBufferReader): TpmsNvCertifyInfo {
const indexName = r.readTpm2b();
const offset = r.readUint16Be();
const nvContents = r.readTpm2b();
return { indexName, offset, nvContents };
}
function readNvDigestCertifyInfo(r: TpmBufferReader): TpmsNvDigestCertifyInfo {
const indexName = r.readTpm2b();
const nvDigest = r.readTpm2b();
return { indexName, nvDigest };
}

View File

@@ -0,0 +1,154 @@
import type { TpmAlgId } from "../alg/alg.js";
/**
* Type definitions for `TPMS_ATTEST` per TCG TPM 2.0 Library Part 2 —
* Structures r1.59 §10.12 and the substructures it composes.
*
* `TpmsAttest` is a discriminated union over the `TPMI_ST_ATTEST` `type`
* field — eight `TPM_ST_ATTEST_*` variants each select a distinct
* `attested: TPMU_ATTEST` member (per §10.11).
*/
/** TPMS_CLOCK_INFO per §10.10 (17 bytes on the wire). */
export interface ClockInfo {
readonly clock: bigint;
readonly resetCount: number;
readonly restartCount: number;
readonly safe: boolean;
}
/** TPMS_CERTIFY_INFO per §10.5 (TPMU_ATTEST member for ATTEST_CERTIFY). */
export interface TpmsCertifyInfo {
readonly name: Uint8Array;
readonly qualifiedName: Uint8Array;
}
/** TPMS_PCR_SELECTION per §10.6.2 — selection of a PCR bank. */
export interface TpmsPcrSelection {
readonly hash: TpmAlgId;
readonly sizeOfSelect: number;
readonly pcrSelect: Uint8Array;
}
/** TPMS_QUOTE_INFO per §10.12.1 (TPMU_ATTEST member for ATTEST_QUOTE). */
export interface TpmsQuoteInfo {
readonly pcrSelect: readonly TpmsPcrSelection[];
readonly pcrDigest: Uint8Array;
}
/** TPMS_SESSION_AUDIT_INFO per §10.12.4. */
export interface TpmsSessionAuditInfo {
readonly exclusiveSession: boolean;
readonly sessionDigest: Uint8Array;
}
/** TPMS_COMMAND_AUDIT_INFO per §10.12.3. */
export interface TpmsCommandAuditInfo {
readonly auditCounter: bigint;
readonly digestAlg: TpmAlgId;
readonly auditDigest: Uint8Array;
readonly commandDigest: Uint8Array;
}
/** TPMS_TIME_INFO per §10.11.6 — nested in TPMS_TIME_ATTEST_INFO. */
export interface TpmsTimeInfo {
readonly time: bigint;
readonly clockInfo: ClockInfo;
}
/** TPMS_TIME_ATTEST_INFO per §10.12.2. */
export interface TpmsTimeAttestInfo {
readonly time: TpmsTimeInfo;
readonly firmwareVersion: bigint;
}
/** TPMS_CREATION_INFO per §10.12.6. */
export interface TpmsCreationInfo {
readonly objectName: Uint8Array;
readonly creationHash: Uint8Array;
}
/** TPMS_NV_CERTIFY_INFO per §10.12.7. */
export interface TpmsNvCertifyInfo {
readonly indexName: Uint8Array;
readonly offset: number;
readonly nvContents: Uint8Array;
}
/** TPMS_NV_DIGEST_CERTIFY_INFO per §10.12.8. */
export interface TpmsNvDigestCertifyInfo {
readonly indexName: Uint8Array;
readonly nvDigest: Uint8Array;
}
interface TpmsAttestCommon {
readonly magic: 0xff544347;
readonly qualifiedSigner: Uint8Array;
readonly extraData: Uint8Array;
readonly clockInfo: ClockInfo;
readonly firmwareVersion: bigint;
}
export interface TpmsAttestCertify extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_CERTIFY";
readonly attested: TpmsCertifyInfo;
}
export interface TpmsAttestQuote extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_QUOTE";
readonly attested: TpmsQuoteInfo;
}
export interface TpmsAttestSessionAudit extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_SESSION_AUDIT";
readonly attested: TpmsSessionAuditInfo;
}
export interface TpmsAttestCommandAudit extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_COMMAND_AUDIT";
readonly attested: TpmsCommandAuditInfo;
}
export interface TpmsAttestTime extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_TIME";
readonly attested: TpmsTimeAttestInfo;
}
export interface TpmsAttestCreation extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_CREATION";
readonly attested: TpmsCreationInfo;
}
export interface TpmsAttestNv extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_NV";
readonly attested: TpmsNvCertifyInfo;
}
export interface TpmsAttestNvDigest extends TpmsAttestCommon {
readonly type: "TPM_ST_ATTEST_NV_DIGEST";
readonly attested: TpmsNvDigestCertifyInfo;
}
export type TpmsAttest =
| TpmsAttestCertify
| TpmsAttestQuote
| TpmsAttestSessionAudit
| TpmsAttestCommandAudit
| TpmsAttestTime
| TpmsAttestCreation
| TpmsAttestNv
| TpmsAttestNvDigest;
/** Numeric `TPMI_ST_ATTEST` values per §10.5. */
export const TPM_ST_ATTEST = Object.freeze({
NV: 0x8014,
COMMAND_AUDIT: 0x8015,
SESSION_AUDIT: 0x8016,
CERTIFY: 0x8017,
QUOTE: 0x8018,
TIME: 0x8019,
CREATION: 0x801a,
NV_DIGEST: 0x801c,
} as const);
export const TPM_GENERATED_VALUE = 0xff544347 as const;

View File

@@ -0,0 +1,26 @@
export { parseTpmsAttest } from "./attest.js";
export {
type ClockInfo,
TPM_GENERATED_VALUE,
TPM_ST_ATTEST,
type TpmsAttest,
type TpmsAttestCertify,
type TpmsAttestCommandAudit,
type TpmsAttestCreation,
type TpmsAttestNv,
type TpmsAttestNvDigest,
type TpmsAttestQuote,
type TpmsAttestSessionAudit,
type TpmsAttestTime,
type TpmsCertifyInfo,
type TpmsCommandAuditInfo,
type TpmsCreationInfo,
type TpmsNvCertifyInfo,
type TpmsNvDigestCertifyInfo,
type TpmsPcrSelection,
type TpmsQuoteInfo,
type TpmsSessionAuditInfo,
type TpmsTimeAttestInfo,
type TpmsTimeInfo,
} from "./attest.types.js";
export { serializeTpmsAttest } from "./serialize.js";

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { TpmStructError } from "../errors/error-codes.js";
import { parseTpmsAttest } from "./attest.js";
import { serializeTpmsAttest } from "./serialize.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const o = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
const u8 = (max: number) => fc.uint8Array({ maxLength: max });
const u32 = fc.integer({ min: 0, max: 0xffffffff });
const u64 = fc.bigInt({ min: 0n, max: 0xffffffffffffffffn });
const safeByte = fc.constantFrom(0, 1);
const certifyArb = fc.tuple(
u8(34),
u8(32),
u64,
u32,
u32,
safeByte,
u64,
u8(34),
u8(34)
);
describe("parseTpmsAttest — round-trip property (CERTIFY)", () => {
it("arbitrary CERTIFY buffers round-trip byte-identical", {
timeout: 60000,
}, () => {
fc.assert(
fc.property(
certifyArb,
([
qSigner,
extra,
clock,
reset,
restart,
safe,
fw,
name,
qName,
]) => {
const bytes = concat(
be32(0xff544347),
be16(0x8017),
tpm2b(new Uint8Array(qSigner)),
tpm2b(new Uint8Array(extra)),
be64(clock),
be32(reset),
be32(restart),
new Uint8Array([safe]),
be64(fw),
tpm2b(new Uint8Array(name)),
tpm2b(new Uint8Array(qName))
);
const re = serializeTpmsAttest(parseTpmsAttest(bytes));
expect(re).toEqual(bytes);
}
),
{ numRuns: 60 }
);
});
});
describe("parseTpmsAttest — single-bit-flip", () => {
it("flipping the magic prefix raises a typed TpmStructError", {
timeout: 30000,
}, () => {
const base = concat(
be32(0xff544347),
be16(0x8017),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(16)),
be64(0n),
be32(0),
be32(0),
new Uint8Array([0]),
be64(0n),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(34))
);
// Flip bit in each of the 4 magic bytes — must all reject.
for (let i = 0; i < 4; i++) {
for (let b = 0; b < 8; b++) {
const m = new Uint8Array(base);
(m[i] as number) ^= 1 << b;
if (m[i] === base[i]) continue;
try {
parseTpmsAttest(m);
} catch (e) {
expect(e).toBeInstanceOf(TpmStructError);
continue;
}
// If it parsed, the flipped bytes happened to remain valid magic
// (impossible because magic is fixed), so fail loudly:
throw new Error("single-bit-flip on magic must reject");
}
}
});
});

View File

@@ -0,0 +1,72 @@
# `attest/serialize` — `TPMS_ATTEST` canonical serialiser
## Purpose
`serializeTpmsAttest` re-emits a parsed `TpmsAttest` value as the
TCG-canonical byte string per Library Part 2 §10.12. The serialiser is
the inverse of `parseTpmsAttest`: for any input the parser accepts,
`serialize(parse(bytes))` returns the same bytes. This invariant is
load-bearing for two downstream uses: signature verification (the TPM
endorsement key signed these exact bytes — a re-serialisation that
differed by a single bit would silently fail every signature check) and
proxy / replay use cases that want to forward a verified attest blob
without trusting the originating wire to preserve the formatting.
## Features
- Byte-identical round-trip across all eight `TPMU_ATTEST` variants.
- Re-emits the 32-bit `magic` prefix regardless of source — the
serialiser is the authoritative encoder, not a passthrough.
- Re-emits `TPM2B_*` size prefixes from the decoded payload length so a
mutated stored size is silently ignored.
- Renders the 17-byte `clockInfo` substructure exactly per §10.10.
- Per-variant attested encoder for `CERTIFY`, `QUOTE`, `SESSION_AUDIT`,
`COMMAND_AUDIT`, `TIME`, `CREATION`, `NV`, `NV_DIGEST`.
## Usage: Basic Example
```ts
import {
parseTpmsAttest,
serializeTpmsAttest,
} from "@suaveplan/tpm-struct/attest";
const att = parseTpmsAttest(bytes);
const re = serializeTpmsAttest(att);
// `re` equals `bytes` byte-for-byte.
```
## Usage: Advanced Example
```ts
import {
parseTpmsAttest,
serializeTpmsAttest,
} from "@suaveplan/tpm-struct/attest";
// Proxy / verifier-of-verifiers pattern: validate, normalise, forward.
function relayAttestation(raw: Uint8Array, verifySig: (b: Uint8Array) => boolean) {
const att = parseTpmsAttest(raw);
const canonical = serializeTpmsAttest(att);
if (!verifySig(canonical)) {
throw new Error("attestation signature did not verify");
}
return { att, canonical };
}
```
## API Reference
- **`serializeTpmsAttest(att: TpmsAttest): Uint8Array`** — returns a
fresh byte string. Does not throw on any well-typed input — the
caller must construct `att` through `parseTpmsAttest` (or a Zod-
validated synthetic constructor) so the input is structurally valid.
## Implementation Notes
The serialiser dispatches on `att.type` via a `switch` that the
TypeScript compiler verifies as exhaustive. Each per-variant encoder
walks the §10.11 / §10.12 field order using `TpmBufferWriter` so size
prefixes and big-endian conventions stay locked to the reader semantics.
The serialiser writes into a single growing buffer to avoid the cost of
multiple `Uint8Array` allocations on the encoder hot path.

View File

@@ -0,0 +1,186 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseTpmsAttest } from "./attest.js";
import { serializeTpmsAttest } from "./serialize.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const o = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
const MAGIC = be32(0xff544347);
const QSIGNER = tpm2b(new Uint8Array(20).fill(0x77));
const EXTRA = tpm2b(new Uint8Array(16).fill(0xab));
const CLOCK = concat(be64(7n), be32(2), be32(1), new Uint8Array([0x00]));
const FW = be64(1n);
describe("serializeTpmsAttest — byte-identical for real-world fixture", () => {
it("ATTEST_CERTIFY round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8017),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34).fill(0x44)),
tpm2b(new Uint8Array(34).fill(0x55))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_TIME round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8019),
QSIGNER,
EXTRA,
CLOCK,
FW,
be64(123n),
CLOCK,
be64(456n)
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_COMMAND_AUDIT round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8015),
QSIGNER,
EXTRA,
CLOCK,
FW,
be64(0xdeadbeefn),
be16(0x000b),
tpm2b(new Uint8Array(32).fill(0xaa)),
tpm2b(new Uint8Array(32).fill(0xbb))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_NV round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x8014),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
be16(0x1234),
tpm2b(new Uint8Array(64).fill(0xee))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_NV_DIGEST round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x801c),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_CREATION round-trips", { timeout: 5000 }, () => {
const bytes = concat(
MAGIC,
be16(0x801a),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_SESSION_AUDIT round-trips (exclusive=true)", {
timeout: 5000,
}, () => {
const bytes = concat(
MAGIC,
be16(0x8016),
QSIGNER,
EXTRA,
CLOCK,
FW,
new Uint8Array([0x01]),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_SESSION_AUDIT round-trips (exclusive=false)", {
timeout: 5000,
}, () => {
const bytes = concat(
MAGIC,
be16(0x8016),
QSIGNER,
EXTRA,
CLOCK,
FW,
new Uint8Array([0x00]),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
it("ATTEST_QUOTE with multi-bank round-trips", {
timeout: 5000,
}, () => {
const bytes = concat(
MAGIC,
be16(0x8018),
QSIGNER,
EXTRA,
CLOCK,
FW,
be32(2),
be16(0x000b),
new Uint8Array([3, 0x01, 0x02, 0x03]),
be16(0x000c),
new Uint8Array([3, 0xaa, 0xbb, 0xcc]),
tpm2b(new Uint8Array(48).fill(0xdd))
);
expect(serializeTpmsAttest(parseTpmsAttest(bytes))).toEqual(bytes);
});
});

View File

@@ -0,0 +1,154 @@
import { nameToAlgId } from "../alg/alg.js";
import { TpmBufferWriter } from "../buffer/buffer.js";
import {
type ClockInfo,
TPM_GENERATED_VALUE,
TPM_ST_ATTEST,
type TpmsAttest,
type TpmsCertifyInfo,
type TpmsCommandAuditInfo,
type TpmsCreationInfo,
type TpmsNvCertifyInfo,
type TpmsNvDigestCertifyInfo,
type TpmsQuoteInfo,
type TpmsSessionAuditInfo,
type TpmsTimeAttestInfo,
type TpmsTimeInfo,
} from "./attest.types.js";
/**
* Re-emit a parsed `TpmsAttest` as a TCG-canonical byte string per Part 2
* §10.12. Round-trip property: for any byte string `b` that `parseTpmsAttest`
* accepts, `serializeTpmsAttest(parseTpmsAttest(b))` returns the same bytes.
*
* This serialiser is the canonical witness for signature verification —
* downstream WebAuthn `tpm`-attestation verifiers re-sign these bytes with
* the TPM endorsement key to check provenance.
*/
export function serializeTpmsAttest(att: TpmsAttest): Uint8Array {
const w = new TpmBufferWriter();
w.writeUint32Be(TPM_GENERATED_VALUE);
w.writeUint16Be(typeIdFor(att.type));
w.writeTpm2b(att.qualifiedSigner);
w.writeTpm2b(att.extraData);
writeClockInfo(w, att.clockInfo);
w.writeUint64Be(att.firmwareVersion);
switch (att.type) {
case "TPM_ST_ATTEST_CERTIFY":
writeCertifyInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_QUOTE":
writeQuoteInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_SESSION_AUDIT":
writeSessionAuditInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_COMMAND_AUDIT":
writeCommandAuditInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_TIME":
writeTimeAttestInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_CREATION":
writeCreationInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_NV":
writeNvCertifyInfo(w, att.attested);
break;
case "TPM_ST_ATTEST_NV_DIGEST":
writeNvDigestCertifyInfo(w, att.attested);
break;
}
return w.toUint8Array();
}
function typeIdFor(name: TpmsAttest["type"]): number {
switch (name) {
case "TPM_ST_ATTEST_CERTIFY":
return TPM_ST_ATTEST.CERTIFY;
case "TPM_ST_ATTEST_QUOTE":
return TPM_ST_ATTEST.QUOTE;
case "TPM_ST_ATTEST_SESSION_AUDIT":
return TPM_ST_ATTEST.SESSION_AUDIT;
case "TPM_ST_ATTEST_COMMAND_AUDIT":
return TPM_ST_ATTEST.COMMAND_AUDIT;
case "TPM_ST_ATTEST_TIME":
return TPM_ST_ATTEST.TIME;
case "TPM_ST_ATTEST_CREATION":
return TPM_ST_ATTEST.CREATION;
case "TPM_ST_ATTEST_NV":
return TPM_ST_ATTEST.NV;
case "TPM_ST_ATTEST_NV_DIGEST":
return TPM_ST_ATTEST.NV_DIGEST;
}
}
function writeClockInfo(w: TpmBufferWriter, c: ClockInfo): void {
w.writeUint64Be(c.clock);
w.writeUint32Be(c.resetCount);
w.writeUint32Be(c.restartCount);
w.writeUint8(c.safe ? 1 : 0);
}
function writeCertifyInfo(w: TpmBufferWriter, c: TpmsCertifyInfo): void {
w.writeTpm2b(c.name);
w.writeTpm2b(c.qualifiedName);
}
function writeQuoteInfo(w: TpmBufferWriter, q: TpmsQuoteInfo): void {
w.writeUint32Be(q.pcrSelect.length);
for (const sel of q.pcrSelect) {
w.writeUint16Be(nameToAlgId(sel.hash));
w.writeUint8(sel.sizeOfSelect);
w.writeBytes(sel.pcrSelect);
}
w.writeTpm2b(q.pcrDigest);
}
function writeSessionAuditInfo(
w: TpmBufferWriter,
s: TpmsSessionAuditInfo
): void {
w.writeUint8(s.exclusiveSession ? 1 : 0);
w.writeTpm2b(s.sessionDigest);
}
function writeCommandAuditInfo(
w: TpmBufferWriter,
c: TpmsCommandAuditInfo
): void {
w.writeUint64Be(c.auditCounter);
w.writeUint16Be(nameToAlgId(c.digestAlg));
w.writeTpm2b(c.auditDigest);
w.writeTpm2b(c.commandDigest);
}
function writeTimeInfo(w: TpmBufferWriter, t: TpmsTimeInfo): void {
w.writeUint64Be(t.time);
writeClockInfo(w, t.clockInfo);
}
function writeTimeAttestInfo(w: TpmBufferWriter, t: TpmsTimeAttestInfo): void {
writeTimeInfo(w, t.time);
w.writeUint64Be(t.firmwareVersion);
}
function writeCreationInfo(w: TpmBufferWriter, c: TpmsCreationInfo): void {
w.writeTpm2b(c.objectName);
w.writeTpm2b(c.creationHash);
}
function writeNvCertifyInfo(w: TpmBufferWriter, n: TpmsNvCertifyInfo): void {
w.writeTpm2b(n.indexName);
w.writeUint16Be(n.offset);
w.writeTpm2b(n.nvContents);
}
function writeNvDigestCertifyInfo(
w: TpmBufferWriter,
n: TpmsNvDigestCertifyInfo
): void {
w.writeTpm2b(n.indexName);
w.writeTpm2b(n.nvDigest);
}

View File

@@ -0,0 +1,86 @@
# `buffer` — TPM 2.0 octet-stream reader and writer
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `buffer` module provides the lowest layer of `@suaveplan/tpm-struct`: a
strictly-bounded, big-endian byte reader (`TpmBufferReader`) and a matching
growing-array writer (`TpmBufferWriter`) shaped to the TCG TPM 2.0 Library
Part 2 §10.1 octet-stream conventions. Every higher-level parser in the
package (the `TPMS_ATTEST` decoder, the `TPMT_PUBLIC` decoder, the name
serialiser) builds on these primitives, which is why they receive their
own subpath export. Keeping bounds enforcement, big-endian semantics, and
the `TPM2B_*` size-prefix protocol in one place means the rest of the
package can be written as straight-line code without per-field bounds
checks or accidental little-endian reads.
## Features
- Big-endian readers for UINT8/16/32/64 (the 64-bit form returns a
`bigint` so it never silently truncates).
- `TPM2B_*` size-prefix reader and writer (UINT16 length + payload).
- Cursor-stable error path: out-of-bounds reads raise
`TpmStructError(TPM_STRUCT.TRUNCATED)` carrying `cursor` and `requested`
on the cause chain so log adapters can format the failure.
- `assertEnd()` rejects trailing bytes — guards against silent length
drift between the wire format and consumers.
- Constant-time-safe `peek(n)` (no advancing, never throws on clip).
- Growing writer with geometric reallocation hint via `initialCapacity`.
## Usage: Basic Example
```ts
import { TpmBufferReader } from "@suaveplan/tpm-struct/buffer";
const reader = new TpmBufferReader(new Uint8Array([0x00, 0x04, 0xde, 0xad, 0xbe, 0xef]));
const payload = reader.readTpm2b(); // Uint8Array[0xde, 0xad, 0xbe, 0xef]
reader.assertEnd();
```
## Usage: Advanced Example
```ts
import { TpmBufferReader, TpmBufferWriter } from "@suaveplan/tpm-struct/buffer";
// Encode then decode an arbitrary TPMS_CLOCK_INFO substructure.
const w = new TpmBufferWriter({ initialCapacity: 17 });
w.writeUint64Be(100n); // clock
w.writeUint32Be(5); // resetCount
w.writeUint32Be(3); // restartCount
w.writeUint8(1); // safe = true
const bytes = w.toUint8Array();
const r = new TpmBufferReader(bytes);
const clock = r.readUint64Be();
const reset = r.readUint32Be();
const restart = r.readUint32Be();
const safe = r.readUint8() !== 0;
r.assertEnd();
```
## API Reference
- **`new TpmBufferReader(bytes, { cursor? })`** — construct over a
`Uint8Array`. Out-of-range `cursor` raises `TPM_STRUCT.TRUNCATED`.
- **`readUint8 / readUint16Be / readUint32Be`** — return `number`.
- **`readUint64Be()`** — returns `bigint`.
- **`readBytes(n)`** — returns a fresh `Uint8Array` copy (never aliases).
- **`readTpm2b()`** / **`readTpm2bWithSize()`** — TCG `TPM2B_*` size-prefix.
- **`peek(n)` / `remaining()` / `cursor()` / `assertEnd()`** — read-only
introspection.
- **`new TpmBufferWriter({ initialCapacity? })`** — growing writer.
- **`writeUint8 / 16Be / 32Be / 64Be / Bytes / Tpm2b`** — mirror writes.
- **`toUint8Array()`** — finalise into a fresh `Uint8Array`.
## Implementation Notes
The reader holds a single `DataView` over the supplied `Uint8Array`'s
backing buffer and advances a private cursor by the exact bytes consumed.
`readTpm2b` rewinds the cursor to the start of the size prefix on a
short-read failure so callers can produce diagnostic dumps from the
failing position. The writer grows its backing array by powers of two
starting at `initialCapacity` (default 256) and reuses the same buffer
across writes. Numeric range checks reject out-of-range writes at the
boundary — this is cheaper than letting `DataView` truncate silently.

View File

@@ -0,0 +1,271 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { TpmStructError } from "../errors/error-codes.js";
import { TpmBufferReader, TpmBufferWriter } from "./buffer.js";
function hex(s: string): Uint8Array {
const clean = s.replace(/\s+/g, "");
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
describe("TpmBufferReader — primitives", () => {
it("readUint8 advances by 1", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("ab cd"));
expect(r.readUint8()).toBe(0xab);
expect(r.cursor()).toBe(1);
expect(r.readUint8()).toBe(0xcd);
expect(r.cursor()).toBe(2);
});
it("readUint16Be reads big-endian", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("01 02"));
expect(r.readUint16Be()).toBe(0x0102);
expect(r.cursor()).toBe(2);
});
it("readUint32Be reads big-endian", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("ff 54 43 47"));
expect(r.readUint32Be()).toBe(0xff544347);
});
it("readUint64Be returns a bigint", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("00 00 00 00 00 00 00 64"));
expect(r.readUint64Be()).toBe(100n);
});
it("readBytes returns a fresh copy", { timeout: 5000 }, () => {
const src = hex("aa bb cc dd");
const r = new TpmBufferReader(src);
const out = r.readBytes(3);
expect(out).toEqual(hex("aa bb cc"));
out[0] = 0;
expect(src[0]).toBe(0xaa); // unchanged
});
it("remaining reflects unread bytes", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("01 02 03"));
expect(r.remaining()).toBe(3);
r.readUint8();
expect(r.remaining()).toBe(2);
});
it("peek returns up to n bytes without advancing", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("11 22 33"));
expect(r.peek(2)).toEqual(hex("11 22"));
expect(r.cursor()).toBe(0);
// peek beyond remaining clips silently — never advances, never throws
expect(r.peek(100)).toEqual(hex("11 22 33"));
});
});
describe("TpmBufferReader — TPM2B", () => {
it("readTpm2b reads 4-byte payload and advances cursor", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("00 04 de ad be ef"));
expect(r.readTpm2b()).toEqual(hex("de ad be ef"));
expect(r.cursor()).toBe(6);
});
it("readTpm2b accepts zero-length", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("00 00"));
expect(r.readTpm2b()).toEqual(new Uint8Array(0));
expect(r.cursor()).toBe(2);
});
it("readTpm2b raises TRUNCATED on short buffer", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("00 08 de ad be ef"));
try {
r.readTpm2b();
expect.unreachable();
} catch (e) {
expect(e).toBeInstanceOf(TpmStructError);
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
});
it("readTpm2bWithSize returns size and payload", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("00 03 aa bb cc"));
const res = r.readTpm2bWithSize();
expect(res.size).toBe(3);
expect(res.bytes).toEqual(hex("aa bb cc"));
});
});
describe("TpmBufferReader — bounds enforcement", () => {
it("readUint32Be past end raises TRUNCATED", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("01 02 03"));
try {
r.readUint32Be();
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
const detail = (
e as unknown as { cause: { detail: { requested: number } } }
).cause.detail;
expect(detail.requested).toBe(4);
}
});
it("readBytes past end raises TRUNCATED", { timeout: 5000 }, () => {
const r = new TpmBufferReader(new Uint8Array(50));
try {
r.readBytes(100);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
const detail = (
e as unknown as { cause: { detail: { requested: number } } }
).cause.detail;
expect(detail.requested).toBe(100);
}
});
it("readBytes rejects negative length", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("aa bb"));
try {
r.readBytes(-1);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
});
it("readUint8/16/64 past end raise TRUNCATED", {
timeout: 5000,
}, () => {
for (const sz of [1, 2, 8] as const) {
const r = new TpmBufferReader(new Uint8Array(0));
try {
if (sz === 1) r.readUint8();
if (sz === 2) r.readUint16Be();
if (sz === 8) r.readUint64Be();
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
}
});
it("assertEnd raises with trailing bytes", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("aa bb"));
r.readUint8();
try {
r.assertEnd();
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
});
it("assertEnd passes at end of buffer", { timeout: 5000 }, () => {
const r = new TpmBufferReader(hex("aa"));
r.readUint8();
expect(() => r.assertEnd()).not.toThrow();
});
it("constructor rejects out-of-range cursor", {
timeout: 5000,
}, () => {
expect(() => new TpmBufferReader(hex("aa"), { cursor: -1 })).toThrow(
TpmStructError
);
expect(() => new TpmBufferReader(hex("aa"), { cursor: 99 })).toThrow(
TpmStructError
);
});
it("constructor accepts cursor at end (== length)", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("aa"), { cursor: 1 });
expect(r.remaining()).toBe(0);
expect(() => r.assertEnd()).not.toThrow();
});
it("readTpm2b on short prefix raises TRUNCATED leaving cursor stable", {
timeout: 5000,
}, () => {
const r = new TpmBufferReader(hex("00")); // only 1 byte of the size prefix
try {
r.readTpm2b();
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.TRUNCATED");
}
});
});
describe("TpmBufferWriter", () => {
it("round-trips uint8/16/32/64 BE", { timeout: 5000 }, () => {
const w = new TpmBufferWriter();
w.writeUint8(0xaa);
w.writeUint16Be(0xbbcc);
w.writeUint32Be(0xddeeff00);
w.writeUint64Be(0x0102030405060708n);
const bytes = w.toUint8Array();
const r = new TpmBufferReader(bytes);
expect(r.readUint8()).toBe(0xaa);
expect(r.readUint16Be()).toBe(0xbbcc);
expect(r.readUint32Be()).toBe(0xddeeff00);
expect(r.readUint64Be()).toBe(0x0102030405060708n);
});
it("writeTpm2b emits size + payload", { timeout: 5000 }, () => {
const w = new TpmBufferWriter();
w.writeTpm2b(hex("de ad"));
expect(w.toUint8Array()).toEqual(hex("00 02 de ad"));
});
it("writeTpm2b accepts zero-length", { timeout: 5000 }, () => {
const w = new TpmBufferWriter();
w.writeTpm2b(new Uint8Array(0));
expect(w.toUint8Array()).toEqual(hex("00 00"));
});
it("grows backing array geometrically", { timeout: 5000 }, () => {
const w = new TpmBufferWriter({ initialCapacity: 4 });
w.writeBytes(new Uint8Array(100).fill(0xab));
expect(w.toUint8Array().length).toBe(100);
});
it("rejects out-of-range writes", { timeout: 5000 }, () => {
const w = new TpmBufferWriter();
expect(() => w.writeUint8(-1)).toThrow(TpmStructError);
expect(() => w.writeUint8(256)).toThrow(TpmStructError);
expect(() => w.writeUint16Be(-1)).toThrow(TpmStructError);
expect(() => w.writeUint16Be(0x10000)).toThrow(TpmStructError);
expect(() => w.writeUint32Be(-1)).toThrow(TpmStructError);
expect(() => w.writeUint32Be(0x100000000)).toThrow(TpmStructError);
expect(() => w.writeUint64Be(-1n)).toThrow(TpmStructError);
expect(() => w.writeUint64Be(0x10000000000000000n)).toThrow(
TpmStructError
);
});
it("rejects TPM2B payloads larger than uint16", {
timeout: 5000,
}, () => {
const w = new TpmBufferWriter();
const big = new Uint8Array(0x10000);
expect(() => w.writeTpm2b(big)).toThrow(TpmStructError);
});
it("cursor tracks writes", { timeout: 5000 }, () => {
const w = new TpmBufferWriter();
expect(w.cursor()).toBe(0);
w.writeUint8(1);
expect(w.cursor()).toBe(1);
});
});

View File

@@ -0,0 +1,261 @@
import {
TPM_STRUCT_CODES,
TpmStructError,
tpmStructError,
} from "../errors/error-codes.js";
import type {
Tpm2bReadResult,
TpmReaderOptions,
TpmWriterOptions,
} from "./buffer.types.js";
/**
* Big-endian, byte-cursor reader over a TPM 2.0 octet-stream buffer
* (TCG TPM 2.0 Library Part 2 — Structures r1.59, §10.1).
*
* Every method advances the cursor by exactly the bytes it consumed. Reads
* that would consume bytes past the end of the underlying `Uint8Array`
* raise `TpmStructError(TPM_STRUCT.TRUNCATED)` carrying `cursor` and
* `requested` on the error's `cause.detail` slot — the reader never
* silently short-reads, never pads with zero bytes, and never wraps.
*/
export class TpmBufferReader {
private readonly bytes: Uint8Array;
private readonly view: DataView;
private pos: number;
constructor(bytes: Uint8Array, options: TpmReaderOptions = {}) {
this.bytes = bytes;
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.length);
const initial = options.cursor ?? 0;
if (initial < 0 || initial > bytes.length) {
throw tpmStructError(
TPM_STRUCT_CODES.TRUNCATED,
`cursor ${initial} out of range [0, ${bytes.length}]`,
{ cursor: initial, requested: 0 }
);
}
this.pos = initial;
}
/** Current 0-based cursor position. */
cursor(): number {
return this.pos;
}
/** Bytes remaining between the cursor and end-of-buffer. */
remaining(): number {
return this.bytes.length - this.pos;
}
/** Peek up to `n` bytes without advancing the cursor (clipped to remaining). */
peek(n: number): Uint8Array {
const end = Math.min(this.pos + n, this.bytes.length);
return this.bytes.subarray(this.pos, end);
}
/** Assert the cursor sits at end-of-buffer; trailing bytes raise TRUNCATED. */
assertEnd(): void {
if (this.pos !== this.bytes.length) {
throw tpmStructError(
TPM_STRUCT_CODES.TRUNCATED,
`${this.bytes.length - this.pos} trailing byte(s) past expected end`,
{ cursor: this.pos, requested: 0 }
);
}
}
private ensure(n: number): void {
if (this.pos + n > this.bytes.length) {
throw tpmStructError(
TPM_STRUCT_CODES.TRUNCATED,
`read of ${n} byte(s) at cursor ${this.pos} would exceed buffer length ${this.bytes.length}`,
{ cursor: this.pos, requested: n }
);
}
}
/** Read one unsigned 8-bit byte. */
readUint8(): number {
this.ensure(1);
const v = this.view.getUint8(this.pos);
this.pos += 1;
return v;
}
/** Read a big-endian UINT16. */
readUint16Be(): number {
this.ensure(2);
const v = this.view.getUint16(this.pos, false);
this.pos += 2;
return v;
}
/** Read a big-endian UINT32. */
readUint32Be(): number {
this.ensure(4);
const v = this.view.getUint32(this.pos, false);
this.pos += 4;
return v;
}
/** Read a big-endian UINT64 as a `bigint`. */
readUint64Be(): bigint {
this.ensure(8);
const v = this.view.getBigUint64(this.pos, false);
this.pos += 8;
return v;
}
/** Read exactly `n` bytes and return them as a fresh `Uint8Array`. */
readBytes(n: number): Uint8Array {
if (n < 0) {
throw tpmStructError(
TPM_STRUCT_CODES.TRUNCATED,
`negative read length ${n}`,
{ cursor: this.pos, requested: n }
);
}
this.ensure(n);
// Slice produces a copy with its own backing buffer so callers can
// retain the result without aliasing into the reader's underlying array.
const out = this.bytes.slice(this.pos, this.pos + n);
this.pos += n;
return out;
}
/**
* Read a TCG `TPM2B_*` value — UINT16 BE size prefix followed by exactly
* `size` payload bytes. Returns the payload as a fresh `Uint8Array`. The
* cursor advances by `2 + size` bytes. Zero-length values return an
* empty array. A declared size that exceeds the remaining buffer raises
* `TPM_STRUCT.TRUNCATED` BEFORE any payload bytes are consumed so the
* cursor is left pointing at the size prefix on failure.
*/
readTpm2b(): Uint8Array {
const before = this.pos;
const size = this.readUint16Be();
if (size > this.remaining()) {
this.pos = before;
throw tpmStructError(
TPM_STRUCT_CODES.TRUNCATED,
`TPM2B declares ${size} byte payload but only ${this.remaining()} remain`,
{ cursor: this.pos, requested: size }
);
}
return this.readBytes(size);
}
/** Same as `readTpm2b` but returns the declared size alongside the payload. */
readTpm2bWithSize(): Tpm2bReadResult {
const bytes = this.readTpm2b();
return { size: bytes.length, bytes };
}
}
/**
* Big-endian, growing byte-writer matching `TpmBufferReader`'s surface.
* Used by `serializeTpmtPublic` / `serializeTpmsAttest` to emit canonical
* TPM 2.0 byte streams suitable for re-hashing and signature verification.
*/
export class TpmBufferWriter {
private buf: Uint8Array;
private view: DataView;
private pos: number;
constructor(options: TpmWriterOptions = {}) {
const cap = options.initialCapacity ?? 256;
this.buf = new Uint8Array(cap);
this.view = new DataView(this.buf.buffer);
this.pos = 0;
}
/** Current write cursor (== bytes written so far). */
cursor(): number {
return this.pos;
}
private grow(needed: number): void {
if (this.pos + needed <= this.buf.length) return;
let cap = this.buf.length;
while (cap < this.pos + needed) cap *= 2;
const next = new Uint8Array(cap);
next.set(this.buf);
this.buf = next;
this.view = new DataView(this.buf.buffer);
}
writeUint8(v: number): void {
if (v < 0 || v > 0xff) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`uint8 value ${v} out of range`
);
}
this.grow(1);
this.view.setUint8(this.pos, v);
this.pos += 1;
}
writeUint16Be(v: number): void {
if (v < 0 || v > 0xffff) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`uint16 value ${v} out of range`
);
}
this.grow(2);
this.view.setUint16(this.pos, v, false);
this.pos += 2;
}
writeUint32Be(v: number): void {
if (v < 0 || v > 0xffffffff) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`uint32 value ${v} out of range`
);
}
this.grow(4);
this.view.setUint32(this.pos, v, false);
this.pos += 4;
}
writeUint64Be(v: bigint): void {
if (v < 0n || v > 0xffffffffffffffffn) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`uint64 value ${v} out of range`
);
}
this.grow(8);
this.view.setBigUint64(this.pos, v, false);
this.pos += 8;
}
writeBytes(b: Uint8Array): void {
this.grow(b.length);
this.buf.set(b, this.pos);
this.pos += b.length;
}
/** Write a `TPM2B_*` value — UINT16 BE size followed by exactly `size` bytes. */
writeTpm2b(b: Uint8Array): void {
if (b.length > 0xffff) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
`TPM2B payload of ${b.length} bytes exceeds UINT16 size prefix`
);
}
this.writeUint16Be(b.length);
this.writeBytes(b);
}
/** Finalise: return the written bytes as a fresh `Uint8Array`. */
toUint8Array(): Uint8Array {
return this.buf.slice(0, this.pos);
}
}
// Re-export the error class to ease consumer narrowing without an extra import.
export { TpmStructError };

View File

@@ -0,0 +1,23 @@
/** Optional construction-time settings for `TpmBufferReader`. */
export interface TpmReaderOptions {
/** Initial cursor position. Defaults to 0. Must be in `[0, bytes.length]`. */
readonly cursor?: number;
}
/** Optional construction-time settings for `TpmBufferWriter`. */
export interface TpmWriterOptions {
/**
* Initial buffer capacity in bytes. The writer grows the backing array
* geometrically; this hint avoids early reallocations for known sizes.
* Defaults to 256.
*/
readonly initialCapacity?: number;
}
/** Structured result of `readTpm2b` for callers that need the size separately. */
export interface Tpm2bReadResult {
/** Declared UINT16 BE size prefix (in bytes). */
readonly size: number;
/** Payload bytes (length === size). */
readonly bytes: Uint8Array;
}

View File

@@ -0,0 +1,6 @@
export { TpmBufferReader, TpmBufferWriter } from "./buffer.js";
export type {
Tpm2bReadResult,
TpmReaderOptions,
TpmWriterOptions,
} from "./buffer.types.js";

View File

@@ -0,0 +1,99 @@
# `diagnostic` — human-readable dump of `TpmsAttest` / `TpmtPublic`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`toDiagnostic` renders a parsed `TpmsAttest` or `TpmtPublic` value as a
human-readable, grep-friendly multi-line string. The dumper exists for
debugging and for failed-attestation post-mortems — a TPM attestation
that fails verification produces a structured `TpmStructError`, but
the byte-level interpretation that led to the failure is what an
operator needs to root-cause the issue (was the wrong algorithm
negotiated? was the wire format corrupted in transit? did a particular
flag bit signal a different operating mode?). The diagnostic dumper
never emits telemetry — it is debug-only and SHOULD NOT be invoked on
production hot paths.
## Features
- Symbolic `TPM_ALG_*` and `TPM_ECC_*` names inlined throughout the
output (no opaque hex algorithm IDs).
- Type-aware rendering for every TpmsAttest variant (`CERTIFY`, `QUOTE`,
`SESSION_AUDIT`, `COMMAND_AUDIT`, `TIME`, `CREATION`, `NV`,
`NV_DIGEST`) and every TpmtPublic branch (RSA, ECC, KEYEDHASH,
SYMCIPHER).
- Compact rendering for `objectAttributes` — only the enabled flag
names appear; `{}` for none enabled.
- Hex rendering for every byte field (with `0x` prefix even on empty
payloads so the absence of bytes is visually distinct from a missing
field).
## Usage: Basic Example
```ts
import { parseTpmtPublic, toDiagnostic } from "@suaveplan/tpm-struct";
const pub = parseTpmtPublic(pubAreaBytes);
console.log(toDiagnostic(pub));
// TPMT_PUBLIC {
// type: TPM_ALG_RSA
// nameAlg: TPM_ALG_SHA256
// objectAttributes: { raw: 0x00040472, flags: {fixedTPM, fixedParent, sensitiveDataOrigin, userWithAuth, sign} }
// ...
// }
```
## Usage: Advanced Example
```ts
import {
parseTpmsAttest,
parseTpmtPublic,
toDiagnostic,
TpmStructError,
} from "@suaveplan/tpm-struct";
// Attach a diagnostic dump to a downstream verifier's structured log
// when verification fails.
function verifyAndLog(
attestBytes: Uint8Array,
pubAreaBytes: Uint8Array,
runVerify: (att: ReturnType<typeof parseTpmsAttest>, pub: ReturnType<typeof parseTpmtPublic>) => void
) {
try {
const att = parseTpmsAttest(attestBytes);
const pub = parseTpmtPublic(pubAreaBytes);
runVerify(att, pub);
} catch (e) {
if (e instanceof TpmStructError) {
try {
console.error("attest:\n" + toDiagnostic(parseTpmsAttest(attestBytes)));
} catch {
// attest itself was malformed; nothing to dump.
}
try {
console.error("pubArea:\n" + toDiagnostic(parseTpmtPublic(pubAreaBytes)));
} catch {
// pubArea itself was malformed; nothing to dump.
}
}
throw e;
}
}
```
## API Reference
- **`toDiagnostic(value: TpmsAttest | TpmtPublic): string`** — return a
multi-line dump. Discriminates on whether the value's `type` starts
with `TPM_ST_ATTEST_` (attest) or `TPM_ALG_` (public).
## Implementation Notes
The dumper is intentionally separate from any per-parser code path so
no production parser pays the rendering cost. Symbolic names appear
inline rather than as hex IDs so the output is grep-friendly — an
operator can search transcripts for `TPM_ALG_RSA` or `TPM_ECC_NIST_P256`
without first cross-referencing the registry.

View File

@@ -0,0 +1,279 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseTpmsAttest } from "../attest/attest.js";
import { parseTpmtPublic } from "../public/public.js";
import { toDiagnostic } from "./diagnostic.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const o = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
describe("toDiagnostic", () => {
it("renders RSA TpmtPublic with symbolic alg names", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
const out = toDiagnostic(parseTpmtPublic(bytes));
expect(out).toContain("TPMT_PUBLIC");
expect(out).toContain("TPM_ALG_RSA");
expect(out).toContain("TPM_ALG_SHA256");
expect(out).toContain("fixedTPM");
expect(out).toContain("keyBits: 2048");
});
it("renders ECC TpmtPublic with curve name", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(0x0003),
be16(0x0010),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
const out = toDiagnostic(parseTpmtPublic(bytes));
expect(out).toContain("TPM_ALG_ECC");
expect(out).toContain("TPM_ECC_NIST_P256");
});
it("renders KEYEDHASH and SYMCIPHER variants", {
timeout: 5000,
}, () => {
const kh = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmtPublic(kh))).toContain(
"TPMS_KEYEDHASH_PARMS"
);
const sc = concat(
be16(0x0025),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0006),
be16(128),
be16(0x0043),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmtPublic(sc))).toContain(
"TPMS_SYMCIPHER_PARMS"
);
});
it("renders TpmsAttest variants", { timeout: 5000 }, () => {
const certify = concat(
be32(0xff544347),
be16(0x8017),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(16)),
be64(0n),
be32(0),
be32(0),
new Uint8Array([0]),
be64(0n),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(34))
);
const out = toDiagnostic(parseTpmsAttest(certify));
expect(out).toContain("TPM_ST_ATTEST_CERTIFY");
expect(out).toContain("TPMS_CERTIFY_INFO");
});
it("renders every TpmsAttest variant string", {
timeout: 5000,
}, () => {
const MAGIC = be32(0xff544347);
const QSIGNER = tpm2b(new Uint8Array(34));
const EXTRA = tpm2b(new Uint8Array(16));
const CLOCK = concat(be64(0n), be32(0), be32(0), new Uint8Array([0]));
const FW = be64(0n);
const quote = concat(
MAGIC,
be16(0x8018),
QSIGNER,
EXTRA,
CLOCK,
FW,
be32(0),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmsAttest(quote))).toContain(
"TPMS_QUOTE_INFO"
);
const audit = concat(
MAGIC,
be16(0x8016),
QSIGNER,
EXTRA,
CLOCK,
FW,
new Uint8Array([0]),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmsAttest(audit))).toContain(
"TPMS_SESSION_AUDIT_INFO"
);
const cmd = concat(
MAGIC,
be16(0x8015),
QSIGNER,
EXTRA,
CLOCK,
FW,
be64(0n),
be16(0x000b),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmsAttest(cmd))).toContain(
"TPMS_COMMAND_AUDIT_INFO"
);
const time = concat(
MAGIC,
be16(0x8019),
QSIGNER,
EXTRA,
CLOCK,
FW,
be64(0n),
CLOCK,
be64(0n)
);
expect(toDiagnostic(parseTpmsAttest(time))).toContain(
"TPMS_TIME_ATTEST_INFO"
);
const creation = concat(
MAGIC,
be16(0x801a),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmsAttest(creation))).toContain(
"TPMS_CREATION_INFO"
);
const nv = concat(
MAGIC,
be16(0x8014),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
be16(0),
tpm2b(new Uint8Array(4))
);
expect(toDiagnostic(parseTpmsAttest(nv))).toContain(
"TPMS_NV_CERTIFY_INFO"
);
const nvd = concat(
MAGIC,
be16(0x801c),
QSIGNER,
EXTRA,
CLOCK,
FW,
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(32))
);
expect(toDiagnostic(parseTpmsAttest(nvd))).toContain(
"TPMS_NV_DIGEST_CERTIFY_INFO"
);
});
it("renders empty objectAttributes as {} not {nothing}", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00000000), // no flags
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
const out = toDiagnostic(parseTpmtPublic(bytes));
expect(out).toContain("flags: {}");
});
it("hex emits 0x for empty byte arrays", { timeout: 5000 }, () => {
const bytes = concat(
be32(0xff544347),
be16(0x8017),
tpm2b(new Uint8Array(0)),
tpm2b(new Uint8Array(0)),
be64(0n),
be32(0),
be32(0),
new Uint8Array([0]),
be64(0n),
tpm2b(new Uint8Array(0)),
tpm2b(new Uint8Array(0))
);
expect(toDiagnostic(parseTpmsAttest(bytes))).toContain("0x");
});
});

View File

@@ -0,0 +1,116 @@
import type { TpmsAttest } from "../attest/attest.types.js";
import type { TpmtPublic } from "../public/public.types.js";
/**
* Render a parsed `TpmsAttest` or `TpmtPublic` as a human-readable multi-line
* dump for debugging.
*
* Telemetry is NEVER emitted from this function — the diagnostic dumper is
* for human consumption only and SHOULD NOT be invoked from production hot
* paths. Symbolic `TPM_ALG_*` and `TPM_ECC_*` names appear inline so the
* output is grep-friendly.
*/
export function toDiagnostic(value: TpmsAttest | TpmtPublic): string {
if (isAttest(value)) {
return renderAttest(value);
}
return renderPublic(value);
}
function isAttest(v: TpmsAttest | TpmtPublic): v is TpmsAttest {
return (
typeof (v as { type?: string }).type === "string" &&
(v as { type: string }).type.startsWith("TPM_ST_ATTEST_")
);
}
function renderAttest(a: TpmsAttest): string {
const lines: string[] = [];
lines.push("TPMS_ATTEST {");
lines.push(` magic: 0x${a.magic.toString(16)}`);
lines.push(` type: ${a.type}`);
lines.push(` qualifiedSigner: ${hex(a.qualifiedSigner)}`);
lines.push(` extraData: ${hex(a.extraData)}`);
lines.push(
` clockInfo: { clock: ${a.clockInfo.clock}n, resetCount: ${a.clockInfo.resetCount}, restartCount: ${a.clockInfo.restartCount}, safe: ${a.clockInfo.safe} }`
);
lines.push(` firmwareVersion: ${a.firmwareVersion}n`);
lines.push(` attested: ${renderAttested(a)}`);
lines.push("}");
return lines.join("\n");
}
function renderAttested(a: TpmsAttest): string {
switch (a.type) {
case "TPM_ST_ATTEST_CERTIFY":
return `TPMS_CERTIFY_INFO { name: ${hex(a.attested.name)}, qualifiedName: ${hex(a.attested.qualifiedName)} }`;
case "TPM_ST_ATTEST_QUOTE":
return `TPMS_QUOTE_INFO { banks: ${a.attested.pcrSelect.length}, pcrDigest: ${hex(a.attested.pcrDigest)} }`;
case "TPM_ST_ATTEST_SESSION_AUDIT":
return `TPMS_SESSION_AUDIT_INFO { exclusive: ${a.attested.exclusiveSession}, sessionDigest: ${hex(a.attested.sessionDigest)} }`;
case "TPM_ST_ATTEST_COMMAND_AUDIT":
return `TPMS_COMMAND_AUDIT_INFO { counter: ${a.attested.auditCounter}n, digestAlg: ${a.attested.digestAlg}, auditDigest: ${hex(a.attested.auditDigest)} }`;
case "TPM_ST_ATTEST_TIME":
return `TPMS_TIME_ATTEST_INFO { time: ${a.attested.time.time}n, firmwareVersion: ${a.attested.firmwareVersion}n }`;
case "TPM_ST_ATTEST_CREATION":
return `TPMS_CREATION_INFO { objectName: ${hex(a.attested.objectName)}, creationHash: ${hex(a.attested.creationHash)} }`;
case "TPM_ST_ATTEST_NV":
return `TPMS_NV_CERTIFY_INFO { indexName: ${hex(a.attested.indexName)}, offset: ${a.attested.offset}, contents: ${hex(a.attested.nvContents)} }`;
case "TPM_ST_ATTEST_NV_DIGEST":
return `TPMS_NV_DIGEST_CERTIFY_INFO { indexName: ${hex(a.attested.indexName)}, nvDigest: ${hex(a.attested.nvDigest)} }`;
}
}
function renderPublic(p: TpmtPublic): string {
const lines: string[] = [];
lines.push("TPMT_PUBLIC {");
lines.push(` type: ${p.type}`);
lines.push(` nameAlg: ${p.nameAlg}`);
lines.push(
` objectAttributes: { raw: 0x${p.objectAttributes.raw.toString(16).padStart(8, "0")}, flags: ${flagSummary(p.objectAttributes.flags)} }`
);
lines.push(` authPolicy: ${hex(p.authPolicy)}`);
if (p.type === "TPM_ALG_RSA") {
lines.push(
` parameters: TPMS_RSA_PARMS { keyBits: ${p.parameters.keyBits}, exponent: ${p.parameters.exponent}, scheme: ${p.parameters.scheme.scheme} }`
);
lines.push(
` unique: TPM2B_PUBLIC_KEY_RSA(${p.unique.modulus.length} bytes)`
);
} else if (p.type === "TPM_ALG_ECC") {
lines.push(
` parameters: TPMS_ECC_PARMS { curve: ${p.parameters.curveID}, scheme: ${p.parameters.scheme.scheme}, kdf: ${p.parameters.kdf.scheme} }`
);
lines.push(
` unique: TPMS_ECC_POINT { x: ${p.unique.x.length} bytes, y: ${p.unique.y.length} bytes }`
);
} else if (p.type === "TPM_ALG_KEYEDHASH") {
lines.push(
` parameters: TPMS_KEYEDHASH_PARMS { scheme: ${p.parameters.scheme} }`
);
lines.push(` unique: TPM2B_DIGEST(${p.unique.length} bytes)`);
} else {
lines.push(
` parameters: TPMS_SYMCIPHER_PARMS { algorithm: ${p.parameters.sym.algorithm} }`
);
lines.push(` unique: TPM2B_DIGEST(${p.unique.length} bytes)`);
}
lines.push("}");
return lines.join("\n");
}
function flagSummary(flags: object): string {
const enabled = Object.entries(flags)
.filter(([, v]) => v === true)
.map(([k]) => k);
return enabled.length === 0 ? "{}" : `{${enabled.join(", ")}}`;
}
function hex(b: Uint8Array): string {
if (b.length === 0) return "0x";
let s = "0x";
for (let i = 0; i < b.length; i++) {
s += (b[i] as number).toString(16).padStart(2, "0");
}
return s;
}

View File

@@ -0,0 +1 @@
export { toDiagnostic } from "./diagnostic.js";

View File

@@ -0,0 +1,104 @@
# `errors/error-codes` — `TPM_STRUCT` error registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `errors` module ships the canonical `defineCodes("TPM_STRUCT", [...])`
registry (10 codes) and the `TpmStructError extends SuaveplanError<TpmStructErrorCode>`
base class every other module throws from. Stable codes are
non-negotiable: consumers (WebAuthn verifiers, attestation gateways,
log scrapers) branch on `err.code` to distinguish "buffer was short"
from "magic prefix wrong" from "the registered code I care about for
my dashboard." A registry with a fixed alphabet is also what makes
the `tpm_struct.parse_errors_total{code}` OTel counter bounded.
## Features
- Ten registered codes covering every failure class from §8.3 (object
attributes), §10.x (attest), §11.x (algorithm and curve), §12.x
(public area), and §16 (name).
- `TpmStructError` extends `SuaveplanError<TpmStructErrorCode>` so
`instanceof` narrows the `.code` union.
- `tpmStructError(code, message, detail?)` helper attaches a structured
`detail` payload (cursor, requested length, Zod issues) to the cause
chain so log adapters can format failures without downcasting.
## Usage: Basic Example
```ts
import {
TPM_STRUCT_CODES,
TpmStructError,
tpmStructError,
} from "@suaveplan/tpm-struct/errors";
try {
// ... parser code
} catch (e) {
if (e instanceof TpmStructError) {
console.error(`tpm-struct failure: ${e.code}`);
if (e.code === TPM_STRUCT_CODES.TRUNCATED) {
// recoverable: ask the upstream for a longer buffer
}
}
}
void tpmStructError;
```
## Usage: Advanced Example
```ts
import {
TPM_STRUCT_CODES,
TpmStructError,
} from "@suaveplan/tpm-struct/errors";
type ParseFailureMetric = "magic" | "truncated" | "alg" | "curve" | "other";
function classify(err: TpmStructError): ParseFailureMetric {
switch (err.code) {
case TPM_STRUCT_CODES.MAGIC_INVALID:
return "magic";
case TPM_STRUCT_CODES.TRUNCATED:
return "truncated";
case TPM_STRUCT_CODES.ALG_UNSUPPORTED:
return "alg";
case TPM_STRUCT_CODES.CURVE_UNSUPPORTED:
return "curve";
default:
return "other";
}
}
// `cause.detail` carries the structured failure context.
function reportTruncated(err: TpmStructError) {
const detail =
((err as unknown as { cause?: { detail?: Record<string, unknown> } })
.cause?.detail) ?? {};
return { cursor: detail.cursor, requested: detail.requested };
}
```
## API Reference
- **`TPM_STRUCT_CODES`** — frozen registry: `MAGIC_INVALID`, `TRUNCATED`,
`ALG_UNSUPPORTED`, `CURVE_UNSUPPORTED`, `TYPE_MISMATCH`, `MALFORMED`,
`NAME_ALG_NULL`, `ATTEST_TYPE_UNSUPPORTED`, `PARAMETERS_INVALID`,
`OBJECT_ATTRIBUTES_RESERVED_NONZERO`. Each value is the literal
`TPM_STRUCT.<NAME>`.
- **`TpmStructError`** — base class, message is always prefixed with the
code literal.
- **`TpmStructErrorCode`** — literal union of the ten code strings.
- **`tpmStructError(code, message, detail?)`** — convenience constructor;
`detail` (when provided) is wrapped in an Error so it survives through
the `cause` chain.
## Implementation Notes
The error message is always prefixed with the code literal so log
scrapers find a stable substring without inspecting `.code`. Non-Error
detail payloads are wrapped in a synthetic `Error` carrying a `detail`
slot — this preserves the standard ECMAScript Error.cause shape while
still surfacing structured fields downstream code can read.

View File

@@ -0,0 +1,153 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SuaveplanError } from "@suaveplan/error";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
TPM_STRUCT_CODES,
TpmStructError,
type TpmStructErrorCode,
tpmStructError,
} from "./error-codes.js";
const SRC_ROOT = join(import.meta.dirname, "..");
describe("TPM_STRUCT_CODES", () => {
it("contains exactly the 10 documented codes", { timeout: 5000 }, () => {
const keys = Object.keys(TPM_STRUCT_CODES).sort();
expect(keys).toEqual([
"ALG_UNSUPPORTED",
"ATTEST_TYPE_UNSUPPORTED",
"CURVE_UNSUPPORTED",
"MAGIC_INVALID",
"MALFORMED",
"NAME_ALG_NULL",
"OBJECT_ATTRIBUTES_RESERVED_NONZERO",
"PARAMETERS_INVALID",
"TRUNCATED",
"TYPE_MISMATCH",
]);
for (const k of keys) {
expect(TPM_STRUCT_CODES[k as keyof typeof TPM_STRUCT_CODES]).toBe(
`TPM_STRUCT.${k}`
);
}
});
it("every TPM_STRUCT code reachable from a production path", {
timeout: 5000,
}, () => {
const sources = readSrcSources(SRC_ROOT);
for (const code of Object.values(TPM_STRUCT_CODES)) {
const reason = (code as string).split(".")[1] as string;
const found = sources.some(({ body }) => body.includes(reason));
expect(found, `code ${code} not referenced in src/`).toBe(true);
}
});
it("literal union pins to registry keys", { timeout: 5000 }, () => {
expectTypeOf<TpmStructErrorCode>().toEqualTypeOf<
| "TPM_STRUCT.MAGIC_INVALID"
| "TPM_STRUCT.TRUNCATED"
| "TPM_STRUCT.ALG_UNSUPPORTED"
| "TPM_STRUCT.CURVE_UNSUPPORTED"
| "TPM_STRUCT.TYPE_MISMATCH"
| "TPM_STRUCT.MALFORMED"
| "TPM_STRUCT.NAME_ALG_NULL"
| "TPM_STRUCT.ATTEST_TYPE_UNSUPPORTED"
| "TPM_STRUCT.PARAMETERS_INVALID"
| "TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO"
>();
});
});
describe("TpmStructError", () => {
it("instances carry the TPM_STRUCT.* code literal", {
timeout: 5000,
}, () => {
const err = tpmStructError(TPM_STRUCT_CODES.MAGIC_INVALID, "test");
expect(err).toBeInstanceOf(TpmStructError);
expect(err).toBeInstanceOf(SuaveplanError);
expect(err.code).toBe("TPM_STRUCT.MAGIC_INVALID");
expect(err.message).toContain("TPM_STRUCT.MAGIC_INVALID");
});
it("threads structured detail through the cause chain", {
timeout: 5000,
}, () => {
const err = tpmStructError(TPM_STRUCT_CODES.TRUNCATED, "short", {
cursor: 5,
requested: 4,
});
const cause = (
err as unknown as {
cause: Error & { detail?: Record<string, unknown> };
}
).cause;
expect(cause).toBeInstanceOf(Error);
expect(cause?.detail).toEqual({ cursor: 5, requested: 4 });
});
it("constructor without options has no cause", { timeout: 5000 }, () => {
const err = new TpmStructError(TPM_STRUCT_CODES.MALFORMED, "no opts");
expect(err.code).toBe("TPM_STRUCT.MALFORMED");
expect((err as unknown as { cause?: unknown }).cause).toBeUndefined();
});
it("constructor accepts an Error cause directly", {
timeout: 5000,
}, () => {
const inner = new Error("inner");
const err = new TpmStructError(TPM_STRUCT_CODES.MALFORMED, "outer", {
cause: inner,
});
expect((err as unknown as { cause: Error }).cause).toBe(inner);
});
it("constructor silently drops non-Error causes", {
timeout: 5000,
}, () => {
const err = new TpmStructError(TPM_STRUCT_CODES.MALFORMED, "outer", {
cause: "string-not-error",
});
expect((err as unknown as { cause?: unknown }).cause).toBeUndefined();
});
it("tpmStructError without detail has no cause", {
timeout: 5000,
}, () => {
const err = tpmStructError(TPM_STRUCT_CODES.MALFORMED, "bare");
expect((err as unknown as { cause?: unknown }).cause).toBeUndefined();
});
});
type SrcSource = { path: string; body: string };
function readSrcSources(root: string): SrcSource[] {
const out: SrcSource[] = [];
// biome lint hint: this is a test helper; readdirSync is the simplest walker
const fs = require("node:fs") as typeof import("node:fs");
function walk(dir: string): void {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (
entry.name === "__fixtures__" ||
entry.name === "__benchmarks__" ||
entry.name === "__integration__"
) {
continue;
}
walk(full);
} else if (
entry.name.endsWith(".ts") &&
!entry.name.endsWith(".test.ts") &&
!entry.name.endsWith(".types.ts") &&
!entry.name.endsWith(".bench.ts")
) {
out.push({ path: full, body: readFileSync(full, "utf8") });
}
}
}
walk(root);
return out;
}

View File

@@ -0,0 +1,77 @@
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable, registered error codes for `@suaveplan/tpm-struct`.
*
* Every code below is thrown by at least one production path in `src/`
* (audited by `error-codes.test.ts`). Codes are stable identifiers — the
* literal `TPM_STRUCT.<REASON>` string never changes between releases.
*
* - `MAGIC_INVALID` — `TPMS_ATTEST` magic prefix `0xff544347` mismatch.
* - `TRUNCATED` — read past end-of-buffer or `TPM2B` declared length
* exceeds remaining bytes. `cause` carries `cursor` and `requested`.
* - `ALG_UNSUPPORTED` — `TPM_ALG_ID` value not in the §11.1 registry,
* or `TPMI_ALG_PUBLIC` value not in `{RSA, ECC, KEYEDHASH, SYMCIPHER}`.
* - `CURVE_UNSUPPORTED` — `TPM_ECC_CURVE` value not in §11.2.5 registry.
* - `TYPE_MISMATCH` — a discriminator does not match the requested
* union member at the parser/serialiser boundary.
* - `MALFORMED` — Zod schema validation failure on a parser output;
* `cause.issues` carries the Zod issue list.
* - `NAME_ALG_NULL` — `computeTpmtPublicName` invoked with
* `nameAlg === TPM_ALG_NULL`; names cannot be computed without a hash.
* - `ATTEST_TYPE_UNSUPPORTED` — `TPMS_ATTEST.type` not in the
* `TPM_ST_ATTEST_*` subset.
* - `PARAMETERS_INVALID` — a `TPMS_*_PARMS` substructure failed its
* own structural check (e.g. RSA `keyBits === 0`).
* - `OBJECT_ATTRIBUTES_RESERVED_NONZERO` — a reserved bit in the
* `TPMA_OBJECT` bitfield was set; per §8.3 reserved bits MUST be 0.
*/
export const TPM_STRUCT_CODES = defineCodes("TPM_STRUCT", [
"MAGIC_INVALID",
"TRUNCATED",
"ALG_UNSUPPORTED",
"CURVE_UNSUPPORTED",
"TYPE_MISMATCH",
"MALFORMED",
"NAME_ALG_NULL",
"ATTEST_TYPE_UNSUPPORTED",
"PARAMETERS_INVALID",
"OBJECT_ATTRIBUTES_RESERVED_NONZERO",
] as const);
/** Literal-typed union of every registered code. */
export type TpmStructErrorCode =
(typeof TPM_STRUCT_CODES)[keyof typeof TPM_STRUCT_CODES];
/** Base class for every error thrown by `@suaveplan/tpm-struct`. */
export class TpmStructError extends SuaveplanError<TpmStructErrorCode> {
constructor(
code: TpmStructErrorCode,
message: string,
options: { cause?: unknown } = {}
) {
const opts: { cause?: Error } = {};
if (options.cause instanceof Error) opts.cause = options.cause;
super(code, `${code}: ${message}`, opts);
}
}
/**
* Construct a `TpmStructError` carrying a structured `detail` payload
* attached to the error's `cause` slot. Detail values (cursor, requested,
* Zod issues, peer field names) survive through the standard Error cause
* chain so log adapters and OTel exporters can format them without
* downcasting to a TPM-struct-specific type.
*/
export function tpmStructError(
code: TpmStructErrorCode,
message: string,
detail?: Readonly<Record<string, unknown>>
): TpmStructError {
if (detail === undefined) return new TpmStructError(code, message);
const wrapped = new Error("tpm-struct detail");
(
wrapped as unknown as { detail: Readonly<Record<string, unknown>> }
).detail = detail;
return new TpmStructError(code, message, { cause: wrapped });
}

View File

@@ -0,0 +1,6 @@
export {
TPM_STRUCT_CODES,
TpmStructError,
type TpmStructErrorCode,
tpmStructError,
} from "./error-codes.js";

View File

@@ -0,0 +1,81 @@
# `extra-data` — `TPMS_ATTEST.extraData` constant-time match
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`extraDataMatches` compares a TPM-emitted `extraData` blob (the value the
TPM signed alongside the attest body) against the relying-party-recomputed
`attToBeSigned` digest per W3C WebAuthn Level 3 §8.3 step 8. The function
is intentionally tiny: it short-circuits on length mismatch (length is
public, not a secret) and then delegates the content comparison to a
caller-injected constant-time equality function. Keeping the constant-
time primitive injectable lets `@suaveplan/tpm-struct` stay universal-
tier (no `@suaveplan/crypto-utils` runtime dep) while letting consumers
wire in whichever hardened equality their crypto stack ships.
## Features
- Length short-circuit (length is not a secret).
- Pluggable equality function via the `ConstantTimeEqual` type.
- Zero-allocation: works directly over the supplied `Uint8Array`s.
## Usage: Basic Example
```ts
import { extraDataMatches } from "@suaveplan/tpm-struct/extra-data";
declare const constantTimeEqual: (a: Uint8Array, b: Uint8Array) => boolean;
if (extraDataMatches(att.extraData, recomputedDigest, constantTimeEqual)) {
// attToBeSigned matched.
}
```
## Usage: Advanced Example
```ts
import { constantTimeEqual } from "@suaveplan/crypto-utils";
import { extraDataMatches } from "@suaveplan/tpm-struct/extra-data";
// Compose with the rest of the WebAuthn `tpm`-attestation chain.
function verifyExtraDataAndName(
att: { extraData: Uint8Array; attested: { name: Uint8Array } },
recomputedAttToBeSigned: Uint8Array,
recomputedName: Uint8Array
): { extraDataOk: boolean; nameOk: boolean } {
return {
extraDataOk: extraDataMatches(
att.extraData,
recomputedAttToBeSigned,
constantTimeEqual
),
nameOk: extraDataMatches(
att.attested.name,
recomputedName,
constantTimeEqual
),
};
}
```
## API Reference
- **`extraDataMatches(actual: Uint8Array, expected: Uint8Array, equal: ConstantTimeEqual): boolean`**
— `true` if and only if `actual.length === expected.length` and
`equal(actual, expected)` returns true. Returns `false` on length
mismatch without invoking `equal`.
- **`ConstantTimeEqual`** — `(a: Uint8Array, b: Uint8Array) => boolean`.
Implementations MUST NOT short-circuit on first byte mismatch — the
wall-time MUST depend only on the longer input's length.
## Implementation Notes
The function deliberately does not own a constant-time-equality fallback.
A naive `for` loop short-circuiting on mismatch would leak content
equality through timing; a sample implementation that XOR-accumulates
every byte is correct but lives in `@suaveplan/crypto-utils`. By keeping
the primitive injectable, this module avoids importing a crypto package
from a universal parser while still surfacing the right API shape
for the WebAuthn step 8 check.

View File

@@ -0,0 +1,49 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { extraDataMatches } from "./extra-data.js";
function bytewise(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
let acc = 0;
for (let i = 0; i < a.length; i++)
acc |= (a[i] as number) ^ (b[i] as number);
return acc === 0;
}
describe("extraDataMatches", () => {
it("returns true for equal buffers", { timeout: 5000 }, () => {
const a = new Uint8Array([1, 2, 3, 4]);
const b = new Uint8Array([1, 2, 3, 4]);
expect(extraDataMatches(a, b, bytewise)).toBe(true);
});
it("returns false for differing content (same length)", {
timeout: 5000,
}, () => {
const a = new Uint8Array([1, 2, 3, 4]);
const b = new Uint8Array([1, 2, 3, 5]);
expect(extraDataMatches(a, b, bytewise)).toBe(false);
});
it("returns false on length mismatch without calling equal", {
timeout: 5000,
}, () => {
let called = false;
const fn = () => {
called = true;
return true;
};
const out = extraDataMatches(
new Uint8Array([1, 2, 3]),
new Uint8Array([1, 2]),
fn
);
expect(out).toBe(false);
expect(called).toBe(false);
});
it("returns true on empty buffers", { timeout: 5000 }, () => {
expect(
extraDataMatches(new Uint8Array(0), new Uint8Array(0), bytewise)
).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import type { ConstantTimeEqual } from "./extra-data.types.js";
/**
* Compare a TPM-attested `extraData` blob against the relying-party-recomputed
* `attToBeSigned` digest per W3C WebAuthn L3 §8.3 step 8.
*
* Returns `false` when lengths differ without invoking the injected
* `equal` function — length is not a secret and short-circuiting on a
* length mismatch is the canonical optimisation for constant-time
* byte-equality APIs. When lengths match, the comparison is delegated to
* the caller-supplied `equal` (typically wired to `@suaveplan/crypto-utils`
* `constantTimeEqual`) so the wall-time does not leak content equality.
*/
export function extraDataMatches(
actual: Uint8Array,
expected: Uint8Array,
equal: ConstantTimeEqual
): boolean {
if (actual.length !== expected.length) return false;
return equal(actual, expected);
}

View File

@@ -0,0 +1,7 @@
/**
* Constant-time byte-equality predicate. The caller injects this so the
* package stays universal (no `@suaveplan/crypto-utils` runtime dep).
* Implementations MUST NOT short-circuit on first mismatch — the wall-time
* MUST depend only on the longer input's length.
*/
export type ConstantTimeEqual = (a: Uint8Array, b: Uint8Array) => boolean;

View File

@@ -0,0 +1,2 @@
export { extraDataMatches } from "./extra-data.js";
export type { ConstantTimeEqual } from "./extra-data.types.js";

View File

@@ -0,0 +1,74 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
describe("subpath exports", () => {
it(". resolves and exports the top-level surface", {
timeout: 5000,
}, async () => {
const m = await import("./index.js");
expect(typeof m.parseTpmsAttest).toBe("function");
expect(typeof m.parseTpmtPublic).toBe("function");
expect(typeof m.computeTpmtPublicName).toBe("function");
expect(typeof m.extraDataMatches).toBe("function");
expect(typeof m.serializeTpmsAttest).toBe("function");
expect(typeof m.serializeTpmtPublic).toBe("function");
expect(typeof m.toDiagnostic).toBe("function");
expect(typeof m.TpmBufferReader).toBe("function");
expect(typeof m.TpmBufferWriter).toBe("function");
expect(m.TPM_STRUCT_CODES).toBeDefined();
expect(m.TPM_ALG).toBeDefined();
expect(m.TPM_ECC_CURVE).toBeDefined();
expect(m.TpmtPublicSchema).toBeDefined();
expect(m.TpmsAttestSchema).toBeDefined();
});
it("./buffer resolves", { timeout: 5000 }, async () => {
const m = await import("./buffer/index.js");
expect(typeof m.TpmBufferReader).toBe("function");
expect(typeof m.TpmBufferWriter).toBe("function");
});
it("./alg resolves", { timeout: 5000 }, async () => {
const m = await import("./alg/index.js");
expect(typeof m.algIdToName).toBe("function");
expect(typeof m.eccCurveToName).toBe("function");
});
it("./public resolves", { timeout: 5000 }, async () => {
const m = await import("./public/index.js");
expect(typeof m.parseTpmtPublic).toBe("function");
expect(typeof m.serializeTpmtPublic).toBe("function");
});
it("./attest resolves", { timeout: 5000 }, async () => {
const m = await import("./attest/index.js");
expect(typeof m.parseTpmsAttest).toBe("function");
expect(typeof m.serializeTpmsAttest).toBe("function");
});
it("./name resolves", { timeout: 5000 }, async () => {
const m = await import("./name/index.js");
expect(typeof m.computeTpmtPublicName).toBe("function");
});
it("./extra-data resolves", { timeout: 5000 }, async () => {
const m = await import("./extra-data/index.js");
expect(typeof m.extraDataMatches).toBe("function");
});
it("./schemas resolves", { timeout: 5000 }, async () => {
const m = await import("./schemas/index.js");
expect(m.TpmtPublicSchema).toBeDefined();
expect(m.TpmsAttestSchema).toBeDefined();
});
it("./errors resolves", { timeout: 5000 }, async () => {
const m = await import("./errors/index.js");
expect(m.TPM_STRUCT_CODES).toBeDefined();
expect(typeof m.TpmStructError).toBe("function");
});
it("./diagnostic resolves", { timeout: 5000 }, async () => {
const m = await import("./diagnostic/index.js");
expect(typeof m.toDiagnostic).toBe("function");
});
});

View File

@@ -0,0 +1,119 @@
// Public surface — explicit named re-exports per openspec/AGENTS.md §6a.
// Algorithm registries
export {
algIdToName,
eccCurveToName,
nameToAlgId,
nameToEccCurve,
TPM_ALG,
TPM_ECC_CURVE,
type TpmAlgId,
type TpmEccCurve,
} from "./alg/alg.js";
// Attest area
export { parseTpmsAttest } from "./attest/attest.js";
export {
type ClockInfo,
TPM_GENERATED_VALUE,
TPM_ST_ATTEST,
type TpmsAttest,
type TpmsAttestCertify,
type TpmsAttestCommandAudit,
type TpmsAttestCreation,
type TpmsAttestNv,
type TpmsAttestNvDigest,
type TpmsAttestQuote,
type TpmsAttestSessionAudit,
type TpmsAttestTime,
type TpmsCertifyInfo,
type TpmsCommandAuditInfo,
type TpmsCreationInfo,
type TpmsNvCertifyInfo,
type TpmsNvDigestCertifyInfo,
type TpmsPcrSelection,
type TpmsQuoteInfo,
type TpmsSessionAuditInfo,
type TpmsTimeAttestInfo,
type TpmsTimeInfo,
} from "./attest/attest.types.js";
export { serializeTpmsAttest } from "./attest/serialize.js";
// Buffer primitives
export { TpmBufferReader, TpmBufferWriter } from "./buffer/buffer.js";
export type {
Tpm2bReadResult,
TpmReaderOptions,
TpmWriterOptions,
} from "./buffer/buffer.types.js";
// Diagnostic
export { toDiagnostic } from "./diagnostic/diagnostic.js";
// Errors
export {
TPM_STRUCT_CODES,
TpmStructError,
type TpmStructErrorCode,
tpmStructError,
} from "./errors/error-codes.js";
// Extra-data
export { extraDataMatches } from "./extra-data/extra-data.js";
export type { ConstantTimeEqual } from "./extra-data/extra-data.types.js";
// Name digest
export { computeTpmtPublicName } from "./name/name.js";
export type { HashLike } from "./name/name.types.js";
// Observability
export {
type ParseKind,
type ParseOutcome,
type PublicBranch,
recordAlgObserved,
recordAttestDuration,
recordNameDuration,
recordParseDuration,
recordParseError,
recordParseTotal,
recordPublicDuration,
resetTpmStructMetricsForTesting,
} from "./observability/metrics.js";
// Public area
export {
decodeObjectAttributes,
encodeObjectAttributes,
} from "./public/object-attributes.js";
export { parseTpmtPublic } from "./public/public.js";
export type {
ObjectAttributes,
ObjectAttributesFlags,
TpmRsaUnique,
TpmsEccParms,
TpmsEccPoint,
TpmsKeyedHashParms,
TpmsRsaParms,
TpmsSymCipherParms,
TpmtEccScheme,
TpmtKdfScheme,
TpmtPublic,
TpmtPublicEcc,
TpmtPublicKeyedHash,
TpmtPublicRsa,
TpmtPublicSymCipher,
TpmtRsaScheme,
TpmtSymDefObject,
} from "./public/public.types.js";
export { serializeTpmtPublic } from "./public/serialize.js";
// Boundary schemas
export {
ClockInfoSchema,
ObjectAttributesSchema,
Tpm2bSchema,
TpmAlgIdSchema,
TpmEccCurveSchema,
TpmsAttestSchema,
TpmsCertifyInfoSchema,
TpmsEccParmsSchema,
TpmsKeyedHashParmsSchema,
TpmsRsaParmsSchema,
TpmsSymCipherParmsSchema,
TpmtPublicSchema,
validateTpmsAttest,
validateTpmtPublic,
} from "./schemas/schemas.js";

View File

@@ -0,0 +1,2 @@
export { computeTpmtPublicName } from "./name.js";
export type { HashLike } from "./name.types.js";

View File

@@ -0,0 +1,86 @@
# `name/name` — TPM 2.0 canonical name digest
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`computeTpmtPublicName` produces the canonical TPM 2.0 name for a
`TpmtPublic` value per TCG TPM 2.0 Library Part 1 — Architecture §16
_Names_. The canonical name is the two-byte big-endian `nameAlg`
identifier followed by the digest of the canonical `TPMT_PUBLIC` byte
serialisation under that algorithm. The W3C WebAuthn Level 3 `tpm`-
attestation verifier compares this exact value against the certified
name carried in the `TPMS_CERTIFY_INFO.name` slot to prove that the
public area the relying party recovered is the one the TPM signed
(WebAuthn L3 §8.3 step 5).
## Features
- Hash backend injected via a `HashLike` interface so this package
stays universal; consumers wire `@suaveplan/crypto-hash` (or any
compatible primitive) at the call site.
- Caller-controlled algorithm selection — the injected hash MUST match
`pub.nameAlg`.
- Rejects `pub.nameAlg === TPM_ALG_NULL` with `TPM_STRUCT.NAME_ALG_NULL`
because a canonical name cannot be computed without a digest.
- OTel: `tpm_struct.compute_name_duration_ms{name_alg}` histogram +
`tpm_struct.parse_total{kind="name",outcome}` counter.
## Usage: Basic Example
```ts
import { createHash } from "node:crypto";
import {
computeTpmtPublicName,
parseTpmtPublic,
} from "@suaveplan/tpm-struct";
const pub = parseTpmtPublic(pubAreaBytes);
const name = computeTpmtPublicName(pub, {
digest: (b) => new Uint8Array(createHash("sha256").update(b).digest()),
});
// name === 0x000b || sha256(canonical TPMT_PUBLIC bytes)
```
## Usage: Advanced Example
```ts
import { sha256, sha384, sha512 } from "@noble/hashes/sha2";
import { sha1 } from "@noble/hashes/legacy";
import {
computeTpmtPublicName,
parseTpmtPublic,
} from "@suaveplan/tpm-struct";
// Route to the correct backend at call time based on the public area's
// nameAlg. The package itself imports no hash code; the caller picks
// the engine.
function hashForNameAlg(nameAlg: string) {
if (nameAlg === "TPM_ALG_SHA1") return { digest: sha1 };
if (nameAlg === "TPM_ALG_SHA256") return { digest: sha256 };
if (nameAlg === "TPM_ALG_SHA384") return { digest: sha384 };
if (nameAlg === "TPM_ALG_SHA512") return { digest: sha512 };
throw new Error(`unsupported nameAlg ${nameAlg}`);
}
const pub = parseTpmtPublic(pubAreaBytes);
const name = computeTpmtPublicName(pub, hashForNameAlg(pub.nameAlg));
```
## API Reference
- **`computeTpmtPublicName(pub: TpmtPublic, hash: HashLike): Uint8Array`**
— return a fresh byte string `nameAlg (2 BE) || hash.digest(serializeTpmtPublic(pub))`.
Throws `TpmStructError(NAME_ALG_NULL)` on `pub.nameAlg ===
"TPM_ALG_NULL"`. Re-throws any error raised by `hash.digest`.
- **`HashLike`** — `{ digest(bytes: Uint8Array): Uint8Array }`. The
digest size MUST match the algorithm (SHA-1 → 20, SHA-256 → 32, etc.).
## Implementation Notes
The function delegates to `serializeTpmtPublic` for the canonical byte
encoding so the parser and the name digest cannot drift independently.
The 2-byte `nameAlg` prefix is the big-endian wire id (looked up via
`nameToAlgId`) rather than the symbolic name — this matches the binary
form the TPM emits and that downstream verifiers reproduce.

View File

@@ -0,0 +1,128 @@
import { createHash } from "node:crypto";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import { parseTpmtPublic } from "../public/public.js";
import { serializeTpmtPublic } from "../public/serialize.js";
import { computeTpmtPublicName } from "./name.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
const sha = (alg: string) => ({
digest(bytes: Uint8Array): Uint8Array {
return new Uint8Array(createHash(alg).update(bytes).digest());
},
});
function rsaFixture(nameAlg: number): Uint8Array {
return concat(
be16(0x0001),
be16(nameAlg),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256).fill(0xab))
);
}
describe("computeTpmtPublicName", () => {
it("SHA-256 name has expected length and prefix", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x000b));
const name = computeTpmtPublicName(pub, sha("sha256"));
expect(name.length).toBe(34); // 2 + 32
expect(name[0]).toBe(0x00);
expect(name[1]).toBe(0x0b);
});
it("SHA-1 name has expected length and prefix", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x0004));
const name = computeTpmtPublicName(pub, sha("sha1"));
expect(name.length).toBe(22); // 2 + 20
expect(name[0]).toBe(0x00);
expect(name[1]).toBe(0x04);
});
it("SHA-384 name has expected length and prefix", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x000c));
const name = computeTpmtPublicName(pub, sha("sha384"));
expect(name.length).toBe(50); // 2 + 48
});
it("SHA-512 name has expected length and prefix", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x000d));
const name = computeTpmtPublicName(pub, sha("sha512"));
expect(name.length).toBe(66); // 2 + 64
});
it("digest body equals H(serializeTpmtPublic(pub))", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x000b));
const bytes = serializeTpmtPublic(pub);
const expected = new Uint8Array(
createHash("sha256").update(bytes).digest()
);
const name = computeTpmtPublicName(pub, sha("sha256"));
expect(name.slice(2)).toEqual(expected);
});
it("name matches WebAuthn fixture digest", { timeout: 5000 }, async () => {
// Re-derive the fixture's expectedName at test time with real SHA-256,
// then assert computeTpmtPublicName agrees byte-for-byte.
const { buildWebAuthnTpmFixture } = await import(
"../__fixtures__/webauthn-tpm-vector.js"
);
const fixture = await buildWebAuthnTpmFixture(
async (b) => new Uint8Array(createHash("sha256").update(b).digest())
);
const pub = parseTpmtPublic(fixture.pubArea);
const name = computeTpmtPublicName(pub, sha("sha256"));
expect(name).toEqual(fixture.expectedName);
});
it("TPM_ALG_NULL nameAlg raises NAME_ALG_NULL", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture(0x0010));
try {
computeTpmtPublicName(pub, sha("sha256"));
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.NAME_ALG_NULL");
}
});
});

View File

@@ -0,0 +1,58 @@
import { nameToAlgId } from "../alg/alg.js";
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
import {
recordParseDuration,
recordParseError,
recordParseTotal,
} from "../observability/metrics.js";
import type { TpmtPublic } from "../public/public.types.js";
import { serializeTpmtPublic } from "../public/serialize.js";
import type { HashLike } from "./name.types.js";
/**
* Compute the canonical TPM 2.0 name of a `TpmtPublic` value per TCG TPM 2.0
* Library Part 1 — Architecture §16 _Names_.
*
* The output is the two-byte big-endian `nameAlg` identifier followed by
* the digest of the canonical `TPMT_PUBLIC` serialisation under that
* algorithm — i.e. `nameAlg (2 BE) || hash.digest(serializeTpmtPublic(pub))`.
*
* The hash backend is injected via `HashLike` so this package stays
* universal (zero hashing dependency). The caller MUST supply a hash
* that matches `pub.nameAlg` (SHA-1/256/384/512). `pub.nameAlg ===
* TPM_ALG_NULL` is rejected with `TPM_STRUCT.NAME_ALG_NULL` because a
* canonical name cannot be computed without a digest.
*/
export function computeTpmtPublicName(
pub: TpmtPublic,
hash: HashLike
): Uint8Array {
const start = performance.now();
try {
if (pub.nameAlg === "TPM_ALG_NULL") {
throw tpmStructError(
TPM_STRUCT_CODES.NAME_ALG_NULL,
"cannot compute name with nameAlg=TPM_ALG_NULL"
);
}
const nameAlgId = nameToAlgId(pub.nameAlg);
const bytes = serializeTpmtPublic(pub);
const digest = hash.digest(bytes);
const out = new Uint8Array(2 + digest.length);
out[0] = (nameAlgId >>> 8) & 0xff;
out[1] = nameAlgId & 0xff;
out.set(digest, 2);
recordParseDuration("name", pub.nameAlg, "ok", durationMs(start));
recordParseTotal("name", "ok");
return out;
} catch (err) {
recordParseDuration("name", pub.nameAlg, "error", durationMs(start));
recordParseTotal("name", "error");
recordParseError(err);
throw err;
}
}
function durationMs(start: number): number {
return Math.max(0, performance.now() - start);
}

View File

@@ -0,0 +1,14 @@
/**
* Injectable hash backend for `computeTpmtPublicName`. The package itself
* imports no hashing code so it stays universal; callers wire in
* `@suaveplan/crypto-hash` (or any compatible primitive) at the call site.
*/
export interface HashLike {
/**
* Compute a fixed-size digest over the supplied bytes. The digest size
* MUST match the `nameAlg` indicated on the `TpmtPublic` being hashed
* (SHA-1 → 20, SHA-256 → 32, SHA-384 → 48, SHA-512 → 64). The caller
* is responsible for selecting the correct algorithm.
*/
digest(bytes: Uint8Array): Uint8Array;
}

View File

@@ -0,0 +1,13 @@
export {
type ParseKind,
type ParseOutcome,
type PublicBranch,
recordAlgObserved,
recordAttestDuration,
recordNameDuration,
recordParseDuration,
recordParseError,
recordParseTotal,
recordPublicDuration,
resetTpmStructMetricsForTesting,
} from "./metrics.js";

View File

@@ -0,0 +1,115 @@
# `observability/metrics` — OTel histograms and counters
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `observability/metrics` module owns the OpenTelemetry surface of
`@suaveplan/tpm-struct`. Every public parser invocation records into
one of three histograms and one of three counters; the histograms
bucket wall-time by `outcome` (and by `type` / `name_alg` where it adds
value), and the counters expose hit / error rates with low-cardinality
attributes drawn from the bounded `TPM_ALG`, `TPM_STRUCT_CODES`, and
`PublicBranch` enums. The full surface is what makes a running TPM-
attestation gateway observable: a sudden spike in
`tpm_struct.parse_errors_total{code="TPM_STRUCT.MAGIC_INVALID"}` is
the canonical signal that an upstream is sending corrupted or
non-TPM blobs.
## Features
- Three histograms: `parse_tpms_attest_duration_ms{outcome}`,
`parse_tpmt_public_duration_ms{type,outcome}`,
`compute_name_duration_ms{name_alg}`.
- Three counters: `parse_total{kind,outcome}`, `parse_errors_total{code}`,
`alg_total{alg}`.
- Every attribute value is a bounded enum (no free-form strings).
- Lazy instrument resolution — instruments are created on first call so
consumers can install a custom `MeterProvider` before any module-load
side effects.
- `resetTpmStructMetricsForTesting()` clears the instrument cache for
tests that swap the global provider between cases.
## Usage: Basic Example
```ts
import { metrics } from "@opentelemetry/api";
import { parseTpmsAttest } from "@suaveplan/tpm-struct";
// Once at app boot, install your MeterProvider.
metrics.setGlobalMeterProvider(myProvider);
// Subsequent calls emit metrics automatically.
parseTpmsAttest(attestBytes);
```
## Usage: Advanced Example
```ts
import {
metrics,
type Counter,
type Histogram,
type Meter,
type MeterProvider,
} from "@opentelemetry/api";
import {
parseTpmsAttest,
resetTpmStructMetricsForTesting,
} from "@suaveplan/tpm-struct";
// In-memory provider for assertions in tests.
function recordingProvider() {
const counters: { name: string; value: number; attrs?: Record<string, string> }[] = [];
const histograms: typeof counters = [];
const meter: Partial<Meter> = {
createCounter: (name: string) =>
({
add: (v: number, a?: Record<string, string>) =>
counters.push({ name, value: v, attrs: a }),
}) as unknown as Counter,
createHistogram: (name: string) =>
({
record: (v: number, a?: Record<string, string>) =>
histograms.push({ name, value: v, attrs: a }),
}) as unknown as Histogram,
};
const provider: Partial<MeterProvider> = {
getMeter: () => meter as Meter,
};
return { provider: provider as MeterProvider, counters, histograms };
}
const { provider, counters, histograms } = recordingProvider();
metrics.setGlobalMeterProvider(provider);
resetTpmStructMetricsForTesting();
parseTpmsAttest(certifyBytes);
console.log(counters, histograms);
```
## API Reference
- **`recordAttestDuration(outcome, durationMs)`** — record a
`parse_tpms_attest_duration_ms` observation.
- **`recordPublicDuration(type, outcome, durationMs)`** — record a
`parse_tpmt_public_duration_ms` observation.
- **`recordNameDuration(nameAlg, durationMs)`** — record a
`compute_name_duration_ms` observation.
- **`recordParseDuration(kind, branchOrAlg, outcome, durationMs)`** —
dispatch helper used by every parser.
- **`recordParseTotal(kind, outcome)`** — increment `parse_total`.
- **`recordParseError(err)`** — increment `parse_errors_total{code}`
using the registered code when `err` is a `TpmStructError`, or
`"TPM_STRUCT.OTHER"` otherwise (sentinel label, not a registry
member).
- **`recordAlgObserved(alg)`** — increment `alg_total{alg}`.
- **`resetTpmStructMetricsForTesting()`** — clear the cached instruments.
## Implementation Notes
Instruments are cached at module scope so the per-call cost stays a
single `Map.get`. The `OTHER` sentinel on `parse_errors_total` is a
defensive label — the parsers only ever throw `TpmStructError`, but
the helper accepts `unknown` because library consumers may forward
arbitrary failures through a wrapping function.

View File

@@ -0,0 +1,237 @@
import type {
Counter,
Histogram,
Meter,
MeterProvider,
MetricOptions,
} from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { parseTpmsAttest } from "../attest/attest.js";
import { parseTpmtPublic } from "../public/public.js";
import {
recordAlgObserved,
recordParseError,
resetTpmStructMetricsForTesting,
} from "./metrics.js";
interface Recorded {
name: string;
value: number;
attrs?: Record<string, string>;
}
function recordingProvider(): {
provider: MeterProvider;
counters: Recorded[];
histograms: Recorded[];
} {
const counters: Recorded[] = [];
const histograms: Recorded[] = [];
const meter: Partial<Meter> = {
createCounter(name: string, _opts?: MetricOptions): Counter {
return {
add: (value: number, attrs?: Record<string, string>) => {
counters.push({ name, value, attrs });
},
} as unknown as Counter;
},
createHistogram(name: string, _opts?: MetricOptions): Histogram {
return {
record: (value: number, attrs?: Record<string, string>) => {
histograms.push({ name, value, attrs });
},
} as unknown as Histogram;
},
};
const provider: Partial<MeterProvider> = {
getMeter: () => meter as Meter,
};
return { provider: provider as MeterProvider, counters, histograms };
}
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function be64(n: bigint): Uint8Array {
const o = new Uint8Array(8);
let v = n;
for (let i = 7; i >= 0; i--) {
o[i] = Number(v & 0xffn);
v >>= 8n;
}
return o;
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
beforeEach(() => {
resetTpmStructMetricsForTesting();
});
afterEach(() => {
metrics.disable();
resetTpmStructMetricsForTesting();
});
function certifyAttest(): Uint8Array {
return concat(
be32(0xff544347),
be16(0x8017),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(16)),
be64(0n),
be32(0),
be32(0),
new Uint8Array([0]),
be64(0n),
tpm2b(new Uint8Array(34)),
tpm2b(new Uint8Array(34))
);
}
function rsa(): Uint8Array {
return concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
}
describe("OTel surface", () => {
it("parse-attest histogram records on success", {
timeout: 5000,
}, () => {
const installed = recordingProvider();
metrics.setGlobalMeterProvider(installed.provider);
resetTpmStructMetricsForTesting();
parseTpmsAttest(certifyAttest());
const dur = installed.histograms.find(
(h) =>
h.name === "tpm_struct.parse_tpms_attest_duration_ms" &&
h.attrs?.outcome === "ok"
);
expect(dur).toBeDefined();
const total = installed.counters.find(
(c) =>
c.name === "tpm_struct.parse_total" &&
c.attrs?.kind === "attest" &&
c.attrs?.outcome === "ok"
);
expect(total?.value).toBe(1);
});
it("parse-public histogram tags type=rsa on success", {
timeout: 5000,
}, () => {
const installed = recordingProvider();
metrics.setGlobalMeterProvider(installed.provider);
resetTpmStructMetricsForTesting();
parseTpmtPublic(rsa());
const dur = installed.histograms.find(
(h) =>
h.name === "tpm_struct.parse_tpmt_public_duration_ms" &&
h.attrs?.type === "rsa" &&
h.attrs?.outcome === "ok"
);
expect(dur).toBeDefined();
});
it("parse-public error counter increments on failure", {
timeout: 5000,
}, () => {
const installed = recordingProvider();
metrics.setGlobalMeterProvider(installed.provider);
resetTpmStructMetricsForTesting();
try {
parseTpmtPublic(
concat(
be16(0x0099),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0))
)
);
} catch {
// expected
}
const err = installed.counters.find(
(c) =>
c.name === "tpm_struct.parse_errors_total" &&
c.attrs?.code === "TPM_STRUCT.ALG_UNSUPPORTED"
);
expect(err?.value).toBe(1);
const total = installed.counters.find(
(c) =>
c.name === "tpm_struct.parse_total" &&
c.attrs?.kind === "public" &&
c.attrs?.outcome === "error"
);
expect(total?.value).toBe(1);
});
it("non-TpmStructError exceptions tag with OTHER", {
timeout: 5000,
}, () => {
const installed = recordingProvider();
metrics.setGlobalMeterProvider(installed.provider);
resetTpmStructMetricsForTesting();
recordParseError(new Error("non-struct"));
const err = installed.counters.find(
(c) =>
c.name === "tpm_struct.parse_errors_total" &&
c.attrs?.code === "TPM_STRUCT.OTHER"
);
expect(err).toBeDefined();
});
it("alg-total counter records on observed alg", {
timeout: 5000,
}, () => {
const installed = recordingProvider();
metrics.setGlobalMeterProvider(installed.provider);
resetTpmStructMetricsForTesting();
// Two calls: first instantiates the counter, second hits the cache
// branch so coverage sees both paths.
recordAlgObserved("TPM_ALG_SHA256");
recordAlgObserved("TPM_ALG_SHA256");
const evs = installed.counters.filter(
(c) =>
c.name === "tpm_struct.alg_total" &&
c.attrs?.alg === "TPM_ALG_SHA256"
);
expect(evs.length).toBe(2);
});
});

View File

@@ -0,0 +1,182 @@
import { type Counter, type Histogram, metrics } from "@opentelemetry/api";
import {
TpmStructError,
type TpmStructErrorCode,
} from "../errors/error-codes.js";
const METER_NAME = "@suaveplan/tpm-struct";
let attestHistogram: Histogram | undefined;
let publicHistogram: Histogram | undefined;
let nameHistogram: Histogram | undefined;
let parseTotalCounter: Counter | undefined;
let parseErrorsCounter: Counter | undefined;
let algTotalCounter: Counter | undefined;
function meter() {
return metrics.getMeter(METER_NAME);
}
function attestHist(): Histogram {
if (!attestHistogram) {
attestHistogram = meter().createHistogram(
"tpm_struct.parse_tpms_attest_duration_ms",
{
description:
"Wall-time for TPMS_ATTEST parse, bucketed by outcome",
unit: "ms",
}
);
}
return attestHistogram;
}
function publicHist(): Histogram {
if (!publicHistogram) {
publicHistogram = meter().createHistogram(
"tpm_struct.parse_tpmt_public_duration_ms",
{
description:
"Wall-time for TPMT_PUBLIC parse, bucketed by type and outcome",
unit: "ms",
}
);
}
return publicHistogram;
}
function nameHist(): Histogram {
if (!nameHistogram) {
nameHistogram = meter().createHistogram(
"tpm_struct.compute_name_duration_ms",
{
description:
"Wall-time for computeTpmtPublicName, bucketed by name_alg",
unit: "ms",
}
);
}
return nameHistogram;
}
function parseTotal(): Counter {
if (!parseTotalCounter) {
parseTotalCounter = meter().createCounter("tpm_struct.parse_total", {
description:
"Public parser invocations bucketed by kind and outcome",
});
}
return parseTotalCounter;
}
function parseErrors(): Counter {
if (!parseErrorsCounter) {
parseErrorsCounter = meter().createCounter(
"tpm_struct.parse_errors_total",
{
description: "TpmStructError throws bucketed by stable code",
}
);
}
return parseErrorsCounter;
}
function algTotal(): Counter {
if (!algTotalCounter) {
algTotalCounter = meter().createCounter("tpm_struct.alg_total", {
description: "TPM_ALG observations bucketed by symbolic name",
});
}
return algTotalCounter;
}
/** Parser kind label (low-cardinality enum). */
export type ParseKind = "attest" | "public" | "name";
/** Outcome label (low-cardinality enum). */
export type ParseOutcome = "ok" | "error";
/** TPMT_PUBLIC branch label (low-cardinality enum). */
export type PublicBranch =
| "rsa"
| "ecc"
| "keyedhash"
| "symcipher"
| "unknown";
/**
* Reset every cached instrument. Tests that install a recording meter
* provider via `metrics.setGlobalMeterProvider` call this between cases
* so subsequent measurements resolve against the new provider.
*/
export function resetTpmStructMetricsForTesting(): void {
attestHistogram = undefined;
publicHistogram = undefined;
nameHistogram = undefined;
parseTotalCounter = undefined;
parseErrorsCounter = undefined;
algTotalCounter = undefined;
}
/** Record a parse-attest duration sample. */
export function recordAttestDuration(
outcome: ParseOutcome,
durationMs: number
): void {
attestHist().record(durationMs, { outcome });
}
/** Record a parse-public duration sample (`type` carried as a low-card enum). */
export function recordPublicDuration(
type: PublicBranch,
outcome: ParseOutcome,
durationMs: number
): void {
publicHist().record(durationMs, { type, outcome });
}
/** Record a compute-name duration sample. */
export function recordNameDuration(nameAlg: string, durationMs: number): void {
nameHist().record(durationMs, { name_alg: nameAlg });
}
/** Dispatch to the correct histogram from a parser. */
export function recordParseDuration(
kind: ParseKind,
branchOrAlg: PublicBranch | string,
outcome: ParseOutcome,
durationMs: number
): void {
if (kind === "attest") {
recordAttestDuration(outcome, durationMs);
} else if (kind === "public") {
recordPublicDuration(branchOrAlg as PublicBranch, outcome, durationMs);
} else {
recordNameDuration(branchOrAlg, durationMs);
}
}
/** Increment the parse-total counter. */
export function recordParseTotal(kind: ParseKind, outcome: ParseOutcome): void {
parseTotal().add(1, { kind, outcome });
}
/** Increment the alg-observed counter. */
export function recordAlgObserved(alg: string): void {
algTotal().add(1, { alg });
}
/**
* Bump the parse-errors counter for a thrown error. Non-TpmStructError
* causes increment with a synthetic `OTHER` code so dashboards still see
* the failure (the registry is not extended; this is a sentinel label).
*/
export function recordParseError(err: unknown): void {
let code: TpmStructErrorCode | "TPM_STRUCT.OTHER";
if (err instanceof TpmStructError) {
code = err.code;
} else {
code = "TPM_STRUCT.OTHER";
}
parseErrors().add(1, { code });
}

View File

@@ -0,0 +1,25 @@
export {
decodeObjectAttributes,
encodeObjectAttributes,
} from "./object-attributes.js";
export { parseTpmtPublic } from "./public.js";
export type {
ObjectAttributes,
ObjectAttributesFlags,
TpmRsaUnique,
TpmsEccParms,
TpmsEccPoint,
TpmsKeyedHashParms,
TpmsRsaParms,
TpmsSymCipherParms,
TpmtEccScheme,
TpmtKdfScheme,
TpmtPublic,
TpmtPublicEcc,
TpmtPublicKeyedHash,
TpmtPublicRsa,
TpmtPublicSymCipher,
TpmtRsaScheme,
TpmtSymDefObject,
} from "./public.types.js";
export { serializeTpmtPublic } from "./serialize.js";

View File

@@ -0,0 +1,84 @@
# `public/object-attributes` — `TPMA_OBJECT` bitfield decoder
## Purpose
`decodeObjectAttributes` (and its inverse `encodeObjectAttributes`) walk
the 32-bit `TPMA_OBJECT` bitfield documented in TCG TPM 2.0 Library
Part 2 §8.3. The bitfield is transmitted big-endian on the wire and
controls every meaningful property of a TPM-managed object — whether it
can sign or decrypt, whether it is `fixedTPM`/`fixedParent`, whether the
TPM imposes a dictionary-attack lockout (`noDA`), and so on. The
decoder enforces the §8.3 normative requirement that reserved bits MUST
be transmitted as 0; a non-zero reserved bit raises
`TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO` rather than silently
ignoring the bit, because the most likely root cause is a wire-format
drift or an attempted attack on an attribute downstream consumers will
not inspect.
## Features
- Decodes the 11 named flags at the §8.3 normative positions:
`fixedTPM` (1), `stClear` (2), `fixedParent` (4),
`sensitiveDataOrigin` (5), `userWithAuth` (6), `adminWithPolicy` (7),
`noDA` (10), `encryptedDuplication` (11), `restricted` (16),
`decrypt` (17), `sign` (18).
- Exposes the raw uint32 alongside the named flags so the canonical
serialiser can re-emit the exact bit pattern even if a future revision
of §8.3 adds new named flags.
- Constant-time mask check for reserved bits; any reserved bit set raises
with the exact mask of offending bits in the error message.
## Usage: Basic Example
```ts
import { decodeObjectAttributes } from "@suaveplan/tpm-struct/public";
const attrs = decodeObjectAttributes(0x00040472);
console.log(attrs.flags.fixedTPM, attrs.flags.sign); // true true
```
## Usage: Advanced Example
```ts
import {
decodeObjectAttributes,
encodeObjectAttributes,
} from "@suaveplan/tpm-struct/public";
const attrs = decodeObjectAttributes(0x00040472);
// Use the typed flag struct to gate a downstream operation.
function requireSigningKey(attrs: ReturnType<typeof decodeObjectAttributes>) {
if (!attrs.flags.sign) {
throw new Error("key is not a signing key");
}
if (attrs.flags.restricted) {
// Restricted signing keys can only sign TPM-internal structures;
// reject for raw signing requests.
throw new Error("restricted signing key");
}
}
// Round-trip preservation: encode → raw → decode again.
const raw = encodeObjectAttributes(attrs);
console.log(decodeObjectAttributes(raw).raw === raw); // true
```
## API Reference
- **`decodeObjectAttributes(raw: number): ObjectAttributes`** — return
`{raw, flags}`. Throws `TpmStructError(OBJECT_ATTRIBUTES_RESERVED_NONZERO)`
on any reserved bit non-zero.
- **`encodeObjectAttributes(attrs: ObjectAttributes): number`** — return
the raw uint32 bit pattern.
- **`FLAG_BITS`** — map of flag name → bit position; useful for tests.
- **`NAMED_BIT_MASK`** — the union of all named bit positions.
## Implementation Notes
The decoder uses unsigned right-shift (`>>>`) throughout so the high bit
of the 32-bit value is treated as unsigned. The reserved-bit mask is
computed at module load by OR-ing every `FLAG_BITS` entry; everything
outside that mask is considered reserved. Adding a new named flag is a
two-line change: extend `FLAG_BITS` and the corresponding interface in
`public.types.ts`.

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import {
decodeObjectAttributes,
encodeObjectAttributes,
FLAG_BITS,
NAMED_BIT_MASK,
} from "./object-attributes.js";
describe("decodeObjectAttributes", () => {
it("decodes the canonical WebAuthn EK attribute mask", {
timeout: 5000,
}, () => {
const v = decodeObjectAttributes(0x00040472);
expect(v.raw).toBe(0x00040472);
expect(v.flags.fixedTPM).toBe(true);
expect(v.flags.sign).toBe(true);
});
it("rejects any reserved bit non-zero", { timeout: 5000 }, () => {
// bit 0, bit 3, bit 12 are reserved per §8.3
for (const bit of [0, 3, 8, 9, 12, 13, 14, 15, 19, 31]) {
const raw = (1 << bit) >>> 0;
try {
decodeObjectAttributes(raw);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO"
);
}
}
});
it("encodeObjectAttributes returns raw bits", { timeout: 5000 }, () => {
const v = decodeObjectAttributes(0x00040472);
expect(encodeObjectAttributes(v)).toBe(0x00040472);
});
it("named mask covers exactly the §8.3 named positions", {
timeout: 5000,
}, () => {
let m = 0;
for (const b of Object.values(FLAG_BITS)) m |= 1 << b;
expect(NAMED_BIT_MASK).toBe(m >>> 0);
});
});

View File

@@ -0,0 +1,88 @@
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
import type {
ObjectAttributes,
ObjectAttributesFlags,
} from "./public.types.js";
/**
* TPMA_OBJECT bit layout per TCG TPM 2.0 Library Part 2 §8.3 (r1.59).
*
* The bitfield is a 32-bit value transmitted big-endian. Bit 0 is reserved
* (must be 0). Named flags occupy specific bit positions; every other bit
* is reserved and MUST be transmitted as 0 per the spec — non-zero reserved
* bits raise `TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO`.
*
* The named bit positions used by this package match the §8.3 normative
* table:
*
* bit 1 — fixedTPM
* bit 2 — stClear
* bit 4 — fixedParent
* bit 5 — sensitiveDataOrigin
* bit 6 — userWithAuth
* bit 7 — adminWithPolicy
* bit 10 — noDA
* bit 11 — encryptedDuplication
* bit 16 — restricted
* bit 17 — decrypt
* bit 18 — sign / encrypt
*/
const FLAG_BITS = {
fixedTPM: 1,
stClear: 2,
fixedParent: 4,
sensitiveDataOrigin: 5,
userWithAuth: 6,
adminWithPolicy: 7,
noDA: 10,
encryptedDuplication: 11,
restricted: 16,
decrypt: 17,
sign: 18,
} as const satisfies Record<keyof ObjectAttributesFlags, number>;
// Mask of all bits that are NOT reserved. Anything else flips
// OBJECT_ATTRIBUTES_RESERVED_NONZERO.
const NAMED_BIT_MASK = (() => {
let m = 0;
for (const bit of Object.values(FLAG_BITS)) m |= 1 << bit;
return m >>> 0;
})();
/**
* Decode a 32-bit `TPMA_OBJECT` value into `{raw, flags}`. Reserved bits
* (any bit not named in §8.3) set non-zero raise `TPM_STRUCT.OBJECT_
* ATTRIBUTES_RESERVED_NONZERO`.
*/
export function decodeObjectAttributes(raw: number): ObjectAttributes {
const reserved = (raw & ~NAMED_BIT_MASK) >>> 0;
if (reserved !== 0) {
throw tpmStructError(
TPM_STRUCT_CODES.OBJECT_ATTRIBUTES_RESERVED_NONZERO,
`TPMA_OBJECT reserved bits set: 0x${reserved.toString(16).padStart(8, "0")}`
);
}
const flags: ObjectAttributesFlags = {
fixedTPM: ((raw >>> FLAG_BITS.fixedTPM) & 1) === 1,
stClear: ((raw >>> FLAG_BITS.stClear) & 1) === 1,
fixedParent: ((raw >>> FLAG_BITS.fixedParent) & 1) === 1,
sensitiveDataOrigin:
((raw >>> FLAG_BITS.sensitiveDataOrigin) & 1) === 1,
userWithAuth: ((raw >>> FLAG_BITS.userWithAuth) & 1) === 1,
adminWithPolicy: ((raw >>> FLAG_BITS.adminWithPolicy) & 1) === 1,
noDA: ((raw >>> FLAG_BITS.noDA) & 1) === 1,
encryptedDuplication:
((raw >>> FLAG_BITS.encryptedDuplication) & 1) === 1,
restricted: ((raw >>> FLAG_BITS.restricted) & 1) === 1,
decrypt: ((raw >>> FLAG_BITS.decrypt) & 1) === 1,
sign: ((raw >>> FLAG_BITS.sign) & 1) === 1,
};
return { raw, flags };
}
/** Inverse of `decodeObjectAttributes` — used by the serializer. */
export function encodeObjectAttributes(attrs: ObjectAttributes): number {
return attrs.raw >>> 0;
}
export { FLAG_BITS, NAMED_BIT_MASK };

View File

@@ -0,0 +1,124 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import fc from "fast-check";
import { parseTpmtPublic } from "./public.js";
import { serializeTpmtPublic } from "./serialize.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
const NAMED_BITS = [1, 2, 4, 5, 6, 7, 10, 11, 16, 17, 18] as const;
const validAttrsArb = fc
.array(fc.boolean(), {
minLength: NAMED_BITS.length,
maxLength: NAMED_BITS.length,
})
.map((flags) => {
let v = 0;
for (let i = 0; i < NAMED_BITS.length; i++) {
if (flags[i]) v |= 1 << (NAMED_BITS[i] as number);
}
return v >>> 0;
});
const u8Array = (max: number) =>
fc.uint8Array({ maxLength: max }).map((u) => new Uint8Array(u));
const rsaArb = fc.tuple(
fc.constantFrom(0x000b, 0x000c, 0x000d, 0x0004), // nameAlg
validAttrsArb,
u8Array(32), // authPolicy
fc.constantFrom(1024, 2048, 3072, 4096), // keyBits
fc.nat(0xfffffe), // exponent (skip 0xffffffff to keep small)
u8Array(512)
);
const eccArb = fc.tuple(
fc.constantFrom(0x000b, 0x000c, 0x000d), // nameAlg
validAttrsArb,
u8Array(32),
fc.constantFrom(0x0003, 0x0004, 0x0005), // curveID
u8Array(96),
u8Array(96)
);
describe("parseTpmtPublic — round-trip property", () => {
it("RSA arbitrary inputs round-trip byte-identical", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(
rsaArb,
([
nameAlg,
attrs,
authPolicy,
keyBits,
rawExponent,
modulus,
]) => {
const bytes = concat(
be16(0x0001),
be16(nameAlg),
be32(attrs),
tpm2b(authPolicy),
be16(0x0010),
be16(0x0010),
be16(keyBits),
be32(rawExponent),
tpm2b(modulus)
);
const re = serializeTpmtPublic(parseTpmtPublic(bytes));
expect(re).toEqual(bytes);
}
),
{ numRuns: 80 }
);
});
it("ECC arbitrary inputs round-trip byte-identical", {
timeout: 30000,
}, () => {
fc.assert(
fc.property(eccArb, ([nameAlg, attrs, authPolicy, curve, x, y]) => {
const bytes = concat(
be16(0x0023),
be16(nameAlg),
be32(attrs),
tpm2b(authPolicy),
be16(0x0010),
be16(0x0010),
be16(curve),
be16(0x0010),
tpm2b(x),
tpm2b(y)
);
const re = serializeTpmtPublic(parseTpmtPublic(bytes));
expect(re).toEqual(bytes);
}),
{ numRuns: 80 }
);
});
});

View File

@@ -0,0 +1,102 @@
# `public/public` — `TPMT_PUBLIC` parser
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs --> - serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- serialize — `serializeTpmtPublic` re-emits a parsed…
- [serialize](./serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
- [serialize](./serialize.md) — `serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the TCG-canonical byte string per Library Part 2 §12.2. I…
## Purpose
`parseTpmtPublic` decodes a TCG TPM 2.0 `TPMT_PUBLIC` byte string per
Library Part 2 §12.2 into a discriminated-union value keyed on the
`TPMI_ALG_PUBLIC` `type` field. The four supported branches —
`TPM_ALG_RSA`, `TPM_ALG_ECC`, `TPM_ALG_KEYEDHASH`, `TPM_ALG_SYMCIPHER` —
each decode their own `TPMS_*_PARMS` parameter substructure and matching
`TPMU_PUBLIC_ID` unique slot. The parser is the central seam between the
on-the-wire byte format and every downstream consumer of TPM public
areas: `computeTpmtPublicName` re-emits the bytes through
`serializeTpmtPublic` and hashes them for the canonical Part 1 §16 name
digest, while the WebAuthn `tpm`-format verifier validates the `pubArea`
against the credential public key in `authData.attestedCredentialData`.
## Features
- Full §12.2 field order enforcement (`type` → `nameAlg` →
`objectAttributes` → `authPolicy` → `parameters` → `unique`).
- RSA-2048/3072/4096 with default-exponent (`0` → `65537`) normalisation
per §12.2.3.5 plus a `rawExponent` slot preserved for round-trip
fidelity.
- ECC P-256/P-384/P-521/BN-256/BN-638/SM2-256 with `curveID` lookup via
the §11.2.5 registry; unknown curves raise `CURVE_UNSUPPORTED`.
- KEYEDHASH NULL / HMAC / XOR schemes per §12.2.3.3.
- SYMCIPHER with full `TPMT_SYM_DEF_OBJECT` decoding per §12.2.3.4.
- `objectAttributes` decoded into raw `uint32` + typed flag struct;
reserved bits non-zero raise `OBJECT_ATTRIBUTES_RESERVED_NONZERO`.
- Boundary Zod schema (`TpmtPublicSchema`) validates the constructed
result before returning — malformed parser output raises `MALFORMED`.
- OTel histograms (`tpm_struct.parse_tpmt_public_duration_ms{type,outcome}`)
- counters (`tpm_struct.parse_total{kind="public",outcome}` and
`tpm_struct.parse_errors_total{code}`).
## Usage: Basic Example
```ts
import { parseTpmtPublic } from "@suaveplan/tpm-struct/public";
const pub = parseTpmtPublic(pubAreaBytes);
if (pub.type === "TPM_ALG_RSA") {
console.log(pub.parameters.keyBits, pub.parameters.exponent);
console.log(pub.unique.modulus.byteLength);
}
```
## Usage: Advanced Example
```ts
import { createHash } from "node:crypto";
import {
computeTpmtPublicName,
parseTpmtPublic,
} from "@suaveplan/tpm-struct";
const pub = parseTpmtPublic(pubAreaBytes);
if (pub.nameAlg === "TPM_ALG_SHA256" && pub.type === "TPM_ALG_ECC") {
const name = computeTpmtPublicName(pub, {
digest: (b) => new Uint8Array(createHash("sha256").update(b).digest()),
});
// `name` is `0x000b || sha256(serializeTpmtPublic(pub))` — feed this
// into the WebAuthn `tpm`-attestation name-match step (W3C WebAuthn
// L3 §8.3 step 5).
}
```
## API Reference
- **`parseTpmtPublic(bytes: Uint8Array): TpmtPublic`** — returns a
discriminated-union value. Throws `TpmStructError` with one of
`ALG_UNSUPPORTED`, `CURVE_UNSUPPORTED`, `OBJECT_ATTRIBUTES_RESERVED_NONZERO`,
`PARAMETERS_INVALID`, `TRUNCATED`, or `MALFORMED`.
- **`TpmtPublic`** — discriminator union over `type`. Each branch carries
`nameAlg`, `objectAttributes`, `authPolicy`, `parameters`, `unique`.
## Implementation Notes
The parser is deliberately straight-line: every field decode advances the
underlying `TpmBufferReader`'s cursor by exactly the bytes named in the
spec. `assertEnd()` is called at the end of every successful parse so a
trailing byte is reported as `TRUNCATED` rather than silently dropped.
The exponent-zero normalisation rule is preserved alongside `rawExponent`
because re-serialisation must emit the original bytes so the §16 name
digest stays stable.

View File

@@ -0,0 +1,519 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import { parseTpmtPublic } from "./public.js";
function hex(s: string): Uint8Array {
const clean = s.replace(/\s+/g, "");
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < out.length; i++) {
out[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
// Minimal RSA-2048 TPMT_PUBLIC fixture.
function rsaFixture(
opts: {
nameAlg?: number;
objectAttributes?: number;
authPolicy?: Uint8Array;
keyBits?: number;
rawExponent?: number;
} = {}
): Uint8Array {
const modulus = new Uint8Array(256).fill(0xab);
return concat(
be16(0x0001), // type RSA
be16(opts.nameAlg ?? 0x000b),
be32(opts.objectAttributes ?? 0x00040472),
tpm2b(opts.authPolicy ?? new Uint8Array(0)),
be16(0x0010), // symmetric NULL
be16(0x0010), // scheme NULL
be16(opts.keyBits ?? 2048),
be32(opts.rawExponent ?? 0),
tpm2b(modulus)
);
}
function eccFixture(): Uint8Array {
const x = new Uint8Array(32).fill(0x11);
const y = new Uint8Array(32).fill(0x22);
return concat(
be16(0x0023), // type ECC
be16(0x000b), // SHA-256
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010), // symmetric NULL
be16(0x0010), // scheme NULL
be16(0x0003), // curve NIST_P256
be16(0x0010), // kdf NULL
tpm2b(x),
tpm2b(y)
);
}
describe("parseTpmtPublic — type discriminator", () => {
it("RSA type selects RSA branch", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture());
expect(pub.type).toBe("TPM_ALG_RSA");
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.keyBits).toBe(2048);
});
it("ECC type selects ECC branch", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(eccFixture());
expect(pub.type).toBe("TPM_ALG_ECC");
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.parameters.curveID).toBe("TPM_ECC_NIST_P256");
});
it("KEYEDHASH type selects KEYEDHASH branch (HMAC scheme)", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0008), // KEYEDHASH
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0005), // HMAC scheme
be16(0x000b), // SHA-256 hashAlg
tpm2b(new Uint8Array(32).fill(0x33))
);
const pub = parseTpmtPublic(bytes);
expect(pub.type).toBe("TPM_ALG_KEYEDHASH");
});
it("KEYEDHASH NULL scheme decodes", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0008), // KEYEDHASH
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010), // NULL scheme
tpm2b(new Uint8Array(32).fill(0x33))
);
const pub = parseTpmtPublic(bytes);
expect(pub.type).toBe("TPM_ALG_KEYEDHASH");
if (pub.type === "TPM_ALG_KEYEDHASH") {
expect(pub.parameters.scheme).toBe("TPM_ALG_NULL");
}
});
it("KEYEDHASH XOR scheme decodes", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x000a), // XOR scheme
be16(0x000b), // hashAlg SHA256
be16(0x000c), // kdf SHA384
tpm2b(new Uint8Array(32))
);
const pub = parseTpmtPublic(bytes);
expect(pub.type).toBe("TPM_ALG_KEYEDHASH");
});
it("KEYEDHASH unsupported scheme raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0001), // bogus scheme (RSA value, not allowed here)
be16(0x000b),
tpm2b(new Uint8Array(32))
);
try {
parseTpmtPublic(bytes);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.PARAMETERS_INVALID"
);
}
});
it("SYMCIPHER type selects SYMCIPHER branch (AES)", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0025), // SYMCIPHER
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
// sym = TPMT_SYM_DEF_OBJECT { AES, 128, CFB }
be16(0x0006), // AES
be16(128), // keyBits
be16(0x0043), // CFB
tpm2b(new Uint8Array(32))
);
const pub = parseTpmtPublic(bytes);
expect(pub.type).toBe("TPM_ALG_SYMCIPHER");
});
it("unknown type raises ALG_UNSUPPORTED", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0099),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0))
);
try {
parseTpmtPublic(bytes);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.ALG_UNSUPPORTED"
);
}
});
it("non-{RSA,ECC,KEYEDHASH,SYMCIPHER} valid alg raises ALG_UNSUPPORTED", {
timeout: 5000,
}, () => {
// 0x0004 is TPM_ALG_SHA1 — known but not a TPMI_ALG_PUBLIC member.
const bytes = concat(
be16(0x0004),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0))
);
expect(() => parseTpmtPublic(bytes)).toThrow(/ALG_UNSUPPORTED/);
});
});
describe("parseTpmtPublic — nameAlg", () => {
it("SHA-256 nameAlg decodes", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture({ nameAlg: 0x000b }));
expect(pub.nameAlg).toBe("TPM_ALG_SHA256");
});
it("TPM_ALG_NULL nameAlg accepted by parser", {
timeout: 5000,
}, () => {
const pub = parseTpmtPublic(rsaFixture({ nameAlg: 0x0010 }));
expect(pub.nameAlg).toBe("TPM_ALG_NULL");
});
});
describe("parseTpmtPublic — objectAttributes", () => {
it("objectAttributes flag round-trip", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(
rsaFixture({ objectAttributes: 0x00040472 })
);
expect(pub.objectAttributes.raw).toBe(0x00040472);
expect(pub.objectAttributes.flags).toEqual({
fixedTPM: true,
stClear: false,
fixedParent: true,
sensitiveDataOrigin: true,
userWithAuth: true,
adminWithPolicy: false,
noDA: true,
encryptedDuplication: false,
restricted: false,
decrypt: false,
sign: true,
});
});
it("reserved bit raises OBJECT_ATTRIBUTES_RESERVED_NONZERO", {
timeout: 5000,
}, () => {
try {
parseTpmtPublic(rsaFixture({ objectAttributes: 0x00000001 })); // bit 0 reserved
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO"
);
}
});
it("every named flag bit can be toggled", { timeout: 5000 }, () => {
// Bits 1,2,4,5,6,7,10,11,16,17,18 named per §8.3
const mask =
(1 << 1) |
(1 << 2) |
(1 << 4) |
(1 << 5) |
(1 << 6) |
(1 << 7) |
(1 << 10) |
(1 << 11) |
(1 << 16) |
(1 << 17) |
(1 << 18);
const pub = parseTpmtPublic(
rsaFixture({ objectAttributes: mask >>> 0 })
);
const f = pub.objectAttributes.flags;
expect(f.fixedTPM).toBe(true);
expect(f.stClear).toBe(true);
expect(f.fixedParent).toBe(true);
expect(f.sensitiveDataOrigin).toBe(true);
expect(f.userWithAuth).toBe(true);
expect(f.adminWithPolicy).toBe(true);
expect(f.noDA).toBe(true);
expect(f.encryptedDuplication).toBe(true);
expect(f.restricted).toBe(true);
expect(f.decrypt).toBe(true);
expect(f.sign).toBe(true);
});
});
describe("parseTpmtPublic — authPolicy (TPM2B_DIGEST)", () => {
it("32-byte authPolicy decodes", { timeout: 5000 }, () => {
const policy = new Uint8Array(32).fill(0x77);
const pub = parseTpmtPublic(rsaFixture({ authPolicy: policy }));
expect(pub.authPolicy).toEqual(policy);
});
it("empty authPolicy decodes", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(
rsaFixture({ authPolicy: new Uint8Array(0) })
);
expect(pub.authPolicy.length).toBe(0);
});
});
describe("parseTpmtPublic — RSA parameters", () => {
it("RSA-2048 parameters decode", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture());
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.keyBits).toBe(2048);
expect(pub.parameters.exponent).toBe(65537);
expect(pub.unique.modulus.length).toBe(256);
});
it("exponent zero normalises to 65537", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture({ rawExponent: 0 }));
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.exponent).toBe(65537);
expect(pub.parameters.rawExponent).toBe(0);
});
it("non-default exponent preserved", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture({ rawExponent: 3 }));
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.exponent).toBe(3);
expect(pub.parameters.rawExponent).toBe(3);
});
it("RSA keyBits=0 raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
try {
parseTpmtPublic(rsaFixture({ keyBits: 0 }));
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.PARAMETERS_INVALID"
);
}
});
it("RSA unique field is the modulus", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(rsaFixture());
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.unique.modulus).toEqual(new Uint8Array(256).fill(0xab));
});
});
describe("parseTpmtPublic — ECC parameters", () => {
it("ECC P-256 parameters decode", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(eccFixture());
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.parameters.curveID).toBe("TPM_ECC_NIST_P256");
expect(pub.unique.x.length).toBe(32);
expect(pub.unique.y.length).toBe(32);
});
it("ECC unique field is the point", { timeout: 5000 }, () => {
const pub = parseTpmtPublic(eccFixture());
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.unique.x).toEqual(new Uint8Array(32).fill(0x11));
expect(pub.unique.y).toEqual(new Uint8Array(32).fill(0x22));
});
it("unknown ECC curve raises CURVE_UNSUPPORTED", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010), // symmetric NULL
be16(0x0010), // scheme NULL
be16(0x0099), // unknown curve
be16(0x0010), // kdf NULL
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
try {
parseTpmtPublic(bytes);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.CURVE_UNSUPPORTED"
);
}
});
it("ECC ECDSA scheme adds hashAlg", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010), // symmetric NULL
be16(0x0018), // scheme ECDSA
be16(0x000b), // hashAlg SHA-256
be16(0x0003), // curve P-256
be16(0x0010), // kdf NULL
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
const pub = parseTpmtPublic(bytes);
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.parameters.scheme.scheme).toBe("TPM_ALG_ECDSA");
expect(pub.parameters.scheme.hashAlg).toBe("TPM_ALG_SHA256");
});
it("ECC ECDAA scheme adds count", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x001a), // ECDAA
be16(0x000b),
be16(7), // count
be16(0x0003),
be16(0x0010),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
const pub = parseTpmtPublic(bytes);
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.parameters.scheme.scheme).toBe("TPM_ALG_ECDAA");
expect(pub.parameters.scheme.count).toBe(7);
});
it("ECC kdf non-NULL decodes hashAlg", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(0x0003),
be16(0x0020), // kdf KDF1_SP800_56A
be16(0x000b),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
const pub = parseTpmtPublic(bytes);
if (pub.type !== "TPM_ALG_ECC") throw new Error("guard");
expect(pub.parameters.kdf.scheme).toBe("TPM_ALG_KDF1_SP800_56A");
expect(pub.parameters.kdf.hashAlg).toBe("TPM_ALG_SHA256");
});
});
describe("parseTpmtPublic — RSA scheme variants", () => {
it("RSA RSASSA scheme decodes hashAlg", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010), // symmetric NULL
be16(0x0014), // RSASSA
be16(0x000b), // SHA-256 hashAlg
be16(2048),
be32(65537),
tpm2b(new Uint8Array(256))
);
const pub = parseTpmtPublic(bytes);
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.scheme.scheme).toBe("TPM_ALG_RSASSA");
expect(pub.parameters.scheme.hashAlg).toBe("TPM_ALG_SHA256");
});
});
describe("parseTpmtPublic — symmetric AES", () => {
it("AES symmetric (non-NULL) reads keyBits and mode", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
// symmetric: AES, 128, CFB
be16(0x0006),
be16(128),
be16(0x0043),
be16(0x0010), // scheme NULL
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
const pub = parseTpmtPublic(bytes);
if (pub.type !== "TPM_ALG_RSA") throw new Error("guard");
expect(pub.parameters.symmetric.algorithm).toBe("TPM_ALG_AES");
expect(pub.parameters.symmetric.keyBits).toBe(128);
expect(pub.parameters.symmetric.mode).toBe("TPM_ALG_CFB");
});
});
describe("parseTpmtPublic — bounds", () => {
it("trailing bytes raise TRUNCATED via assertEnd", {
timeout: 5000,
}, () => {
const bytes = concat(rsaFixture(), hex("ff"));
expect(() => parseTpmtPublic(bytes)).toThrow(/TRUNCATED/);
});
});
describe("parseTpmtPublic — fixture helpers", () => {
it("hex/concat/be helpers compose properly", { timeout: 5000 }, () => {
// Sanity: my fixture builder produces 2+2+4+2+ ... well-formed bytes.
const buf = rsaFixture();
expect(buf[0]).toBe(0x00);
expect(buf[1]).toBe(0x01); // RSA
});
});

View File

@@ -0,0 +1,236 @@
import { algIdToName, eccCurveToName, type TpmAlgId } from "../alg/alg.js";
import { TpmBufferReader } from "../buffer/buffer.js";
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
import {
recordParseDuration,
recordParseError,
recordParseTotal,
} from "../observability/metrics.js";
import { validateTpmtPublic } from "../schemas/schemas.js";
import { decodeObjectAttributes } from "./object-attributes.js";
import type {
TpmsEccParms,
TpmsEccPoint,
TpmsKeyedHashParms,
TpmsRsaParms,
TpmsSymCipherParms,
TpmtEccScheme,
TpmtKdfScheme,
TpmtPublic,
TpmtRsaScheme,
TpmtSymDefObject,
} from "./public.types.js";
/**
* Decode a TCG TPM 2.0 `TPMT_PUBLIC` byte string into a discriminated-union
* value per Part 2 §12.2. The buffer is consumed end-to-end; trailing bytes
* raise `TPM_STRUCT.TRUNCATED` via `assertEnd()`.
*
* The parser observes the §12.2 field order strictly:
*
* 1. `type: TPMI_ALG_PUBLIC` (UINT16 BE)
* 2. `nameAlg: TPMI_ALG_HASH` (UINT16 BE)
* 3. `objectAttributes: TPMA_OBJECT` (UINT32 BE bitfield, §8.3)
* 4. `authPolicy: TPM2B_DIGEST`
* 5. `parameters: TPMU_PUBLIC_PARMS` dispatched by `type`
* 6. `unique: TPMU_PUBLIC_ID` dispatched by `type`
*
* Each branch (`RSA`, `ECC`, `KEYEDHASH`, `SYMCIPHER`) decodes its own
* `TPMS_*_PARMS` substructure and `TPMU_PUBLIC_ID` member. Unsupported
* algorithm identifiers raise `TPM_STRUCT.ALG_UNSUPPORTED`.
*/
export function parseTpmtPublic(bytes: Uint8Array): TpmtPublic {
const start = performance.now();
try {
const r = new TpmBufferReader(bytes);
const typeId = r.readUint16Be();
const typeName = algIdToName(typeId);
const nameAlgId = r.readUint16Be();
const nameAlg = algIdToName(nameAlgId);
const rawAttrs = r.readUint32Be();
const objectAttributes = decodeObjectAttributes(rawAttrs);
const authPolicy = r.readTpm2b();
let parsed: TpmtPublic;
if (typeName === "TPM_ALG_RSA") {
const parameters = readRsaParms(r);
const unique = { modulus: r.readTpm2b() };
parsed = {
type: "TPM_ALG_RSA",
nameAlg,
objectAttributes,
authPolicy,
parameters,
unique,
};
} else if (typeName === "TPM_ALG_ECC") {
const parameters = readEccParms(r);
const unique: TpmsEccPoint = {
x: r.readTpm2b(),
y: r.readTpm2b(),
};
parsed = {
type: "TPM_ALG_ECC",
nameAlg,
objectAttributes,
authPolicy,
parameters,
unique,
};
} else if (typeName === "TPM_ALG_KEYEDHASH") {
const parameters = readKeyedHashParms(r);
const unique = r.readTpm2b();
parsed = {
type: "TPM_ALG_KEYEDHASH",
nameAlg,
objectAttributes,
authPolicy,
parameters,
unique,
};
} else if (typeName === "TPM_ALG_SYMCIPHER") {
const parameters = readSymCipherParms(r);
const unique = r.readTpm2b();
parsed = {
type: "TPM_ALG_SYMCIPHER",
nameAlg,
objectAttributes,
authPolicy,
parameters,
unique,
};
} else {
throw tpmStructError(
TPM_STRUCT_CODES.ALG_UNSUPPORTED,
`TPMT_PUBLIC.type ${typeName} is not RSA/ECC/KEYEDHASH/SYMCIPHER`
);
}
r.assertEnd();
validateTpmtPublic(parsed);
recordParseDuration(
"public",
typeBranchTag(typeName),
"ok",
durationMs(start)
);
recordParseTotal("public", "ok");
return parsed;
} catch (err) {
const dur = durationMs(start);
recordParseDuration("public", "unknown", "error", dur);
recordParseTotal("public", "error");
recordParseError(err);
throw err;
}
}
function durationMs(start: number): number {
return Math.max(0, performance.now() - start);
}
const PUBLIC_BRANCH_TAG: Record<
string,
"rsa" | "ecc" | "keyedhash" | "symcipher"
> = {
TPM_ALG_RSA: "rsa",
TPM_ALG_ECC: "ecc",
TPM_ALG_KEYEDHASH: "keyedhash",
TPM_ALG_SYMCIPHER: "symcipher",
};
function typeBranchTag(
name: TpmAlgId
): "rsa" | "ecc" | "keyedhash" | "symcipher" {
// The caller has already verified `name` is in the lookup; the
// assertion is type-only — runtime narrowing was performed upstream.
return PUBLIC_BRANCH_TAG[name] as "rsa" | "ecc" | "keyedhash" | "symcipher";
}
/**
* Decode `TPMT_SYM_DEF_OBJECT` per §11.1.7. When the algorithm is
* `TPM_ALG_NULL` no further fields are emitted. Otherwise the layout is
* `algorithm: UINT16 BE`, `keyBits: UINT16 BE`, `mode: UINT16 BE`.
*/
function readSymDefObject(r: TpmBufferReader): TpmtSymDefObject {
const alg = algIdToName(r.readUint16Be());
if (alg === "TPM_ALG_NULL") {
return { algorithm: alg };
}
const keyBits = r.readUint16Be();
const mode = algIdToName(r.readUint16Be());
return { algorithm: alg, keyBits, mode };
}
function readRsaScheme(r: TpmBufferReader): TpmtRsaScheme {
const scheme = algIdToName(r.readUint16Be());
if (scheme === "TPM_ALG_NULL") return { scheme };
const hashAlg = algIdToName(r.readUint16Be());
return { scheme, hashAlg };
}
function readEccScheme(r: TpmBufferReader): TpmtEccScheme {
const scheme = algIdToName(r.readUint16Be());
if (scheme === "TPM_ALG_NULL") return { scheme };
const hashAlg = algIdToName(r.readUint16Be());
if (scheme === "TPM_ALG_ECDAA") {
const count = r.readUint16Be();
return { scheme, hashAlg, count };
}
return { scheme, hashAlg };
}
function readKdfScheme(r: TpmBufferReader): TpmtKdfScheme {
const scheme = algIdToName(r.readUint16Be());
if (scheme === "TPM_ALG_NULL") return { scheme };
const hashAlg = algIdToName(r.readUint16Be());
return { scheme, hashAlg };
}
function readRsaParms(r: TpmBufferReader): TpmsRsaParms {
const symmetric = readSymDefObject(r);
const scheme = readRsaScheme(r);
const keyBits = r.readUint16Be();
const rawExponent = r.readUint32Be();
if (keyBits === 0) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"RSA parameters declare keyBits=0"
);
}
// Per §12.2.3.5: an exponent of 0 means the default 65537 (F4).
const exponent = rawExponent === 0 ? 65537 : rawExponent;
return { symmetric, scheme, keyBits, exponent, rawExponent };
}
function readEccParms(r: TpmBufferReader): TpmsEccParms {
const symmetric = readSymDefObject(r);
const scheme = readEccScheme(r);
const curveID = eccCurveToName(r.readUint16Be());
const kdf = readKdfScheme(r);
return { symmetric, scheme, curveID, kdf };
}
function readKeyedHashParms(r: TpmBufferReader): TpmsKeyedHashParms {
const scheme = algIdToName(r.readUint16Be());
if (scheme === "TPM_ALG_NULL") {
return { scheme };
}
if (scheme === "TPM_ALG_HMAC") {
const hashAlg = algIdToName(r.readUint16Be());
return { scheme, hashAlg };
}
if (scheme === "TPM_ALG_XOR") {
const hashAlg = algIdToName(r.readUint16Be());
const kdf = algIdToName(r.readUint16Be());
return { scheme, hashAlg, kdf };
}
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
`TPMS_KEYEDHASH_PARMS.scheme ${scheme} not in {NULL, HMAC, XOR}`
);
}
function readSymCipherParms(r: TpmBufferReader): TpmsSymCipherParms {
const sym = readSymDefObject(r);
return { sym };
}

View File

@@ -0,0 +1,137 @@
import type { TpmAlgId, TpmEccCurve } from "../alg/alg.js";
/**
* Type definitions for `TPMT_PUBLIC` per TCG TPM 2.0 Library Part 2 —
* Structures r1.59, §12.2 and the substructures it composes.
*
* The top-level `TpmtPublic` is a discriminated union over the
* `TPMI_ALG_PUBLIC` `type` field — `RSA`, `ECC`, `KEYEDHASH`, and
* `SYMCIPHER` each select a distinct `parameters` / `unique` pair.
*/
/** TPMA_OBJECT decoded flag layout per §8.3 (object-attributes bitfield). */
export interface ObjectAttributesFlags {
readonly fixedTPM: boolean;
readonly stClear: boolean;
readonly fixedParent: boolean;
readonly sensitiveDataOrigin: boolean;
readonly userWithAuth: boolean;
readonly adminWithPolicy: boolean;
readonly noDA: boolean;
readonly encryptedDuplication: boolean;
readonly restricted: boolean;
readonly decrypt: boolean;
readonly sign: boolean;
}
/** Raw uint32 + decoded named flags for the TPMA_OBJECT bitfield. */
export interface ObjectAttributes {
readonly raw: number;
readonly flags: ObjectAttributesFlags;
}
/** TPMT_SYM_DEF_OBJECT per §11.1.7 — symmetric algorithm specifier. */
export interface TpmtSymDefObject {
readonly algorithm: TpmAlgId;
readonly keyBits?: number;
readonly mode?: TpmAlgId;
}
/** TPMT_RSA_SCHEME per §11.2.4.2. */
export interface TpmtRsaScheme {
readonly scheme: TpmAlgId;
readonly hashAlg?: TpmAlgId;
}
/** TPMT_ECC_SCHEME per §11.2.5.6. */
export interface TpmtEccScheme {
readonly scheme: TpmAlgId;
readonly hashAlg?: TpmAlgId;
readonly count?: number;
}
/** TPMT_KDF_SCHEME per §11.2.3.2. */
export interface TpmtKdfScheme {
readonly scheme: TpmAlgId;
readonly hashAlg?: TpmAlgId;
}
/** TPMS_RSA_PARMS per §12.2.3.5. */
export interface TpmsRsaParms {
readonly symmetric: TpmtSymDefObject;
readonly scheme: TpmtRsaScheme;
readonly keyBits: number;
/** Normalised exponent: `0` on the wire is reported as `65537` here. */
readonly exponent: number;
/** The raw uint32 exponent value as it appeared on the wire. */
readonly rawExponent: number;
}
/** TPMS_ECC_PARMS per §12.2.3.6. */
export interface TpmsEccParms {
readonly symmetric: TpmtSymDefObject;
readonly scheme: TpmtEccScheme;
readonly curveID: TpmEccCurve;
readonly kdf: TpmtKdfScheme;
}
/** TPMS_KEYEDHASH_PARMS per §12.2.3.3. */
export interface TpmsKeyedHashParms {
readonly scheme: TpmAlgId;
/** Present only when `scheme === TPM_ALG_HMAC` (XOR keyed-hash). */
readonly hashAlg?: TpmAlgId;
/** Present only when `scheme === TPM_ALG_XOR` (kdf hash). */
readonly kdf?: TpmAlgId;
}
/** TPMS_SYMCIPHER_PARMS per §12.2.3.4. */
export interface TpmsSymCipherParms {
readonly sym: TpmtSymDefObject;
}
/** TPMS_ECC_POINT per §11.2.5.2 — affine x,y coordinates. */
export interface TpmsEccPoint {
readonly x: Uint8Array;
readonly y: Uint8Array;
}
/** RSA unique slot — modulus bytes. */
export interface TpmRsaUnique {
readonly modulus: Uint8Array;
}
interface TpmtPublicCommon {
readonly nameAlg: TpmAlgId;
readonly objectAttributes: ObjectAttributes;
readonly authPolicy: Uint8Array;
}
export interface TpmtPublicRsa extends TpmtPublicCommon {
readonly type: "TPM_ALG_RSA";
readonly parameters: TpmsRsaParms;
readonly unique: TpmRsaUnique;
}
export interface TpmtPublicEcc extends TpmtPublicCommon {
readonly type: "TPM_ALG_ECC";
readonly parameters: TpmsEccParms;
readonly unique: TpmsEccPoint;
}
export interface TpmtPublicKeyedHash extends TpmtPublicCommon {
readonly type: "TPM_ALG_KEYEDHASH";
readonly parameters: TpmsKeyedHashParms;
readonly unique: Uint8Array;
}
export interface TpmtPublicSymCipher extends TpmtPublicCommon {
readonly type: "TPM_ALG_SYMCIPHER";
readonly parameters: TpmsSymCipherParms;
readonly unique: Uint8Array;
}
export type TpmtPublic =
| TpmtPublicRsa
| TpmtPublicEcc
| TpmtPublicKeyedHash
| TpmtPublicSymCipher;

View File

@@ -0,0 +1,189 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import type {
TpmsEccParms,
TpmsKeyedHashParms,
TpmtPublic,
} from "./public.types.js";
import { serializeTpmtPublic } from "./serialize.js";
// All these tests construct hand-built `TpmtPublic`-shaped values that
// violate a sub-invariant the parser never produces. They exercise the
// defensive PARAMETERS_INVALID guards in the serialiser.
const okAttrs = {
raw: 0x00040472,
flags: {
fixedTPM: true,
stClear: false,
fixedParent: true,
sensitiveDataOrigin: true,
userWithAuth: true,
adminWithPolicy: false,
noDA: true,
encryptedDuplication: false,
restricted: false,
decrypt: false,
sign: true,
},
};
describe("serializeTpmtPublic — defensive errors", () => {
it("symDef non-NULL without keyBits/mode raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const pub: TpmtPublic = {
type: "TPM_ALG_RSA",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters: {
symmetric: { algorithm: "TPM_ALG_AES" }, // missing keyBits/mode
scheme: { scheme: "TPM_ALG_NULL" },
keyBits: 2048,
exponent: 65537,
rawExponent: 0,
},
unique: { modulus: new Uint8Array(256) },
};
try {
serializeTpmtPublic(pub);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe(
"TPM_STRUCT.PARAMETERS_INVALID"
);
}
});
it("RSA scheme non-NULL without hashAlg raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const pub: TpmtPublic = {
type: "TPM_ALG_RSA",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters: {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_RSASSA" }, // missing hashAlg
keyBits: 2048,
exponent: 65537,
rawExponent: 0,
},
unique: { modulus: new Uint8Array(256) },
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("ECC scheme non-NULL without hashAlg raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsEccParms = {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_ECDSA" }, // missing hashAlg
curveID: "TPM_ECC_NIST_P256",
kdf: { scheme: "TPM_ALG_NULL" },
};
const pub: TpmtPublic = {
type: "TPM_ALG_ECC",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: { x: new Uint8Array(32), y: new Uint8Array(32) },
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("ECDAA scheme without count raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsEccParms = {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_ECDAA", hashAlg: "TPM_ALG_SHA256" }, // missing count
curveID: "TPM_ECC_NIST_P256",
kdf: { scheme: "TPM_ALG_NULL" },
};
const pub: TpmtPublic = {
type: "TPM_ALG_ECC",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: { x: new Uint8Array(32), y: new Uint8Array(32) },
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("ECC kdf non-NULL without hashAlg raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsEccParms = {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_NULL" },
curveID: "TPM_ECC_NIST_P256",
kdf: { scheme: "TPM_ALG_KDF1_SP800_56A" }, // missing hashAlg
};
const pub: TpmtPublic = {
type: "TPM_ALG_ECC",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: { x: new Uint8Array(32), y: new Uint8Array(32) },
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("KEYEDHASH HMAC without hashAlg raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsKeyedHashParms = {
scheme: "TPM_ALG_HMAC",
};
const pub: TpmtPublic = {
type: "TPM_ALG_KEYEDHASH",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: new Uint8Array(32),
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("KEYEDHASH XOR without hashAlg/kdf raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsKeyedHashParms = {
scheme: "TPM_ALG_XOR",
};
const pub: TpmtPublic = {
type: "TPM_ALG_KEYEDHASH",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: new Uint8Array(32),
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
it("KEYEDHASH unsupported scheme raises PARAMETERS_INVALID", {
timeout: 5000,
}, () => {
const parameters: TpmsKeyedHashParms = {
scheme: "TPM_ALG_RSA" as unknown as "TPM_ALG_NULL",
};
const pub: TpmtPublic = {
type: "TPM_ALG_KEYEDHASH",
nameAlg: "TPM_ALG_SHA256",
objectAttributes: okAttrs,
authPolicy: new Uint8Array(0),
parameters,
unique: new Uint8Array(32),
};
expect(() => serializeTpmtPublic(pub)).toThrow(/PARAMETERS_INVALID/);
});
});

View File

@@ -0,0 +1,86 @@
# `public/serialize` — `TPMT_PUBLIC` canonical serialiser
## Purpose
`serializeTpmtPublic` re-emits a parsed `TpmtPublic` value as the
TCG-canonical byte string per Library Part 2 §12.2. It is the
round-trip partner of `parseTpmtPublic`: for any input the parser
accepts, `serialize(parse(bytes))` returns the same bytes. This
property is load-bearing — `computeTpmtPublicName` per Part 1 §16
hashes the canonical bytes to produce the TPM name digest, and any
mismatch between the parser and the serialiser would silently produce a
wrong name, which in turn would silently fail every downstream
attestation check.
## Features
- Byte-identical round-trip across RSA, ECC, KEYEDHASH, and SYMCIPHER
branches.
- Preserves `rawExponent` (default-exponent normalisation is a parse-time
convenience; the wire bytes are emitted unchanged).
- Re-writes every `TPM2B_*` size prefix from the decoded payload length —
the serialiser never trusts a stored size value.
- Schema-validated input via the public API: only outputs of
`parseTpmtPublic` (which are validated) can reach the serialiser.
## Usage: Basic Example
```ts
import {
parseTpmtPublic,
serializeTpmtPublic,
} from "@suaveplan/tpm-struct/public";
const pub = parseTpmtPublic(bytes);
const reSerialised = serializeTpmtPublic(pub);
// reSerialised equals bytes byte-for-byte.
```
## Usage: Advanced Example
```ts
import {
parseTpmtPublic,
serializeTpmtPublic,
} from "@suaveplan/tpm-struct/public";
// Re-emit pub-area bytes through the canonical serialiser before signing
// or hashing. This is the canonical witness for TCG TPM 2.0 Library
// Part 1 §16 name computation.
function canonicalPubArea(rawPubArea: Uint8Array): Uint8Array {
return serializeTpmtPublic(parseTpmtPublic(rawPubArea));
}
// Property test pattern used by `property.test.ts`:
import fc from "fast-check";
fc.assert(
fc.property(fc.uint8Array({ maxLength: 600 }), (bytes) => {
// Parsing might throw; that is expected for invalid inputs.
// The serialiser is exercised only on parse-success paths.
let parsed;
try {
parsed = parseTpmtPublic(bytes);
} catch {
return true;
}
return serializeTpmtPublic(parsed).join() === bytes.join();
}),
{ numRuns: 100 }
);
```
## API Reference
- **`serializeTpmtPublic(pub: TpmtPublic): Uint8Array`** — returns a
fresh byte string. Throws `TpmStructError(PARAMETERS_INVALID)` if a
parameter substructure is internally inconsistent (e.g. non-NULL RSA
scheme without `hashAlg`).
## Implementation Notes
The serialiser dispatches on `pub.type` and walks the §12.2 field order.
The symmetric / scheme / KDF sub-writers each consult their algorithm
discriminator to decide whether trailing fields are emitted (e.g. an RSA
scheme of `TPM_ALG_NULL` emits only the 2-byte scheme id and no hashAlg).
This mirrors how the parser reads those substructures and is what makes
the round-trip property hold across the full input space.

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseTpmtPublic } from "./public.js";
import { serializeTpmtPublic } from "./serialize.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
describe("serializeTpmtPublic — round-trip", () => {
it("byte-identical RSA round-trip", { timeout: 5000 }, () => {
const modulus = new Uint8Array(256).fill(0xab);
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(modulus)
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical ECC P-256 round-trip", { timeout: 5000 }, () => {
const x = new Uint8Array(32).fill(0x11);
const y = new Uint8Array(32).fill(0x22);
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0018), // ECDSA scheme
be16(0x000b), // hashAlg SHA-256
be16(0x0003),
be16(0x0010),
tpm2b(x),
tpm2b(y)
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical KEYEDHASH NULL round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical KEYEDHASH HMAC round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0005),
be16(0x000b),
tpm2b(new Uint8Array(32).fill(0x33))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical KEYEDHASH XOR round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0008),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x000a),
be16(0x000b),
be16(0x000c),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical SYMCIPHER round-trip", { timeout: 5000 }, () => {
const bytes = concat(
be16(0x0025),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0006),
be16(128),
be16(0x0043),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical RSA with RSASSA scheme round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0014), // RSASSA
be16(0x000b), // hashAlg
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical ECC ECDAA scheme round-trip (count present)", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x001a), // ECDAA
be16(0x000b),
be16(5), // count
be16(0x0003),
be16(0x0010),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical ECC kdf non-NULL round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0023),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(0x0003),
be16(0x0020), // KDF1_SP800_56A
be16(0x000b),
tpm2b(new Uint8Array(32)),
tpm2b(new Uint8Array(32))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
it("byte-identical RSA with AES symmetric round-trip", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0006),
be16(128),
be16(0x0043),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
expect(serializeTpmtPublic(parseTpmtPublic(bytes))).toEqual(bytes);
});
});

View File

@@ -0,0 +1,141 @@
import { nameToAlgId, nameToEccCurve } from "../alg/alg.js";
import { TpmBufferWriter } from "../buffer/buffer.js";
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
import { encodeObjectAttributes } from "./object-attributes.js";
import type {
TpmsEccParms,
TpmsKeyedHashParms,
TpmsRsaParms,
TpmsSymCipherParms,
TpmtPublic,
TpmtSymDefObject,
} from "./public.types.js";
/**
* Re-emit a parsed `TpmtPublic` as a TCG-canonical byte string per Part 2
* §12.2. Round-trip property: for any byte string `b` that `parseTpmtPublic`
* accepts, `serializeTpmtPublic(parseTpmtPublic(b))` returns the same bytes.
*
* This function powers `computeTpmtPublicName` (Part 1 §16) — the canonical
* name digest is the hash of these bytes prefixed by the 2-byte name-alg.
*/
export function serializeTpmtPublic(pub: TpmtPublic): Uint8Array {
const w = new TpmBufferWriter();
w.writeUint16Be(nameToAlgId(pub.type));
w.writeUint16Be(nameToAlgId(pub.nameAlg));
w.writeUint32Be(encodeObjectAttributes(pub.objectAttributes));
w.writeTpm2b(pub.authPolicy);
if (pub.type === "TPM_ALG_RSA") {
writeRsaParms(w, pub.parameters);
w.writeTpm2b(pub.unique.modulus);
} else if (pub.type === "TPM_ALG_ECC") {
writeEccParms(w, pub.parameters);
w.writeTpm2b(pub.unique.x);
w.writeTpm2b(pub.unique.y);
} else if (pub.type === "TPM_ALG_KEYEDHASH") {
writeKeyedHashParms(w, pub.parameters);
w.writeTpm2b(pub.unique);
} else {
writeSymCipherParms(w, pub.parameters);
w.writeTpm2b(pub.unique);
}
return w.toUint8Array();
}
function writeSymDefObject(w: TpmBufferWriter, s: TpmtSymDefObject): void {
w.writeUint16Be(nameToAlgId(s.algorithm));
if (s.algorithm === "TPM_ALG_NULL") return;
if (s.keyBits === undefined || s.mode === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"TPMT_SYM_DEF_OBJECT non-NULL algorithm requires keyBits and mode"
);
}
w.writeUint16Be(s.keyBits);
w.writeUint16Be(nameToAlgId(s.mode));
}
function writeRsaParms(w: TpmBufferWriter, p: TpmsRsaParms): void {
writeSymDefObject(w, p.symmetric);
w.writeUint16Be(nameToAlgId(p.scheme.scheme));
if (p.scheme.scheme !== "TPM_ALG_NULL") {
if (p.scheme.hashAlg === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"RSA scheme non-NULL requires hashAlg"
);
}
w.writeUint16Be(nameToAlgId(p.scheme.hashAlg));
}
w.writeUint16Be(p.keyBits);
w.writeUint32Be(p.rawExponent);
}
function writeEccParms(w: TpmBufferWriter, p: TpmsEccParms): void {
writeSymDefObject(w, p.symmetric);
w.writeUint16Be(nameToAlgId(p.scheme.scheme));
if (p.scheme.scheme !== "TPM_ALG_NULL") {
if (p.scheme.hashAlg === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"ECC scheme non-NULL requires hashAlg"
);
}
w.writeUint16Be(nameToAlgId(p.scheme.hashAlg));
if (p.scheme.scheme === "TPM_ALG_ECDAA") {
if (p.scheme.count === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"ECDAA scheme requires count"
);
}
w.writeUint16Be(p.scheme.count);
}
}
w.writeUint16Be(nameToEccCurve(p.curveID));
w.writeUint16Be(nameToAlgId(p.kdf.scheme));
if (p.kdf.scheme !== "TPM_ALG_NULL") {
if (p.kdf.hashAlg === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"KDF scheme non-NULL requires hashAlg"
);
}
w.writeUint16Be(nameToAlgId(p.kdf.hashAlg));
}
}
function writeKeyedHashParms(w: TpmBufferWriter, p: TpmsKeyedHashParms): void {
w.writeUint16Be(nameToAlgId(p.scheme));
if (p.scheme === "TPM_ALG_NULL") return;
if (p.scheme === "TPM_ALG_HMAC") {
if (p.hashAlg === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"HMAC keyedhash requires hashAlg"
);
}
w.writeUint16Be(nameToAlgId(p.hashAlg));
return;
}
if (p.scheme === "TPM_ALG_XOR") {
if (p.hashAlg === undefined || p.kdf === undefined) {
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
"XOR keyedhash requires hashAlg and kdf"
);
}
w.writeUint16Be(nameToAlgId(p.hashAlg));
w.writeUint16Be(nameToAlgId(p.kdf));
return;
}
throw tpmStructError(
TPM_STRUCT_CODES.PARAMETERS_INVALID,
`TPMS_KEYEDHASH_PARMS unsupported scheme ${p.scheme}`
);
}
function writeSymCipherParms(w: TpmBufferWriter, p: TpmsSymCipherParms): void {
writeSymDefObject(w, p.sym);
}

View File

@@ -0,0 +1,16 @@
export {
ClockInfoSchema,
ObjectAttributesSchema,
Tpm2bSchema,
TpmAlgIdSchema,
TpmEccCurveSchema,
TpmsAttestSchema,
TpmsCertifyInfoSchema,
TpmsEccParmsSchema,
TpmsKeyedHashParmsSchema,
TpmsRsaParmsSchema,
TpmsSymCipherParmsSchema,
TpmtPublicSchema,
validateTpmsAttest,
validateTpmtPublic,
} from "./schemas.js";

View File

@@ -0,0 +1,84 @@
# `schemas` — Zod boundary schemas
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `schemas` module exports a Zod schema for every public return type
of `@suaveplan/tpm-struct`. Every public parser validates its
constructed result through the matching schema BEFORE returning. A
schema-validation failure raises `TpmStructError(TPM_STRUCT.MALFORMED)`
with the Zod issue array attached to `cause.detail.issues`. The schemas
are the last line of defence against parser bugs: even if the byte
decoder produces a misshaped object (e.g. due to a future refactor),
the boundary catches the mismatch before the value escapes into a
consumer.
## Features
- Schema coverage for: `TpmsAttest`, `TpmtPublic`, the four parameter
substructures (`TpmsRsaParms`, `TpmsEccParms`, `TpmsKeyedHashParms`,
`TpmsSymCipherParms`), `TpmsCertifyInfo`, `ClockInfo`, `Tpm2b`,
`TpmAlgId`, `TpmEccCurve`, and `ObjectAttributes`.
- `TpmtPublicSchema` and `TpmsAttestSchema` are discriminated unions on
the public `type` field so consumers can narrow the parsed result
exhaustively without manual `if/else` chains.
- `validateTpmtPublic` / `validateTpmsAttest` wrap `safeParse` and
rethrow Zod failures as typed `TpmStructError` instances.
## Usage: Basic Example
```ts
import { TpmtPublicSchema } from "@suaveplan/tpm-struct/schemas";
const parsed = TpmtPublicSchema.parse(value); // throws ZodError on failure
```
## Usage: Advanced Example
```ts
import {
parseTpmtPublic,
TpmtPublicSchema,
} from "@suaveplan/tpm-struct";
// Re-validate a deserialised JSON payload at the consumer boundary
// (e.g. an admin tool that snapshots parsed TpmtPublic to JSON for
// inspection). Note that JSON cannot round-trip `Uint8Array` directly —
// the consumer must rehydrate `Uint8Array` slots before parsing.
function rehydrateAndValidate(jsonShape: unknown): ReturnType<typeof parseTpmtPublic> {
const candidate = rehydrateBytes(jsonShape);
// Validate with the schema; throws ZodError on failure (the
// package's own error wrapping is reserved for parser-emitted
// failures, not admin-tool ingestion).
return TpmtPublicSchema.parse(candidate);
}
declare function rehydrateBytes(v: unknown): unknown;
```
## API Reference
- **`TpmtPublicSchema`** — discriminated union on `type` with four
branches.
- **`TpmsAttestSchema`** — discriminated union on `type` with eight
branches.
- **`TpmsRsaParmsSchema` / `TpmsEccParmsSchema` /
`TpmsKeyedHashParmsSchema` / `TpmsSymCipherParmsSchema`** — parameter
substructures.
- **`TpmsCertifyInfoSchema` / `ClockInfoSchema`** — common substructures.
- **`Tpm2bSchema`** — runtime check that a value is a `Uint8Array`.
- **`TpmAlgIdSchema` / `TpmEccCurveSchema`** — string regex for symbolic
names.
- **`ObjectAttributesSchema`** — `{raw, flags}` shape.
- **`validateTpmtPublic(value)` / `validateTpmsAttest(value)`** —
throwing wrappers raising `TPM_STRUCT.MALFORMED`.
## Implementation Notes
The discriminated-union schemas use `z.discriminatedUnion("type", [...])`
so Zod evaluates only the matching branch. This keeps validation
proportional to the size of the matching branch rather than the union;
on the hot path that matters because every successful parse runs the
matching schema once before returning.

View File

@@ -0,0 +1,233 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { TpmStructError } from "../errors/error-codes.js";
import { parseTpmtPublic } from "../public/public.js";
import {
ClockInfoSchema,
ObjectAttributesSchema,
Tpm2bSchema,
TpmAlgIdSchema,
TpmEccCurveSchema,
TpmsAttestSchema,
TpmsCertifyInfoSchema,
TpmsEccParmsSchema,
TpmsKeyedHashParmsSchema,
TpmsRsaParmsSchema,
TpmsSymCipherParmsSchema,
TpmtPublicSchema,
validateTpmsAttest,
validateTpmtPublic,
} from "./schemas.js";
function concat(...parts: Uint8Array[]): Uint8Array {
let n = 0;
for (const p of parts) n += p.length;
const o = new Uint8Array(n);
let off = 0;
for (const p of parts) {
o.set(p, off);
off += p.length;
}
return o;
}
function be16(n: number): Uint8Array {
return new Uint8Array([(n >>> 8) & 0xff, n & 0xff]);
}
function be32(n: number): Uint8Array {
return new Uint8Array([
(n >>> 24) & 0xff,
(n >>> 16) & 0xff,
(n >>> 8) & 0xff,
n & 0xff,
]);
}
function tpm2b(b: Uint8Array): Uint8Array {
return concat(be16(b.length), b);
}
describe("schemas — known-good", () => {
it("TpmtPublicSchema accepts parsed RSA fixture", {
timeout: 5000,
}, () => {
const bytes = concat(
be16(0x0001),
be16(0x000b),
be32(0x00040472),
tpm2b(new Uint8Array(0)),
be16(0x0010),
be16(0x0010),
be16(2048),
be32(0),
tpm2b(new Uint8Array(256))
);
const pub = parseTpmtPublic(bytes);
expect(() => TpmtPublicSchema.parse(pub)).not.toThrow();
});
it("Tpm2bSchema requires Uint8Array", { timeout: 5000 }, () => {
expect(Tpm2bSchema.safeParse(new Uint8Array(4)).success).toBe(true);
expect(Tpm2bSchema.safeParse([1, 2, 3]).success).toBe(false);
});
it("TpmAlgIdSchema enforces TPM_ALG_ prefix", { timeout: 5000 }, () => {
expect(TpmAlgIdSchema.safeParse("TPM_ALG_SHA256").success).toBe(true);
expect(TpmAlgIdSchema.safeParse("SHA256").success).toBe(false);
});
it("TpmEccCurveSchema enforces TPM_ECC_ prefix", {
timeout: 5000,
}, () => {
expect(TpmEccCurveSchema.safeParse("TPM_ECC_NIST_P256").success).toBe(
true
);
expect(TpmEccCurveSchema.safeParse("NIST_P256").success).toBe(false);
});
it("ObjectAttributesSchema requires raw + flags", {
timeout: 5000,
}, () => {
const v = {
raw: 0,
flags: {
fixedTPM: false,
stClear: false,
fixedParent: false,
sensitiveDataOrigin: false,
userWithAuth: false,
adminWithPolicy: false,
noDA: false,
encryptedDuplication: false,
restricted: false,
decrypt: false,
sign: false,
},
};
expect(ObjectAttributesSchema.safeParse(v).success).toBe(true);
});
it("ClockInfoSchema validates structure", { timeout: 5000 }, () => {
expect(
ClockInfoSchema.safeParse({
clock: 0n,
resetCount: 0,
restartCount: 0,
safe: false,
}).success
).toBe(true);
});
it("TpmsCertifyInfoSchema requires byte fields", {
timeout: 5000,
}, () => {
expect(
TpmsCertifyInfoSchema.safeParse({
name: new Uint8Array(2),
qualifiedName: new Uint8Array(2),
}).success
).toBe(true);
});
it("parameter schemas accept canonical shapes", {
timeout: 5000,
}, () => {
expect(
TpmsRsaParmsSchema.safeParse({
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_NULL" },
keyBits: 2048,
exponent: 65537,
rawExponent: 0,
}).success
).toBe(true);
expect(
TpmsEccParmsSchema.safeParse({
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_NULL" },
curveID: "TPM_ECC_NIST_P256",
kdf: { scheme: "TPM_ALG_NULL" },
}).success
).toBe(true);
expect(
TpmsKeyedHashParmsSchema.safeParse({
scheme: "TPM_ALG_NULL",
}).success
).toBe(true);
expect(
TpmsSymCipherParmsSchema.safeParse({
sym: {
algorithm: "TPM_ALG_AES",
keyBits: 128,
mode: "TPM_ALG_CFB",
},
}).success
).toBe(true);
});
});
describe("schemas — rejection paths", () => {
it("TpmtPublicSchema rejects missing nameAlg with MALFORMED", {
timeout: 5000,
}, () => {
const bad = {
type: "TPM_ALG_RSA" as const,
// nameAlg missing
objectAttributes: { raw: 0, flags: {} as never },
authPolicy: new Uint8Array(0),
parameters: {
symmetric: { algorithm: "TPM_ALG_NULL" },
scheme: { scheme: "TPM_ALG_NULL" },
keyBits: 2048,
exponent: 65537,
rawExponent: 0,
},
unique: { modulus: new Uint8Array(256) },
};
try {
validateTpmtPublic(bad);
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.MALFORMED");
const detail = (
e as unknown as { cause: { detail: { issues: unknown[] } } }
).cause.detail;
expect(Array.isArray(detail.issues)).toBe(true);
}
});
it("validateTpmsAttest rejects shape mismatches", {
timeout: 5000,
}, () => {
try {
validateTpmsAttest({ type: "BOGUS", magic: 0xff544347 });
expect.unreachable();
} catch (e) {
expect((e as TpmStructError).code).toBe("TPM_STRUCT.MALFORMED");
}
});
it("TpmsAttestSchema accepts canonical certify shape", {
timeout: 5000,
}, () => {
expect(
TpmsAttestSchema.safeParse({
type: "TPM_ST_ATTEST_CERTIFY",
magic: 0xff544347,
qualifiedSigner: new Uint8Array(34),
extraData: new Uint8Array(16),
clockInfo: {
clock: 0n,
resetCount: 0,
restartCount: 0,
safe: true,
},
firmwareVersion: 0n,
attested: {
name: new Uint8Array(34),
qualifiedName: new Uint8Array(34),
},
}).success
).toBe(true);
});
});

View File

@@ -0,0 +1,283 @@
// schema: boundary-only — consumers may invoke `.parse(...)` externally;
// the package itself validates via `validateTpmtPublic` / `validateTpmsAttest`.
import z from "zod";
import { TPM_STRUCT_CODES, tpmStructError } from "../errors/error-codes.js";
/**
* Zod boundary schemas for every public return type of `@suaveplan/tpm-struct`.
*
* Every public parser validates its constructed result through the matching
* schema BEFORE returning. A schema rejection raises a
* `TpmStructError(TPM_STRUCT.MALFORMED)` whose `cause.detail.issues` carries
* the raw Zod issue list. The boundary catches parser bugs before they
* propagate misshaped values to consumers.
*/
const u8Array = z.custom<Uint8Array>(
(v) => v instanceof Uint8Array,
"Uint8Array required"
);
export const Tpm2bSchema = u8Array;
export const TpmAlgIdSchema = z
.string()
.regex(/^TPM_ALG_[A-Z0-9_]+$/u, "TPM_ALG_<NAME>");
export const TpmEccCurveSchema = z
.string()
.regex(/^TPM_ECC_[A-Z0-9_]+$/u, "TPM_ECC_<NAME>");
export const ObjectAttributesSchema = z.object({
raw: z.number().int().nonnegative(),
flags: z.object({
fixedTPM: z.boolean(),
stClear: z.boolean(),
fixedParent: z.boolean(),
sensitiveDataOrigin: z.boolean(),
userWithAuth: z.boolean(),
adminWithPolicy: z.boolean(),
noDA: z.boolean(),
encryptedDuplication: z.boolean(),
restricted: z.boolean(),
decrypt: z.boolean(),
sign: z.boolean(),
}),
});
const symDefObjectSchema = z.object({
algorithm: TpmAlgIdSchema,
keyBits: z.number().int().nonnegative().optional(),
mode: TpmAlgIdSchema.optional(),
});
export const TpmsRsaParmsSchema = z.object({
symmetric: symDefObjectSchema,
scheme: z.object({
scheme: TpmAlgIdSchema,
hashAlg: TpmAlgIdSchema.optional(),
}),
keyBits: z.number().int().positive(),
exponent: z.number().int().positive(),
rawExponent: z.number().int().nonnegative(),
});
export const TpmsEccParmsSchema = z.object({
symmetric: symDefObjectSchema,
scheme: z.object({
scheme: TpmAlgIdSchema,
hashAlg: TpmAlgIdSchema.optional(),
count: z.number().int().nonnegative().optional(),
}),
curveID: TpmEccCurveSchema,
kdf: z.object({
scheme: TpmAlgIdSchema,
hashAlg: TpmAlgIdSchema.optional(),
}),
});
export const TpmsKeyedHashParmsSchema = z.object({
scheme: TpmAlgIdSchema,
hashAlg: TpmAlgIdSchema.optional(),
kdf: TpmAlgIdSchema.optional(),
});
export const TpmsSymCipherParmsSchema = z.object({
sym: symDefObjectSchema,
});
const commonPublic = {
nameAlg: TpmAlgIdSchema,
objectAttributes: ObjectAttributesSchema,
authPolicy: u8Array,
} as const;
export const TpmtPublicSchema = z.discriminatedUnion("type", [
z.object({
...commonPublic,
type: z.literal("TPM_ALG_RSA"),
parameters: TpmsRsaParmsSchema,
unique: z.object({ modulus: u8Array }),
}),
z.object({
...commonPublic,
type: z.literal("TPM_ALG_ECC"),
parameters: TpmsEccParmsSchema,
unique: z.object({ x: u8Array, y: u8Array }),
}),
z.object({
...commonPublic,
type: z.literal("TPM_ALG_KEYEDHASH"),
parameters: TpmsKeyedHashParmsSchema,
unique: u8Array,
}),
z.object({
...commonPublic,
type: z.literal("TPM_ALG_SYMCIPHER"),
parameters: TpmsSymCipherParmsSchema,
unique: u8Array,
}),
]);
export const ClockInfoSchema = z.object({
clock: z.bigint().nonnegative(),
resetCount: z.number().int().nonnegative(),
restartCount: z.number().int().nonnegative(),
safe: z.boolean(),
});
export const TpmsCertifyInfoSchema = z.object({
name: u8Array,
qualifiedName: u8Array,
});
const TpmsQuoteInfoSchema = z.object({
pcrSelect: z.array(
z.object({
hash: TpmAlgIdSchema,
sizeOfSelect: z.number().int().nonnegative(),
pcrSelect: u8Array,
})
),
pcrDigest: u8Array,
});
const TpmsSessionAuditInfoSchema = z.object({
exclusiveSession: z.boolean(),
sessionDigest: u8Array,
});
const TpmsCommandAuditInfoSchema = z.object({
auditCounter: z.bigint().nonnegative(),
digestAlg: TpmAlgIdSchema,
auditDigest: u8Array,
commandDigest: u8Array,
});
const TpmsTimeInfoSchema = z.object({
time: z.bigint().nonnegative(),
clockInfo: ClockInfoSchema,
});
const TpmsTimeAttestInfoSchema = z.object({
time: TpmsTimeInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
});
const TpmsCreationInfoSchema = z.object({
objectName: u8Array,
creationHash: u8Array,
});
const TpmsNvCertifyInfoSchema = z.object({
indexName: u8Array,
offset: z.number().int().nonnegative(),
nvContents: u8Array,
});
const TpmsNvDigestCertifyInfoSchema = z.object({
indexName: u8Array,
nvDigest: u8Array,
});
export const TpmsAttestSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("TPM_ST_ATTEST_CERTIFY"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsCertifyInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_QUOTE"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsQuoteInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_SESSION_AUDIT"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsSessionAuditInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_COMMAND_AUDIT"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsCommandAuditInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_TIME"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsTimeAttestInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_CREATION"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsCreationInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_NV"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsNvCertifyInfoSchema,
}),
z.object({
type: z.literal("TPM_ST_ATTEST_NV_DIGEST"),
magic: z.literal(0xff544347),
qualifiedSigner: u8Array,
extraData: u8Array,
clockInfo: ClockInfoSchema,
firmwareVersion: z.bigint().nonnegative(),
attested: TpmsNvDigestCertifyInfoSchema,
}),
]);
/**
* Validate a parsed `TpmtPublic` value through `TpmtPublicSchema`. On
* failure, raise `TPM_STRUCT.MALFORMED` with the Zod issues on `cause.detail`.
*/
export function validateTpmtPublic(value: unknown): void {
const r = TpmtPublicSchema.safeParse(value);
if (!r.success) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
"TpmtPublic boundary schema rejected parser output",
{ issues: r.error.issues }
);
}
}
/** Validate a parsed `TpmsAttest` through `TpmsAttestSchema`. */
export function validateTpmsAttest(value: unknown): void {
const r = TpmsAttestSchema.safeParse(value);
if (!r.success) {
throw tpmStructError(
TPM_STRUCT_CODES.MALFORMED,
"TpmsAttest boundary schema rejected parser output",
{ issues: r.error.issues }
);
}
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,51 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/tpm-struct",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"buffer/index": "src/buffer/index.ts",
"alg/index": "src/alg/index.ts",
"public/index": "src/public/index.ts",
"attest/index": "src/attest/index.ts",
"name/index": "src/name/index.ts",
"extra-data/index": "src/extra-data/index.ts",
"schemas/index": "src/schemas/index.ts",
"errors/index": "src/errors/index.ts",
"diagnostic/index": "src/diagnostic/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/types",
"tslib",
"zod",
"@opentelemetry/api",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,6 @@
import { createTestConfig } from "@suaveplan/testing/config";
export default createTestConfig({
testTimeout: 30_000,
additionalCoverageExcludes: ["src/__fixtures__/**", "src/__benchmarks__/**"],
});