diff --git a/CHANGELOG.md b/CHANGELOG.md index c0b48d01..994826aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ Format: [Semantic Versioning](https://semver.org/). Spec versions follow `MAJOR. ### Fixed +- **`TraceSandboxAdapter`'s documentation claimed a guarantee it does not provide: that a caller cannot claim hardware it does not have.** `SandboxAttestation` validates shape only -- `platform` against the enum on `RuntimeInfo`, `measurement` against the `sha256:`/`sha384:` digest pattern -- and has never checked a quote, a signature, or a nonce. `_runtime()` then copies `platform` and `measurement` from the attestation into the record unchanged. Nothing stops the same process that constructs a `SandboxAttestation` from inventing both values, e.g. `SandboxAttestation(platform="amd-sev-snp", measurement="sha256:" + "0" * 64)`, and `build_trust_record()` accepts it, `TrustRecord.model_validate()` accepts the result, and `sign_record()` signs it -- producing a Level 1-shaped record with no hardware evidence behind it. The module docstring's "**It will not let a caller claim hardware it does not have**" and its closing claim that "a record that says `tpm2` therefore carries a measurement that something other than this process produced" were both false as written: they described appraisal this code does not perform. This isn't a gap unique to the sandbox adapter -- `docs/trust-levels.md`'s Level 1 section already states the same boundary for the format generally ("Merely changing `runtime.platform`, copying a nonzero digest, or setting `appraisal.status="affirming"` does not establish that evidence" and "`agentrust_trace.verify_record` does not itself appraise hardware quotes") -- but `sandbox.py`'s docstring and `docs/integration/sandbox-runtime.md` asserted the opposite for this adapter specifically, which is what made it a documentation defect rather than a restatement of a known limitation. Fabricating an attestation was never a bypass of anything this adapter checks; the check that was missing had never existed and was never implemented, only claimed. Both docs are corrected to say what is actually enforced (accepted-platform and digest-shape validation) and to state plainly that verifying genuine evidence from the named platform, before constructing a `SandboxAttestation`, is the caller's responsibility -- consistent with how every other Level 1 producer in this codebase is documented. The same unconditional phrasing also remained in `build_trust_record()`'s docstring and in the integration guide's "Adding a root of trust" opening line and Levels table; corrected there too, with the guide's table gaining an explicit assurance column so record shape and verified hardware assurance are no longer collapsed together. No runtime behavior changes: `SandboxAttestation` and `TraceSandboxAdapter` accept exactly the input they always accepted. A new regression test, `test_a_fabricated_but_well_shaped_attestation_is_accepted_verbatim`, pins the actual contract so it cannot silently drift toward either a false sense of verification or an undocumented new rejection. + - **`cnf.jwk` accepted an RSA confirmation key carrying no key material.** The schema says "Keys must carry actual key material" and enforced it for `OKP` and `EC` only, so a `cnf.jwk` of `{"kty": "RSA"}` with no `n` and no `e` validated, and the record then failed inside the verifier, where `sign.jwk_thumbprint` reports the missing thumbprint member. Nothing was accepted that should have been refused, since every path downstream fails closed. What was wrong is which instrument spoke: the schema is the artifact an implementation in any language validates against, and it was not the thing that told the producer the key was unusable. `RSA` now requires `n` and `e`, which states what the description already claimed and refuses nothing that verifies. A `kty` enum is deliberately not added, because section 3.2.1 states signing algorithms per envelope context and fixes no set for the embedded-signature form of section 3.2.2, so narrowing `kty` here would add a constraint the specification does not make. `models.JWK`, which is exported and is what a Python caller reaches, carried the same `OKP`/`EC`-only table and is corrected with it; `n` and `e` are declared members there too, so a non-string modulus is refused rather than stored as an untyped extra. A parametrized test now checks the schema and the model against each other on every case, since a key one takes and the other refuses fails somewhere the producer did not choose. Both copies of the schema move together, and a test asserts they are the same bytes. - **`provenance.verify_record()` and `intent_bridge.verify_bridge()` raised exceptions their own modules do not document when the untrusted `signature` field was malformed.** Both functions decode a caller-supplied signature before its shape has been established. `provenance.verify_record()` never checked that `record["signature"]` was a string at all: `signature + "=" * (-len(signature) % 4)` ran directly on whatever JSON value sat under the key, and a non-string (an int, a bool, a list, a nested object) raised a bare `TypeError` (`object of type 'int' has no len()` for an int; a `TypeError` on `+` for a dict or list) rather than the `ProvenanceError` this function documents for every other malformed input, including its own signature-presence check three lines above. `intent_bridge.verify_bridge()` did check the type, but called `sign._b64url_decode()` unwrapped: that function raises the bare `ValueError` `sign` documents for itself, not an `IntentBridgeError`, so a correctly-typed but undecodable string (too short to pad to a whole byte, or carrying a non-ASCII character) escaped as that `ValueError`. Same shape as the `rfc8785.CanonicalizationError` leak the "Six public functions" fix (below) already closed in this module; that sweep did not cover this call site. Neither is a signature-verification bypass: a malformed signature was always rejected, only with the wrong exception type, so a caller written against the module's own documented exception (as both modules' docstrings instruct) would see an uncaught crash instead of a handled refusal. Both now reuse `sign._b64url_decode()`, already `sign.verify_record()`'s own guard for this exact field, and wrap its `ValueError` in the calling module's documented type. In `provenance.verify_record()` the guard is placed where the crash it replaces was, after the `cnf.jwk` checks, so a record with more than one defect still reports them in the same order it did before. 13 regression tests added across both modules' non-string and malformed-base64-string cases, plus one pinning that check order. diff --git a/docs/integration/sandbox-runtime.md b/docs/integration/sandbox-runtime.md index 75456f9b..cbc1fbe6 100644 --- a/docs/integration/sandbox-runtime.md +++ b/docs/integration/sandbox-runtime.md @@ -52,7 +52,12 @@ it. `runtime.platform` reads `software-only`. ## Adding a root of trust -Pass a `SandboxAttestation` and the same call emits Level 1. Nothing else changes. +Pass a `SandboxAttestation` and the same call emits a Level 1-*shaped* record: +`runtime.platform` and `runtime.measurement` carry the supplied evidence verbatim. +Nothing else about the call changes. Actual Level 1 assurance requires that evidence +to have been independently verified -- by your own attestation verifier, against +genuine hardware from the named platform -- *before* you construct the +`SandboxAttestation` below. See the boundary spelled out just after the example. ```python from agentrust_trace.adapters import SandboxAttestation @@ -71,11 +76,30 @@ This is the point of the adapter spanning both levels. A sandbox runs wherever t customer runs it, and the deployments that most need evidence are often the ones with the least hardware. One code path covers a developer laptop and a confidential VM. -**The adapter will not let you claim hardware you do not have.** `platform` is only ever -set from a supplied attestation; an attestation may not name `software-only`; the +**The adapter checks the shape of an attestation, not its truth.** `platform` is only +ever set from a supplied attestation; an attestation may not name `software-only`; the platform is checked against the enum on `RuntimeInfo` rather than a copy of it; and the -measurement must be a `sha256:` or `sha384:` digest. A record that says `tpm2` therefore -carries a measurement something other than this process produced. +measurement must be a `sha256:` or `sha384:` digest. None of that is cryptographic +verification: nothing here checks a quote, a signature, or a nonce, so code in your own +process can construct a `SandboxAttestation` with an invented platform and an invented +digest and the adapter will emit a record that says `tpm2` (or any other platform) +anyway. Call this adapter only after your own attestation verifier has checked genuine +evidence from the named platform. This is the same boundary [trust levels](../trust-levels.md#level-1-hardware-evidence) +states for every Level 1 producer: `agentrust_trace.verify_record` does not appraise +hardware quotes, and this adapter does not either. + +**Verified evidence is still not the whole requirement.** [Trust levels](../trust-levels.md#level-1-hardware-evidence) +says Level 1 needs "authenticated evidence binding the record-signing key to the expected +environment," and [verification](../verification.md#verifying-hardware-rooted-records) +puts the responsibility for that binding on the producing profile -- this adapter, for a +sandbox runtime. It does not define one: nothing here ties `measurement` or `nonce` to the +key you eventually pass to `sign_record`, and that key is chosen independently of, and +after, whatever attestation you verified. Verifying a genuine quote and then signing with +an unrelated key is still not Level 1 assurance; this code cannot distinguish that case +from a fabricated one. If your platform's quote supports a caller-supplied challenge +(TPM qualifying data, SEV-SNP `REPORT_DATA`, TDX `REPORTDATA`), request it with that field +set to a value derived from the signing key's RFC 7638 thumbprint, verify the binding +yourself, and only then carry the challenge through as `nonce`. ## Field mapping @@ -112,11 +136,11 @@ when an appraisal actually happened. ## Levels -| Level | What you pass | `runtime.platform` | -|---|---|---| -| 0 | nothing extra | `software-only` | -| 1 | a `SandboxAttestation` | the attested platform | -| 2 | Level 1 plus `transparency=` a SCITT receipt URI | the attested platform | +| Level | What you pass | `runtime.platform` | Assurance this establishes on its own | +| ----- | ------------------------------------------------ | ---------------------- | -------------------------------------- | +| 0 | nothing extra | `software-only` | None claimed -- honestly unattested | +| 1 | a `SandboxAttestation` | the attested platform | Record shape only. Real Level 1 assurance requires the supplied evidence to have been independently verified by your own attestation verifier before construction, *and* that verification to bind the record-signing key to the attested environment -- this adapter defines no such binding | +| 2 | Level 1 plus `transparency=` a SCITT receipt URI | the attested platform | Same as Level 1, plus a transparency receipt -- the receipt does not itself verify the hardware evidence | `transparency` defaults to `None`, which leaves the key out of the record. That is correct below Level 2: an unanchored record has no receipt to name. @@ -128,8 +152,15 @@ this adapter. ## Worked example -`examples/sandbox-runtime.json` is a TPM 2.0 rooted record produced by this adapter, -validating as-is against the schema. +`examples/sandbox-runtime.json` shows the record *shape* this adapter emits for a +`tpm2` attestation, and it validates as-is against the schema. It is not a TPM-rooted +record: `runtime.measurement`, `runtime.nonce`, and `cnf.jwk` in that file are +illustrative placeholder values with no relationship to each other or to any real quote +-- decoding the `nonce` shows it is literally the string `sandbox-runtime-nonce`, not a +challenge bound to the `cnf.jwk` beside it. Do not copy this file as a template for a +genuinely attested record without replacing every one of those fields with values your +own attestation verifier produced, including a `nonce` actually bound to your signing +key if your platform supports that (see "Adding a root of trust" above). ## Related diff --git a/examples/README.md b/examples/README.md index a27df32d..fb676c68 100644 --- a/examples/README.md +++ b/examples/README.md @@ -21,9 +21,10 @@ checking any archived record, including these. - `amd-sev-snp.json`: AMD SEV-SNP example. - `nvidia-h100.json`: NVIDIA H100 Confidential Computing example. - `tpm2.json`: TPM 2.0 example. -- `sandbox-runtime.json`: a sandboxed agent runtime, TPM 2.0 rooted. Produced by - `TraceSandboxAdapter`; the decision log is a kernel-sandbox policy trace rather - than MCP tool calls. +- `sandbox-runtime.json`: a sandboxed agent runtime in the shape a `tpm2` attestation + produces. Its measurement, nonce and key are placeholders; see + `docs/integration/sandbox-runtime.md`. Produced by `TraceSandboxAdapter`; the decision + log is a kernel-sandbox policy trace rather than MCP tool calls. - `action-receipts/`: informative fixture shapes for action-level receipt verification. These are not TRACE Trust Records and are not validated against `schema/trace-claim.json`. diff --git a/src/agentrust_trace/adapters/sandbox.py b/src/agentrust_trace/adapters/sandbox.py index 6210e9df..9c7afe3c 100644 --- a/src/agentrust_trace/adapters/sandbox.py +++ b/src/agentrust_trace/adapters/sandbox.py @@ -27,11 +27,42 @@ the call changes. An adapter that could only emit Level 0 would force a second code path for the deployments that matter most. -**It will not let a caller claim hardware it does not have.** ``platform`` is only ever -set from a supplied attestation, an attestation may not name ``software-only``, and the -platform is checked against the enum on :class:`~agentrust_trace.models.RuntimeInfo` -rather than a copy of it. A record that says ``tpm2`` therefore carries a measurement -that something other than this process produced. +**It rejects the ways a caller can misname unattested evidence; it does not appraise the +evidence itself.** ``platform`` is only ever set from a supplied attestation, an +attestation may not name ``software-only``, the platform is checked against the enum on +:class:`~agentrust_trace.models.RuntimeInfo` rather than a copy of it, and +``measurement`` must be a ``sha256:``/``sha384:`` digest. That is shape validation, not +cryptographic verification: nothing here checks a quote, a signature, or a nonce, so +constructing a :class:`SandboxAttestation` with an invented platform and an invented +digest is accepted and reaches the record unchanged. This is the same boundary +docs/trust-levels.md states for every Level 1 producer -- +``agentrust_trace.sign.verify_record`` does not appraise hardware quotes either -- and it +applies here for the same reason: appraising the evidence requires the platform's own +verification path (a TDX/SEV-SNP quote check against the vendor's key, a TPM quote check +against a known PCR policy, and so on), which is outside what a record-shaped library can +do generically. Pass a :class:`SandboxAttestation` only after your own attestation +verifier has checked genuine evidence from the named platform; passing one built from +data your process invented produces a Level 1-shaped record with no Level 1 assurance +behind it. + +**Independently verified evidence is necessary but not sufficient.** docs/trust-levels.md +is explicit that Level 1 needs "authenticated evidence binding the record-signing key to +the expected environment," and docs/verification.md puts the responsibility for that +binding on "the producing profile." This adapter is that producing profile for a sandbox +runtime, and it defines no binding: nothing here ties ``measurement`` (or ``nonce``) to +the specific key that ends up in the record's ``cnf.jwk`` -- the key :func:`sign_record` +is called with is chosen independently of, and after, whatever attestation was verified. +A caller who verifies a genuine quote and then signs with an unrelated key produces a +record that is no more bound to hardware than a fabricated one; :class:`SandboxAttestation` +and :func:`~agentrust_trace.sign.sign_record` cannot tell the two cases apart. If your +attestation flow supports a caller-supplied challenge (a TPM quote's qualifying data, an +SNP report's ``REPORT_DATA``, a TDX quote's ``REPORTDATA``), bind it yourself: request the +quote with that field set to a value derived from the signing key you are about to pass to +``sign_record`` (for example its RFC 7638 thumbprint), verify the quote's binding to that +value in your own verifier, and only then carry it through as :attr:`SandboxAttestation.nonce` +so a downstream verifier that knows your convention can check it too. +:func:`~agentrust_trace.sign.verify_record` does not check this binding either -- nothing +in this codebase does; it is on you and on whatever verifier you point at these records. Sandbox identity and image are carried in the existing v0.2 fields (``subject`` and ``build_provenance.digest``). A dedicated ``sandbox`` object belongs in a later profile; @@ -80,6 +111,21 @@ class SandboxAttestation: Supply this when the host produced attestation evidence. Omit it and the record is Level 0, marked ``software-only``, which is the honest description of a sandbox on a machine with no root of trust. + + This dataclass validates shape only (an accepted platform name, a digest-shaped + measurement); it does not verify that the evidence is genuine. Construct one only + from a measurement your own attestation verifier obtained from the named platform, + not from a value your process computed or invented -- nothing downstream of this + constructor can tell the difference. + + Verifying the evidence is genuine is still not the same as Level 1 assurance: TRACE + also requires that evidence to bind the record-signing key to the attested + environment (see the module docstring). This dataclass has no field that expresses + that binding on its own -- ``nonce`` is carried through to the record verbatim, on + trust, and is not checked against ``measurement``, against any key, or against + anything else. If you bind your quote's challenge to your signing key yourself, + ``nonce`` is where that value goes; if you do not, leaving it unset is more honest + than filling it with a value that implies a binding nobody verified. """ platform: str @@ -94,6 +140,12 @@ class SandboxAttestation: rim_uri: str | None = None firmware_version: str | None = None nonce: str | None = None + """Opaque, carried into ``runtime.nonce`` unchanged. If your attestation verifier + bound the quote's challenge to the key you will sign this record with, this is + where that challenge goes; nothing here binds it to the key, and downstream + ``verify_record(expected_nonce=...)`` compares it only with a value the verifier + already knows. Omit it rather than fill it with a value that was never actually + bound to anything.""" def __post_init__(self) -> None: if not isinstance(self.platform, str): @@ -248,7 +300,18 @@ def __init__( def build_trust_record(self, session: SandboxSessionResult) -> dict[str, Any]: """Return an unsigned Trust Record for *session*. - Level 0 when ``session.attestation`` is ``None``, Level 1 when it is supplied. + Level 0 when ``session.attestation`` is ``None``. Otherwise the record is + Level 1-*shaped*: ``runtime.platform`` and ``runtime.measurement`` carry the + supplied evidence verbatim, with no cryptographic check performed on it here. + Actual Level 1 assurance requires that evidence to have been independently + verified -- by your own attestation verifier, against the named platform -- + before you construct the :class:`SandboxAttestation`, *and* it requires that + verification to bind the key :func:`~agentrust_trace.sign.sign_record` is + called with to the attested environment. This method has no way to check + either: the ``cnf`` it returns is a placeholder, and the real key arrives + later, at ``sign_record``, decided independently of whatever attestation was + passed here. See the module docstring and :class:`SandboxAttestation` for this + boundary. """ bundle_hash = self.bundle_hash(session.policy_bundle_bytes) runtime = self._runtime(session, bundle_hash) diff --git a/tests/test_sandbox_adapter.py b/tests/test_sandbox_adapter.py index e6d1050d..85d75ba1 100644 --- a/tests/test_sandbox_adapter.py +++ b/tests/test_sandbox_adapter.py @@ -175,7 +175,7 @@ def test_the_same_session_is_level_0_or_level_1_by_attestation_alone() -> None: # --------------------------------------------------------------------------- -# 4. A caller must not be able to claim hardware it does not have +# 4. A caller cannot misname unattested evidence (shape validation only) # --------------------------------------------------------------------------- def test_attestation_rejects_software_only_as_a_platform() -> None: @@ -197,6 +197,89 @@ def test_attestation_rejects_a_measurement_that_is_not_a_digest(measurement: str SandboxAttestation(platform="tpm2", measurement=measurement) +# --------------------------------------------------------------------------- +# 4b. Shape validation is not evidence verification, and must not be mistaken +# for it: SandboxAttestation/TraceSandboxAdapter do not, and cannot from +# this input alone, verify that a measurement was ever produced by the +# named platform. A digest-shaped, enum-valid attestation is accepted +# verbatim even when the caller invented every byte of it. Pinning this +# documents the actual contract (docs/trust-levels.md's Level 1 boundary: +# "agentrust_trace.verify_record does not itself appraise hardware +# quotes") rather than letting it silently regress into a false sense of +# verification, or silently regress into the adapter starting to reject +# input it has never had grounds to trust or distrust. +# --------------------------------------------------------------------------- + +def test_a_fabricated_but_well_shaped_attestation_is_accepted_verbatim() -> None: + """Documents the boundary: shape validation, not cryptographic appraisal. + + Nothing in ``SandboxAttestation`` or ``TraceSandboxAdapter`` checks a quote, a + signature, or a nonce. A caller who never touched real hardware can build an + attestation entirely from invented values, as long as they are shaped like real + evidence, and the adapter reproduces them in the signed record unchanged. Verifying + that a measurement actually came from the named platform is the responsibility of + the caller's own attestation verifier, run *before* constructing the + ``SandboxAttestation`` -- see the sandbox.py module docstring and + docs/integration/sandbox-runtime.md. + """ + fabricated = SandboxAttestation( + platform="amd-sev-snp", + measurement="sha256:" + "0" * 64, + ) + record = _make_adapter().build_trust_record(_make_session(attestation=fabricated)) + assert record["runtime"]["platform"] == "amd-sev-snp" + assert record["runtime"]["measurement"] == "sha256:" + "0" * 64 + # Structurally valid, and signable, despite carrying no actual hardware evidence. + TrustRecord.model_validate(record) + key = generate_key() + signed = sign_record(record, key) + verify_record(signed, key_to_jwk(key)) + + +def test_genuinely_verified_evidence_is_still_not_bound_to_the_signing_key() -> None: + """Documents a second, separate gap from the fabricated-evidence one above. + + Even a caller who *did* independently verify genuine hardware evidence before + constructing a ``SandboxAttestation`` -- doing everything the module docstring now + asks of them -- still cannot get real Level 1 assurance out of this adapter, because + nothing here binds that evidence to the specific key the record ends up signed + with. ``nonce`` is carried through verbatim and is never checked against + ``cnf.jwk``, against the signing key passed to ``sign_record``, or against + anything else. Two records built from the identical (hypothetically genuine) + attestation but signed with unrelated, unrelated-to-the-hardware keys both verify + successfully; nothing distinguishes "the verified key" from "any key the caller + felt like using afterwards". Per docs/trust-levels.md, Level 1 requires + "authenticated evidence binding the record-signing key to the expected + environment"; per docs/verification.md, defining that binding is this producing + profile's job, and it does not define one. + """ + same_attestation = SandboxAttestation( + platform="tpm2", + measurement=TPM_MEASUREMENT, + # A caller following the module's own advice: a nonce that claims to bind a + # challenge to *some* key. Nothing checks that it binds to the key used below. + nonce="claimed-binding-to-key-A", + ) + record_a = _make_adapter().build_trust_record(_make_session(attestation=same_attestation)) + record_b = _make_adapter().build_trust_record(_make_session(attestation=same_attestation)) + + key_a = generate_key() + key_b = generate_key() # Unrelated to whatever "claimed-binding-to-key-A" meant. + + signed_a = sign_record(record_a, key_a) + signed_b = sign_record(record_b, key_b) + + # Both verify: the adapter and sign_record() accept the same "verified" evidence + # bound to a nonce string regardless of which key actually signs the record. + verify_record(signed_a, key_to_jwk(key_a)) + verify_record(signed_b, key_to_jwk(key_b)) + # The nonce, and therefore the claimed binding, is identical in both -- yet the + # embedded confirmation keys differ. Nothing here or in sign_record/verify_record + # detects that the "binding" is meaningless. + assert signed_a["runtime"]["nonce"] == signed_b["runtime"]["nonce"] + assert signed_a["cnf"]["jwk"]["x"] != signed_b["cnf"]["jwk"]["x"] + + def test_accepted_platforms_are_read_from_the_model() -> None: """Guards against a hand-maintained copy drifting from RuntimeInfo.""" from agentrust_trace.adapters.sandbox import _PLATFORMS