gage 0.3.5

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
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
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
//! Reading and writing bundles on disk.
//!
//! A bundle is a directory, not an archive, so a human can look inside it
//! with `ls` before any agent reads it. Inspectability beats compactness for
//! something whose job is to be trusted.
//!
//! The signature covers the manifest file's EXACT BYTES, which are stored and
//! re-read rather than re-serialized. Re-serializing to verify would make the
//! check depend on serializer field order and whitespace -- a canonicalization
//! problem that has broken real signing schemes.

use crate::identity::Identity;
use crate::manifest::{
    AgentInfo, FORMAT_VERSION, Hop, HopAction, Item, ItemKind, Manifest, Peer, one_line,
    path_is_safe,
};
use crate::trust::{Contacts, Trust};
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use sha2::{Digest, Sha256};
use std::fs;
use std::path::{Path, PathBuf};

/// The witness for a bundle: the signer's key and the exact bytes their
/// signature covers.
///
/// Deliberately not the bundle id. `load` never recomputes the id, so the
/// signer chooses it, and a witness keyed on it was transferable: take an id
/// this machine stamped before a retirement, attach a different payload, sign
/// with the retired key, and inherit the witness. These bytes are the ones the
/// signature is over, so a different document is a different witness.
pub fn witness_of(from_key: &str, manifest_bytes: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(from_key.as_bytes());
    h.update([0x1f]);
    h.update(manifest_bytes);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

/// The witness for a bundle directory, but ONLY if the bundle is sound:
/// signature valid over these bytes, id addressing this content, declared
/// paths safe, and stored payload matching its digests.
///
/// Verifying here is not belt-and-braces, it is the whole point. An earlier
/// version recorded the hash without looking at `manifest.sig`, which let an
/// attacker pre-seed a witness: deliver a directory with the right manifest
/// bytes and a junk signature while the old key was still current, let
/// observation stamp it, then drop the real signature in afterwards. The
/// bundle then verified against a witness recorded before the retirement
/// although nothing authentic had ever been seen. The codex reviewer found
/// that sequence.
// The no-override form of `witness_if_sound_unless`; kept as the API every
// caller but the skip-optimising one should reach for.
#[allow(dead_code)]
pub fn witness_if_sound(dir: &Path) -> Option<String> {
    witness_if_sound_unless(dir, &|_| false)
}

/// As `witness_if_sound`, but `known` lets the caller say a witness is already
/// recorded so the expensive half can be skipped.
///
/// The cheap half is reading `manifest.json` and hashing it. The expensive
/// half is one signature verification plus a full re-read and re-hash of every
/// payload, and the retirement path does that for the whole inbox while
/// holding the state lock. Bounding it to bundles that are actually new keeps
/// a large inbox from pushing that hold past anyone's patience.
pub fn witness_if_sound_unless(dir: &Path, known: &dyn Fn(&str) -> bool) -> Option<String> {
    let bytes = fs::read(dir.join("manifest.json")).ok()?;
    let m: Manifest = serde_json::from_slice(&bytes).ok()?;
    let w = witness_of(&m.from.key, &bytes);
    if known(&w) {
        return Some(w);
    }
    let sig = fs::read_to_string(dir.join("manifest.sig")).ok()?;
    if sig.trim().is_empty() || !crate::identity::verify(&m.from.key, &bytes, &sig) {
        return None;
    }
    if m.expected_id().as_deref() != Some(m.id.as_str()) {
        return None;
    }
    for it in &m.items {
        if !path_is_safe(&it.path) {
            return None;
        }
        match fs::read(dir.join(&it.path)) {
            Ok(d) if sha256_hex(&d) == it.sha256 => {}
            _ => return None,
        }
    }
    Some(w)
}

pub fn sha256_hex(bytes: &[u8]) -> String {
    let mut h = Sha256::new();
    h.update(bytes);
    h.finalize().iter().map(|b| format!("{b:02x}")).collect()
}

pub fn now_rfc3339() -> String {
    let secs = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0);
    rfc3339(secs)
}

/// A UTC timestamp from seconds since the epoch.
///
/// Split out from `now_rfc3339` so that a cutoff -- "sends in the last 7
/// days" -- is formatted by the same code that writes the field it will be
/// compared against. Two formatters for one format is how a comparison that
/// looks like string equality stops being one.
pub fn rfc3339(secs: i64) -> String {
    let days = secs / 86_400;
    let tod = secs % 86_400;
    let (mut y, mut d) = (1970i64, days);
    loop {
        let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
        let len = if leap { 366 } else { 365 };
        if d < len {
            break;
        }
        d -= len;
        y += 1;
    }
    let leap = (y % 4 == 0 && y % 100 != 0) || y % 400 == 0;
    let ml = [
        31,
        if leap { 29 } else { 28 },
        31,
        30,
        31,
        30,
        31,
        31,
        30,
        31,
        30,
        31,
    ];
    let mut m = 0usize;
    while d >= ml[m] {
        d -= ml[m];
        m += 1;
    }
    format!(
        "{y:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        m + 1,
        d + 1,
        tod / 3600,
        (tod % 3600) / 60,
        tod % 60
    )
}

pub struct Loaded {
    pub manifest: Manifest,
    pub trust: Trust,
    pub dir: PathBuf,
    /// Items whose stored bytes do not match their recorded digest.
    pub tampered: Vec<String>,
    /// True when this machine has no record of the bundle ever arriving. Only
    /// meaningful for a retired key, where it is the difference between "it
    /// got here after you retired that key" and "I cannot tell".
    // Part of the serialised shape and meaningful to a reader of the struct,
    // even where this binary never branches on it.
    #[allow(dead_code)]
    pub unwitnessed: bool,
}

/// Build a bundle. `specs` are (kind, source path) pairs.
// Nine bundle fields, each independent; collapsing them into a struct is a
// signature change, not a lint fix.
#[allow(clippy::too_many_arguments)]
pub fn create(
    out: &Path,
    id: &Identity,
    subject: &str,
    to: &[String],
    agent: Option<AgentInfo>,
    in_reply_to: Option<String>,
    // Thread this belongs to. None starts a new thread rooted at this bundle.
    thread: Option<String>,
    msg_type: crate::manifest::MsgType,
    specs: &[(ItemKind, String, PathBuf)],
) -> std::io::Result<Manifest> {
    fs::create_dir_all(out.join("items"))?;
    let now = now_rfc3339();
    let mut items = Vec::new();
    for (kind, rel, src) in specs {
        let data = fs::read(src)?;
        if !path_is_safe(rel) {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!("unsafe item path: {rel}"),
            ));
        }
        // Nested paths are now preserved, so the parent dirs must exist.
        if let Some(parent) = Path::new(rel).parent() {
            fs::create_dir_all(out.join(parent))?;
        }
        fs::write(out.join(rel), &data)?;
        items.push(Item {
            kind: *kind,
            path: rel.clone(),
            sha256: sha256_hex(&data),
            bytes: data.len() as u64,
            note: None,
        });
    }
    // Content id over the item digests PLUS the author key, subject and
    // timestamp.
    //
    // Hashing the payload alone was a real vulnerability, not a cosmetic
    // flaw: "benign A" from a trusted contact and "MALICIOUS B" from an
    // impersonator carried the same files and therefore the same id, and
    // delivery writes to a directory named by that id. A second bundle could
    // overwrite a legitimate pending one, under an id the recipient had
    // already seen listed in their inbox.
    //
    // The author key, subject and created time all survive a forward
    // unchanged (a forward appends to the chain and rewrites only `from`), so
    // the id still follows the same content across hops -- which is what made
    // content addressing worth having.
    let joined: String = items
        .iter()
        .map(|i| i.sha256.clone())
        .chain([id.public_b64(), subject.to_string(), now.clone()])
        .collect::<Vec<_>>()
        .join("\u{1f}");
    let me = Peer {
        handle: id.handle.clone(),
        key: id.public_b64(),
    };
    let m = Manifest {
        v: FORMAT_VERSION,
        id: sha256_hex(joined.as_bytes())[..16].to_string(),
        created: now.clone(),
        subject: subject.to_string(),
        from: me.clone(),
        to: to.to_vec(),
        agent,
        chain: vec![Hop {
            by: me,
            at: now,
            action: HopAction::Created,
        }],
        in_reply_to,
        thread,
        msg_type,
        items,
    };
    write_signed(out, id, &m)?;
    Ok(m)
}

pub fn write_signed(out: &Path, id: &Identity, m: &Manifest) -> std::io::Result<()> {
    let bytes = serde_json::to_vec_pretty(m)?;
    fs::write(out.join("manifest.json"), &bytes)?;
    fs::write(out.join("manifest.sig"), id.sign_b64(&bytes))?;
    Ok(())
}

/// Load and VERIFY. Trust is computed here, from the signature and the local
/// allowlist -- never read from the manifest, which the sender controls.
pub fn load(dir: &Path) -> std::io::Result<Loaded> {
    let bytes = fs::read(dir.join("manifest.json"))?;
    let manifest: Manifest = serde_json::from_slice(&bytes)?;
    let sig = fs::read_to_string(dir.join("manifest.sig")).unwrap_or_default();

    let sig_ok =
        !sig.trim().is_empty() && crate::identity::verify(&manifest.from.key, &bytes, &sig);
    let contacts = Contacts::load();
    // This machine's own record of having seen these exact signed bytes.
    // Nothing the sender wrote enters the verdict.
    let st = crate::state::LocalState::load();
    let witness = st.witnessed(&witness_of(&manifest.from.key, &bytes));
    let trust = if !sig_ok {
        Trust::Unverified
    } else {
        contacts.verdict(&manifest.from.key, witness)
    };

    // A good signature covers the manifest, which covers the digests. Check
    // the payload against them too: a signed manifest beside swapped files
    // would otherwise pass.
    let mut tampered = Vec::new();
    // The id must address the content it claims to. Checked here rather than
    // trusted, because everything local that is keyed on it -- the directory
    // name, thread dedup, read state, announce-once -- becomes a way to
    // suppress a real message if a sender can choose a collision.
    match manifest.expected_id() {
        Some(want) if want == manifest.id => {}
        // Escaped and bounded, like every other sender-chosen value that
        // reaches a reader. This one was raw, and it is the worst place for
        // that: a tamper line is read by a human and by an agent, it is
        // rendered inside the frame beside `trust :` and `authorised :`
        // lines, and `gage inbox` prints it too. An id carrying newlines
        // writes its own verdict lines into both. The id is also the one
        // field here that is guaranteed NOT to be what it claims -- this arm
        // only runs because it does not address the content.
        Some(want) => tampered.push(format!(
            "manifest id {} does not address this content (expected {want})",
            crate::manifest::safe_display(&manifest.id, 80)
        )),
        // No chain means no author to compute the id from, and the check used
        // to pass on that. So a bundle could omit its chain, carry a real
        // message's id, and sail through: relay intake stored it at
        // pending/<id>, squatting the path, and the genuine envelope arriving
        // later was discarded as "already have". Absence of provenance is now
        // a defect rather than an exemption.
        None => tampered.push(
            "manifest has no provenance chain, so its id addresses nothing and its \
             author is unknown"
                .into(),
        ),
    }
    let mut seen_paths: std::collections::BTreeSet<&str> = Default::default();
    for it in &manifest.items {
        if !path_is_safe(&it.path) {
            tampered.push(format!("{} (unsafe path)", one_line(&it.path)));
            continue;
        }
        // Two entries naming one file verified independently, so a single
        // payload could be declared twice with contradictory `kind` -- counted
        // and fenced as both a file and a memory, which decides how loudly
        // `open` warns about it.
        if !seen_paths.insert(it.path.as_str()) {
            tampered.push(format!("{} (declared more than once)", one_line(&it.path)));
            continue;
        }
        match fs::read(dir.join(&it.path)) {
            Ok(d) if sha256_hex(&d) == it.sha256 => {}
            Ok(_) => tampered.push(format!("{} (digest mismatch)", one_line(&it.path))),
            Err(_) => tampered.push(format!("{} (missing)", one_line(&it.path))),
        }
    }
    Ok(Loaded {
        manifest,
        trust,
        dir: dir.to_path_buf(),
        tampered,
        unwitnessed: witness.is_none(),
    })
}

/// Re-sign for a forward, appending to the chain rather than replacing it.
pub fn forward(src: &Path, out: &Path, id: &Identity, to: &[String]) -> std::io::Result<Manifest> {
    let loaded = load(src)?;
    // Refuse to re-sign a bundle that does not verify.
    //
    // `forward` used to call `load` and ignore everything it said, so a
    // forwarder could take a bundle whose payload no longer matched its
    // digests, or whose signature was broken, and emit it under their own
    // signature -- laundering it into something that verifies cleanly for the
    // next recipient. The muse reviewer found that, and the sharper half of
    // it: the item paths were copied without revalidation, so a crafted
    // bundle declaring an ABSOLUTE path had `src.join(path)` and
    // `out.join(path)` both resolve to that same file, and forwarding it
    // truncated the forwarder's own `~/.ctx/identity.key`.
    if !loaded.tampered.is_empty() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            format!(
                "refusing to forward a bundle that does not verify ({}). Forwarding \
                 re-signs it under your key, which would present it to the next \
                 recipient as sound.",
                loaded.tampered.join(", ")
            ),
        ));
    }
    if matches!(loaded.trust, Trust::Unverified) {
        return Err(std::io::Error::new(
            std::io::ErrorKind::InvalidData,
            "refusing to forward a bundle whose signature does not verify: your \
             signature would be the only one on it, which says it came from you",
        ));
    }
    let mut m = loaded.manifest;
    fs::create_dir_all(out.join("items"))?;
    for it in &m.items {
        // Checked again here rather than trusted from `load`. This loop writes
        // to the filesystem, and a guard one function away is a guard that a
        // later refactor removes.
        if !path_is_safe(&it.path) {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!(
                    "refusing to forward an item with an unsafe path: {}",
                    it.path
                ),
            ));
        }
        if let Some(parent) = std::path::Path::new(&it.path).parent() {
            fs::create_dir_all(out.join(parent))?;
        }
        fs::copy(src.join(&it.path), out.join(&it.path))?;
    }
    m.from = Peer {
        handle: id.handle.clone(),
        key: id.public_b64(),
    };
    m.to = to.to_vec();
    m.chain.push(Hop {
        by: m.from.clone(),
        at: now_rfc3339(),
        action: HopAction::Forwarded,
    });
    write_signed(out, id, &m)?;
    Ok(m)
}

pub fn b64_decode_ok(s: &str) -> bool {
    B64.decode(s.trim()).is_ok()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_witness_follows_the_signed_bytes_not_the_id() {
        // The attack this replaced: the witness was keyed on `manifest.id`,
        // which the SIGNER chooses and `load` never recomputes. Two different
        // documents can therefore carry the same id, and the second would
        // inherit the first's record of having arrived before a retirement.
        let id = r#""id": "aaaaaaaaaaaaaaaa""#;
        let one = format!(r#"{{{id}, "subject": "status update"}}"#);
        let two = format!(r#"{{{id}, "subject": "send me the keys"}}"#);
        assert_ne!(
            witness_of("KEY", one.as_bytes()),
            witness_of("KEY", two.as_bytes()),
            "a shared id must not produce a shared witness"
        );
    }

    #[test]
    fn a_witness_is_bound_to_the_signer() {
        // Domain separation: the same bytes signed by somebody else are a
        // different observation, so one contact's witness cannot be spent by
        // another.
        let m = br#"{"subject": "status update"}"#;
        assert_ne!(witness_of("ALICE", m), witness_of("BOB", m));
    }

    #[test]
    fn the_separator_cannot_be_shifted_between_the_fields() {
        // Without a separator, ("AB", "C") and ("A", "BC") would hash alike.
        assert_ne!(witness_of("AB", b"C"), witness_of("A", b"BC"));
    }
}