Files
suaveplan/openspec/archive/2026-05-11-enterprise-tpm-struct/proposal.md
2026-05-27 23:29:53 +01:00

20 KiB
Raw Blame History

@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_PARAMETERs 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)
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).