Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 4 additions & 1 deletion .github/actions/generate-policy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ RUN apt-get update && \

RUN pip install --no-cache-dir pyyaml

# One pinned toolchain serves both renderers, since the reference values in
# every generated policy are a function of it.
# TODO Switch to ref on a main branch
ARG CVM_MEASURE_REPO=https://github.com/cohere-ai/cvm-measure.git
ARG CVM_MEASURE_REF=7c2e57fd546f5f25056376e936adccf498e9ef81
ARG CVM_MEASURE_REF=350faa569c63b8a59202b36a1d413119c405440b

RUN git init /tmp/cvm-measure && \
git -C /tmp/cvm-measure remote add origin "${CVM_MEASURE_REPO}" && \
Expand Down
10 changes: 9 additions & 1 deletion .github/actions/generate-policy/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ inputs:
description: "Path to write intermediate artifacts (baselines, firmware, UKI, per-target measurements)"
required: true
policy-types:
description: "Which policies to generate, space- or comma-separated. Supported: ita"
description: "Which policies to generate, space- or comma-separated. Supported: ita, trustee"
required: true
predicate-file:
description: "Path to predicate JSON file; read and updated in place with target data (optional)"
Expand All @@ -25,6 +25,14 @@ outputs:
description: "Path to the generated ITA policy, empty unless ita was requested"
ita-target-count:
description: "How many manifest targets the ITA policy covers, 0 if none matched"
trustee-cpu-policy-file:
description: "Path to the generated Trustee CPU policy, empty unless trustee was requested"
trustee-gpu-policy-file:
description: "Path to the generated Trustee GPU policy, empty unless trustee was requested"
trustee-policy-dir:
description: "Directory holding the Trustee policies, to configure alongside policy_ids: [trustee_policy]"
trustee-target-count:
description: "How many manifest targets the Trustee policies cover, 0 if none matched"

runs:
using: "docker"
Expand Down
2 changes: 2 additions & 0 deletions .github/actions/generate-policy/generate_policy/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
policy_output_dir,
)
from .ita import ItaRenderer
from .trustee import TrusteeRenderer


def build_renderers(output_dir: Path) -> dict[str, Renderer]:
Expand All @@ -38,6 +39,7 @@ def build_renderers(output_dir: Path) -> dict[str, Renderer]:
baselines_repo=os.environ["BASELINES_REPO"],
output_dir=output_dir,
),
TrusteeRenderer.name: TrusteeRenderer(output_dir=output_dir),
}


Expand Down
7 changes: 7 additions & 0 deletions .github/actions/generate-policy/generate_policy/generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ class RenderResult:
outputs: dict[str, str]
predicate_targets: dict[int, dict]

# Top-level predicate keys, for whatever a service's policies assert that
# is not a property of any one target. A renderer namespaces its own key.
predicate_metadata: dict = field(default_factory=dict)


@dataclass
class GenerationContext:
Expand Down Expand Up @@ -334,6 +338,7 @@ def generate_policy(
resolved.append(ResolvedTarget(index, target, machine))

predicate_targets: dict[int, dict] = {}
predicate_metadata: dict = {}
target_counts: dict[str, int] = {}
for renderer in renderers:
selected: list[ResolvedTarget] = []
Expand All @@ -354,6 +359,7 @@ def generate_policy(
sys.exit(1)
for index, entry in result.predicate_targets.items():
predicate_targets.setdefault(index, {}).update(entry)
predicate_metadata.update(result.predicate_metadata)
target_counts[renderer.name] = len(selected)
write_outputs({
**result.outputs,
Expand Down Expand Up @@ -382,6 +388,7 @@ def generate_policy(
# legible from the signed artifact rather than discovered later from
# a node failing attestation.
predicate["target_counts"] = target_counts
predicate.update(predicate_metadata)
predicate["targets"] = [
predicate_targets[index] for index in sorted(predicate_targets)
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,13 @@ matches_tdx if {
nvgpu_device_base_checks(gpu) if {
gpu.hwmodel == "GH100"

# One unique entry per PodVM image in the manifest. An empty set denies,
# since indexing it is undefined.
# Debug off. Without it the measurements below say nothing about what the
# device will do next.
gpu.dbgstat == "disabled"

# One entry per distinct driver version across the manifest's PodVM images,
# so images sharing a driver collapse to one. An empty set denies, since
# indexing it is undefined.
${NVIDIA_DRIVER_VERSIONS}
accepted_gpu_driver_versions[gpu["x-nvidia-gpu-driver-version"]]

Expand All @@ -101,16 +106,22 @@ ${NVIDIA_DRIVER_VERSIONS}
gpu["x-nvidia-gpu-attestation-report-parsed"] == true
gpu["x-nvidia-gpu-attestation-report-signature-verified"] == true
gpu["x-nvidia-gpu-attestation-report-cert-chain"]["x-nvidia-cert-status"] == "valid"
gpu["x-nvidia-gpu-attestation-report-cert-chain"]["x-nvidia-cert-ocsp-status"] == "good"
gpu["x-nvidia-gpu-attestation-report-cert-chain-fwid-match"] == true

# Status alone leaves a revoked signer valid, so every chain is checked
# against OCSP too, the RIM signers included: a RIM NVIDIA has disowned
# would otherwise still license the measurements compared against it.
gpu["x-nvidia-gpu-driver-rim-cert-chain"]["x-nvidia-cert-status"] == "valid"
gpu["x-nvidia-gpu-driver-rim-cert-chain"]["x-nvidia-cert-ocsp-status"] == "good"
gpu["x-nvidia-gpu-driver-rim-fetched"] == true
gpu["x-nvidia-gpu-driver-rim-measurements-available"] == true
gpu["x-nvidia-gpu-driver-rim-schema-validated"] == true
gpu["x-nvidia-gpu-driver-rim-signature-verified"] == true
gpu["x-nvidia-gpu-driver-rim-version-match"] == true

gpu["x-nvidia-gpu-vbios-rim-cert-chain"]["x-nvidia-cert-status"] == "valid"
gpu["x-nvidia-gpu-vbios-rim-cert-chain"]["x-nvidia-cert-ocsp-status"] == "good"
gpu["x-nvidia-gpu-vbios-rim-fetched"] == true
gpu["x-nvidia-gpu-vbios-rim-measurements-available"] == true
gpu["x-nvidia-gpu-vbios-rim-schema-validated"] == true
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/generate-policy/generate_policy/ita.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
ResolvedTarget,
resolve_nvidia_driver_versions,
)
from .measure import compute_initdata_rtmr3, compute_measurements
from .measure import compute_initdata_rtmr3, compute_tdx_measurements

TEMPLATE = Path(__file__).parent / "ita-template.rego"

Expand Down Expand Up @@ -366,7 +366,7 @@ def _get_platform_measurements(
f" Computing platform measurements for "
f"{baseline.firmware_sha384[:12]}/{baseline.version}..."
)
computed = compute_measurements(
computed = compute_tdx_measurements(
ram_gib=ram_gib,
initdata=initdata,
firmware_path=baseline.firmware_path,
Expand Down
42 changes: 38 additions & 4 deletions .github/actions/generate-policy/generate_policy/measure.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""Compute TDX measurements for a single target using cvm-measure."""
"""Compute a single target's measurements using cvm-measure.

One function per cvm-measure subcommand rather than one parameterized by TEE:
the subcommands do not take the same inputs, because the registers they predict
are not derived from the same things.
"""

from __future__ import annotations

Expand Down Expand Up @@ -43,7 +48,7 @@ def resolve_initdata(target: dict, manifest_file: Path) -> bytes:
return initdata


def compute_measurements(
def compute_tdx_measurements(
ram_gib: int,
initdata: bytes,
firmware_path: Path,
Expand All @@ -58,7 +63,7 @@ def compute_measurements(
initdata_toml = output_dir / "initdata.toml"
initdata_toml.write_bytes(initdata)

cmd = [
return _run_cvm_measure([
"cvm-measure", "tdx",
"--firmware", str(firmware_path),
"--uki", str(uki_path),
Expand All @@ -67,8 +72,37 @@ def compute_measurements(
"--ram", str(ram_gib),
"--initdata", str(initdata_toml),
"--output-format", "json",
]
], output_dir)


def compute_azure_snp_pcrs(
initdata: bytes,
uki_path: Path,
disk_path: Path,
output_dir: Path,
) -> dict:
"""Compute Azure SEV-SNP vTPM PCRs and write measurements.json.

No firmware and no baseline, unlike the TDX path: Azure publishes no
firmware blob, and every register this predicts is a function of the image
bytes or of the initdata. Guest memory does not enter into it either, so
no --ram.
"""
output_dir.mkdir(parents=True, exist_ok=True)

initdata_toml = output_dir / "initdata.toml"
initdata_toml.write_bytes(initdata)

return _run_cvm_measure([
"cvm-measure", "azure-snp",
"--uki", str(uki_path),
"--disk", str(disk_path),
"--initdata", str(initdata_toml),
"--output-format", "json",
], output_dir)


def _run_cvm_measure(cmd: list[str], output_dir: Path) -> dict:
print(f" $ {' '.join(cmd)}")
try:
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
# Trustee CPU appraisal policy for Cohere pod VMs.
#

package policy

import rego.v1

# AR4SI defaults. rats-cert treats 2..=31 as affirming, so these deny.
# 33: "Runtime memory includes executables ... which are not recognized."
default executables := 33

# 97: "A Verifier does not recognize an Attester's hardware or firmware."
default hardware := 97

# 36: "Elements of the configuration relevant to security are unavailable."
default configuration := 36

# 0 is "no assertion" for the dimensions this policy does not speak to. For
# example, with Azure SEV-SNP, the dm-verity roothash claim reaches
# executables through PCR 9, so file-system stays silent.
default file_system := 0

default instance_identity := 0

default runtime_opaque := 0

default storage_opaque := 0

default sourced_data := 0

trust_claims := {
"executables": executables,
"hardware": hardware,
"configuration": configuration,
"file-system": file_system,
"instance-identity": instance_identity,
"runtime-opaque": runtime_opaque,
"storage-opaque": storage_opaque,
"sourced-data": sourced_data,
}

##### Azure SEV-SNP (az-snp-vtpm)
#
# Every rule in this section is guarded on the attester key, so a section for
# another attester can be appended without two of them assigning one
# dimension different values. Helpers are prefixed with the attester for the
# same reason.
#
# Claim shapes (deps/verifier/src/az_snp_vtpm/mod.rs). Getting one wrong fails
# open into a default:
#
# - PCR keys are zero padded: pcr04, not pcr4.
# - Booleans and integers alike arrive as STRINGS.
# - measurement is STANDARD base64 of 48 bytes, not hex.
# - report_data is the TPM quote's extraData rather than the SNP report
# field of that name, and is nothing to pin: the AS has already checked it
# against the runtime data its caller supplied.
#
# An exact match here means a reboot that lets systemd-repart rewrite the
# partition UUID moves PCR 5 and is expected to fail attestation.

azsnp := input["az-snp-vtpm"]

# Microsoft's paravisor (the "Virtual Firmware" / IGVM image) as launched by
# the AMD PSP: the SNP launch measurement, base64 of 48 bytes.
#
# THIS IS LOAD BEARING. The vTPM emulating the PCRs below runs inside that
# paravisor at VMPL 0, so without this pin anyone with SEV-SNP hardware could
# run their own VMPL 0 code and quote whatever PCRs they liked. It belongs in
# executables beside them for that reason, not in hardware with the TCB.
#
# A set, because Azure firmware rolls change the value with no advance notice:
# add the new measurement alongside the old to ride one out.
azsnp_paravisor_measurements := {
${AZSNP_PARAVISOR_MEASUREMENTS}
}

# Minimum AMD secure processor TCB. Greater or equal rather than exact, so a
# platform TCB roll forward is accepted while a rollback is refused.
#
# This is a counterpart for TDX's tcb_level_not_revoked, which has no SNP
# equivalent
azsnp_min_tcb := {
${AZSNP_MIN_TCB}
}

# A section that matched no targets substitutes to nothing, leaving the name
# with no definition at all, which Rego rejects as an unsafe variable instead
# of evaluating it to false. These defaults keep that policy loadable and
# deny-only; they never apply once a generated block is present, since each
# block is a complete rule yielding true.
#
# The static rules below need no default. They always have a definition and
# merely evaluate to undefined when they do not match, which fails the
# enclosing body exactly as false would.
default azsnp_image_ok := false

default azsnp_initdata_ok := false

# Every claim arrives as a string, and Rego orders numbers before strings, so
# a bare `azsnp.reported_tcb_snp >= 27` is true for any string at all and
# fails open. to_number is what makes these floors mean anything.
azsnp_tcb_ok if {
to_number(azsnp.reported_tcb_bootloader) >= azsnp_min_tcb.bootloader
to_number(azsnp.reported_tcb_tee) >= azsnp_min_tcb.tee
to_number(azsnp.reported_tcb_snp) >= azsnp_min_tcb.snp
to_number(azsnp.reported_tcb_microcode) >= azsnp_min_tcb.microcode
}

# Guest policy bits set at SNP_LAUNCH_START. Debug would let the hypervisor
# read guest memory; a migration agent could move the VM elsewhere. Both are
# a total loss of the confidentiality this is here to provide.
#
# policy_smt_allowed only permits SMT and is true on our nodes today. What
# matters is whether the platform enables it, which azsnp_platform_ok pins.
azsnp_launch_policy_ok if {
azsnp.policy_debug_allowed == "false"
azsnp.policy_migrate_ma == "false"
}

# PLATFORM_INFO, signed by the PSP along with the rest of the report. SMT
# enabled means sibling threads, and so a cross-thread side channel into this
# guest from whatever shares the core.
azsnp_platform_ok if {
azsnp.platform_smt_enabled == "false"
}

# 3: "Only a recognized genuine set of approved executables have been loaded
# during the boot process."
executables := 3 if {
azsnp
azsnp.measurement in azsnp_paravisor_measurements
azsnp_image_ok
}

# 2: "An Attester has passed its hardware and/or firmware verifications
# needed to demonstrate that these are genuine/supported."
hardware := 2 if {
azsnp
azsnp_tcb_ok
}

# 2: "The configuration is a known and approved config."
configuration := 2 if {
azsnp
azsnp_launch_policy_ok
azsnp_platform_ok
azsnp_initdata_ok
}

# One block per pod VM image, asserting all four registers together. They are
# all measured from the same disk.raw, so splitting them into separate rules
# would admit image A's UKI beside image B's partition table: a disk that has
# never existed. Fusing them makes the tuple atomic.
#
# PCR 4 The firmware's boot chain: EV_EFI_ACTION, a separator, then
# Authenticode over the whole UKI and over its .linux section
# loaded as its own PE. The only measurement of systemd-stub
# itself.
# PCR 5 The GPT partition table.
# PCR 9 The kernel command line the EFI stub passed as LoadOptions and
# the .ucode||.initrd blob it handed the kernel. The dm-verity
# roothash= lives on that command line, so this transitively pins
# the whole root filesystem.
# PCR 11 systemd-stub's measurement of every UKI section it recognises,
# name then content. The OS image identity.
${AZSNP_IMAGE_BLOCKS}

# One block per deployment initdata, which carries the Kata agent policy and
# the KBS configuration. Read through input.init_data rather than tpm.pcr08:
# the two are byte-identical here, since extend_claim overwrites init_data
# with hex of PCR 8, but the named claim reads as an initdata binding instead
# of an opaque register compare.
#
# The value is sha256(0x00 * 32 || initdata_digest[:32]). A digest wider than
# the register is truncated to fit rather than re-hashed, so the usual sha384
# initdata gives sha256(0 || sha384(toml)[:32]) and not sha256(0 ||
# sha256(toml)).
#
# Kept separate from the image blocks, so any approved image pairs with any
# approved initdata. That factoring is deliberate: a model's initdata places
# no constraint on which approved image version the host runs, and it is what
# turns N x M blocks into N + M.
${AZSNP_INITDATA_BLOCKS}
Loading