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
use crate::audit::AuditArgs;
use crate::{custody, CliError, CliResult};
use clap::Args;
use sha2::{Digest, Sha256};
use std::io::Write;
use std::path::{Path, PathBuf};
/// `zynk reveal <audit_id>` — the operator un-redaction (ADR 034 D8). Decrypt the
/// retained ciphertext in memory, re-verify the plaintext hash against the recorded
/// disclosure contract, write an operator-verified `reveal` proof FIRST, and ONLY
/// THEN emit the plaintext to stdout. Proof-before-disclosure is load-bearing: if the
/// proof write fails, NO plaintext is ever emitted — there is no un-audited reveal.
#[derive(Debug, Args)]
pub struct RevealArgs {
#[arg(
long,
default_value = "outputs",
help = "runtime outputs root where the reveal proof's audit.md is appended"
)]
pub root: PathBuf,
#[arg(
long,
help = "live DB path holding the custody vault; defaults to the cwd .zynk/zynk.db (reveal needs the DB — there is no file-only reveal)"
)]
pub db: Option<PathBuf>,
#[arg(
long,
help = "path to the operator-owned custody key file (default <db-dir>/custody.key or $ZYNK_CUSTODY_KEY_FILE)"
)]
pub custody_key_file: Option<PathBuf>,
#[arg(
long,
default_value = "operator",
help = "actor recorded as the source/observer of the reveal proof"
)]
pub actor: String,
#[arg(long)]
pub timestamp: Option<String>,
#[arg(help = "the audit_id of the retained record to reveal")]
pub audit_id: String,
}
pub fn run(args: RevealArgs) -> CliResult<()> {
// Reveal needs the live DB (the vault). There is NO `--no-db` reveal: this is a
// read + an audited proof write, both against the DB. Resolve the default/explicit
// target (no_db=false) and require the DB file to actually exist — the read-only
// open never auto-creates, so a missing DB is a clear, early error rather than a
// confusing open failure.
let db_path = crate::db::resolve_projection_target(args.db.as_deref(), false)
.into_path_and_mode()
.map(|(path, _explicit)| path)
.ok_or_else(|| CliError::failure("reveal needs the live DB"))?;
if !db_path.exists() {
return Err(CliError::failure(format!(
"reveal needs the live DB, but none was found at {} (pass --db <path>)",
db_path.display()
)));
}
let conn = crate::db::open_read_database(&db_path)?;
// Revealable? A record with no retained custody (legacy, or written without
// --retain-custody) is NOT revealable — fail loud, emit nothing.
let vault = crate::db::read_custody_vault(&conn, &args.audit_id)?.ok_or_else(|| {
CliError::usage(format!(
"{} is not revealable (no retained custody)",
args.audit_id
))
})?;
// Crypto agility (ADR 034 D6): an unsupported cipher/key version fails loud — we
// never attempt to decrypt something this build can't verify.
if vault.cipher_id != custody::CUSTODY_CIPHER_ID
|| vault.key_version != custody::CUSTODY_KEY_VERSION
{
return Err(CliError::failure(format!(
"{} uses unsupported custody cipher/version ({}/{})",
args.audit_id, vault.cipher_id, vault.key_version
)));
}
// The reveal proof lands in the revealed record's OWN session; payload_hash is the
// disclosure contract. One read so the two can never diverge.
let (session_id, payload_hash) = crate::db::read_reveal_target(&conn, &args.audit_id)?
.ok_or_else(|| CliError::failure(format!("no audit record for {}", args.audit_id)))?;
// Load the operator key (reveal never creates a key) and decrypt IN MEMORY.
let key_path = custody::resolve_key_path(args.custody_key_file.as_deref(), &db_path);
let key = custody::load_existing_key(&key_path)?;
let plaintext = custody::decrypt(
&key,
&args.audit_id,
&payload_hash,
&vault.nonce,
&vault.ciphertext,
)?;
// ADR 034 D4: re-verify the plaintext hash. AEAD success alone is NOT sufficient —
// the recorded payload_hash is the disclosure contract, so a decrypted plaintext
// whose sha256 differs from it is a loud abort (no proof, no plaintext).
let recomputed = format!("sha256:{:x}", Sha256::digest(&plaintext));
if recomputed != payload_hash {
return Err(CliError::failure(format!(
"{} reveal aborted: decrypted plaintext hash does not match the recorded record",
args.audit_id
)));
}
// PROOF BEFORE DISCLOSURE: write the operator-verified reveal proof FIRST. If this
// returns Err, run() returns it here — BEFORE any stdout write — so no plaintext is
// ever emitted for an unaudited reveal.
write_reveal_proof(&args, &db_path, &session_id)?;
// ONLY now emit the plaintext (the single deliberate disclosure of the recovered
// payload — it goes to stdout ONLY, never to any DB column / corpus / the proof).
std::io::stdout()
.write_all(&plaintext)
.map_err(|error| CliError::failure(format!("failed to write plaintext: {error}")))?;
Ok(())
}
/// Write the operator-verified `reveal` proof via the SAME audited path `decide`/`audit`
/// use (validate → file-first → project). The proof is a NON-transport operator sentinel
/// (ADR 024: `delivery_status=observed`, never `sent`; `verified_by=operator`). Its
/// stored payload is a NON-sensitive descriptor (`revealed <audit_id>`) — NEVER the
/// plaintext. Returns Err if any of validate/write/project fails, so the caller can
/// abort before disclosure.
fn write_reveal_proof(args: &RevealArgs, db_path: &Path, session_id: &str) -> CliResult<()> {
let proof = AuditArgs {
profile: None,
root: args.root.clone(),
session_id: session_id.to_string(),
audit_id: None,
previous_audit_id: None,
timestamp: args.timestamp.clone(),
due: None,
source_agent: args.actor.clone(),
source_address: "cli".to_string(),
// v1 M3a R1 P1: NON-transport operator record — use the M2a `decide`-style
// "none" sentinels (see src/decide.rs), NOT empty strings. Empty participant
// fields flow through render_record's Some(target_agent) into a bogus
// `agents.agent_id=''` row and a `:` known-target that pollutes the ADR 031
// composer/write allow-list.
target_agent: "none".to_string(),
target_address: "none".to_string(),
transport: "none".to_string(),
workspace_id: "none".to_string(),
transport_thread_id: None,
mid: format!("reveal-{}", args.audit_id),
record_type: "reveal".to_string(),
mode: None,
// The reveal proof points at the revealed record.
r#ref: Some(args.audit_id.clone()),
re: None,
command_origin: "operator".to_string(),
// A NON-sensitive descriptor — NEVER the plaintext.
payload: Some(format!("revealed {}", args.audit_id)),
payload_file: None,
// `full` so the (non-sensitive) descriptor is the recorded excerpt; the
// plaintext is never any part of this record.
payload_redaction_policy: "full".to_string(),
payload_ref: None,
sensitive_category: None,
excerpt_chars: 12,
delivery_status: "observed".to_string(),
observed_by: args.actor.clone(),
verified_by: "operator".to_string(),
db: Some(db_path.to_path_buf()),
no_db: false,
retain_custody: false,
custody_key_file: None,
};
let profile = crate::profile::load_profile(proof.profile.as_deref())?;
crate::audit::validate_audit_args(&proof, &profile)?;
let (_audit_path, record) = crate::audit::write_audit_file(&proof, &profile)?;
crate::audit::project_record(proof.db.as_deref(), proof.no_db, &proof.root, &record)
}