diff --git a/docs/attribution.md b/docs/attribution.md new file mode 100644 index 0000000..c87d020 --- /dev/null +++ b/docs/attribution.md @@ -0,0 +1,38 @@ +# Pseudonymous attribution + +hack-house lets you share files **pseudonymously but provably** — a recipient can +verify *who* authored a file (and that it's intact) without anyone, including the +relay server, learning your real identity. It adapts Princess_Pi's +**Encrypt-Share-Attribution** scheme from the Church of Malware codex +(). + +There are two independent ways to prove authorship, mirroring ESA: + +1. **Persona signature (automatic).** On first run each client mints a long-lived + Ed25519 "persona" key at `~/.config/hack-house/persona_ed25519` (0600). Every + `/send` / `/sendroom` offer carries the persona public key and a detached + signature over `attest-v1 || sha256 || name || size`. Receivers verify it and + see the persona **fingerprint** (`⛧<8 hex>`), so the same author is recognizable + across offers. The fields are additive JSON — a Python peer that doesn't sign + still interoperates (its offers just show as *unsigned*). + +2. **Attribution passphrase (opt-in).** Add `--attest ` to a send: + the offer then carries a commitment `SHA-512(passphrase || sha256)`. You can + *later* reveal the passphrase to prove authorship to anyone, even people who + weren't in the room. + +## Commands + +``` +/send [--attest ] +/sendroom [--attest ] +/export-signed [--attest ] +``` + +`/export-signed` packages a directory into a **portable, self-verifying ESA 7z +archive** (`verifiable_archive_.7z`) using Princess_Pi's exact format: a fresh +per-round Ed25519 key signs an inner `contents.7z`, SHA-512 checksums cover the +outer layer, and bundled `verify-everything.sh` / `test_validate_passphrase.sh` +let anyone with `bash` + `7z` + `ssh-keygen` verify it — no hack-house needed. The +builder is `hh/tools/esa/esa_build.sh` (embedded in the binary). Requires `7z`, +`ssh-keygen`, `sha512sum`, `shred`, `openssl` on the host. diff --git a/hh/Cargo.toml b/hh/Cargo.toml index 1e77166..38259c0 100644 --- a/hh/Cargo.toml +++ b/hh/Cargo.toml @@ -19,6 +19,8 @@ fernet = "0.2" base64 = "0.22" rand = "0.8" hex = "0.4" +# pseudonymous attribution (persona signing keys, ESA-style) +ed25519-dalek = "2" # net reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } diff --git a/hh/src/app.rs b/hh/src/app.rs index 20afba4..72a76fd 100644 --- a/hh/src/app.rs +++ b/hh/src/app.rs @@ -3,6 +3,7 @@ use crate::ft; use crate::layout::Layout; use crate::net::{self, Session}; +use crate::persona::{self, Persona}; use crate::sbx; use crate::theme::Theme; use crate::ui; @@ -171,6 +172,9 @@ pub enum Pane { pub struct App { pub me: String, + /// This client's pseudonymous signing identity. Signs every file offer and + /// backs `/export-signed`; loaded/persisted once at startup. + pub persona: Arc, pub lines: Vec, pub users: Vec, pub capacity: usize, @@ -234,6 +238,7 @@ impl App { fn new(me: String) -> Self { Self { me, + persona: Arc::new(Persona::load_or_create()), lines: Vec::new(), users: Vec::new(), capacity: 0, @@ -367,7 +372,7 @@ impl App { self.connected = true; self.chat_scroll = 0; self.sys(format!("joined as {} ⛧", self.me)); - self.sys("/sbx launch · /drive (F2 releases) · /ai start · /ai · /send · /sendroom · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); + self.sys("/sbx launch · /drive (F2 releases) · /ai start · /ai · /send · /sendroom · /export-signed · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit"); } Net::Message(l) => self.push_line(l), Net::Roster { users, capacity } => { @@ -567,6 +572,83 @@ fn send_frame(out: &UnboundedSender, room: &fernet::Fernet, value: serde_ let _ = out.send(WsMsg::Text(room.encrypt(value.to_string().as_bytes()))); } +/// The bundled Encrypt-Share-Attribution builder (Princess_Pi's ESA scheme, +/// non-interactive). Embedded in the binary so `/export-signed` is self-contained; +/// materialized to a temp file and run when invoked. +const ESA_BUILD: &str = include_str!("../tools/esa/esa_build.sh"); + +/// Split a trailing `--attest ` off a send/export command. Returns +/// `(payload_part, Some(passphrase))`, or `(whole, None)` if the flag is absent +/// or has no value. +fn split_attest(s: &str) -> (&str, Option<&str>) { + match s.split_once("--attest ") { + Some((head, pass)) => { + let pass = pass.trim(); + (head.trim_end(), (!pass.is_empty()).then_some(pass)) + } + None => (s, None), + } +} + +/// `/export-signed `: build a portable ESA archive (fresh Ed25519 key signs +/// an inner 7z of `dir`, SHA-512 checksums, self-contained verify scripts, and — +/// with `--attest` — a revealable attribution commitment). Runs off the UI thread +/// (7z + ssh-keygen are slow) and reports the archive path back via the channel. +fn export_signed(app: &mut App, app_tx: &UnboundedSender, src: &str, attest: Option<&str>) { + let src = src.to_string(); + let attest = attest.map(str::to_string); + let tx = app_tx.clone(); + app.sys(format!("⛧ building attributable archive from {src}…")); + tokio::task::spawn_blocking(move || match run_esa_build(&src, attest.as_deref()) { + Ok(out) => { + let _ = tx.send(Net::Sys(format!( + "⛧ signed archive ready: {out} — recipients run ./verify-everything.sh (inside) to check integrity + signature" + ))); + } + Err(e) => { + let _ = tx.send(Net::Err(format!("export-signed failed: {e}"))); + } + }); +} + +/// Materialize the embedded ESA script and run it non-interactively. Returns the +/// path to the produced `verifiable_archive_.7z` (the script's last stdout +/// line), or an error carrying the script's stderr. +fn run_esa_build(src: &str, attest: Option<&str>) -> anyhow::Result { + let script = std::env::temp_dir().join(format!("hh-esa-build-{}.sh", std::process::id())); + std::fs::write(&script, ESA_BUILD)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o700))?; + } + let mut cmd = std::process::Command::new("bash"); + cmd.arg(&script).arg("--src").arg(src); + if let Some(p) = attest { + cmd.arg("--attrib-pass").arg(p); + } + let out = cmd.output(); + let _ = std::fs::remove_file(&script); + let out = out?; + if !out.status.success() { + let msg = String::from_utf8_lossy(&out.stderr); + anyhow::bail!( + "{}", + msg.trim().lines().last().unwrap_or("archive build failed") + ); + } + let stdout = String::from_utf8_lossy(&out.stdout); + let path = stdout + .lines() + .rev() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .trim() + .to_string(); + anyhow::ensure!(!path.is_empty(), "archive built but no path reported"); + Ok(path) +} + /// Read `path` and broadcast a file/dir offer. `to = Some(user)` targets one /// member (only they're prompted); `to = None` offers to the whole room. The /// payload is staged in `active_send` and streamed once an /accept arrives. @@ -578,11 +660,16 @@ fn offer_payload( app_tx: &UnboundedSender, path: &str, to: Option<&str>, + // Optional ESA-style attribution passphrase: when set, the offer carries a + // `SHA-512(passphrase || sha256)` commitment the sender can later open. + attest: Option<&str>, ) { *send_seq += 1; let id = format!("{}-{}", app.me, send_seq); let path = path.to_string(); let to = to.map(str::to_string); + let attest = attest.map(str::to_string); + let persona = app.persona.clone(); let out = out_tx.clone(); let room = room.clone(); let atx = app_tx.clone(); @@ -601,9 +688,20 @@ fn offer_payload( size: s.size, to: to.clone(), }); + // Attribution: sign the content hash (+ name/size) with our persona + // key so receivers can verify authorship. Additive JSON fields — a + // Python receiver just ignores them. + let sig = persona.sign_b64(&persona::attest_msg(&s.sha256, &s.name, s.size)); let mut frame = json!({ - "_ft":"offer","id": id,"name": s.name,"size": s.size,"sha256": s.sha256,"dir": s.dir + "_ft":"offer","id": id,"name": s.name,"size": s.size,"sha256": s.sha256,"dir": s.dir, + "persona": persona.pub_b64(), "sig": sig }); + if let Some(pass) = &attest { + frame["attrib"] = json!(persona::commitment(pass, &s.sha256)); + let _ = atx.send(Net::Sys( + "⛧ attribution commitment attached — reveal the passphrase later to prove authorship".into(), + )); + } if let Some(t) = &to { frame["to"] = json!(t); } @@ -702,6 +800,31 @@ fn handle_ft( if o.dir { ", directory" } else { "" }, if o.to.is_some() { " directly to you" } else { "" }, )); + // Attribution: verify the sender's persona signature over the content + // hash, and surface the pseudonym fingerprint so peers can recognize + // "the same author" across offers. + match (&o.persona, &o.sig) { + (Some(pk), Some(sig)) => { + let msg = persona::attest_msg(&o.sha256, &o.name, o.size); + if persona::verify(pk, sig, &msg) { + let fp = persona::fingerprint_of(pk).unwrap_or_else(|| "unknown".into()); + app.sys(format!( + " ⛧ signed by persona ⛧{fp} ✓ (attributable){}", + if o.attrib.is_some() { + " · attribution passphrase committed" + } else { + "" + } + )); + } else { + app.err(format!( + " ⚠ {} — BAD persona signature; author UNVERIFIED", + o.name + )); + } + } + _ => app.sys(" (unsigned — no attribution proof)"), + } app.transfers.insert( o.id.clone(), Transfer { @@ -1733,12 +1856,15 @@ fn handle_command( app.sys("you don't have drive permission — the owner can /grant you"); } } else if let Some(rest) = line.strip_prefix("/sendroom ") { - // Offer a file/dir to the whole room — anyone may /accept. - offer_payload(app, send_seq, out_tx, room, app_tx, rest.trim(), None); + // Offer a file/dir to the whole room — anyone may /accept. An optional + // trailing `--attest ` attaches a revealable attribution proof. + let (path, attest) = split_attest(rest.trim()); + offer_payload(app, send_seq, out_tx, room, app_tx, path, None, attest); } else if let Some(rest) = line.strip_prefix("/send ") { // Direct send to one member: `/send `. Everyone receives the - // broadcast offer, but only is prompted to /accept. - let rest = rest.trim(); + // broadcast offer, but only is prompted to /accept. An optional + // trailing `--attest ` attaches a revealable attribution proof. + let (rest, attest) = split_attest(rest.trim()); match rest.split_once(char::is_whitespace) { Some((who, path)) => { let (who, path) = (who.trim(), path.trim()); @@ -1756,7 +1882,7 @@ fn handle_command( .join(" · "); app.err(format!("no member '{who}' in the room — try: {roster}")); } else { - offer_payload(app, send_seq, out_tx, room, app_tx, path, Some(who)); + offer_payload(app, send_seq, out_tx, room, app_tx, path, Some(who), attest); } } None => app.sys("usage: /send · /sendroom for everyone"), @@ -1779,6 +1905,15 @@ fn handle_command( } else { app.sys("no pending offer"); } + } else if let Some(rest) = line.strip_prefix("/export-signed") { + // Package a directory into a portable, self-verifying ESA archive. + let (src, attest) = split_attest(rest.trim()); + let src = src.trim(); + if src.is_empty() { + app.sys("usage: /export-signed [--attest ] — build a portable ESA-signed 7z"); + } else { + export_signed(app, app_tx, src, attest); + } } else if let Some(rest) = line.strip_prefix("/sbx") { let mut p = rest.split_whitespace(); match p.next() { diff --git a/hh/src/ft.rs b/hh/src/ft.rs index 2438533..bd0aa14 100644 --- a/hh/src/ft.rs +++ b/hh/src/ft.rs @@ -30,6 +30,14 @@ pub struct Offer { pub sha256: String, pub dir: bool, pub from: String, + /// Base64 Ed25519 persona public key of the sender (attribution). Absent on + /// legacy/Python senders that don't sign — wire-compatible either way. + pub persona: Option, + /// Base64 detached signature over `persona::attest_msg(sha256, name, size)`. + pub sig: Option, + /// Optional ESA-style attribution commitment `SHA-512(passphrase || sha256)` + /// the sender can later open by revealing the passphrase. + pub attrib: Option, /// Direct-send recipient: `Some(username)` means only that member should be /// prompted; `None` (or absent/empty on the wire) means the whole room. The /// relay still broadcasts to everyone, so this is an advisory app-layer @@ -313,6 +321,9 @@ pub fn parse(text: &str, sender: &str) -> Option { sha256: v["sha256"].as_str().unwrap_or("").to_string(), dir: v["dir"].as_bool().unwrap_or(false), from: sender.to_string(), + persona: v["persona"].as_str().map(String::from), + sig: v["sig"].as_str().map(String::from), + attrib: v["attrib"].as_str().map(String::from), to: match v["to"].as_str() { Some(s) if !s.is_empty() => Some(s.to_string()), _ => None, @@ -354,6 +365,9 @@ mod tests { sha256: src.sha256.clone(), dir: src.dir, from: "x".into(), + persona: None, + sig: None, + attrib: None, to: None, }; let (tmp, sha) = sink.finish().unwrap(); diff --git a/hh/src/main.rs b/hh/src/main.rs index 3be6f56..2110a77 100644 --- a/hh/src/main.rs +++ b/hh/src/main.rs @@ -9,6 +9,7 @@ mod crypto; mod ft; mod layout; mod net; +mod persona; mod sbx; mod theme; mod ui; diff --git a/hh/src/persona.rs b/hh/src/persona.rs new file mode 100644 index 0000000..ff35602 --- /dev/null +++ b/hh/src/persona.rs @@ -0,0 +1,158 @@ +//! Pseudonymous attribution — a persistent Ed25519 "persona" key that signs the +//! files you share, plus an optional revealable attribution commitment. +//! +//! Modeled on Princess_Pi's *Encrypt-Share-Attribution* (Church of Malware codex): +//! prove authorship two independent ways without ever binding to a real identity — +//! 1. an **Ed25519 signature** over the file's content hash (automatic), and +//! 2. a later **passphrase reveal** matching a `SHA-512(passphrase || sha256)` +//! commitment (opt-in via `--attest`). +//! The private key persists at `~/.config/hack-house/persona_ed25519`, so the same +//! pseudonym signs across sessions: peers can link "the same author" and verify +//! integrity, while the server (and even peers) never learn who that author is. + +use base64::engine::general_purpose::STANDARD; +use base64::Engine; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use sha2::{Digest, Sha256, Sha512}; +use std::path::{Path, PathBuf}; + +/// A long-lived signing identity (the seed is 32 bytes on disk, 0600). +pub struct Persona { + signing: SigningKey, +} + +impl Persona { + /// Load the persisted key, or mint + persist a new one. Never fails: if the + /// config dir is unreadable/unwritable we fall back to an ephemeral in-memory + /// key so signing still works for this session. + pub fn load_or_create() -> Self { + if let Some(path) = key_path() { + if let Ok(bytes) = std::fs::read(&path) { + if let Ok(seed) = <[u8; 32]>::try_from(bytes.as_slice()) { + return Self { + signing: SigningKey::from_bytes(&seed), + }; + } + } + let signing = gen(); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + if std::fs::write(&path, signing.to_bytes()).is_ok() { + harden(&path); + } + return Self { signing }; + } + Self { signing: gen() } + } + + /// Base64 of the 32-byte Ed25519 public key — shipped in each offer frame. + pub fn pub_b64(&self) -> String { + STANDARD.encode(self.signing.verifying_key().to_bytes()) + } + + /// Base64 detached signature over `msg`. + pub fn sign_b64(&self, msg: &[u8]) -> String { + STANDARD.encode(self.signing.sign(msg).to_bytes()) + } + + /// Short human tag for this persona (sha256 of the pubkey, first 4 bytes hex). + /// Handy for a future roster badge; peers currently render `fingerprint_of` + /// the incoming pubkey directly. + #[allow(dead_code)] + pub fn fingerprint(&self) -> String { + fingerprint_of(&self.pub_b64()).unwrap_or_else(|| "unknown".into()) + } +} + +fn gen() -> SigningKey { + let mut seed = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut seed); + SigningKey::from_bytes(&seed) +} + +#[cfg(unix)] +fn harden(path: &Path) { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); +} +#[cfg(not(unix))] +fn harden(_path: &Path) {} + +fn key_path() -> Option { + let home = std::env::var_os("HOME")?; + Some( + PathBuf::from(home) + .join(".config") + .join("hack-house") + .join("persona_ed25519"), + ) +} + +/// Canonical bytes signed for a file offer — binds the content hash, name, and +/// size so a signature can't be lifted onto a different file. +pub fn attest_msg(sha256_hex: &str, name: &str, size: u64) -> Vec { + format!("hh-attest-v1\n{sha256_hex}\n{name}\n{size}").into_bytes() +} + +/// Short fingerprint tag from a base64 pubkey (sha256 → first 4 bytes hex). +pub fn fingerprint_of(pub_b64: &str) -> Option { + let raw = STANDARD.decode(pub_b64).ok()?; + let d = Sha256::digest(&raw); + Some(hex::encode(&d[..4])) +} + +/// Verify an offer signature. Returns false on any malformed input. +pub fn verify(pub_b64: &str, sig_b64: &str, msg: &[u8]) -> bool { + let inner = || -> Option { + let pk_raw = STANDARD.decode(pub_b64).ok()?; + let pk = VerifyingKey::from_bytes(&<[u8; 32]>::try_from(pk_raw.as_slice()).ok()?).ok()?; + let sig_raw = STANDARD.decode(sig_b64).ok()?; + let sig = Signature::from_bytes(&<[u8; 64]>::try_from(sig_raw.as_slice()).ok()?); + Some(pk.verify(msg, &sig).is_ok()) + }; + inner().unwrap_or(false) +} + +/// Attribution commitment, ESA-style: `SHA-512(passphrase || sha256_hex)`. The +/// author can later reveal the passphrase; anyone recomputes this against the +/// (signed) content hash to confirm authorship. +pub fn commitment(passphrase: &str, sha256_hex: &str) -> String { + let mut h = Sha512::new(); + h.update(passphrase.as_bytes()); + h.update(sha256_hex.as_bytes()); + hex::encode(h.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sign_verify_roundtrip() { + let p = Persona { signing: gen() }; + let msg = attest_msg("deadbeef", "note.txt", 42); + let sig = p.sign_b64(&msg); + assert!(verify(&p.pub_b64(), &sig, &msg), "valid signature verifies"); + // Tampering with any bound field breaks verification. + let bad = attest_msg("deadbeef", "note.txt", 43); + assert!(!verify(&p.pub_b64(), &sig, &bad), "size tamper rejected"); + assert!(!verify(&p.pub_b64(), "AAAA", &msg), "garbage sig rejected"); + } + + #[test] + fn commitment_reveal() { + let c = commitment("correct horse battery staple pony", "abc123"); + assert_eq!(c, commitment("correct horse battery staple pony", "abc123")); + assert_ne!(c, commitment("wrong passphrase", "abc123")); + assert_eq!(c.len(), 128, "sha512 hex"); + } + + #[test] + fn fingerprint_is_stable_and_short() { + let p = Persona { signing: gen() }; + let fp = p.fingerprint(); + assert_eq!(fp.len(), 8, "4 bytes → 8 hex chars"); + assert_eq!(Some(fp), fingerprint_of(&p.pub_b64())); + } +} diff --git a/hh/tools/esa/esa_build.sh b/hh/tools/esa/esa_build.sh new file mode 100755 index 0000000..110c546 --- /dev/null +++ b/hh/tools/esa/esa_build.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +# hack-house /export-signed — non-interactive Encrypt-Share-Attribution builder. +# +# Faithful to Princess_Pi's ESA (Church of Malware codex, +# https://git.thecoven.info/PrincessPi/Encrypt-Share-Attribution): a fresh +# per-round Ed25519 key signs an inner 7z of your files; SHA-512 checksums are +# taken over the outer layer; an optional revealable attribution commitment +# `SHA-512(passphrase || contents.7z)` is stored; and self-contained verify +# scripts ride along so anyone with bash + 7z + ssh-keygen can check it — no +# hack-house required. +# +# Usage: esa_build.sh --src [--attrib-pass ] [--random] [--encrypt ] +# On success, prints the path to the produced verifiable_archive_.7z on stdout. + +set -o nounset -o pipefail + +SRC=""; ATTRIB=""; DO_RANDOM=0; ENCPASS="" +while [[ $# -gt 0 ]]; do + case "$1" in + --src) SRC="${2:-}"; shift 2;; + --attrib-pass) ATTRIB="${2:-}"; shift 2;; + --random) DO_RANDOM=1; shift;; + --encrypt) ENCPASS="${2:-}"; shift 2;; + *) echo "unknown arg: $1" >&2; exit 2;; + esac +done + +[[ -n "$SRC" && -d "$SRC" ]] || { echo "need --src " >&2; exit 2; } +for dep in 7z ssh-keygen sha512sum shred openssl; do + command -v "$dep" >/dev/null 2>&1 || { echo "missing required tool: $dep" >&2; exit 3; } +done + +ts=$(date +%s) +work=$(mktemp -d "${TMPDIR:-/tmp}/hh-esa-${ts}-XXXXXX") || { echo "mktemp failed" >&2; exit 1; } +out="$work/out" +key="$work/.private_ed25519_${ts}" +tag="file-integrity" +mkdir -p "$out/contents" + +# Best-effort shred of the ephemeral private key on any exit. +cleanup() { [[ -f "$key" ]] && shred -uz "$key" 2>/dev/null; rm -f "$key.pub" 2>/dev/null; rm -rf "$work" 2>/dev/null; } +trap cleanup EXIT + +die() { echo "$1" >&2; exit 1; } + +# 1. Fresh per-round Ed25519 signing key; ship the pubkey as an allowed-signers file. +ssh-keygen -t ed25519 -C anonymous -N '' -f "$key" >/dev/null 2>&1 || die "ssh-keygen failed" +echo "anonymous namespaces=\"$tag\" $(cat "$key.pub")" > "$out/anonymous_signer" + +# 2. Stage the payload; optionally inject 32 random bytes (deniability — breaks +# correlation of otherwise-identical archives / signatures). +cp -a "$SRC/." "$out/contents/" 2>/dev/null || cp -r "$SRC/." "$out/contents/" || die "copy source failed" +[[ $DO_RANDOM -eq 1 ]] && openssl rand -out "$out/contents/.entropy" 32 >/dev/null 2>&1 + +# 3. Compress the inner volume and drop the plaintext tree. +7z a "$out/contents.7z" "$out/contents" >/dev/null 2>&1 || die "7z (inner) failed" +rm -rf "$out/contents" + +# 4. Sign the inner archive. +ssh-keygen -Y sign -f "$key" -n "$tag" "$out/contents.7z" >/dev/null 2>&1 || die "signing failed" + +# 5. Optional attribution commitment: SHA-512(passphrase || contents.7z). +if [[ -n "$ATTRIB" ]]; then + { printf '%s' "$ATTRIB"; cat "$out/contents.7z"; } | sha512sum | awk '{print $1}' \ + > "$out/attribution-checksum.sha512" || die "attribution commitment failed" +fi + +# 6. Ship self-contained verifiers. +cat > "$out/verify-everything.sh" <<'EOF' +#!/bin/bash +# Verify this ESA archive: inner integrity, checksums, and the Ed25519 signature. +set -e +echo -n "contents.7z integrity ... "; 7z t contents.7z >/dev/null 2>&1 && echo OK +echo -n "sha512 checksums ... "; sha512sum -c checksums.sha512 >/dev/null 2>&1 && echo OK +echo -n "ed25519 signature ... "; ssh-keygen -Y verify -f ./anonymous_signer -I anonymous -n file-integrity -s contents.7z.sig < contents.7z >/dev/null 2>&1 && echo OK +echo "all checks passed." +EOF +cat > "$out/test_validate_passphrase.sh" <<'EOF' +#!/bin/bash +# Prove authorship by revealing the attribution passphrase for this archive. +set -e +[ -f attribution-checksum.sha512 ] || { echo "no attribution commitment in this archive"; exit 1; } +want=$(cat attribution-checksum.sha512) +pass="${1:-}"; [ -z "$pass" ] && { read -rsp 'attribution passphrase: ' pass; echo; } +got=$( ( printf '%s' "$pass"; cat contents.7z ) | sha512sum | awk '{print $1}') +[ "$want" = "$got" ] && echo "attribution OK — passphrase matches commitment" || { echo "attribution FAIL"; exit 1; } +EOF +chmod +x "$out/verify-everything.sh" "$out/test_validate_passphrase.sh" + +# 7. SHA-512 over every outer file (excluding the checksum file itself). +( cd "$out" && files=$(ls -1 | grep -vx 'checksums.sha512'); sha512sum $files > checksums.sha512 ) \ + || die "checksum generation failed" + +# 8. Package the outer layer (optionally encrypted, filenames included). +dest_dir="$(cd "$(dirname "$SRC")" && pwd)" +final="$dest_dir/verifiable_archive_${ts}.7z" +if [[ -n "$ENCPASS" ]]; then + ( cd "$work" && 7z a "$final" out -p"$ENCPASS" -mhe=on >/dev/null 2>&1 ) || die "7z (outer, encrypted) failed" +else + ( cd "$work" && 7z a "$final" out >/dev/null 2>&1 ) || die "7z (outer) failed" +fi + +echo "$final"