Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
# `@suaveplan/tpm-struct` — TPM 2.0 attestation-structure parser
**Package**: `@suaveplan/tpm-struct` (NEW)
**Status**: PROPOSED
**Type**: new-package (genesis-original; greenfield — no upstream port)
**Date**: 2026-05-11
**Path**: `packages/security/tpm-struct/`
## Why
`@suaveplan/webauthn`'s `tpm` attestation-statement verifier (W3C WebAuthn L3 §8.3 — _TPM Attestation Statement Format_) is blocked on a universal-tier TPM 2.0 attestation-structure parser. The verifier MUST decode `TPMS_ATTEST` from the `attestationData` field (TCG TPM 2.0 Library Part 2 §10.12), MUST decode `TPMT_PUBLIC` from the `pubArea` field (TCG Part 2 §12.2), MUST compute the canonical TPM name digest of that public area (TCG Part 1 §16) so it can match it against `authData.attestedCredentialData.credentialPublicKey`, and MUST verify that `extraData` from the attest blob equals the `attToBeSigned` digest the relying party recomputes (WebAuthn L3 §8.3 step 8). External libraries (`tpm-js`, `node-tss`, `tss.js`) either drag in a TPM driver, target Node-only, ship CommonJS with no type information, or implement only a sliver of Part 2 §10/§11/§12. An in-house, spec-cited, zero-dep, buffer-only parser keeps `webauthn` universal-tier and gives every future TPM 2.0 attestation context (AIK enrollment per TCG _Credential Profile EK 2.0_, remote attestation, vTPM provisioning) one canonical implementation with one error registry, one OTel surface, and one set of test vectors.
The same parser is webauthn-agnostic. The public surface decodes TPM 2.0 byte strings; it neither imports nor knows about WebAuthn's `attStmt` map, `authData`, or COSE keys.
## What changes
### New package: `@suaveplan/tpm-struct`
- ADDED: `packages/security/tpm-struct/` package scaffold (`package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`, `README.md`).
- ADDED: `src/buffer/buffer.ts` — `TpmBufferReader` (`readUint8`, `readUint16Be`, `readUint32Be`, `readUint64Be: bigint`, `readBytes(n)`, `readTpm2b(): Uint8Array`, `peek(n)`, `remaining()`, `assertEnd()`) and `TpmBufferWriter` (matching write surface plus `writeTpm2b(bytes)`, `toUint8Array()`) per TCG Part 2 §10.1 octet-stream conventions; every read past `bytes.length` raises `TPM_STRUCT.TRUNCATED` carrying cursor + requested-length in `cause`.
- ADDED: `src/buffer/buffer.types.ts` — `TpmReaderOptions`, `TpmWriterOptions`, `Tpm2bReadResult`.
- ADDED: `src/alg/alg.ts` — `TPM_ALG` frozen registry mirroring TCG Part 2 §11.1 (`NULL = 0x0010`, `RSA = 0x0001`, `SHA1 = 0x0004`, `HMAC = 0x0005`, `AES = 0x0006`, `KEYEDHASH = 0x0008`, `SHA256 = 0x000b`, `SHA384 = 0x000c`, `SHA512 = 0x000d`, `XOR = 0x000a`, `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`, `CTR = 0x0040`, `OFB = 0x0041`, `CBC = 0x0042`, `CFB = 0x0043`, `ECB = 0x0044`); `TPM_ECC_CURVE` frozen registry (`NIST_P256 = 0x0003`, `NIST_P384 = 0x0004`, `NIST_P521 = 0x0005`, `BN_P256 = 0x0010`, `BN_P638 = 0x0011`, `SM2_P256 = 0x0020`); `algIdToName(id)` returns the symbolic name; unknown IDs raise `TPM_STRUCT.ALG_UNSUPPORTED`. `eccCurveToName` does the same for curves; unknown raises `TPM_STRUCT.CURVE_UNSUPPORTED`.
- ADDED: `src/alg/alg.types.ts` — `TpmAlgId`, `TpmEccCurve` literal unions.
- ADDED: `src/public/public.ts` — `parseTpmtPublic(bytes): TpmtPublic` per TCG Part 2 §12.2; reads `type` (TPMI_ALG_PUBLIC), `nameAlg` (TPMI_ALG_HASH), `objectAttributes` (32-bit TPMA_OBJECT per §8.3 — exposes raw `uint32` and named-flag accessor), `authPolicy` (TPM2B_DIGEST), `parameters` (`TPMS_RSA_PARMS` per §12.2.3.5 with `symmetric` `TPMT_SYM_DEF_OBJECT`, `scheme` `TPMT_RSA_SCHEME`, `keyBits`, `exponent` (uint32, `0` ⇒ default 65537)), `parameters` (`TPMS_ECC_PARMS` per §12.2.3.6 with `symmetric`, `scheme` `TPMT_ECC_SCHEME`, `curveID` `TPMI_ECC_CURVE`, `kdf` `TPMT_KDF_SCHEME`), `unique` (`TPM2B_PUBLIC_KEY_RSA` for RSA or `TPMS_ECC_POINT` `{ x, y }` of two `TPM2B_ECC_PARAMETER`s for ECC). KEYEDHASH and SYMCIPHER branches decode their `TPMS_*_PARMS` per §12.2.3.3 / §12.2.3.4 and return the same discriminated-union shape.
- ADDED: `src/public/public.types.ts` — `TpmtPublic` discriminator on `type`; `TpmsRsaParms`, `TpmsEccParms`, `TpmsKeyedHashParms`, `TpmsSymCipherParms`, `TpmsEccPoint`, `ObjectAttributes` (typed flag struct + raw `uint32` slot).
- ADDED: `src/public/serialize.ts` — `serializeTpmtPublic(pub): Uint8Array` produces byte-identical re-serialisation of any `parseTpmtPublic` output; powers `computeTpmtPublicName`.
- ADDED: `src/attest/attest.ts` — `parseTpmsAttest(bytes): TpmsAttest` per TCG Part 2 §10.12; validates 4-byte magic `0xff544347` (`TPM_GENERATED_VALUE`, ASCII "TCG\xff" in big-endian); reads `type` (`TPMI_ST_ATTEST` — `TPM_ST_ATTEST_CERTIFY = 0x8017`, `TPM_ST_ATTEST_QUOTE = 0x8018`, `TPM_ST_ATTEST_SESSION_AUDIT = 0x8016`, `TPM_ST_ATTEST_COMMAND_AUDIT = 0x8015`, `TPM_ST_ATTEST_TIME = 0x8019`, `TPM_ST_ATTEST_CREATION = 0x801a`, `TPM_ST_ATTEST_NV = 0x8014`, `TPM_ST_ATTEST_NV_DIGEST = 0x801c`), `qualifiedSigner` (TPM2B_NAME), `extraData` (TPM2B_DATA), `clockInfo` (17 bytes: uint64 clock, uint32 resetCount, uint32 restartCount, uint8 safe), `firmwareVersion` (uint64), and dispatches `attested` to the matching `TPMU_ATTEST` member (per §10.11). For `TPM_ST_ATTEST_CERTIFY` the `attested` is `TPMS_CERTIFY_INFO` (§10.5) — `name` + `qualifiedName` both `TPM2B_NAME`.
- ADDED: `src/attest/attest.types.ts` — `TpmsAttest` discriminator on `type`; `TpmsCertifyInfo`, `TpmsQuoteInfo`, `TpmsSessionAuditInfo`, `TpmsCommandAuditInfo`, `TpmsTimeAttestInfo`, `TpmsCreationInfo`, `TpmsNvCertifyInfo`, `TpmsNvDigestCertifyInfo`, `ClockInfo`.
- ADDED: `src/attest/serialize.ts` — `serializeTpmsAttest(att): Uint8Array` produces byte-identical re-serialisation of any `parseTpmsAttest` output.
- ADDED: `src/name/name.ts` — `computeTpmtPublicName(pub, hash): Uint8Array` per TCG Part 1 §16. Output = `nameAlg (2 BE bytes) || H_nameAlg(serializeTpmtPublic(pub))`. Hash backend injected via `HashLike` (caller supplies a SHA-1/256/384/512 implementation matching `nameAlg`; the package itself imports no hash code). `nameAlg === TPM_ALG_NULL` is rejected with `TPM_STRUCT.NAME_ALG_NULL`.
- ADDED: `src/name/name.types.ts` — `HashLike` interface (`digest(bytes: Uint8Array): Uint8Array`).
- ADDED: `src/extra-data/extra-data.ts` — `extraDataMatches(actual: Uint8Array, expected: Uint8Array, equal: ConstantTimeEqual): boolean`; the comparison function is injected to keep the package zero-runtime-dep on crypto-utils.
- ADDED: `src/extra-data/extra-data.types.ts` — `ConstantTimeEqual` function signature.
- ADDED: `src/schemas/schemas.ts` — Zod boundary schemas: `TpmsAttestSchema`, `TpmtPublicSchema`, `TpmsRsaParmsSchema`, `TpmsEccParmsSchema`, `TpmsKeyedHashParmsSchema`, `TpmsSymCipherParmsSchema`, `TpmsCertifyInfoSchema`, `ClockInfoSchema`, `Tpm2bSchema`, `TpmAlgIdSchema`, `TpmEccCurveSchema`, `ObjectAttributesSchema`. Every public parser validates output through the matching schema; failure raises `TPM_STRUCT.MALFORMED` with Zod issues attached as `cause.issues`.
- ADDED: `src/errors/error-codes.ts` — `defineCodes("TPM_STRUCT", [...])` registry: `MAGIC_INVALID`, `TRUNCATED`, `ALG_UNSUPPORTED`, `CURVE_UNSUPPORTED`, `TYPE_MISMATCH`, `MALFORMED`, `NAME_ALG_NULL`, `ATTEST_TYPE_UNSUPPORTED`, `PARAMETERS_INVALID`, `OBJECT_ATTRIBUTES_RESERVED_NONZERO`. `TpmStructError extends SuaveplanError<TpmStructErrorCode>`.
- ADDED: `src/observability/metrics.ts` — histograms `tpm_struct.parse_tpms_attest_duration_ms{outcome}`, `tpm_struct.parse_tpmt_public_duration_ms{type,outcome}`, `tpm_struct.compute_name_duration_ms{name_alg}`; counters `tpm_struct.parse_total{kind,outcome}`, `tpm_struct.parse_errors_total{code}`, `tpm_struct.alg_total{alg}`. All attributes low-cardinality enums.
- ADDED: `src/diagnostic/diagnostic.ts` — `toDiagnostic(value: TpmsAttest | TpmtPublic): string` human-readable dump with `TPM_ALG` symbolic names inlined; debug-only; never invoked from production hot paths.
- ADDED: `src/__fixtures__/webauthn-tpm-vector.ts` — known-good real-world WebAuthn `tpm`-format attestation byte strings (`attestationData` + `pubArea`) plus expected parsed structures and expected name digest. Used by the WebAuthn integration scenario.
- ADDED: `src/__benchmarks__/parse-tpms-attest.bench.ts`, `parse-tpmt-public-rsa2048.bench.ts`, `parse-tpmt-public-ecc-p256.bench.ts`, `compute-name.bench.ts` — mitata suites.
- ADDED: `src/index.ts` — explicit named re-exports; no `export *` (per `openspec/AGENTS.md` §6a).
### Subpath exports
- `.` — root barrel: `parseTpmsAttest`, `parseTpmtPublic`, `computeTpmtPublicName`, `extraDataMatches`, `TPM_ALG`, `TPM_ECC_CURVE`, error codes.
- `./buffer` — `TpmBufferReader`, `TpmBufferWriter`, types.
- `./alg` — `TPM_ALG`, `TPM_ECC_CURVE`, `algIdToName`, `eccCurveToName`, literal-union types.
- `./public` — `parseTpmtPublic`, `serializeTpmtPublic`, `TpmtPublic`, parameter / point / attribute types.
- `./attest` — `parseTpmsAttest`, `serializeTpmsAttest`, `TpmsAttest`, every `TPMU_ATTEST` member type, `ClockInfo`.
- `./name` — `computeTpmtPublicName`, `HashLike`.
- `./extra-data` — `extraDataMatches`, `ConstantTimeEqual`.
- `./schemas` — Zod boundary schemas.
- `./errors` — `TPM_STRUCT_CODES`, `TpmStructError`, `TpmStructErrorCode`.
- `./diagnostic` — `toDiagnostic`.
### Dependencies
- `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError` base.
- `@suaveplan/types workspace:^` — shared interface contracts.
- `@opentelemetry/api catalog:observability` — histograms + counters.
- `tslib catalog:build`.
- `zod catalog:forms` — option + result boundary validation.
- **No** `node:*` imports, **no** `tpm-js` / `node-tss` / `tss.js` / `@noble/*` / `@stablelib/*`. Pure TypeScript over `Uint8Array` + `DataView`.
## Out of scope
- **TPM I/O / TPM commands** — no `TPM2_*` command marshalling, no TSS device driver, no `/dev/tpmrm0` or Windows `Tbsi_*` integration, no TPM2_TOOLS shell wrapping. _Justification_: this package is buffer-only; TPM device interaction is the job of a TSS-shaped package that is not currently planned. Folding it in would require a Node-only target and pull in driver-level concerns that no current consumer needs.
- **TPM key creation / sealing / unsealing** — no `TPM2_Create`, no `TPM2_Load`, no auth-session orchestration. _Justification_: this is a parser/serialiser, not a key manager.
- **TPM session management / HMAC / parameter encryption** — no session contexts, no encryption sessions, no parameter encryption per TCG Part 1 §19. _Justification_: same — buffer parsing only.
- **AIK / EK credential validation chain** — TCG _Credential Profile_ EK 2.0 procedures (EK certificate verification, manufacturer-CA pin) are the WebAuthn verifier's job; this package returns the parsed structures it needs to do that work.
- **TPM 1.2 structures** — TPM 1.2 (`TPM_PUBKEY`, `TPM_QUOTE_INFO`) is out of scope; the WebAuthn `tpm` format mandates TPM 2.0.
- **COSE / CBOR / WebAuthn-format-specific concerns** — `@suaveplan/cbor` handles CBOR, `@suaveplan/webauthn` handles the `attStmt` map structure. This package never sees CBOR bytes.
## Source mapping
This package is genesis-original; no `../suaveplan/enterprise/tpm-struct.md` predecessor exists. The source-of-record is the TCG TPM 2.0 specification (Library Part 2 — Structures, revision 1.59, plus Library Part 1 — Architecture for the name digest) and W3C WebAuthn Level 3 for the consumer contract.
| Requirement | Source | Notes |
|---|---|---|
| `parseTpmsAttest` magic check (`0xff544347` / "TCG\xff") | TCG Part 2 §10.12 `TPMS_ATTEST.magic` + §6.2 `TPM_GENERATED_VALUE` | Big-endian uint32; mismatch ⇒ `MAGIC_INVALID` |
| `parseTpmsAttest` type field | TCG Part 2 §10.12 `type: TPMI_ST_ATTEST` + §10.5 `TPM_ST` constants | Eight-member subset of `TPM_ST_ATTEST_*` |
| `parseTpmsAttest` qualifiedSigner / extraData / clockInfo / firmwareVersion / attested | TCG Part 2 §10.12 field layout | `qualifiedSigner` = `TPM2B_NAME`; `extraData` = `TPM2B_DATA`; `clockInfo` per §10.10 (17 bytes); `firmwareVersion` = uint64 BE |
| `attested` union dispatch | TCG Part 2 §10.11 `TPMU_ATTEST` + §10.5 `TPMS_CERTIFY_INFO` | `TPM_ST_ATTEST_CERTIFY` ⇒ `TPMS_CERTIFY_INFO` (name + qualifiedName) |
| `parseTpmtPublic` type | TCG Part 2 §12.2 `TPMT_PUBLIC.type: TPMI_ALG_PUBLIC` | `RSA` / `ECC` / `KEYEDHASH` / `SYMCIPHER` |
| `parseTpmtPublic` nameAlg | TCG Part 2 §12.2 `nameAlg: TPMI_ALG_HASH` + §11.1 | `SHA-1/256/384/512`; `NULL` rejected for name-digest call site |
| `parseTpmtPublic` objectAttributes | TCG Part 2 §8.3 `TPMA_OBJECT` 32-bit bitfield | Named-flag accessor + raw uint32; reserved bits non-zero ⇒ `OBJECT_ATTRIBUTES_RESERVED_NONZERO` |
| `parseTpmtPublic` authPolicy | TCG Part 2 §12.2 `authPolicy: TPM2B_DIGEST` | Length-prefixed |
| RSA parameters | TCG Part 2 §12.2.3.5 `TPMS_RSA_PARMS` | symmetric / scheme / keyBits / exponent (uint32, `0` ⇒ default 65537 per spec) |
| ECC parameters | TCG Part 2 §12.2.3.6 `TPMS_ECC_PARMS` | symmetric / scheme / curveID (`TPMI_ECC_CURVE`) / kdf; curve lookup via §11.2.5 |
| RSA unique field | TCG Part 2 §12.2 `unique: TPM2B_PUBLIC_KEY_RSA` | Modulus bytes |
| ECC unique field | TCG Part 2 §12.2 `unique: TPMS_ECC_POINT` | `x` + `y` each `TPM2B_ECC_PARAMETER` |
| `computeTpmtPublicName` | TCG Part 1 §16 _Names_ | `nameAlg (2 BE) || H_nameAlg(TPMT_PUBLIC)` |
| `TPM2B` reader | TCG Part 2 §10.1 `TPM2B_*` size-prefixed buffer | UINT16 BE length + exact bytes |
| `TPM_ALG_ID` registry | TCG Part 2 §11.1 ALG_TABLE | 31-entry registry; unknown ⇒ `ALG_UNSUPPORTED` |
| WebAuthn consumer contract (`extraData`, `pubArea` match) | W3C WebAuthn L3 §8.3 steps 4–10 | `extraData === attToBeSigned`; `pubArea name === credentialPublicKey name` |
| EK credential context | TCG _Credential Profile EK 2.0_ v2.3 §3 | Informational — drives the `attested` union shape decisions |
### Requirements with no source line
| Requirement | Justification |
|---|---|
| `defineCodes("TPM_STRUCT", [...])` 10-code registry | Genesis convention (`CLAUDE.md` Quality + `@suaveplan/error`); each code maps to a distinct §10 / §11 / §12 / §16 failure class so callers can branch on root cause. |
| Zod boundary schemas at every public type | Genesis convention (`CLAUDE.md` §11). A buggy reader cannot leak a partially-populated object past the boundary. |
| OTel surface (parse + name histograms; parse-total + parse-errors + alg counters) | Genesis convention — every hot-path package emits histograms + counters. `alg` attribute bounded by §11.1 registry; `code` bounded by the 10-entry code registry. |
| 300-line file budget + 100/100/100/100 coverage + sibling `.md` per impl file | `CLAUDE.md` Quality + Documentation sections. |
| Property tests (round-trip + single-bit-flip) | `CLAUDE.md` "no mocks" rule; round-trip is the only test that proves parser + serialiser agree under arbitrary valid inputs. |
| Mitata benchmarks | `CLAUDE.md` global benchmark requirement; baseline numbers feed README perf table. |
| Hash-injection via `HashLike` | Cross-cutting design — keeps the package universal-tier and free of crypto-engine selection; consumer wires `@suaveplan/crypto-hash` at the call site. |
## Verification approach
Every requirement carries a `**Verified by:**` line in `specs/tpm-struct/spec.md` naming concrete `it(...)` titles inside the test files below.
- `packages/security/tpm-struct/src/buffer/buffer.test.ts` — reader cursor advancement; `readTpm2b` length/payload round-trip; `assertEnd` rejects trailing bytes; out-of-bounds reads raise `TRUNCATED`.
- `packages/security/tpm-struct/src/alg/alg.test.ts` — every §11.1 entry round-trips through `algIdToName`; unknown ID raises `ALG_UNSUPPORTED`; every §11.2.5 curve round-trips through `eccCurveToName`.
- `packages/security/tpm-struct/src/public/public.test.ts` — `parseTpmtPublic` for RSA-2048, ECC P-256, ECC P-384, KEYEDHASH, SYMCIPHER; `objectAttributes` flag round-trip; reserved bits non-zero raises `OBJECT_ATTRIBUTES_RESERVED_NONZERO`.
- `packages/security/tpm-struct/src/public/property.test.ts` — fast-check round-trip: arbitrary valid `TpmtPublic` → `serializeTpmtPublic` → `parseTpmtPublic` byte-identical.
- `packages/security/tpm-struct/src/public/serialize.test.ts` — `serializeTpmtPublic` produces byte-identical output for parsed real-world fixtures.
- `packages/security/tpm-struct/src/attest/attest.test.ts` — magic mismatch raises `MAGIC_INVALID`; every `TPM_ST_ATTEST_*` variant decodes the matching `attested` union member; unknown `type` raises `ATTEST_TYPE_UNSUPPORTED`.
- `packages/security/tpm-struct/src/attest/property.test.ts` — fast-check round-trip: arbitrary valid `TpmsAttest` → `serializeTpmsAttest` → `parseTpmsAttest` byte-identical; single-bit-flip on any field either decodes to a different value or raises a typed `TpmStructError`.
- `packages/security/tpm-struct/src/attest/serialize.test.ts` — `serializeTpmsAttest` byte-identical for parsed real-world fixtures.
- `packages/security/tpm-struct/src/name/name.test.ts` — `computeTpmtPublicName` matches the TCG Part 1 §16 expected output for SHA-1/256/384/512 nameAlg variants; `nameAlg === TPM_ALG_NULL` raises `NAME_ALG_NULL`.
- `packages/security/tpm-struct/src/extra-data/extra-data.test.ts` — `extraDataMatches` true on equal buffers, false on mismatched bytes, false on length mismatch; delegates to the injected `ConstantTimeEqual`.
- `packages/security/tpm-struct/src/schemas/schemas.test.ts` — every Zod schema accepts a known-good fixture and rejects a malformed fixture with `MALFORMED` + Zod issues on `cause`.
- `packages/security/tpm-struct/src/errors/error-codes.test.ts` — every code in the `TPM_STRUCT_CODES` registry is thrown by ≥1 production path; `expectTypeOf<TpmStructErrorCode>().toEqualTypeOf<…>()` pins the literal union.
- `packages/security/tpm-struct/src/observability/metrics.test.ts` — histograms + counters emit under a real in-memory `MeterProvider`; `alg` and `code` attributes match the bounded enums.
- `packages/security/tpm-struct/src/diagnostic/diagnostic.test.ts` — `toDiagnostic` emits symbolic algorithm names; never invokes parser hot paths (no metric emission during diagnostic).
- `packages/security/tpm-struct/src/__integration__/webauthn-vector.test.ts` — known-good WebAuthn `tpm` attestation fixture: `parseTpmsAttest(attestationData)` succeeds; `parseTpmtPublic(pubArea)` succeeds; `computeTpmtPublicName(parsedPub, sha256)` equals the expected fixture digest byte-for-byte.
- `packages/security/tpm-struct/src/index.test.ts` — every documented subpath export resolves.
## Migration impact
None — additive only; this is the first delta of a new package. No existing genesis code imports from `@suaveplan/tpm-struct`. `@suaveplan/webauthn` is the gating consumer and lands in a follow-up change once this package archives; that change SHALL add the `workspace:^` dep edge and wire the `tpm`-attestation verifier.
## Impact
### Affected dependents
`bunx turbo ls --filter=...@suaveplan/tpm-struct` returns no current dependents (the package does not yet exist on `genesis`). The pending consumer is:
- `@suaveplan/webauthn` — [ ] tests run / [ ] docs reviewed / [x] no action needed in THIS change (reason: webauthn is itself pending; the `tpm`-attestation verifier wiring lands in the webauthn migration change once this package archives, per `STATUS.md` and `openspec/AGENTS.md` §0b).
No foundation-package fan-out applies (this is a new universal-tier leaf package; foundation deps `@suaveplan/error` and `@suaveplan/types` are unchanged).

View File

@@ -0,0 +1,364 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: tpm-struct (enterprise-tpm-struct)
**Package**: `@suaveplan/tpm-struct`
**Status**: PROPOSED — first delta; becomes canonical spec on archive.
> Every requirement below carries a `**Verified by:**` line at authoring time. (See `openspec/AGENTS.md` Section 13.)
## ADDED Requirements
### Requirement: `parseTpmsAttest` validates `TPM_GENERATED_VALUE` magic prefix
The package SHALL export `parseTpmsAttest(bytes: Uint8Array): TpmsAttest` and SHALL reject any input whose first four bytes do not equal `0xff 0x54 0x43 0x47` (the big-endian encoding of `TPM_GENERATED_VALUE = 0xff544347`, ASCII `"\xffTCG"`) per TCG TPM 2.0 Library Part 2 §10.12 + §6.2. Magic-prefix mismatch SHALL raise `TpmStructError` with code `TPM_STRUCT.MAGIC_INVALID` before any further bytes are consumed.
#### Scenario: well-formed magic prefix is accepted
- **WHEN** `parseTpmsAttest(bytes)` is called with a buffer whose first four bytes equal `0xff 0x54 0x43 0x47` and whose remainder is a valid `TPMS_ATTEST`
- **THEN** the call returns a `TpmsAttest` value without throwing
#### Scenario: incorrect magic byte raises MAGIC_INVALID
- **WHEN** `parseTpmsAttest(bytes)` is called with a buffer whose first four bytes equal `0xff 0x54 0x43 0x46` (final byte off-by-one)
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.MAGIC_INVALID"`
#### Scenario: shorter-than-magic input raises TRUNCATED
- **WHEN** `parseTpmsAttest(bytes)` is called with a 3-byte buffer
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.TRUNCATED"`
**Verified by:** `packages/security/tpm-struct/src/attest/attest.test.ts::"accepts well-formed magic prefix"` AND `packages/security/tpm-struct/src/attest/attest.test.ts::"rejects incorrect magic with MAGIC_INVALID"` AND `packages/security/tpm-struct/src/attest/attest.test.ts::"rejects 3-byte buffer with TRUNCATED"`
---
### Requirement: `parseTpmsAttest` decodes `TPMI_ST_ATTEST` type
The package SHALL decode the `type` field of `TPMS_ATTEST` as a UINT16 BE drawn from the `TPM_ST_ATTEST_*` subset of TCG Part 2 §10.5 (`TPM_ST_ATTEST_NV = 0x8014`, `TPM_ST_ATTEST_COMMAND_AUDIT = 0x8015`, `TPM_ST_ATTEST_SESSION_AUDIT = 0x8016`, `TPM_ST_ATTEST_CERTIFY = 0x8017`, `TPM_ST_ATTEST_QUOTE = 0x8018`, `TPM_ST_ATTEST_TIME = 0x8019`, `TPM_ST_ATTEST_CREATION = 0x801a`, `TPM_ST_ATTEST_NV_DIGEST = 0x801c`). The decoded value SHALL be exposed as the discriminator on the returned `TpmsAttest` object. Unknown `type` values SHALL raise `TPM_STRUCT.ATTEST_TYPE_UNSUPPORTED`.
#### Scenario: TPM_ST_ATTEST_CERTIFY discriminates to certify branch
- **WHEN** `parseTpmsAttest(bytes)` is called with a buffer carrying `type === 0x8017`
- **THEN** the returned value has `type === "TPM_ST_ATTEST_CERTIFY"` and an `attested: TpmsCertifyInfo`
#### Scenario: TPM_ST_ATTEST_QUOTE discriminates to quote branch
- **WHEN** `parseTpmsAttest(bytes)` is called with a buffer carrying `type === 0x8018`
- **THEN** the returned value has `type === "TPM_ST_ATTEST_QUOTE"` and an `attested: TpmsQuoteInfo`
#### Scenario: unknown type raises ATTEST_TYPE_UNSUPPORTED
- **WHEN** `parseTpmsAttest(bytes)` is called with a buffer carrying `type === 0x8099`
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.ATTEST_TYPE_UNSUPPORTED"`
**Verified by:** `packages/security/tpm-struct/src/attest/attest.test.ts::"decodes TPM_ST_ATTEST_CERTIFY"` AND `packages/security/tpm-struct/src/attest/attest.test.ts::"decodes TPM_ST_ATTEST_QUOTE"` AND `packages/security/tpm-struct/src/attest/attest.test.ts::"rejects unknown attest type with ATTEST_TYPE_UNSUPPORTED"`
---
### Requirement: `parseTpmsAttest` decodes `qualifiedSigner`, `extraData`, `clockInfo`, `firmwareVersion`, `attested`
The package SHALL decode the trailing fields of `TPMS_ATTEST` per TCG Part 2 §10.12 in field order: `qualifiedSigner: TPM2B_NAME` (UINT16 BE length + name bytes), `extraData: TPM2B_DATA` (UINT16 BE length + data bytes), `clockInfo: TPMS_CLOCK_INFO` (uint64 BE clock, uint32 BE resetCount, uint32 BE restartCount, uint8 safe — 17 bytes total per §10.10), `firmwareVersion: UINT64` BE, and the `attested: TPMU_ATTEST` union member selected by `type` per §10.11. For `TPM_ST_ATTEST_CERTIFY` the `attested` SHALL be `TPMS_CERTIFY_INFO` containing `name: TPM2B_NAME` followed by `qualifiedName: TPM2B_NAME` per §10.5.
#### Scenario: certify attest round-trips through serialize
- **WHEN** `parseTpmsAttest(bytes)` is called with a valid `TPM_ST_ATTEST_CERTIFY` buffer, then `serializeTpmsAttest` is invoked on the result
- **THEN** the re-serialised bytes equal the original input byte-for-byte
#### Scenario: clockInfo decodes 17 bytes in correct field order
- **WHEN** `parseTpmsAttest(bytes)` is called with `clockInfo` segment `0000000000000064 00000005 00000003 01` (clock=100, reset=5, restart=3, safe=true)
- **THEN** the returned `clockInfo` equals `{ clock: 100n, resetCount: 5, restartCount: 3, safe: true }`
**Verified by:** `packages/security/tpm-struct/src/attest/attest.test.ts::"certify attest round-trips through serializeTpmsAttest"` AND `packages/security/tpm-struct/src/attest/attest.test.ts::"clockInfo decodes 17-byte field correctly"` AND `packages/security/tpm-struct/src/attest/serialize.test.ts::"byte-identical re-serialisation for real-world fixture"`
---
### Requirement: `parseTpmtPublic` decodes `type` (TPMI_ALG_PUBLIC)
The package SHALL export `parseTpmtPublic(bytes: Uint8Array): TpmtPublic` and SHALL decode the first UINT16 BE field as `type: TPMI_ALG_PUBLIC` per TCG Part 2 §12.2, recognising `TPM_ALG_RSA = 0x0001`, `TPM_ALG_ECC = 0x0023`, `TPM_ALG_KEYEDHASH = 0x0008`, and `TPM_ALG_SYMCIPHER = 0x0025`. The decoded value SHALL drive the parameter / unique union dispatch. Any other `type` SHALL raise `TPM_STRUCT.ALG_UNSUPPORTED`.
#### Scenario: TPM_ALG_RSA selects RSA branch
- **WHEN** `parseTpmtPublic(bytes)` is called with `type === 0x0001`
- **THEN** the returned value has `type === "TPM_ALG_RSA"` and `parameters: TpmsRsaParms`
#### Scenario: TPM_ALG_ECC selects ECC branch
- **WHEN** `parseTpmtPublic(bytes)` is called with `type === 0x0023`
- **THEN** the returned value has `type === "TPM_ALG_ECC"` and `parameters: TpmsEccParms`
#### Scenario: unsupported algorithm raises ALG_UNSUPPORTED
- **WHEN** `parseTpmtPublic(bytes)` is called with `type === 0x0099`
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.ALG_UNSUPPORTED"`
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"RSA type selects RSA branch"` AND `packages/security/tpm-struct/src/public/public.test.ts::"ECC type selects ECC branch"` AND `packages/security/tpm-struct/src/public/public.test.ts::"unknown type raises ALG_UNSUPPORTED"`
---
### Requirement: `parseTpmtPublic` decodes `nameAlg` (TPMI_ALG_HASH)
The package SHALL decode the second UINT16 BE field of `TPMT_PUBLIC` as `nameAlg: TPMI_ALG_HASH` per TCG Part 2 §12.2, recognising at minimum `TPM_ALG_SHA1 = 0x0004`, `TPM_ALG_SHA256 = 0x000b`, `TPM_ALG_SHA384 = 0x000c`, `TPM_ALG_SHA512 = 0x000d`, and the sentinel `TPM_ALG_NULL = 0x0010`. The decoded value SHALL be exposed on the returned `TpmtPublic` so `computeTpmtPublicName` can route to the correct hash.
#### Scenario: SHA-256 nameAlg decodes
- **WHEN** `parseTpmtPublic(bytes)` is called with `nameAlg === 0x000b`
- **THEN** the returned value has `nameAlg === "TPM_ALG_SHA256"`
#### Scenario: TPM_ALG_NULL nameAlg is accepted by the parser
- **WHEN** `parseTpmtPublic(bytes)` is called with `nameAlg === 0x0010`
- **THEN** the returned value has `nameAlg === "TPM_ALG_NULL"` (the name-digest call site rejects it; the parser does not)
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"SHA-256 nameAlg decodes"` AND `packages/security/tpm-struct/src/public/public.test.ts::"TPM_ALG_NULL nameAlg accepted by parser"`
---
### Requirement: `parseTpmtPublic` decodes `objectAttributes` (TPMA_OBJECT bitfield)
The package SHALL decode `objectAttributes` as a UINT32 BE bitfield per TCG Part 2 §8.3 and SHALL expose it both as the raw `uint32` and as a typed `ObjectAttributes` record with named flags (`fixedTPM`, `stClear`, `fixedParent`, `sensitiveDataOrigin`, `userWithAuth`, `adminWithPolicy`, `noDA`, `encryptedDuplication`, `restricted`, `decrypt`, `sign`). Any reserved bit set non-zero SHALL raise `TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO`.
#### Scenario: well-formed attributes round-trip
- **WHEN** `parseTpmtPublic(bytes)` is called with `objectAttributes === 0x00040472` (fixedTPM + fixedParent + sensitiveDataOrigin + userWithAuth + sign)
- **THEN** the returned `objectAttributes.flags` equals `{ fixedTPM: true, fixedParent: true, sensitiveDataOrigin: true, userWithAuth: true, sign: true, ... }` with all other flags false
#### Scenario: reserved bit raises OBJECT_ATTRIBUTES_RESERVED_NONZERO
- **WHEN** `parseTpmtPublic(bytes)` is called with `objectAttributes === 0x00000001` (reserved bit 0)
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.OBJECT_ATTRIBUTES_RESERVED_NONZERO"`
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"objectAttributes flag round-trip"` AND `packages/security/tpm-struct/src/public/public.test.ts::"reserved bit raises OBJECT_ATTRIBUTES_RESERVED_NONZERO"`
---
### Requirement: `parseTpmtPublic` decodes `authPolicy` (TPM2B_DIGEST)
The package SHALL decode `authPolicy` as a `TPM2B_DIGEST` per TCG Part 2 §12.2 — UINT16 BE length followed by exactly that many bytes of policy digest. Length-zero `authPolicy` SHALL be accepted and SHALL produce an empty `Uint8Array`.
#### Scenario: 32-byte authPolicy decodes
- **WHEN** `parseTpmtPublic(bytes)` is called with `authPolicy` segment `0020` followed by 32 bytes
- **THEN** the returned `authPolicy` is a 32-byte `Uint8Array` equal to those bytes
#### Scenario: empty authPolicy decodes
- **WHEN** `parseTpmtPublic(bytes)` is called with `authPolicy` segment `0000`
- **THEN** the returned `authPolicy` is a zero-length `Uint8Array`
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"32-byte authPolicy decodes"` AND `packages/security/tpm-struct/src/public/public.test.ts::"empty authPolicy decodes"`
---
### Requirement: RSA parameter decoding (TPMS_RSA_PARMS)
When `parseTpmtPublic` encounters `type === TPM_ALG_RSA`, the package SHALL decode `parameters` as `TPMS_RSA_PARMS` per TCG Part 2 §12.2.3.5: `symmetric: TPMT_SYM_DEF_OBJECT`, `scheme: TPMT_RSA_SCHEME`, `keyBits: UINT16 BE` (TPMI_RSA_KEY_BITS), `exponent: UINT32 BE`. Per §12.2.3.5, an `exponent` value of `0` SHALL be normalised to `65537` (Fermat F4) on output while preserving the raw field on a `rawExponent` slot for round-trip fidelity. The `unique` field SHALL be decoded as `TPM2B_PUBLIC_KEY_RSA` (UINT16 BE length + modulus bytes).
#### Scenario: RSA-2048 parameters decode
- **WHEN** `parseTpmtPublic(bytes)` is called with a real-world RSA-2048 `TPMT_PUBLIC` fixture
- **THEN** the returned `parameters.keyBits === 2048`, `parameters.exponent === 65537`, and `unique.modulus.length === 256`
#### Scenario: exponent zero normalises to 65537
- **WHEN** `parseTpmtPublic(bytes)` is called with an `exponent` field of `0x00000000`
- **THEN** the returned `parameters.exponent === 65537` and `parameters.rawExponent === 0`
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"RSA-2048 parameters decode"` AND `packages/security/tpm-struct/src/public/public.test.ts::"exponent zero normalises to 65537"`
---
### Requirement: ECC parameter decoding (TPMS_ECC_PARMS) with curveID lookup
When `parseTpmtPublic` encounters `type === TPM_ALG_ECC`, the package SHALL decode `parameters` as `TPMS_ECC_PARMS` per TCG Part 2 §12.2.3.6: `symmetric: TPMT_SYM_DEF_OBJECT`, `scheme: TPMT_ECC_SCHEME`, `curveID: TPMI_ECC_CURVE`, `kdf: TPMT_KDF_SCHEME`. The `curveID` SHALL be looked up in the `TPM_ECC_CURVE` registry (§11.2.5) — `NIST_P256 = 0x0003`, `NIST_P384 = 0x0004`, `NIST_P521 = 0x0005`, `BN_P256 = 0x0010`, `BN_P638 = 0x0011`, `SM2_P256 = 0x0020`. Unknown curve IDs SHALL raise `TPM_STRUCT.CURVE_UNSUPPORTED`.
#### Scenario: ECC P-256 parameters decode
- **WHEN** `parseTpmtPublic(bytes)` is called with a real-world ECC P-256 `TPMT_PUBLIC` fixture
- **THEN** the returned `parameters.curveID === "TPM_ECC_NIST_P256"` and `unique.x.length === 32` and `unique.y.length === 32`
#### Scenario: unknown ECC curve raises CURVE_UNSUPPORTED
- **WHEN** `parseTpmtPublic(bytes)` is called with `curveID === 0x0099`
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.CURVE_UNSUPPORTED"`
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"ECC P-256 parameters decode"` AND `packages/security/tpm-struct/src/public/public.test.ts::"unknown ECC curve raises CURVE_UNSUPPORTED"`
---
### Requirement: `unique` field decoding (TPM2B_PUBLIC_KEY_RSA or TPMS_ECC_POINT)
The package SHALL decode the `unique` field of `TPMT_PUBLIC` per TCG Part 2 §12.2 according to the `type` discriminator: for `TPM_ALG_RSA` the field is `TPM2B_PUBLIC_KEY_RSA` (UINT16 BE length + modulus bytes); for `TPM_ALG_ECC` the field is `TPMS_ECC_POINT` carrying `x: TPM2B_ECC_PARAMETER` followed by `y: TPM2B_ECC_PARAMETER` (each a UINT16 BE length + parameter bytes).
#### Scenario: RSA unique field is the modulus
- **WHEN** `parseTpmtPublic(bytes)` is called with an RSA-2048 fixture
- **THEN** the returned `unique.modulus` equals the 256 modulus bytes from the fixture
#### Scenario: ECC unique field is the point
- **WHEN** `parseTpmtPublic(bytes)` is called with an ECC P-256 fixture
- **THEN** the returned `unique` equals `{ x: <32 bytes>, y: <32 bytes> }` matching the fixture's affine coordinates
**Verified by:** `packages/security/tpm-struct/src/public/public.test.ts::"RSA unique field is the modulus"` AND `packages/security/tpm-struct/src/public/public.test.ts::"ECC unique field is the point"`
---
### Requirement: `computeTpmtPublicName` produces `nameAlg || H_nameAlg(TPMT_PUBLIC)`
The package SHALL export `computeTpmtPublicName(pub: TpmtPublic, hash: HashLike): Uint8Array` and SHALL produce the canonical name per TCG TPM 2.0 Library Part 1 §16 — the two-byte big-endian `nameAlg` identifier followed by the digest of the canonical `TPMT_PUBLIC` byte serialisation computed via the injected `hash.digest` function. The injected hash MUST match `pub.nameAlg`; the package itself imports no hashing code. `pub.nameAlg === TPM_ALG_NULL` SHALL raise `TPM_STRUCT.NAME_ALG_NULL` — names cannot be computed without a digest.
#### Scenario: SHA-256 name has expected length and prefix
- **WHEN** `computeTpmtPublicName(pub, sha256)` is called for a `TpmtPublic` with `nameAlg === "TPM_ALG_SHA256"`
- **THEN** the returned `Uint8Array` has length 34 and starts with the bytes `0x00 0x0b`
#### Scenario: name matches WebAuthn fixture digest
- **WHEN** `computeTpmtPublicName(parsedPubArea, sha256)` is called against the WebAuthn `tpm`-attestation fixture
- **THEN** the returned bytes equal the fixture's `expectedName` byte-for-byte
#### Scenario: TPM_ALG_NULL nameAlg raises NAME_ALG_NULL
- **WHEN** `computeTpmtPublicName(pub, sha256)` is called with `pub.nameAlg === "TPM_ALG_NULL"`
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.NAME_ALG_NULL"`
**Verified by:** `packages/security/tpm-struct/src/name/name.test.ts::"SHA-256 name has expected length and prefix"` AND `packages/security/tpm-struct/src/name/name.test.ts::"name matches WebAuthn fixture digest"` AND `packages/security/tpm-struct/src/name/name.test.ts::"TPM_ALG_NULL nameAlg raises NAME_ALG_NULL"`
---
### Requirement: `TPM2B` length-prefix reader
The package SHALL provide `TpmBufferReader.readTpm2b()` returning a `Uint8Array` of exactly the bytes following the UINT16 BE size prefix per TCG Part 2 §10.1 `TPM2B_*` convention. The reader SHALL advance the cursor by `2 + size` bytes. The reader SHALL accept zero-length `TPM2B` values, returning an empty `Uint8Array`. If the declared size exceeds the remaining buffer length, the reader SHALL raise `TPM_STRUCT.TRUNCATED`.
#### Scenario: TPM2B with 4-byte payload
- **WHEN** `readTpm2b()` is called over the bytes `0004 deadbeef`
- **THEN** the call returns a 4-byte `Uint8Array` equal to `[0xde, 0xad, 0xbe, 0xef]` and the reader cursor advances by 6
#### Scenario: zero-length TPM2B returns empty buffer
- **WHEN** `readTpm2b()` is called over the bytes `0000`
- **THEN** the call returns a zero-length `Uint8Array` and the reader cursor advances by 2
#### Scenario: declared size exceeds remaining buffer raises TRUNCATED
- **WHEN** `readTpm2b()` is called over the bytes `0008 deadbeef` (declared 8, available 4)
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.TRUNCATED"`
**Verified by:** `packages/security/tpm-struct/src/buffer/buffer.test.ts::"readTpm2b reads 4-byte payload and advances cursor"` AND `packages/security/tpm-struct/src/buffer/buffer.test.ts::"readTpm2b accepts zero-length"` AND `packages/security/tpm-struct/src/buffer/buffer.test.ts::"readTpm2b raises TRUNCATED on short buffer"`
---
### Requirement: bounds enforcement on every reader operation
Every `TpmBufferReader` method SHALL raise `TpmStructError` with code `TPM_STRUCT.TRUNCATED` when a read would consume bytes past the end of the underlying buffer. The error SHALL carry `cause.cursor` (the cursor at the time of the failed read) and `cause.requested` (the number of bytes the read attempted to consume). The reader SHALL NOT silently return short reads or pad with zero bytes.
#### Scenario: readUint32Be past end raises TRUNCATED
- **WHEN** `readUint32Be()` is called with only 3 bytes remaining
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.TRUNCATED"` and `cause.requested === 4`
#### Scenario: readBytes past end raises TRUNCATED
- **WHEN** `readBytes(100)` is called with 50 bytes remaining
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.TRUNCATED"` and `cause.requested === 100`
#### Scenario: assertEnd raises when trailing bytes remain
- **WHEN** `assertEnd()` is called with 1 byte remaining in the buffer
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.TRUNCATED"`
**Verified by:** `packages/security/tpm-struct/src/buffer/buffer.test.ts::"readUint32Be past end raises TRUNCATED"` AND `packages/security/tpm-struct/src/buffer/buffer.test.ts::"readBytes past end raises TRUNCATED"` AND `packages/security/tpm-struct/src/buffer/buffer.test.ts::"assertEnd raises with trailing bytes"`
---
### Requirement: `TPM2_ALG_ID` registry and symbolic lookup
The package SHALL expose a frozen `TPM_ALG` const-object whose properties are the symbolic names of every TPM 2.0 algorithm identifier defined in TCG Part 2 §11.1 ALG_TABLE (at minimum: `NULL`, `RSA`, `SHA1`, `HMAC`, `AES`, `KEYEDHASH`, `SHA256`, `SHA384`, `SHA512`, `XOR`, `RSASSA`, `RSAES`, `RSAPSS`, `OAEP`, `ECDSA`, `ECDH`, `ECDAA`, `SM2`, `ECSCHNORR`, `ECMQV`, `KDF1_SP800_56A`, `KDF2`, `KDF1_SP800_108`, `ECC`, `SYMCIPHER`, `CTR`, `OFB`, `CBC`, `CFB`, `ECB`). The package SHALL export `algIdToName(id: number): TpmAlgId` returning the symbolic name. Unknown numeric IDs SHALL raise `TPM_STRUCT.ALG_UNSUPPORTED`.
#### Scenario: known numeric id decodes to symbolic name
- **WHEN** `algIdToName(0x000b)` is called
- **THEN** the call returns `"TPM_ALG_SHA256"`
#### Scenario: unknown numeric id raises ALG_UNSUPPORTED
- **WHEN** `algIdToName(0x0099)` is called
- **THEN** the call throws `TpmStructError` with `code === "TPM_STRUCT.ALG_UNSUPPORTED"`
**Verified by:** `packages/security/tpm-struct/src/alg/alg.test.ts::"known numeric id decodes to symbolic name"` AND `packages/security/tpm-struct/src/alg/alg.test.ts::"unknown numeric id raises ALG_UNSUPPORTED"`
---
### Requirement: `TPM_STRUCT` error registry coverage
The package SHALL ship a `defineCodes("TPM_STRUCT", [...])` registry containing exactly the following 10 codes: `MAGIC_INVALID`, `TRUNCATED`, `ALG_UNSUPPORTED`, `CURVE_UNSUPPORTED`, `TYPE_MISMATCH`, `MALFORMED`, `NAME_ALG_NULL`, `ATTEST_TYPE_UNSUPPORTED`, `PARAMETERS_INVALID`, `OBJECT_ATTRIBUTES_RESERVED_NONZERO`. Every code SHALL be thrown by at least one production code path. The package SHALL export `TpmStructError extends SuaveplanError<TpmStructErrorCode>` and the `TpmStructErrorCode` literal union SHALL exactly equal the registry keys (pinned with `expectTypeOf`).
#### Scenario: every code is thrown by a production path
- **WHEN** the registry-coverage test iterates every `TpmStructErrorCode` and runs the matching trigger fixture
- **THEN** each trigger raises a `TpmStructError` whose `code` matches the iterated code
#### Scenario: literal union pins to registry keys
- **WHEN** `expectTypeOf<TpmStructErrorCode>().toEqualTypeOf<...>()` runs at type-check time
- **THEN** the type-check passes for the canonical 10-member union and fails for any other shape
**Verified by:** `packages/security/tpm-struct/src/errors/error-codes.test.ts::"every code is thrown by a production path"` AND `packages/security/tpm-struct/src/errors/error-codes.test.ts::"literal union pins to registry keys"`
---
### Requirement: Zod boundary schemas validate every public return type
The package SHALL ship Zod schemas for every public return type (`TpmsAttestSchema`, `TpmtPublicSchema`, `TpmsRsaParmsSchema`, `TpmsEccParmsSchema`, `TpmsKeyedHashParmsSchema`, `TpmsSymCipherParmsSchema`, `TpmsCertifyInfoSchema`, `ClockInfoSchema`, `Tpm2bSchema`, `TpmAlgIdSchema`, `TpmEccCurveSchema`, `ObjectAttributesSchema`). Every public parser SHALL validate its constructed result through the matching schema before returning. Schema-validation failure SHALL raise `TPM_STRUCT.MALFORMED` carrying the Zod issue list on `cause.issues`.
#### Scenario: schema accepts known-good fixture
- **WHEN** `TpmtPublicSchema.parse(parsed)` is called with the output of `parseTpmtPublic` over a real-world fixture
- **THEN** the call returns without throwing
#### Scenario: schema rejects malformed object with MALFORMED
- **WHEN** a hand-constructed `TpmtPublic`-shaped value is missing the `nameAlg` field and `TpmtPublicSchema.parse(value)` is invoked behind the parser boundary
- **THEN** the surfaced error is a `TpmStructError` with `code === "TPM_STRUCT.MALFORMED"` and `cause.issues` is the Zod issue array
**Verified by:** `packages/security/tpm-struct/src/schemas/schemas.test.ts::"schema accepts known-good fixture"` AND `packages/security/tpm-struct/src/schemas/schemas.test.ts::"schema rejects malformed object with MALFORMED"`
---
### Requirement: OpenTelemetry histograms and counters
The package SHALL emit on every public parser invocation: `tpm_struct.parse_tpms_attest_duration_ms` histogram with `outcome` attribute (`ok` | `error`); `tpm_struct.parse_tpmt_public_duration_ms` histogram with `type` (`rsa` | `ecc` | `keyedhash` | `symcipher`) and `outcome` attributes; `tpm_struct.compute_name_duration_ms` histogram with `name_alg` attribute. Counters: `tpm_struct.parse_total{kind,outcome}` (`kind ∈ attest | public | name`, `outcome ∈ ok | error`); `tpm_struct.parse_errors_total{code}` (`code` drawn from the `TPM_STRUCT` registry); `tpm_struct.alg_total{alg}` (`alg` drawn from `TPM_ALG`). Every attribute value SHALL be a bounded enum drawn from a frozen registry; no free-form strings SHALL flow into telemetry attributes.
#### Scenario: parse-attest histogram records on success
- **WHEN** `parseTpmsAttest(bytes)` succeeds under an in-memory `MeterProvider`
- **THEN** `tpm_struct.parse_tpms_attest_duration_ms{outcome="ok"}` records exactly one observation and `tpm_struct.parse_total{kind="attest",outcome="ok"}` increments by 1
#### Scenario: parse-public error counter increments on failure
- **WHEN** `parseTpmtPublic(bytes)` raises `ALG_UNSUPPORTED` under an in-memory `MeterProvider`
- **THEN** `tpm_struct.parse_errors_total{code="TPM_STRUCT.ALG_UNSUPPORTED"}` increments by 1 and `tpm_struct.parse_total{kind="public",outcome="error"}` increments by 1
**Verified by:** `packages/security/tpm-struct/src/observability/metrics.test.ts::"parse-attest histogram records on success"` AND `packages/security/tpm-struct/src/observability/metrics.test.ts::"parse-public error counter increments on failure"`
---
### Requirement: WebAuthn `tpm`-attestation integration vector
The package SHALL ship a real-world WebAuthn `tpm`-format attestation fixture (`attestationData` byte string + `pubArea` byte string + expected parsed structures + expected name digest under SHA-256). The integration test SHALL exercise `parseTpmsAttest(attestationData)`, `parseTpmtPublic(pubArea)`, and `computeTpmtPublicName(parsedPub, sha256)` end-to-end and SHALL assert that the resulting name equals the fixture's `expectedName` byte-for-byte. The injected `HashLike` SHALL be a real SHA-256 implementation (no mocks).
#### Scenario: attestationData parses end-to-end
- **WHEN** `parseTpmsAttest(fixture.attestationData)` is called
- **THEN** the returned value has `type === "TPM_ST_ATTEST_CERTIFY"` and `attested` matches `fixture.expectedAttested`
#### Scenario: pubArea parses and produces expected name
- **WHEN** `parseTpmtPublic(fixture.pubArea)` is called and `computeTpmtPublicName(parsedPub, sha256)` is invoked on the result
- **THEN** the returned name `Uint8Array` equals `fixture.expectedName` byte-for-byte
**Verified by:** `packages/security/tpm-struct/src/__integration__/webauthn-vector.test.ts::"attestationData parses end-to-end"` AND `packages/security/tpm-struct/src/__integration__/webauthn-vector.test.ts::"pubArea parses and produces expected name"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,163 @@
# Tasks — `@suaveplan/tpm-struct` (TPM 2.0 attestation-structure parser)
> Every checkbox below MUST reflect work actually done in this change. Do not retroactively tick boxes. Do not tick a box for a test that doesn't exist or doesn't pin the requirement it claims to verify.
## 1. Spec authoring
- [x] 1.1 Read TCG TPM 2.0 Library Part 2 — Structures r1.59 end-to-end (focus §10.1, §10.5, §10.10, §10.11, §10.12, §11.1, §11.2.5, §8.3, §12.2)
- [x] 1.2 Read TCG TPM 2.0 Library Part 1 — Architecture §16 (Names)
- [x] 1.3 Read W3C WebAuthn Level 3 §8.3 _TPM Attestation Statement Format_ end-to-end
- [x] 1.4 Read TCG _Credential Profile EK 2.0_ v2.3 (informational — AIK / EK context)
- [x] 1.5 Read `openspec/AGENTS.md` §0, §0a, §0b, §2b, §6a, §7, §13, §14, §14a
- [x] 1.6 Confirm `proposal.md` covers every public-surface item, every subpath, and every dependency
- [x] 1.7 Confirm `specs/tpm-struct/spec.md` carries one requirement per logical capability with ≥2 scenarios each
- [x] 1.8 Confirm every requirement has a `**Verified by:**` line naming concrete `it(...)` titles in concrete test file paths
- [x] 1.9 Source-mapping table cites every TCG / W3C section consumed
## 2. Package scaffold
- [x] 2.1 Create `packages/security/tpm-struct/` with `src/{buffer,alg,public,attest,name,extra-data,schemas,errors,observability,diagnostic,__fixtures__,__integration__,__benchmarks__}/`
- [x] 2.2 `package.json` — `@suaveplan/tpm-struct@0.0.0`, `type: module`, `sideEffects: false`, deps `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@opentelemetry/api catalog:observability`, `tslib catalog:build`, `zod catalog:forms` (no bare versions)
- [x] 2.3 `package.json` `exports` map declares `.`, `./buffer`, `./alg`, `./public`, `./attest`, `./name`, `./extra-data`, `./schemas`, `./errors`, `./diagnostic` (10 subpaths) — each mapped to `dist/<feature>/<feature>.js` + `.d.ts`
- [x] 2.4 `tsconfig.lib.json` extends `@suaveplan/typescript-config/library.json`
- [x] 2.5 `vite.config.ts` multi-entry build (one entry per subpath) + `vitest.config.ts` calling `createTestConfig()` from `@suaveplan/testing/config`
- [x] 2.6 `README.md` with package name, description, `bun add` install, quick-start example, `## Modules` table linking every co-located `.md`
## 3. Implementation — buffer primitives
- [x] 3.1 `src/buffer/buffer.ts` — `TpmBufferReader` (`readUint8`, `readUint16Be`, `readUint32Be`, `readUint64Be: bigint`, `readBytes(n)`, `readTpm2b(): Uint8Array`, `peek(n)`, `remaining()`, `assertEnd()`)
- [x] 3.2 `src/buffer/buffer.ts` — `TpmBufferWriter` (mirror writes + `writeTpm2b(bytes)` + `toUint8Array()`)
- [x] 3.3 Out-of-bounds read raises `TPM_STRUCT.TRUNCATED` with cursor + requested-length in `cause`
- [x] 3.4 `src/buffer/buffer.types.ts` — `TpmReaderOptions`, `TpmWriterOptions`, `Tpm2bReadResult`
- [x] 3.5 Sibling `src/buffer/buffer.md` ≥200 words with basic + advanced examples
## 4. Implementation — algorithm registry
- [x] 4.1 `src/alg/alg.ts` — `TPM_ALG` frozen 31-entry registry per TCG Part 2 §11.1
- [x] 4.2 `src/alg/alg.ts` — `TPM_ECC_CURVE` frozen 6-entry registry per TCG Part 2 §11.2.5
- [x] 4.3 `algIdToName(id)` returns symbolic name; unknown raises `ALG_UNSUPPORTED`
- [x] 4.4 `eccCurveToName(id)` returns symbolic name; unknown raises `CURVE_UNSUPPORTED`
- [x] 4.5 `src/alg/alg.types.ts` — `TpmAlgId`, `TpmEccCurve` literal unions
- [x] 4.6 Sibling `src/alg/alg.md` ≥200 words
## 5. Implementation — `TPMT_PUBLIC` parser + serialiser
- [x] 5.1 `src/public/public.ts` — `parseTpmtPublic(bytes): TpmtPublic` per TCG Part 2 §12.2 header (type, nameAlg, objectAttributes, authPolicy)
- [x] 5.2 RSA branch: `TPMS_RSA_PARMS` per §12.2.3.5 + `TPM2B_PUBLIC_KEY_RSA` unique
- [x] 5.3 ECC branch: `TPMS_ECC_PARMS` per §12.2.3.6 + `TPMS_ECC_POINT` unique with curveID lookup
- [x] 5.4 KEYEDHASH branch: `TPMS_KEYEDHASH_PARMS` per §12.2.3.3 + `TPM2B_DIGEST` unique
- [x] 5.5 SYMCIPHER branch: `TPMS_SYMCIPHER_PARMS` per §12.2.3.4 + `TPM2B_DIGEST` unique
- [x] 5.6 `objectAttributes` reserved bits non-zero raises `OBJECT_ATTRIBUTES_RESERVED_NONZERO`
- [x] 5.7 `src/public/serialize.ts` — `serializeTpmtPublic(pub): Uint8Array` byte-identical round-trip
- [x] 5.8 `src/public/public.types.ts` — `TpmtPublic` discriminator + parameter / point / attribute types
- [x] 5.9 Sibling `.md` for `public.ts` and `serialize.ts` (≥200 words each)
## 6. Implementation — `TPMS_ATTEST` parser + serialiser
- [x] 6.1 `src/attest/attest.ts` — magic validation: `0xff544347` mismatch raises `MAGIC_INVALID`
- [x] 6.2 `parseTpmsAttest` reads type, qualifiedSigner (TPM2B_NAME), extraData (TPM2B_DATA), clockInfo (17 bytes), firmwareVersion (uint64 BE)
- [x] 6.3 Eight `TPMU_ATTEST` branches: `TPM_ST_ATTEST_CERTIFY` ⇒ `TPMS_CERTIFY_INFO`; the other seven per TCG Part 2 §10.11
- [x] 6.4 Unknown `type` raises `ATTEST_TYPE_UNSUPPORTED`
- [x] 6.5 `src/attest/serialize.ts` — `serializeTpmsAttest(att): Uint8Array` byte-identical round-trip
- [x] 6.6 `src/attest/attest.types.ts` — `TpmsAttest` discriminator + every `TPMU_ATTEST` member type + `ClockInfo`
- [x] 6.7 Sibling `.md` for `attest.ts` and `serialize.ts` (≥200 words each)
## 7. Implementation — name digest
- [x] 7.1 `src/name/name.ts` — `computeTpmtPublicName(pub, hash): Uint8Array` per TCG Part 1 §16
- [x] 7.2 Output = `nameAlg (2 BE) || hash.digest(serializeTpmtPublic(pub))`
- [x] 7.3 `nameAlg === TPM_ALG_NULL` raises `NAME_ALG_NULL`
- [x] 7.4 `src/name/name.types.ts` — `HashLike` interface
- [x] 7.5 Sibling `src/name/name.md` ≥200 words
## 8. Implementation — extra-data helper
- [x] 8.1 `src/extra-data/extra-data.ts` — `extraDataMatches(actual, expected, equal): boolean`
- [x] 8.2 Length mismatch returns false without calling `equal`
- [x] 8.3 `src/extra-data/extra-data.types.ts` — `ConstantTimeEqual` signature
- [x] 8.4 Sibling `.md` ≥200 words
## 9. Implementation — Zod schemas
- [x] 9.1 `src/schemas/schemas.ts` — every public type has a matching schema (`TpmsAttestSchema`, `TpmtPublicSchema`, `TpmsRsaParmsSchema`, `TpmsEccParmsSchema`, `TpmsKeyedHashParmsSchema`, `TpmsSymCipherParmsSchema`, `TpmsCertifyInfoSchema`, `ClockInfoSchema`, `Tpm2bSchema`, `TpmAlgIdSchema`, `TpmEccCurveSchema`, `ObjectAttributesSchema`)
- [x] 9.2 Every public parser validates its output through the matching schema before returning
- [x] 9.3 Schema failure raises `MALFORMED` with Zod issues on `cause.issues`
- [x] 9.4 Sibling `.md` ≥200 words
## 10. Implementation — error registry
- [x] 10.1 `src/errors/error-codes.ts` — `defineCodes("TPM_STRUCT", [...])` 10 codes: `MAGIC_INVALID`, `TRUNCATED`, `ALG_UNSUPPORTED`, `CURVE_UNSUPPORTED`, `TYPE_MISMATCH`, `MALFORMED`, `NAME_ALG_NULL`, `ATTEST_TYPE_UNSUPPORTED`, `PARAMETERS_INVALID`, `OBJECT_ATTRIBUTES_RESERVED_NONZERO`
- [x] 10.2 `TpmStructError extends SuaveplanError<TpmStructErrorCode>`
- [x] 10.3 Every code thrown by ≥1 production path (registry-coverage test pins this)
- [x] 10.4 Sibling `.md` ≥200 words
## 11. Implementation — OTel surface
- [x] 11.1 `src/observability/metrics.ts` — histograms `tpm_struct.parse_tpms_attest_duration_ms{outcome}`, `tpm_struct.parse_tpmt_public_duration_ms{type,outcome}`, `tpm_struct.compute_name_duration_ms{name_alg}`
- [x] 11.2 Counters `tpm_struct.parse_total{kind,outcome}`, `tpm_struct.parse_errors_total{code}`, `tpm_struct.alg_total{alg}`
- [x] 11.3 Every attribute is a bounded enum from the registries (no free-form strings)
- [x] 11.4 Hot paths emit under a real in-memory `MeterProvider` (no mocks)
- [x] 11.5 Sibling `.md` ≥200 words
## 12. Implementation — diagnostic emitter
- [x] 12.1 `src/diagnostic/diagnostic.ts` — `toDiagnostic(value: TpmsAttest | TpmtPublic): string`
- [x] 12.2 Symbolic `TPM_ALG` and `TPM_ECC_CURVE` names inlined
- [x] 12.3 Never invokes parser hot paths; never emits telemetry
- [x] 12.4 Sibling `.md` ≥200 words
## 13. Fixtures + integration test
- [x] 13.1 `src/__fixtures__/webauthn-tpm-vector.ts` — real-world WebAuthn `tpm` attestation byte strings + expected parsed structures + expected name digest
- [x] 13.2 `src/__integration__/webauthn-vector.test.ts` — `parseTpmsAttest(attestationData)` + `parseTpmtPublic(pubArea)` + `computeTpmtPublicName` round-trip matches expected digest
- [x] 13.3 Test uses a real SHA-256 implementation injected via `HashLike` (no mocks)
## 14. Tests
- [x] 14.1 Unit tests at each `src/<feature>/<feature>.test.ts` covering every scenario in the spec delta
- [x] 14.2 Property tests at `src/public/property.test.ts` and `src/attest/property.test.ts` (fast-check round-trip + single-bit-flip)
- [x] 14.3 Spec-conformance test at `src/index.test.ts` resolves every subpath export
- [x] 14.4 Every `it(...)` carries `{ timeout: 5000 }` (or 30000 for genuinely async)
- [x] 14.5 Every test imports runner primitives from `@suaveplan/testing/runner` — zero direct `from "vitest"` / `from "bun:test"` (per `openspec/AGENTS.md` §7)
## 15. Documentation
- [x] 15.1 Sibling `.md` for every implementation `.ts` (≥200 words, basic + advanced examples, full API reference, Implementation Notes)
- [x] 15.2 README links every co-located `.md` under `## Modules`
- [x] 15.3 Update `CLAUDE.md` package listing under §Security / supply chain to add `tpm-struct`
## 16. Benchmarks
- [x] 16.1 `src/__benchmarks__/parse-tpms-attest.bench.ts` — ~1 KiB attest buffer
- [x] 16.2 `src/__benchmarks__/parse-tpmt-public-rsa2048.bench.ts` — RSA-2048 public area
- [x] 16.3 `src/__benchmarks__/parse-tpmt-public-ecc-p256.bench.ts` — ECC P-256 public area
- [x] 16.4 `src/__benchmarks__/compute-name.bench.ts` — name digest with injected SHA-256
- [x] 16.5 Baseline numbers published in README
## 17. Pre-archive verification (the 5-gate triple)
- [x] 17.1 `bun run check` — Biome 0 errors / 0 warnings / 0 info across the whole monorepo
- [x] 17.2 `bunx turbo lint typecheck test --filter=@suaveplan/tpm-struct` — green
- [x] 17.3 `bunx turbo test:coverage --filter=@suaveplan/tpm-struct` — 100/100/100/100
- [x] 17.4 `bun scripts/openspec-conformance.ts @suaveplan/tpm-struct` — exit 0, no `error` findings
- [x] 17.5 `bunx turbo build --filter=@suaveplan/tpm-struct` — green
## 18. Pre-archive 7-row checklist (AGENTS.md §13)
- [x] 18.1 Generics threaded; `expectTypeOf` assertions on the literal unions (`TpmAlgId`, `TpmEccCurve`, `TpmStructErrorCode`, every discriminator)
- [x] 18.2 Zod schemas wired at every declared boundary
- [x] 18.3 Every error code in `error-codes.ts` thrown by at least one production path
- [x] 18.4 `objectAttributes` reserved-bits-strict mode tested both on (raise) and off (default raise; documented invariant — no off-mode)
- [x] 18.5 OTel instruments emitted from production paths and asserted with a real in-memory `MeterProvider`
- [x] 18.6 No I/O imports — `grep -rnE "from ['\"](node:|tpm-js|node-tss|tss.js)" packages/security/tpm-struct/src/` returns zero
- [x] 18.7 No retroactive tick — every box above reflects work done in this change
## 19. Archive
- [x] 19.1 Fold spec delta into `openspec/specs/tpm-struct/spec.md`
- [x] 19.2 Move `openspec/changes/enterprise-tpm-struct/` → `openspec/archive/2026-MM-DD-enterprise-tpm-struct/`
- [x] 19.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
- [x] 19.4 Refresh `MISSING.yml` via `bun scripts/generate-missing.ts`
- [x] 19.5 Update `STATUS.md` to move `@suaveplan/tpm-struct` to Completed
- [x] 19.6 Update `SUMMARY.md` with the per-package paragraph
- [x] 19.7 `bunx markdownlint-cli2 --fix` on touched files; re-stage; verify clean