Initial Commit
This commit is contained in:
133
packages/security-stack/security/tpm-struct/README.md
Normal file
133
packages/security-stack/security/tpm-struct/README.md
Normal 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.
|
||||
|
||||
80
packages/security-stack/security/tpm-struct/package.json
Normal file
80
packages/security-stack/security/tpm-struct/package.json
Normal 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"]
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
93
packages/security-stack/security/tpm-struct/src/alg/alg.md
Normal file
93
packages/security-stack/security/tpm-struct/src/alg/alg.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
152
packages/security-stack/security/tpm-struct/src/alg/alg.ts
Normal file
152
packages/security-stack/security/tpm-struct/src/alg/alg.ts
Normal 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];
|
||||
}
|
||||
@@ -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";
|
||||
12
packages/security-stack/security/tpm-struct/src/alg/index.ts
Normal file
12
packages/security-stack/security/tpm-struct/src/alg/index.ts
Normal 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";
|
||||
112
packages/security-stack/security/tpm-struct/src/attest/attest.md
Normal file
112
packages/security-stack/security/tpm-struct/src/attest/attest.md
Normal 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.
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
244
packages/security-stack/security/tpm-struct/src/attest/attest.ts
Normal file
244
packages/security-stack/security/tpm-struct/src/attest/attest.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
261
packages/security-stack/security/tpm-struct/src/buffer/buffer.ts
Normal file
261
packages/security-stack/security/tpm-struct/src/buffer/buffer.ts
Normal 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 };
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export { TpmBufferReader, TpmBufferWriter } from "./buffer.js";
|
||||
export type {
|
||||
Tpm2bReadResult,
|
||||
TpmReaderOptions,
|
||||
TpmWriterOptions,
|
||||
} from "./buffer.types.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { toDiagnostic } from "./diagnostic.js";
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
TPM_STRUCT_CODES,
|
||||
TpmStructError,
|
||||
type TpmStructErrorCode,
|
||||
tpmStructError,
|
||||
} from "./error-codes.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { extraDataMatches } from "./extra-data.js";
|
||||
export type { ConstantTimeEqual } from "./extra-data.types.js";
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
119
packages/security-stack/security/tpm-struct/src/index.ts
Normal file
119
packages/security-stack/security/tpm-struct/src/index.ts
Normal 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";
|
||||
@@ -0,0 +1,2 @@
|
||||
export { computeTpmtPublicName } from "./name.js";
|
||||
export type { HashLike } from "./name.types.js";
|
||||
86
packages/security-stack/security/tpm-struct/src/name/name.md
Normal file
86
packages/security-stack/security/tpm-struct/src/name/name.md
Normal 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.
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
58
packages/security-stack/security/tpm-struct/src/name/name.ts
Normal file
58
packages/security-stack/security/tpm-struct/src/name/name.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export {
|
||||
type ParseKind,
|
||||
type ParseOutcome,
|
||||
type PublicBranch,
|
||||
recordAlgObserved,
|
||||
recordAttestDuration,
|
||||
recordNameDuration,
|
||||
recordParseDuration,
|
||||
recordParseError,
|
||||
recordParseTotal,
|
||||
recordPublicDuration,
|
||||
resetTpmStructMetricsForTesting,
|
||||
} from "./metrics.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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 }
|
||||
);
|
||||
});
|
||||
});
|
||||
102
packages/security-stack/security/tpm-struct/src/public/public.md
Normal file
102
packages/security-stack/security/tpm-struct/src/public/public.md
Normal 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.
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
236
packages/security-stack/security/tpm-struct/src/public/public.ts
Normal file
236
packages/security-stack/security/tpm-struct/src/public/public.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export {
|
||||
ClockInfoSchema,
|
||||
ObjectAttributesSchema,
|
||||
Tpm2bSchema,
|
||||
TpmAlgIdSchema,
|
||||
TpmEccCurveSchema,
|
||||
TpmsAttestSchema,
|
||||
TpmsCertifyInfoSchema,
|
||||
TpmsEccParmsSchema,
|
||||
TpmsKeyedHashParmsSchema,
|
||||
TpmsRsaParmsSchema,
|
||||
TpmsSymCipherParmsSchema,
|
||||
TpmtPublicSchema,
|
||||
validateTpmsAttest,
|
||||
validateTpmtPublic,
|
||||
} from "./schemas.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
51
packages/security-stack/security/tpm-struct/vite.config.ts
Normal file
51
packages/security-stack/security/tpm-struct/vite.config.ts
Normal 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:/,
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { createTestConfig } from "@suaveplan/testing/config";
|
||||
|
||||
export default createTestConfig({
|
||||
testTimeout: 30_000,
|
||||
additionalCoverageExcludes: ["src/__fixtures__/**", "src/__benchmarks__/**"],
|
||||
});
|
||||
Reference in New Issue
Block a user