//! Signed erasures: an owner-signed statement carried in an ordinary row of
//! the repository shun table.
//!
//! The row is Fossil's, byte for byte: `MTIME 'UUID' scom 'SCOM'`, as
//! Fossil's configure.c sends a `/shun` row (D. Richard Hipp). Everything
//! this module adds lives inside SCOM, so a peer that knows nothing of it
//! (stock Fossil, an older zit) reads the row as a plain shun with an odd
//! comment, which is the safe direction:
//!
//! e1:SIGNATURE:NOT-BEFORE:KIND[ REASON]
//!
//! SIGNATURE is 86 characters of unpadded base64url over a 64-byte Ed25519
//! signature, NOT-BEFORE is decimal Unix seconds, KIND is `erase` or
//! `cancel`, and REASON is the operator's note. The signed message is
//! domain-separated and binds the repository's project code, so a row signed
//! for one repository verifies in no other:
//!
//! zit-erasure-v1 LF PROJECT-CODE LF HASH LF EPOCH LF NOT-BEFORE LF KIND LF REASON
//!
//! HASH and EPOCH are the row's own `uuid` and `mtime`, so nothing is stored
//! twice. The module is pure apart from the key file helpers at the end.
const std = @import("std");
const Ed25519 = std.crypto.sign.Ed25519;
const base64 = std.base64.url_safe_no_pad;
/// The repository setting holding the pinned public key, 64 lowercase hex.
pub const setting_name: []const u8 = "erasure-key";
/// The delay a signed erasure carries unless `--now` asks for none.
pub const default_delay: i64 = 24 * 60 * 60;
/// The bound on a whole SCOM, the shun table's reason bound.
pub const max_scom: usize = 200;
pub const prefix: []const u8 = "e1:";
pub const signature_text_len: usize = base64.Encoder.calcSize(Ed25519.Signature.encoded_length);
pub const key_hex_len: usize = 2 * Ed25519.PublicKey.encoded_length;
pub const seed_len: usize = Ed25519.KeyPair.seed_length;
pub const max_project_code: usize = 255;
/// The reason of the plain row a store records once a signed erasure of a
/// name has taken effect here: the erasure stays applied whatever becomes of
/// the key that signed it (a replaced `erasure-key` verifies nothing old).
/// Plain, so it never travels as a statement and no peer acts on it as one.
pub const applied_reason: []const u8 = "erasure applied";
pub fn isApplied(reason: []const u8) bool {
return std.mem.eql(u8, reason, applied_reason);
}
const domain: []const u8 = "zit-erasure-v1\n";
/// Domain, project code, a 64-hex name, two i64s, the longest kind, the
/// reason, and the six separators.
const max_message: usize = domain.len + max_project_code + 64 + 2 * 20 + "cancel".len + max_scom + 6;
pub const Kind = enum {
erase,
cancel,
pub fn word(k: Kind) []const u8 {
return @tagName(k);
}
};
pub const PublicKey = [Ed25519.PublicKey.encoded_length]u8;
pub const Seed = [seed_len]u8;
/// A parsed `e1:` statement. `reason` borrows the SCOM it came from.
pub const Statement = struct {
signature: [Ed25519.Signature.encoded_length]u8,
not_before: i64,
kind: Kind,
reason: []const u8,
};
/// What a row's SCOM is: a plain shun comment, a well-formed statement, or a
/// comment that claims the `e1:` form and does not have it.
pub const Form = union(enum) {
plain,
signed: Statement,
malformed,
};
pub fn classify(scom: []const u8) Form {
if (!std.mem.startsWith(u8, scom, prefix)) return .plain;
var rest = scom[prefix.len..];
if (rest.len < signature_text_len + 1 or rest[signature_text_len] != ':') return .malformed;
var st: Statement = undefined;
base64.Decoder.decode(&st.signature, rest[0..signature_text_len]) catch return .malformed;
rest = rest[signature_text_len + 1 ..];
const colon = std.mem.indexOfScalar(u8, rest, ':') orelse return .malformed;
st.not_before = parseEpoch(rest[0..colon]) orelse return .malformed;
rest = rest[colon + 1 ..];
const space = std.mem.indexOfScalar(u8, rest, ' ');
const kind_text = rest[0 .. space orelse rest.len];
st.kind = std.meta.stringToEnum(Kind, kind_text) orelse return .malformed;
st.reason = if (space) |s| rest[s + 1 ..] else "";
return .{ .signed = st };
}
/// Decimal seconds with no sign, no leading zero and no padding, so one
/// value has exactly one spelling and a SCOM re-emits to the same bytes.
fn parseEpoch(text: []const u8) ?i64 {
if (text.len == 0 or text.len > 19) return null;
if (text[0] == '0' and text.len > 1) return null;
for (text) |c| if (c < '0' or c > '9') return null;
return std.fmt.parseInt(i64, text, 10) catch null;
}
pub const SignError = error{ ReasonTooLong, ProjectCodeTooLong, BadKey, BadEpoch };
/// The row identity a statement is signed over.
pub const Row = struct {
project_code: []const u8,
/// Lowercase hex, the row's `uuid`.
hash_hex: []const u8,
/// The row's `mtime`.
epoch: i64,
};
fn message(buf: *[max_message]u8, row: Row, not_before: i64, kind: Kind, reason: []const u8) []const u8 {
std.debug.assert(row.project_code.len <= max_project_code);
std.debug.assert(row.hash_hex.len <= 64);
std.debug.assert(reason.len <= max_scom);
return std.fmt.bufPrint(buf, "{s}{s}\n{s}\n{d}\n{d}\n{s}\n{s}", .{
domain, row.project_code, row.hash_hex, row.epoch, not_before, kind.word(), reason,
}) catch unreachable; // max_message bounds every field
}
/// Sign `row` and write its SCOM into `out`. Deterministic: Ed25519 with no
/// added noise, so a device without a good entropy source cannot get the
/// nonce wrong.
pub fn sign(seed: Seed, row: Row, not_before: i64, kind: Kind, reason: []const u8, out: *[max_scom]u8) SignError![]const u8 {
if (row.project_code.len > max_project_code) return error.ProjectCodeTooLong;
if (row.epoch < 0 or not_before < row.epoch) return error.BadEpoch;
if (reason.len > max_scom) return error.ReasonTooLong;
const kp = Ed25519.KeyPair.generateDeterministic(seed) catch return error.BadKey;
var msg_buf: [max_message]u8 = undefined;
const msg = message(&msg_buf, row, not_before, kind, reason);
const sig = kp.sign(msg, null) catch return error.BadKey;
var sig_text: [signature_text_len]u8 = undefined;
_ = base64.Encoder.encode(&sig_text, &sig.toBytes());
const sep: []const u8 = if (reason.len == 0) "" else " ";
return std.fmt.bufPrint(out, "{s}{s}:{d}:{s}{s}{s}", .{ prefix, &sig_text, not_before, kind.word(), sep, reason }) catch
error.ReasonTooLong;
}
/// Whether `st`, read from a row naming `row`, verifies against `key`.
pub fn verify(key: PublicKey, row: Row, st: Statement) bool {
if (row.project_code.len > max_project_code or row.hash_hex.len > 64 or st.reason.len > max_scom) return false;
const pk = Ed25519.PublicKey.fromBytes(key) catch return false;
var msg_buf: [max_message]u8 = undefined;
const msg = message(&msg_buf, row, st.not_before, st.kind, st.reason);
const sig = Ed25519.Signature.fromBytes(st.signature);
sig.verify(msg, pk) catch return false;
return true;
}
/// The verdict on one row's SCOM against an optional pinned key.
pub const Verdict = union(enum) {
/// Not a statement: a plain shun.
plain,
/// A statement that verifies against the pinned key.
verified: Statement,
/// A statement that does not verify: no key is pinned, another key or
/// another repository signed it, a byte changed, or the form is broken.
unverified,
};
pub fn judge(key: ?PublicKey, row: Row, scom: []const u8) Verdict {
return switch (classify(scom)) {
.plain => .plain,
.malformed => .unverified,
.signed => |st| if (key) |k| (if (verify(k, row, st)) .{ .verified = st } else .unverified) else .unverified,
};
}
pub fn publicKeyOf(seed: Seed) error{BadKey}!PublicKey {
const kp = Ed25519.KeyPair.generateDeterministic(seed) catch return error.BadKey;
return kp.public_key.toBytes();
}
/// A key's text form: 64 lowercase hex. Upper case is accepted on input.
pub fn parseKeyHex(comptime N: usize, text: []const u8) ?[N]u8 {
const trimmed = std.mem.trim(u8, text, " \t\r\n");
if (trimmed.len != 2 * N) return null;
var out: [N]u8 = undefined;
_ = std.fmt.hexToBytes(&out, trimmed) catch return null;
return out;
}
pub fn keyHex(bytes: [32]u8) [64]u8 {
return std.fmt.bytesToHex(bytes, .lower);
}
/// A public key pinned in the repository setting, or null when the setting
/// holds nothing a key can be read from.
pub fn pinnedKey(setting_value: ?[]const u8) ?PublicKey {
const v = setting_value orelse return null;
const pk = parseKeyHex(32, v) orelse return null;
_ = Ed25519.PublicKey.fromBytes(pk) catch return null;
return pk;
}
// ---------------------------------------------------------------------------
// The private key file: `$XDG_CONFIG_HOME/zit/keys/PROJECT-CODE.erasure`,
// falling back to `$HOME/.config`, mode 0600, the raw 32-byte seed. It never
// enters the repository, the wire, or any settings file that syncs.
// ---------------------------------------------------------------------------
pub const key_subdir: []const u8 = "zit/keys";
pub const key_suffix: []const u8 = ".erasure";
/// The directory holding key files, or null when no home is known.
pub fn keyDir(environ: anytype, buf: []u8) ?[]const u8 {
// An empty XDG_CONFIG_HOME is unset, as the XDG base-directory
// specification reads it.
if (environ.getPosix("XDG_CONFIG_HOME")) |xdg| if (xdg.len != 0) {
return std.fmt.bufPrint(buf, "{s}/{s}", .{ xdg, key_subdir }) catch null;
};
const home = environ.getPosix("HOME") orelse return null;
if (home.len == 0) return null;
return std.fmt.bufPrint(buf, "{s}/.config/{s}", .{ home, key_subdir }) catch null;
}
/// A key file's basename. A project code is hex in every repository zit or
/// Fossil makes; anything else is refused rather than used as a path.
pub fn keyFileName(project_code: []const u8, buf: []u8) ?[]const u8 {
if (project_code.len == 0 or project_code.len > max_project_code) return null;
for (project_code) |c| if (!std.ascii.isAlphanumeric(c)) return null;
return std.fmt.bufPrint(buf, "{s}{s}", .{ project_code, key_suffix }) catch null;
}
pub const KeyFileError = error{ NoHome, BadProjectCode, NotFound, Malformed, Storage, Exists };
/// Write `seed` as a fresh key file, refusing to replace one.
pub fn writeKeyFile(io: std.Io, environ: anytype, project_code: []const u8, seed: Seed, path_out: []u8) KeyFileError![]const u8 {
var dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = keyDir(environ, &dir_buf) orelse return error.NoHome;
var name_buf: [max_project_code + key_suffix.len]u8 = undefined;
const name = keyFileName(project_code, &name_buf) orelse return error.BadProjectCode;
const path = std.fmt.bufPrint(path_out, "{s}/{s}", .{ dir_path, name }) catch return error.Storage;
std.Io.Dir.cwd().createDirPath(io, dir_path) catch return error.Storage;
// The directory holding private keys is the owner's alone.
std.Io.Dir.cwd().setFilePermissions(io, dir_path, std.Io.File.Permissions.fromMode(0o700), .{}) catch return error.Storage;
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{}) catch return error.Storage;
defer dir.close(io);
var file = dir.createFile(io, name, .{
.exclusive = true,
.permissions = std.Io.File.Permissions.fromMode(0o600),
}) catch |e| return if (e == error.PathAlreadyExists) error.Exists else error.Storage;
defer file.close(io);
file.writePositionalAll(io, &seed, 0) catch return error.Storage;
file.sync(io) catch return error.Storage;
return path;
}
pub fn readKeyFile(io: std.Io, environ: anytype, project_code: []const u8) KeyFileError!Seed {
var dir_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path = keyDir(environ, &dir_buf) orelse return error.NoHome;
var name_buf: [max_project_code + key_suffix.len]u8 = undefined;
const name = keyFileName(project_code, &name_buf) orelse return error.BadProjectCode;
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{}) catch return error.NotFound;
defer dir.close(io);
var buf: [seed_len + 1]u8 = undefined;
const got = dir.readFile(io, name, &buf) catch |e| return if (e == error.FileNotFound) error.NotFound else error.Storage;
if (got.len != seed_len) return error.Malformed;
return got[0..seed_len].*;
}
// ---------------------------------------------------------------------------
const testing = std.testing;
const test_seed: Seed = @splat(0x11);
const test_row: Row = .{
.project_code = "0123456789abcdef0123456789abcdef01234567",
.hash_hex = "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
.epoch = 1_800_000_000,
};
test "erasure: a signed statement round-trips through its SCOM and verifies" {
var buf: [max_scom]u8 = undefined;
const scom = try sign(test_seed, test_row, test_row.epoch + default_delay, .erase, "court order 12", &buf);
try testing.expect(std.mem.startsWith(u8, scom, "e1:"));
const st = classify(scom).signed;
try testing.expectEqual(test_row.epoch + default_delay, st.not_before);
try testing.expectEqual(Kind.erase, st.kind);
try testing.expectEqualStrings("court order 12", st.reason);
const pk = try publicKeyOf(test_seed);
try testing.expect(verify(pk, test_row, st));
try testing.expectEqual(Verdict.verified, std.meta.activeTag(judge(pk, test_row, scom)));
}
test "erasure: the known-answer vector pins the message and the encoding" {
// RFC 8032 section 7.1 TEST 1: an all-zero-message vector pins the curve.
const rfc_seed = parseKeyHex(32, "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60").?;
try testing.expectEqualStrings(
"d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
&keyHex(try publicKeyOf(rfc_seed)),
);
// And the zit message over that key, cross-checked with OpenSSL 3's
// `pkeyutl -sign -rawin`: a change to the domain, the field
// order or the separators changes these bytes.
var buf: [max_scom]u8 = undefined;
const scom = try sign(rfc_seed, test_row, test_row.epoch, .erase, "", &buf);
try testing.expectEqualStrings(
"e1:YddmzqetPOw6vM447QsK_CYZBDCO1BpdG4_az-naZlTxaT0yFxgcH9SErSOywG0ZGvcDpM2EMq6vLumc_nxLDg:1800000000:erase",
scom,
);
}
test "erasure: a changed byte in hash, epoch, not-before, kind, reason or project code fails" {
var buf: [max_scom]u8 = undefined;
const nb = test_row.epoch + 60;
const scom = try sign(test_seed, test_row, nb, .erase, "why", &buf);
const pk = try publicKeyOf(test_seed);
const st = classify(scom).signed;
var r = test_row;
r.hash_hex = "b1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90";
try testing.expect(!verify(pk, r, st));
r = test_row;
r.epoch += 1;
try testing.expect(!verify(pk, r, st));
r = test_row;
r.project_code = "1123456789abcdef0123456789abcdef01234567";
try testing.expect(!verify(pk, r, st));
var s2 = st;
s2.not_before += 1;
try testing.expect(!verify(pk, test_row, s2));
s2 = st;
s2.kind = .cancel;
try testing.expect(!verify(pk, test_row, s2));
s2 = st;
s2.reason = "whz";
try testing.expect(!verify(pk, test_row, s2));
// Another key verifies nothing; no key is unverified, not plain.
const other = try publicKeyOf(@as(Seed, @splat(0x22)));
try testing.expect(!verify(other, test_row, st));
try testing.expectEqual(Verdict.unverified, std.meta.activeTag(judge(null, test_row, scom)));
}
test "erasure: the SCOM grammar separates plain, signed and malformed" {
try testing.expectEqual(Form.plain, std.meta.activeTag(classify("")));
try testing.expectEqual(Form.plain, std.meta.activeTag(classify("DMCA takedown 2026-08")));
try testing.expectEqual(Form.malformed, std.meta.activeTag(classify("e1:short")));
var buf: [max_scom]u8 = undefined;
const scom = try sign(test_seed, test_row, test_row.epoch, .cancel, "", &buf);
try testing.expectEqual(Kind.cancel, classify(scom).signed.kind);
try testing.expectEqualStrings("", classify(scom).signed.reason);
// A padded or signed not-before has a second spelling; refused.
var bad: [max_scom]u8 = undefined;
const colon = std.mem.indexOfScalarPos(u8, scom, prefix.len, ':').?;
const padded = try std.fmt.bufPrint(&bad, "{s}:0{s}", .{ scom[0..colon], scom[colon + 1 ..] });
try testing.expectEqual(Form.malformed, std.meta.activeTag(classify(padded)));
const wrong_kind = try std.fmt.bufPrint(&bad, "{s}:1:purge", .{scom[0..colon]});
try testing.expectEqual(Form.malformed, std.meta.activeTag(classify(wrong_kind)));
}
test "erasure: a quote in the reason survives the shun row's quoting" {
var buf: [max_scom]u8 = undefined;
const scom = try sign(test_seed, test_row, test_row.epoch, .erase, "owner's request", &buf);
const pk = try publicKeyOf(test_seed);
try testing.expect(verify(pk, test_row, classify(scom).signed));
try testing.expectEqualStrings("owner's request", classify(scom).signed.reason);
}
test "erasure: the reason bound is the whole SCOM's, and a long reason is refused" {
var buf: [max_scom]u8 = undefined;
const overhead = prefix.len + signature_text_len + 1 + 10 + 1 + "erase".len + 1;
var long: [max_scom]u8 = @splat('r');
_ = try sign(test_seed, test_row, test_row.epoch, .erase, long[0 .. max_scom - overhead], &buf);
try testing.expectError(error.ReasonTooLong, sign(test_seed, test_row, test_row.epoch, .erase, long[0 .. max_scom - overhead + 1], &buf));
try testing.expectError(error.BadEpoch, sign(test_seed, test_row, test_row.epoch - 1, .erase, "", &buf));
}
test "erasure: an empty XDG_CONFIG_HOME falls back to HOME" {
const Env = struct {
xdg: ?[]const u8,
fn getPosix(self: @This(), name: []const u8) ?[]const u8 {
if (std.mem.eql(u8, name, "XDG_CONFIG_HOME")) return self.xdg;
if (std.mem.eql(u8, name, "HOME")) return "/home/o";
return null;
}
};
var buf: [128]u8 = undefined;
try testing.expectEqualStrings("/home/o/.config/zit/keys", keyDir(Env{ .xdg = "" }, &buf).?);
try testing.expectEqualStrings("/x/zit/keys", keyDir(Env{ .xdg = "/x" }, &buf).?);
try testing.expectEqualStrings("/home/o/.config/zit/keys", keyDir(Env{ .xdg = null }, &buf).?);
}
test "erasure: key text and the pinned setting" {
const pk = try publicKeyOf(test_seed);
const hex = keyHex(pk);
try testing.expectEqualSlices(u8, &pk, &pinnedKey(&hex).?);
try testing.expect(pinnedKey(null) == null);
try testing.expect(pinnedKey("zz") == null);
var nb: [64]u8 = undefined;
try testing.expect(keyFileName("../etc", &nb) == null);
try testing.expectEqualStrings("abc123.erasure", keyFileName("abc123", &nb).?);
}