1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
//! `assay evidence attest` — sign a bundle's manifest as an in-toto/DSSE attestation.
//!
//! Wraps `assay_evidence::attestation` (ADR-039): opens and verifies an evidence
//! bundle, builds an in-toto v1 Statement over its integrity root, and signs it
//! as a DSSE envelope with an Ed25519 key (PKCS#8 PEM, as produced by
//! `assay mcp tool keygen`). The anchor (transparency log / timestamp) stays
//! external. Attestation binds who-said-it and the bundle content; it does not
//! upgrade observed support.
use anyhow::{Context, Result};
use assay_common::limits::{LimitKind, LimitReader};
use assay_evidence::attestation::{sign_statement, statement_for_bundle_with_limits};
use assay_evidence::VerifyLimits;
use clap::Args;
use ed25519_dalek::pkcs8::DecodePrivateKey;
use ed25519_dalek::SigningKey;
use std::fs::File;
use std::io::Read;
use std::path::PathBuf;
#[derive(Debug, Args, Clone)]
pub struct AttestArgs {
/// Path to the evidence bundle (.tar.gz) to attest.
#[arg(long)]
pub bundle: PathBuf,
/// Path to the Ed25519 private key (PKCS#8 PEM; see `assay mcp tool keygen`).
#[arg(long)]
pub key: PathBuf,
/// Not accepted: the evidence-bundle/v1 predicate is derived from the bundle so a consumer
/// can cross-check it against the artifact (ADR-044). Passing this flag is an error.
#[arg(long)]
pub predicate: Option<PathBuf>,
/// Write the DSSE envelope here (default: stdout).
#[arg(long)]
pub out: Option<PathBuf>,
}
/// Read a bundle file, never taking in more than the source ceiling allows.
///
/// A named function so the ceiling can be exercised with a small limit. Inline, the only way to
/// prove it bit was to hand the command a file larger than the 100 MB default, which is why the
/// equivalent bound elsewhere in this repository went untested for as long as it did.
fn read_bundle_bounded(path: &std::path::Path, limits: VerifyLimits) -> Result<Vec<u8>> {
let mut file = File::open(path).with_context(|| format!("open bundle {}", path.display()))?;
let mut bytes = Vec::new();
LimitReader::new(&mut file, limits.max_bundle_bytes, LimitKind::SourceBytes)
.read_to_end(&mut bytes)
.with_context(|| format!("read bundle {}", path.display()))?;
Ok(bytes)
}
pub fn cmd_attest(args: AttestArgs) -> Result<i32> {
run(args)?;
Ok(0)
}
fn run(args: AttestArgs) -> Result<()> {
// 1. Refuse an unusable invocation before touching anything on disk.
//
// `--predicate` is rejected rather than silently ignored: every v1 field is derived from the
// bundle so a consumer can cross-check it against the artifact, and a predicate the caller
// writes by hand cannot offer that.
//
// Ordered first because the operator should learn what is wrong with their command, not
// what is wrong with their filesystem. Checked after the reads, a bad path masked the flag
// error entirely, so someone debugging a missing bundle would fix the path and only then
// discover their predicate was never going to be used.
if args.predicate.is_some() {
anyhow::bail!(
"--predicate is not accepted for evidence-bundle/v1: every field is derived from the \
bundle so a consumer can cross-check it against the artifact (ADR-044)"
);
}
// 2. Read the bundle through the source ceiling, before anything is materialized.
//
// ADR-043 §1: the limit applies to the stream. `std::fs::read` sized the allocation from
// the file, so an oversized archive was already in memory by the time `max_bundle_bytes`
// could have an opinion — the same class this repository closed for `evidence push`, left
// open here because this path reads the bundle for a different reason.
//
// `args.bundle` is a user-selected path by contract: this subcommand exists to attest a
// bundle the operator names, and the key and output paths are theirs too. Containing them
// under the repository root would change the published interface to satisfy a taint
// tracker, so the path stays operator-chosen and the exposure that matters — how much of it
// is read — is bounded here instead.
let limits = VerifyLimits::default();
let bundle_bytes = read_bundle_bounded(&args.bundle, limits)?;
// 3. Load the Ed25519 signing key (PKCS#8 PEM).
let pem = std::fs::read_to_string(&args.key)
.with_context(|| format!("read key {}", args.key.display()))?;
let key = SigningKey::from_pkcs8_pem(&pem).context("parse Ed25519 PKCS#8 PEM key")?;
// 4. Build the statement from the bounded bytes. Verification, predicate derivation and the
// subject name all happen inside that one call, against these bytes, so the CLI has no way
// to pair a predicate with an artifact it does not describe.
let statement = statement_for_bundle_with_limits(&bundle_bytes, limits)?;
let envelope = sign_statement(&statement, &key).context("sign in-toto statement")?;
let json = serde_json::to_string_pretty(&envelope).context("serialize DSSE envelope")?;
// 5. Write the DSSE envelope.
match &args.out {
Some(p) => {
std::fs::write(p, format!("{json}\n"))
.with_context(|| format!("write {}", p.display()))?;
eprintln!("Attestation: {}", p.display());
}
None => println!("{json}"),
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use assay_evidence::attestation::{verify_envelope_signature, DsseEnvelope};
use assay_evidence::bundle::BundleWriter;
use assay_evidence::types::{EvidenceEvent, ProducerMeta};
use ed25519_dalek::pkcs8::{spki::der::pem::LineEnding, EncodePrivateKey};
struct Fixture {
dir: PathBuf,
bundle: PathBuf,
key: PathBuf,
out: PathBuf,
signing: SigningKey,
}
impl Drop for Fixture {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn fixture(tag: &str) -> Fixture {
let dir =
std::env::temp_dir().join(format!("assay-attest-cli-{}-{tag}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bundle = dir.join("bundle.tar.gz");
let key = dir.join("private_key.pem");
let out = dir.join("attestation.json");
let producer = ProducerMeta {
name: "assay-cli".into(),
version: "test".into(),
git: None,
};
let file = File::create(&bundle).unwrap();
let mut writer = BundleWriter::new(file).with_producer(producer.clone());
writer.add_event(
EvidenceEvent::new(
"assay.test.event",
"urn:assay:test",
"attest_run",
0,
serde_json::json!({}),
)
.with_producer(&producer),
);
writer.finish().unwrap();
let signing = SigningKey::from_bytes(&[7u8; 32]);
std::fs::write(
&key,
signing.to_pkcs8_pem(LineEnding::LF).unwrap().as_bytes(),
)
.unwrap();
Fixture {
dir,
bundle,
key,
out,
signing,
}
}
#[test]
fn attest_produces_an_envelope_that_verifies_against_the_bundle() {
let f = fixture("happy");
run(AttestArgs {
bundle: f.bundle.clone(),
key: f.key.clone(),
predicate: None,
out: Some(f.out.clone()),
})
.expect("attest");
let raw = std::fs::read_to_string(&f.out).unwrap();
let envelope: DsseEnvelope = serde_json::from_str(&raw).unwrap();
// Signature alone is a state, not a verdict: it says who signed, not which artifact.
let signed = verify_envelope_signature(&envelope, &f.signing.verifying_key()).expect("sig");
assert_eq!(signed.statement.type_, "https://in-toto.io/Statement/v1");
// The verdict needs the bytes. Read through the command's own bounded reader rather than
// `std::fs::read`: the test path is test-authored, but reaching for the unbounded call
// here is what let the production path keep one for so long.
let bytes = read_bundle_bounded(&f.bundle, VerifyLimits::default()).expect("read");
let attested = assay_evidence::attestation::verify_attestation_for_bundle(
&envelope,
&f.signing.verifying_key(),
&bytes,
)
.expect("attestation must verify against the bundle it describes");
assert_eq!(attested.predicate.run.event_count, 1);
let mut other = bytes.clone();
other.extend_from_slice(b"trailing");
let err = assay_evidence::attestation::verify_attestation_for_bundle(
&envelope,
&f.signing.verifying_key(),
&other,
)
.expect_err("a different artifact must not match");
assert!(err.to_string().contains("does not match"), "got: {err}");
}
/// `--predicate` is refused, not ignored.
///
/// Silently dropping it would leave the operator believing a predicate they wrote was signed.
#[test]
fn an_explicit_predicate_is_refused() {
let f = fixture("predicate");
let predicate = f.dir.join("predicate.json");
std::fs::write(&predicate, "{}").unwrap();
let err = run(AttestArgs {
bundle: f.bundle.clone(),
key: f.key.clone(),
predicate: Some(predicate),
out: Some(f.out.clone()),
})
.expect_err("--predicate must be refused");
assert!(err.to_string().contains("--predicate"), "got: {err}");
assert!(!f.out.exists(), "nothing may be signed after a refusal");
}
/// The refusal comes before any file is touched, and the paths prove the order.
///
/// Both paths do not exist. If the flag were checked after the bundle read, the command would
/// fail on the missing file instead — a different refusal, for a different reason, that a test
/// asserting only `is_err()` would happily accept. The operator gets told what is wrong with
/// their invocation rather than what is wrong with their filesystem.
#[test]
fn the_predicate_flag_is_refused_before_any_file_is_touched() {
let f = fixture("order");
let predicate = f.dir.join("predicate.json");
std::fs::write(&predicate, "{}").unwrap();
let err = run(AttestArgs {
bundle: f.dir.join("no-such-bundle.tar.gz"),
key: f.dir.join("no-such-key.pem"),
predicate: Some(predicate),
out: Some(f.out.clone()),
})
.expect_err("--predicate must be refused");
assert!(
err.to_string().contains("--predicate"),
"the refusal must be about the flag, got: {err}"
);
assert!(
!err.to_string().contains("no-such-bundle"),
"the bundle must not have been opened, got: {err}"
);
assert!(!f.out.exists(), "nothing may be written after a refusal");
}
/// The help text must not promise behaviour the command refuses.
#[test]
fn the_predicate_help_does_not_advertise_a_default() {
use clap::Args as _;
let help = AttestArgs::augment_args(clap::Command::new("attest"))
.render_long_help()
.to_string();
assert!(
!help.contains("default: a minimal summary"),
"the help still advertises a default for a flag that is rejected:\n{help}"
);
// Case-insensitive: the claim is about what the help says, not how it is capitalised.
assert!(
help.to_lowercase().contains("not accepted"),
"the help must say the flag is rejected:\n{help}"
);
}
/// The source ceiling applies to the read itself, before the bundle is materialized.
#[test]
fn the_source_ceiling_bounds_the_read() {
let f = fixture("ceiling");
let tiny = VerifyLimits {
max_bundle_bytes: 8,
..VerifyLimits::default()
};
let err =
read_bundle_bounded(&f.bundle, tiny).expect_err("must refuse an oversized source");
let chain = format!("{err:#}");
assert!(
chain.contains("source bytes limit"),
"expected the source ceiling to be the reason, got: {chain}"
);
// And the same file passes under the real ceiling, so the case is about the limit rather
// than about the file being unreadable.
assert!(read_bundle_bounded(&f.bundle, VerifyLimits::default()).is_ok());
}
}