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
311
312
313
314
315
316
317
318
319
//! Cosign/sigstore artifact-layout + tamper-detection classifier for
//! `apr publish sign` (CRUX-A-17).
//!
//! Contract: `contracts/crux-A-17-v1.yaml`.
//!
//! Three algorithm-level necessary conditions live here, all pure:
//!
//! 1. `artifact_paths_for(manifest)` computes the well-known `.sig`,
//! `.crt`, and `.bundle` sidecar paths. Without these three artifacts
//! existing, `cosign verify-blob` cannot succeed. Full round-trip
//! discharge of FALSIFY-CRUX-A-17-001 blocks on the external cosign
//! binary invocation.
//!
//! 2. `verify_content_hash(blob, expected_sha256)` is the sha256 gate
//! that MUST fail before any PKI check if the blob differs from
//! the signed one. This proves fail-closed tamper detection at the
//! hash layer — a necessary condition for FALSIFY-CRUX-A-17-002.
//!
//! 3. `bundle_contains_rekor_payload(bundle_json)` parses the bundle
//! JSON and returns true iff a `Payload` or `rekorBundle`-shaped key
//! is present — the inclusion-proof envelope. Necessary condition
//! for FALSIFY-CRUX-A-17-003. Full rekor-signature verification
//! blocks on live Rekor transparency-log access.
use sha2::{Digest, Sha256};
use std::path::{Path, PathBuf};
/// Trio of detached signing artifacts that `apr publish sign` must
/// produce alongside a signed manifest. Path conventions match cosign's
/// `sign-blob --output-signature/--output-certificate/--bundle` flags.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SigningArtifacts {
pub sig: PathBuf,
pub cert: PathBuf,
pub bundle: PathBuf,
}
impl SigningArtifacts {
/// True iff all three sidecar files exist on disk. This is the
/// post-condition a `verify-blob` caller needs before it can run.
pub fn all_present(&self) -> bool {
self.sig.is_file() && self.cert.is_file() && self.bundle.is_file()
}
}
/// Compute the well-known sidecar paths for a given manifest.
///
/// Convention: for `foo.json`, produce `foo.json.sig`, `foo.json.crt`,
/// and `foo.json.bundle`. This keeps the three artifacts adjacent in
/// directory listings and matches the cosign CLI's default flag output.
pub fn artifact_paths_for(manifest: &Path) -> SigningArtifacts {
let base = manifest.as_os_str().to_os_string();
let mut sig = base.clone();
sig.push(".sig");
let mut cert = base.clone();
cert.push(".crt");
let mut bundle = base;
bundle.push(".bundle");
SigningArtifacts {
sig: PathBuf::from(sig),
cert: PathBuf::from(cert),
bundle: PathBuf::from(bundle),
}
}
/// Outcome of a content-hash pre-check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VerifyOutcome {
/// sha256 matches the recorded expectation — PKI check may proceed.
ContentHashMatches,
/// sha256 differs — verify MUST fail closed before any PKI work.
ContentHashMismatch {
got_hex: String,
expected_hex: String,
},
}
/// Compute the sha256 of a blob and compare against an expected digest.
///
/// This is the necessary-condition gate for FALSIFY-CRUX-A-17-002:
/// tamper detection fails closed. If the bytes differ by even one bit,
/// the hash differs, and the verify pipeline rejects the blob before
/// reaching any X.509 / Rekor / Fulcio work.
pub fn verify_content_hash(blob: &[u8], expected_sha256: &[u8; 32]) -> VerifyOutcome {
let mut h = Sha256::new();
h.update(blob);
let got: [u8; 32] = h.finalize().into();
if &got == expected_sha256 {
VerifyOutcome::ContentHashMatches
} else {
VerifyOutcome::ContentHashMismatch {
got_hex: to_hex(&got),
expected_hex: to_hex(expected_sha256),
}
}
}
fn to_hex(bytes: &[u8; 32]) -> String {
let mut s = String::with_capacity(64);
for b in bytes {
s.push_str(&format!("{b:02x}"));
}
s
}
/// Parse a cosign/rekor bundle JSON string and check whether it carries
/// an inclusion-proof-shaped payload.
///
/// Accepts either the modern cosign bundle (top-level `Payload` +
/// `SignedEntryTimestamp`) or the legacy rekor-bundle key. Does NOT
/// cryptographically verify the inclusion — that requires a live Rekor
/// client call. This classifier only proves the envelope is shaped
/// right, which is a necessary condition for FALSIFY-CRUX-A-17-003.
pub fn bundle_contains_rekor_payload(bundle_json: &str) -> bool {
let Ok(v) = serde_json::from_str::<serde_json::Value>(bundle_json) else {
return false;
};
let Some(obj) = v.as_object() else {
return false;
};
// Cosign ≥1.10: top-level "Payload" (Rekor SET envelope) plus "Base64Signature".
if obj.contains_key("Payload") && obj.contains_key("Base64Signature") {
return true;
}
// Legacy bundle shape: nested "rekorBundle".
if obj.contains_key("rekorBundle") {
return true;
}
// Tolerate case-insensitive fallback (some tooling produces "rekor_bundle").
if obj.keys().any(|k| k.to_ascii_lowercase().contains("rekor")) {
return true;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
// ===== Artifact path layout =====
#[test]
fn artifact_paths_use_dot_suffix_convention() {
let p = Path::new("/var/artifacts/manifest.json");
let a = artifact_paths_for(p);
assert_eq!(a.sig, PathBuf::from("/var/artifacts/manifest.json.sig"));
assert_eq!(a.cert, PathBuf::from("/var/artifacts/manifest.json.crt"));
assert_eq!(
a.bundle,
PathBuf::from("/var/artifacts/manifest.json.bundle")
);
}
#[test]
fn artifact_paths_adjacent_to_manifest() {
// All three sidecars MUST share a parent with the manifest so
// `cp -r` of the manifest dir carries them along.
let p = Path::new("/tmp/x/y/m.json");
let a = artifact_paths_for(p);
assert_eq!(a.sig.parent(), p.parent());
assert_eq!(a.cert.parent(), p.parent());
assert_eq!(a.bundle.parent(), p.parent());
}
#[test]
fn artifact_paths_relative_manifest_is_fine() {
let p = Path::new("manifest.json");
let a = artifact_paths_for(p);
assert_eq!(a.sig, PathBuf::from("manifest.json.sig"));
}
#[test]
fn artifact_paths_are_deterministic() {
let p = Path::new("/tmp/m.json");
assert_eq!(artifact_paths_for(p), artifact_paths_for(p));
}
// ===== Content-hash tamper detection =====
fn sha256_of(b: &[u8]) -> [u8; 32] {
let mut h = Sha256::new();
h.update(b);
h.finalize().into()
}
#[test]
fn content_hash_matches_identical_blob() {
let blob = b"aprender model manifest v1";
let expected = sha256_of(blob);
assert_eq!(
verify_content_hash(blob, &expected),
VerifyOutcome::ContentHashMatches
);
}
#[test]
fn content_hash_rejects_single_byte_flip() {
// Necessary condition for FALSIFY-CRUX-A-17-002: any mutation
// must fail the hash gate.
let blob = b"aprender model manifest v1";
let expected = sha256_of(blob);
let mut tampered = blob.to_vec();
tampered[0] ^= 0x01;
let r = verify_content_hash(&tampered, &expected);
assert!(
matches!(r, VerifyOutcome::ContentHashMismatch { .. }),
"must reject single-byte flip: {r:?}"
);
}
#[test]
fn content_hash_rejects_trailing_byte_append() {
let blob = b"aprender model manifest v1";
let expected = sha256_of(blob);
let mut tampered = blob.to_vec();
tampered.push(0x00);
assert!(matches!(
verify_content_hash(&tampered, &expected),
VerifyOutcome::ContentHashMismatch { .. }
));
}
#[test]
fn content_hash_rejects_truncation() {
let blob = b"aprender model manifest v1";
let expected = sha256_of(blob);
let tampered = &blob[..blob.len() - 1];
assert!(matches!(
verify_content_hash(tampered, &expected),
VerifyOutcome::ContentHashMismatch { .. }
));
}
#[test]
fn content_hash_rejects_empty_against_nonempty() {
let original = b"not empty";
let expected = sha256_of(original);
assert!(matches!(
verify_content_hash(&[], &expected),
VerifyOutcome::ContentHashMismatch { .. }
));
}
#[test]
fn content_hash_mismatch_carries_hex_for_debug() {
let blob = b"a";
let expected = sha256_of(b"b");
match verify_content_hash(blob, &expected) {
VerifyOutcome::ContentHashMismatch {
got_hex,
expected_hex,
} => {
assert_eq!(got_hex.len(), 64);
assert_eq!(expected_hex.len(), 64);
assert_ne!(got_hex, expected_hex);
}
other => panic!("expected mismatch, got {other:?}"),
}
}
// ===== Rekor bundle shape =====
#[test]
fn bundle_with_cosign_modern_shape_is_recognized() {
let json = r#"{"Payload":"dGVzdA==","Base64Signature":"c2ln"}"#;
assert!(bundle_contains_rekor_payload(json));
}
#[test]
fn bundle_with_legacy_rekor_bundle_key_is_recognized() {
let json = r#"{"rekorBundle": {"SignedEntryTimestamp": "abc"}}"#;
assert!(bundle_contains_rekor_payload(json));
}
#[test]
fn bundle_with_rekor_snake_case_key_is_recognized() {
// Some wrappers emit rekor_bundle — we accept it as a courtesy.
let json = r#"{"rekor_bundle": {"x": "y"}}"#;
assert!(bundle_contains_rekor_payload(json));
}
#[test]
fn bundle_without_rekor_or_payload_is_rejected() {
let json = r#"{"foo": "bar"}"#;
assert!(!bundle_contains_rekor_payload(json));
}
#[test]
fn bundle_with_only_payload_but_no_signature_is_rejected() {
// Payload alone is not enough — cosign modern bundle requires
// both Payload and Base64Signature to constitute an envelope.
let json = r#"{"Payload": "dGVzdA=="}"#;
assert!(!bundle_contains_rekor_payload(json));
}
#[test]
fn malformed_bundle_json_is_rejected() {
assert!(!bundle_contains_rekor_payload("{not json"));
assert!(!bundle_contains_rekor_payload(""));
assert!(!bundle_contains_rekor_payload("null"));
assert!(!bundle_contains_rekor_payload("[]"));
}
#[test]
fn bundle_classifier_is_pure() {
let json = r#"{"rekorBundle": {}}"#;
let a = bundle_contains_rekor_payload(json);
let b = bundle_contains_rekor_payload(json);
assert_eq!(a, b);
}
// ===== Cross-concern =====
#[test]
fn all_present_false_when_paths_do_not_exist() {
let a = artifact_paths_for(Path::new("/no/such/path/manifest.json"));
assert!(!a.all_present());
}
}