|
| 1 | +// Proto representation of predicate type |
| 2 | +// https://in-toto.io/attestation/adversarial-execution-evidence/v0.7 |
| 3 | +// |
| 4 | +// Validation of all fields is left to the users of this proto. The predicate's |
| 5 | +// soundness lives in the verifier rules (statement well-formedness, the two |
| 6 | +// coverage/tier consumption gates, the offline `result` recompute, the |
| 7 | +// digest-integrity checks, and the per-record DSSE signature), never in this |
| 8 | +// schema: a proto-valid object is not necessarily a sound attestation. |
| 9 | +// |
| 10 | +// ----------------------------------------------------------------------------- |
| 11 | +// TWO PROPERTIES OF THIS PREDICATE MAKE THIS PROTO SUBTLE. READ BOTH BEFORE |
| 12 | +// EDITING OR GENERATING CODE FROM IT. |
| 13 | +// ----------------------------------------------------------------------------- |
| 14 | +// |
| 15 | +// 1. STRING-TYPED VOCABULARIES, NEVER proto3 ENUMS. |
| 16 | +// |
| 17 | +// `result`, `containmentObserved`, `basis`, `method`, `attribution`, |
| 18 | +// `actualLayer`, and |
| 19 | +// `networkPosture.posture` carry closed value vocabularies, yet every one is |
| 20 | +// a `string` here, not a proto3 `enum`. This is deliberate and load-bearing: |
| 21 | +// |
| 22 | +// - The AEE vocabularies are consumer-side, FAIL-CLOSED closed vocabularies. |
| 23 | +// A value outside the carried vocabulary is STRUCTURALLY VALID and |
| 24 | +// fail-closes at the verifier's recompute gate (a fail-closed row forces |
| 25 | +// `result` to `fail`), exactly as an absent `basis`/`method` does. A |
| 26 | +// proto3 enum cannot express this: an unknown wire value would deserialize |
| 27 | +// to the zero member (`UNKNOWN = 0`) and be silently swallowed, erasing |
| 28 | +// the very fail-closed signal the recompute depends on. String preserves |
| 29 | +// the unknown value verbatim for the verifier to reject. The altitude of |
| 30 | +// that rejection differs by member and the markdown states which: an |
| 31 | +// unknown `basis`/`method`/`attribution`/`containmentObserved` |
| 32 | +// fail-closes a row, while |
| 33 | +// an unknown `actualLayer` or `networkPosture.posture` makes the whole |
| 34 | +// statement malformed. Either way the verifier must see the value it was |
| 35 | +// given, which is what an enum would take away. |
| 36 | +// - `containmentObserved`'s admissible label set is not even fixed by this |
| 37 | +// predicate: it is producer vocabulary carried on-wire in |
| 38 | +// `observationEnvironment.observationVocabulary.{labels,caught}` and read |
| 39 | +// by the verifier, so no enum could enumerate it. |
| 40 | +// - It matches the in-toto house convention: there are ZERO proto3 `enum` |
| 41 | +// declarations across any merged in-toto predicate `.proto` |
| 42 | +// (vsa, scai, test_result, provenance, release, vulns, svr, ...); every |
| 43 | +// closed vocabulary in those predicates travels as a `string`. |
| 44 | +// |
| 45 | +// 2. THIS PROTO IS FOR TRANSPORT/CODEGEN ONLY. ITS JSON OUTPUT MUST NEVER BE |
| 46 | +// RE-CANONICALIZED FOR SIGNING OR RE-HASHED. |
| 47 | +// |
| 48 | +// The CANONICAL BYTES of an AEE attestation are the on-wire JSON emitted by |
| 49 | +// the producer. The DSSE signature is computed over |
| 50 | +// `PAE(payloadType, SERIALIZED_BODY)` -- the Pre-Authentication Encoding over |
| 51 | +// the body BYTES VERBATIM -- and every downstream digest (corpus manifest |
| 52 | +// digest, observation-vocabulary digest, the network-posture object digest |
| 53 | +// the run binding folds in, `batchRoot` leaves, and the run binding itself) |
| 54 | +// is taken over RFC 8785 (JCS) canonical bytes. proto3 canonical ProtoJSON is |
| 55 | +// NOT RFC 8785 / JCS and cannot be made so; at least two divergences are |
| 56 | +// unfixable: |
| 57 | +// |
| 58 | +// - ProtoJSON emits 64-bit integers (`int64`/`uint64`) as QUOTED strings, |
| 59 | +// a JSON token-type change JCS never makes (JCS keeps them as numbers). |
| 60 | +// - ProtoJSON preserves proto field declaration order; JCS lexicographically |
| 61 | +// sorts object member names by UTF-16 code unit. The two orderings differ. |
| 62 | +// (ProtoJSON/Struct also re-formats timestamps, re-pads base64, and may |
| 63 | +// drop or reformat members, each a further divergence.) |
| 64 | +// |
| 65 | +// Therefore a consumer MUST NOT parse an attestation into this proto, re-emit |
| 66 | +// its JSON, and re-hash or re-verify against that output: it would compute a |
| 67 | +// digest over bytes the producer never signed and reject a genuine bundle |
| 68 | +// (or, worse, mask a tampered one). Verify DSSE over the producer's exact |
| 69 | +// on-wire bytes; use this proto only for language bindings and transport. |
| 70 | +// (This mirrors sigstore's decision to DELETE its proto-derived JSON-Schema |
| 71 | +// rather than maintain a co-equal signed schema.) |
| 72 | +// |
| 73 | +// Consequences of (2) visible below: `manifest.classes`, |
| 74 | +// `manifest.expectedPayloads` and `_ext` use google.protobuf.Struct (proto3 |
| 75 | +// maps cannot carry repeated/array values, so a message-wrapped modeling |
| 76 | +// would ALTER the JSON shape); `payload` is the |
| 77 | +// opaque base64 string carried verbatim (not proto `bytes`, whose base64 |
| 78 | +// decode/re-encode round-trip could alter padding); and the reserved |
| 79 | +// `aee*` payload members (aeeKind, aeeMethod, aeeRunBinding, armedAt, |
| 80 | +// aeePostureDigest, aeeStillArmed, aeeDropCount, aeeChainScope, aeeRunSeq, |
| 81 | +// aeePrevRunBinding, aeePayloadCommitment, aeeAssessedAttacks, |
| 82 | +// aeeObservedSet, aeeObservedAttacks, aeeVersion, ...) are NOT modeled as |
| 83 | +// proto fields: they |
| 84 | +// live INSIDE the base64 `payload` bytes and are producer-signed and |
| 85 | +// opaque-until-verified (verify-then-read), never sibling wire fields. |
| 86 | + |
| 87 | +syntax = "proto3"; |
| 88 | + |
| 89 | +package in_toto_attestation.predicates.adversarial_execution_evidence.v07; |
| 90 | + |
| 91 | +import "in_toto_attestation/v1/resource_descriptor.proto"; |
| 92 | +import "google/protobuf/struct.proto"; |
| 93 | +import "google/protobuf/timestamp.proto"; |
| 94 | + |
| 95 | +option go_package = "github.com/in-toto/attestation/go/predicates/adversarial_execution_evidence/v07"; |
| 96 | +option java_package = "io.github.intoto.attestation.predicates.adversarial_execution_evidence.v07"; |
| 97 | + |
| 98 | +// The single signed artifact one bounded adversarial-stimulus run emits per |
| 99 | +// system under test. |
| 100 | +message AdversarialExecutionEvidence { |
| 101 | + // The fused fail|degraded|pass_indirect|pass reduction, recomputed offline |
| 102 | + // from the rest of the predicate as the MINIMUM of three independent |
| 103 | + // conditions under that order. `pass_indirect` is the value a |
| 104 | + // coverage-complete statement takes when some clean row declares a `basis` |
| 105 | + // other than `substrate` or a `method` other than `intercepted`; it says |
| 106 | + // nothing about signature verification, which is the evidence tier's |
| 107 | + // question and is not recomputable. STRING, not enum (see header rule 1): |
| 108 | + // the recompute reads this value, and a value the recompute does not |
| 109 | + // reproduce is invalid. |
| 110 | + string result = 1; |
| 111 | + |
| 112 | + ObservationEnvironment observation_environment = 2 [json_name = "observationEnvironment"]; |
| 113 | + |
| 114 | + Coverage coverage = 3; |
| 115 | + |
| 116 | + repeated AttackResult attack_results = 4 [json_name = "attackResults"]; |
| 117 | + |
| 118 | + // Optional. Absent on an artifact-only statement. When present and non-empty, |
| 119 | + // `batch_root` is required (a rule the verifier enforces, not this proto). |
| 120 | + repeated ObservationRecord observation_records = 5 [json_name = "observationRecords"]; |
| 121 | + |
| 122 | + // RFC 6962 Merkle root over `observation_records` (SHA-256, domain-separated, |
| 123 | + // recursive split). Required iff `observation_records` is non-empty, omitted |
| 124 | + // otherwise. lowercase 64-hex. |
| 125 | + string batch_root = 6 [json_name = "batchRoot"]; |
| 126 | + |
| 127 | + // Optional, advisory. Explicit negative-scope statements; a verifier MUST NOT |
| 128 | + // require it and nothing in it weakens a required check. |
| 129 | + repeated string does_not_assert = 7 [json_name = "doesNotAssert"]; |
| 130 | + |
| 131 | + // When the producer signed the evidence bundle (RFC 3339). Modeled as a |
| 132 | + // Timestamp to match the in-toto house style (cf. vsa `timeVerified`); on the |
| 133 | + // wire this is the producer's exact RFC 3339 string, which is the canonical |
| 134 | + // byte -- see header rule 2 on why ProtoJSON's reformatted output must not be |
| 135 | + // re-hashed. |
| 136 | + // |
| 137 | + // The markdown pins the profile this type leaves open: the date-time |
| 138 | + // separator and the zone designator are uppercase, and the zone designator is |
| 139 | + // `Z`, `+00:00` or `-00:00`. ProtoJSON parsers accept spellings outside that |
| 140 | + // profile, so parsing a statement into this message is not a conformance |
| 141 | + // check on the field; the verifier checks the carried string. |
| 142 | + google.protobuf.Timestamp issued_at = 8 [json_name = "issuedAt"]; |
| 143 | + |
| 144 | + // Optional producer extension. |
| 145 | + ExecutionEnvelope execution_envelope = 9 [json_name = "executionEnvelope"]; |
| 146 | + |
| 147 | + // Optional producer extension bag (`_ext` on the wire). Free-form JSON object. |
| 148 | + google.protobuf.Struct ext = 10 [json_name = "_ext"]; |
| 149 | +} |
| 150 | + |
| 151 | +// The digest-pinned context the evidence was earned under. Two of its six |
| 152 | +// members are ResourceDescriptors and the other four are locally typed; the |
| 153 | +// markdown states the rule that decides which, and its reason. |
| 154 | +message ObservationEnvironment { |
| 155 | + // The subject reference of the substrate's own attestation. A |
| 156 | + // ResourceDescriptor: the member carries a name and a digest and nothing |
| 157 | + // else, which is what the descriptor holds, and the verifier's requirement |
| 158 | + // of a `sha256` digest is one the descriptor specification permits a context |
| 159 | + // using the type to impose. The JSON member names are identical either way, |
| 160 | + // so this typing is not a wire change. |
| 161 | + in_toto_attestation.v1.ResourceDescriptor substrate = 1; |
| 162 | + |
| 163 | + // The corpus thrown at the subject. |
| 164 | + message Corpus { |
| 165 | + string name = 1; |
| 166 | + // RECOMMENDED as a purl, e.g. pkg:<producer>/<corpus>@<version>. |
| 167 | + string uri = 2; |
| 168 | + // JCS digest of the embedded `manifest` (algorithm -> lowercase-hex). |
| 169 | + map<string, string> digest = 3; |
| 170 | + |
| 171 | + // A map from assessment class code to the complete array of attack |
| 172 | + // identifiers it defines, e.g. {"CO":["CO-EXFIL-1"]}. Modeled as a Struct, |
| 173 | + // NOT map<string, repeated string>: proto3 map values cannot be `repeated`, |
| 174 | + // so any message-wrapped modeling would inject an extra JSON nesting level |
| 175 | + // and change the on-wire shape. A Struct's ProtoJSON reproduces the |
| 176 | + // map-of-arrays exactly. (Verifier re-derives `corpus.digest` from these |
| 177 | + // bytes; see header rule 2.) |
| 178 | + message Manifest { |
| 179 | + google.protobuf.Struct classes = 1; |
| 180 | + |
| 181 | + // Optional. attackId -> the array of lowercase 64-hex commitment values |
| 182 | + // a substrate is expected to carry when it observes that attack. A |
| 183 | + // Struct for the same reason `classes` is one: the values are arrays, |
| 184 | + // which a proto3 map cannot hold without injecting a nesting level the |
| 185 | + // wire shape does not have. It sits inside the pre-image |
| 186 | + // `corpus.digest` is taken over, so adding, editing or removing an |
| 187 | + // entry after the arming record is signed derives a run binding the |
| 188 | + // statement's own records do not carry. |
| 189 | + google.protobuf.Struct expected_payloads = 2 [json_name = "expectedPayloads"]; |
| 190 | + } |
| 191 | + Manifest manifest = 4; |
| 192 | + } |
| 193 | + Corpus corpus = 2; |
| 194 | + |
| 195 | + // JCS digest of the parsed catch-policy document. A ResourceDescriptor for |
| 196 | + // the same reason `substrate` is one: a digest and nothing beside it. |
| 197 | + in_toto_attestation.v1.ResourceDescriptor catch_policy = 3 [json_name = "catchPolicy"]; |
| 198 | + |
| 199 | + // The substrate-authoritative egress posture and its configuration digest. |
| 200 | + // |
| 201 | + // The run binding takes the RFC 8785 canonical digest of THIS WHOLE MESSAGE |
| 202 | + // as carried on the wire, not the value of its `digest` member, so every |
| 203 | + // member here is inside the substrate's signature and a member added or |
| 204 | + // removed after the arming record is signed invalidates the statement. That |
| 205 | + // is a reason not to extend this message casually: unlike the descriptors |
| 206 | + // above, a producer-specific member here changes a signed digest. |
| 207 | + message NetworkPosture { |
| 208 | + // STRING, not enum (see header rule 1): the registered posture vocabulary |
| 209 | + // is closed and fail-closed at exactly four values -- no_network, |
| 210 | + // allowlist, sinkhole, unsafe_bypass_egress -- and an unregistered value |
| 211 | + // makes the statement malformed. A proto3 enum would deserialize the |
| 212 | + // unregistered value to the zero member and erase the rejection, which is |
| 213 | + // the same reason every other closed vocabulary here is a string. |
| 214 | + string posture = 1; |
| 215 | + map<string, string> digest = 2; |
| 216 | + } |
| 217 | + NetworkPosture network_posture = 4 [json_name = "networkPosture"]; |
| 218 | + |
| 219 | + // The producer's on-wire, versioned observation label set. The recompute and |
| 220 | + // the coverage-validity gate read THIS carried set (not the producer's docs), |
| 221 | + // so archived attestations verify without external documentation. |
| 222 | + message ObservationVocabulary { |
| 223 | + // JCS digest of {"caught":[...],"labels":[...]} (algorithm -> lowercase-hex). |
| 224 | + // This value is also a run-binding input, so narrowing `caught` after the |
| 225 | + // run changes every record's required aeeRunBinding rather than merely |
| 226 | + // re-deriving this digest against the arrays beside it. |
| 227 | + map<string, string> digest = 1; |
| 228 | + // Every `containmentObserved` value the producer can emit; sorted ascending |
| 229 | + // by UTF-16 code unit, no duplicates, BMP-only (verifier soundness rules). |
| 230 | + repeated string labels = 2; |
| 231 | + // The subset of `labels` whose observation is a caught containment event. |
| 232 | + repeated string caught = 3; |
| 233 | + } |
| 234 | + ObservationVocabulary observation_vocabulary = 5 [json_name = "observationVocabulary"]; |
| 235 | + |
| 236 | + // The substrate-emitted run-start value folded into the run binding. OPTIONAL |
| 237 | + // here; the verifier requires it exactly when any `attack_results` row is |
| 238 | + // basis:substrate. |
| 239 | + message RunEntropy { |
| 240 | + map<string, string> digest = 1; |
| 241 | + } |
| 242 | + RunEntropy run_entropy = 6 [json_name = "runEntropy"]; |
| 243 | +} |
| 244 | + |
| 245 | +// The coverage bound: the disjoint partition of the manifest's classes into |
| 246 | +// assessed / out-of-scope / routed-elsewhere. |
| 247 | +message Coverage { |
| 248 | + // Class codes actually assessed. |
| 249 | + repeated string assessed_classes = 1 [json_name = "assessedClasses"]; |
| 250 | + // Class code -> reason string; empty when complete. A non-empty map forces |
| 251 | + // `result` to degraded. |
| 252 | + map<string, string> out_of_scope = 2 [json_name = "outOfScope"]; |
| 253 | + // Class code -> reason string; empty when complete. A non-empty map forces |
| 254 | + // `result` to degraded. |
| 255 | + map<string, string> routed_elsewhere = 3 [json_name = "routedElsewhere"]; |
| 256 | +} |
| 257 | + |
| 258 | +// One row per executed attack. |
| 259 | +message AttackResult { |
| 260 | + // Must appear in the manifest; unique across rows (one row per executed attack). |
| 261 | + string attack_id = 1 [json_name = "attackId"]; |
| 262 | + |
| 263 | + // A label from the carried `observationVocabulary.labels`. STRING, not enum |
| 264 | + // (see header rule 1): the admissible set travels on-wire in |
| 265 | + // `observationVocabulary`; a label outside the carried set is valid here and |
| 266 | + // fail-closes at the recompute gate. |
| 267 | + string containment_observed = 2 [json_name = "containmentObserved"]; |
| 268 | + |
| 269 | + // The observation's vantage (producer MUST emit `substrate` or `artifact`). |
| 270 | + // STRING, not enum: an absent or out-of-vocabulary value is valid-but- |
| 271 | + // fail-closed at the recompute gate, never proto/schema-rejected. NOT a |
| 272 | + // proto-required field for the same reason. |
| 273 | + string basis = 3; |
| 274 | + |
| 275 | + // The observation's directness (producer MUST emit `intercepted` or |
| 276 | + // `reconstructed`). STRING, not enum; same fail-closed rule as `basis`. |
| 277 | + string method = 4; |
| 278 | + |
| 279 | + // Which enforcement layer acted, drawn from the registered layer vocabulary, |
| 280 | + // OR the literal string "none" (the clean-row sentinel). STRING, not enum: a |
| 281 | + // missing value is a MALFORMED statement (a different altitude than the |
| 282 | + // fail-closed row members); the closed layer set is producer identity the |
| 283 | + // verifier checks. |
| 284 | + string actual_layer = 5 [json_name = "actualLayer"]; |
| 285 | + |
| 286 | + // Indexes into `observation_records` binding this row to its covering records. |
| 287 | + // uint32 (not int64) so ProtoJSON emits bare JSON numbers, matching the |
| 288 | + // on-wire `[0]` shape (see header rule 2 on int64 -> quoted-string divergence). |
| 289 | + // An out-of-range index makes the statement malformed (verifier rule). |
| 290 | + repeated uint32 observation_refs = 6 [json_name = "observationRefs"]; |
| 291 | + |
| 292 | + // Optional producer-defined tokens, positionally parallel to |
| 293 | + // `observation_refs`, naming the sub-observation within a referenced record's |
| 294 | + // committed payload. Producer vocabulary; nothing normative reads it. |
| 295 | + repeated string observation_selectors = 7 [json_name = "observationSelectors"]; |
| 296 | + |
| 297 | + // How firmly this row is bound to the records that cover it (producer MUST |
| 298 | + // emit `pinned` or `paired`). STRING, not enum; same fail-closed rule as |
| 299 | + // `basis`. A `pinned` row is checked against the corpus manifest's |
| 300 | + // `expectedPayloads` and the covering records' `aeePayloadCommitment`; a |
| 301 | + // `paired` row rests on a producer-asserted correspondence. The value |
| 302 | + // constrains the binding and never the result, the method or the evidence |
| 303 | + // tier. It is declared last because its field number is new, not because it |
| 304 | + // is optional: the verifier requires it on every row. |
| 305 | + string attribution = 8; |
| 306 | +} |
| 307 | + |
| 308 | +// One DSSE envelope per observation. The producer signs at observation time, |
| 309 | +// before attribution; a consumer verifies the signature (DSSE PAE over |
| 310 | +// `payload_type` and the base64-decoded `payload`) BEFORE reading any field |
| 311 | +// inside the payload (verify-then-read). |
| 312 | +// |
| 313 | +// NOTE (header rule 2): the reserved `aee*` members the verifier reads |
| 314 | +// post-verify (aeeRunBinding, aeeKind, aeeMethod, and per-kind members |
| 315 | +// armedAt / aeePostureDigest / aeeStillArmed / aeeDropCount / aeeDropBound / |
| 316 | +// aeeRunSeq / aeePrevRunBinding / aeeChainScope / aeeVersion, ...) live INSIDE |
| 317 | +// the base64 `payload` bytes as top-level members of the signed +json object. |
| 318 | +// They are producer-signed and opaque-until-verified and are deliberately NOT |
| 319 | +// modeled as proto fields of this record. |
| 320 | +message ObservationRecord { |
| 321 | + // base64(exact canonical +json bytes the substrate signed). Carried as the |
| 322 | + // opaque base64 STRING verbatim (not proto `bytes`): the DSSE PAE is over the |
| 323 | + // producer's exact base64-decoded bytes, and proto `bytes` would round-trip |
| 324 | + // through its own base64 decode/re-encode, which may alter padding. |
| 325 | + string payload = 1; |
| 326 | + |
| 327 | + // A producer-defined media type ending in `+json`. The exact bytes are the |
| 328 | + // first PAE input, so a differing value changes the signed bytes. |
| 329 | + string payload_type = 2 [json_name = "payloadType"]; |
| 330 | + |
| 331 | + // DSSE signature. `keyid` is an unauthenticated lookup hint and OPTIONAL |
| 332 | + // (never the check itself); only `sig` carries the signature. |
| 333 | + message Signature { |
| 334 | + string keyid = 1; |
| 335 | + string sig = 2; |
| 336 | + } |
| 337 | + repeated Signature signatures = 3; |
| 338 | + |
| 339 | + // Optional predicate-layer emission sequence (1-based) of this record within |
| 340 | + // the run's signed stream: the stable join key a vmi.attribution record's |
| 341 | + // catch_seq resolves against, robust to a later filter or reorder of |
| 342 | + // `observation_records`. The signed payload carries no seq. uint32 so |
| 343 | + // ProtoJSON emits a bare number (see header rule 2). Absent only when the |
| 344 | + // producer emitted an un-sequenced stream. |
| 345 | + uint32 seq = 4; |
| 346 | +} |
| 347 | + |
| 348 | +// Optional producer extension: linkage to a distributed-trace context. |
| 349 | +message ExecutionEnvelope { |
| 350 | + string otel_trace_id = 1 [json_name = "otelTraceId"]; |
| 351 | +} |
0 commit comments