feat(attribution): pseudonymous file attribution (ESA-style)
Adapt Princess_Pi's Encrypt-Share-Attribution scheme so shared files are anonymous but provably attributable. A. In-session: a persistent Ed25519 persona key (~/.config/hack-house/ persona_ed25519) signs every /send and /sendroom offer over the content hash; receivers verify it and see the persona fingerprint. Optional --attest <passphrase> attaches a revealable SHA-512(pass||sha256) commitment. Additive JSON — wire-compatible with the Python client. B. Portable: /export-signed <dir> packages a directory into Princess_Pi's exact ESA 7z (fresh per-round Ed25519 sig over an inner 7z, SHA-512 checksums, bundled verify scripts). Builder embedded from hh/tools/esa/esa_build.sh; verifiable with just bash+7z+ssh-keygen. Tests: 45 pass (3 new persona). ESA archive build + verify-everything.sh + passphrase reveal verified end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
(<https://git.thecoven.info/PrincessPi/Encrypt-Share-Attribution>).
|
||||
|
||||
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 <passphrase>` 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 <user> <path> [--attest <passphrase>]
|
||||
/sendroom <path> [--attest <passphrase>]
|
||||
/export-signed <dir> [--attest <passphrase>]
|
||||
```
|
||||
|
||||
`/export-signed` packages a directory into a **portable, self-verifying ESA 7z
|
||||
archive** (`verifiable_archive_<ts>.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.
|
||||
@@ -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"] }
|
||||
|
||||
+142
-7
@@ -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<Persona>,
|
||||
pub lines: Vec<ChatLine>,
|
||||
pub users: Vec<User>,
|
||||
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 <docker|multipass|vbox> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /pw show password · PgUp/PgDn scroll chat · ctrl-q quit");
|
||||
self.sys("/sbx launch <docker|multipass|vbox> · /drive (F2 releases) · /ai start · /ai <question> · /send <user> <file> · /sendroom <file> · /export-signed <dir> · /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<WsMsg>, 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 <passphrase>` 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 <dir>`: 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<Net>, 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_<ts>.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<String> {
|
||||
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<Net>,
|
||||
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 <passphrase>` 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 <user> <path>`. Everyone receives the
|
||||
// broadcast offer, but only <user> is prompted to /accept.
|
||||
let rest = rest.trim();
|
||||
// broadcast offer, but only <user> is prompted to /accept. An optional
|
||||
// trailing `--attest <passphrase>` 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 <user> <path> · /sendroom <path> 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 <dir> [--attest <passphrase>] — 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() {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Base64 detached signature over `persona::attest_msg(sha256, name, size)`.
|
||||
pub sig: Option<String>,
|
||||
/// Optional ESA-style attribution commitment `SHA-512(passphrase || sha256)`
|
||||
/// the sender can later open by revealing the passphrase.
|
||||
pub attrib: Option<String>,
|
||||
/// 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<Ft> {
|
||||
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();
|
||||
|
||||
@@ -9,6 +9,7 @@ mod crypto;
|
||||
mod ft;
|
||||
mod layout;
|
||||
mod net;
|
||||
mod persona;
|
||||
mod sbx;
|
||||
mod theme;
|
||||
mod ui;
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<u8> {
|
||||
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<String> {
|
||||
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<bool> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
Executable
+103
@@ -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 <dir> [--attrib-pass <passphrase>] [--random] [--encrypt <passphrase>]
|
||||
# On success, prints the path to the produced verifiable_archive_<ts>.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 <existing directory>" >&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"
|
||||
Reference in New Issue
Block a user