Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 43 additions & 12 deletions docs/integration/sandbox-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down
74 changes: 68 additions & 6 deletions src/agentrust_trace/adapters/sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -94,6 +140,11 @@ 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 or downstream checks that binding, so an
unrelated string is accepted just as readily. 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):
Expand Down Expand Up @@ -248,7 +299,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)
Expand Down
Loading
Loading