Originally created by: fu351
Originally owned by: nighshift-labs
The decision log and the policy-change ledger are already append-only. Signing them would make tampering detectable, hardening the audit story for anyone who needs stronger integrity guarantees locally.
What to build
0600 fingerprint key or a dedicated signing key — never committed).doberman-CLI verify path that reports whether the chain is intact.Where to start: src/doberman/storage/ (the decision log + ledger schema) and the existing keyed-HMAC fingerprint helper. Keep it redaction-safe (sign the already-redacted records; never widen what's stored).
Difficulty: level-10 — a bounded, satisfying crypto addition to an existing schema, but a new subsystem: design discussion before any code.
Building this? A few project invariants any change must respect:
New here? See CONTRIBUTING.md (github.com), and come chat in the Discord — happy to help you scope any of this.
Originally posted by: nighshift-labs
I traced the current public tip and prepared a bounded design sample: deliverables/doberman-signed-ledger-micro-delivery.md. The decision and policy ledgers are redacted SQLite append paths, but neither stores signatures or chain links today; the existing fingerprint key is fail-closed HMAC, not an independently verifiable ledger signature. Before code, which policy should the implementation lock: Ed25519 vs keyed HMAC, shared vs per-ledger keys, and additive columns vs a sidecar table for legacy unsigned rows? If you choose that boundary, is this the slice you want implemented?
Originally posted by: fu351
@nighshift-labs Really impressed with the depth that you went into for this issue。 You traced that neither ledger stores signatures or chain links today, and that the fingerprint key is fail-closed HMAC rather than an independently verifiable signature. That's exactly the reading of the current tip I'd hoped someone would do before touching this. You didn't wait for a spec, you mapped the ground first. Right instinct for a level-10.
So here's my thinking on the three decisions. (Just a suggestion and my take on things, not a definitive ruling). It's all open, and I'd genuinely rather land it in conversation with you than hand down a spec.
1. Ed25519 vs keyed HMAC. I'd lean Ed25519, behind a small signer seam, and I want to be honest about why, because it isn't the obvious reason. Against the threat that actually worries me here, a co-resident process running as your same OS user, Ed25519 buys us nothing over HMAC: the default OS keyrings let a same-user process read the key either way, so that attacker forges under either scheme. What asymmetric signing really buys us is a log someone else can verify without holding a forging key, which is the exportable, independently-auditable story the enterprise tier is built on. To me that's worth the
cryptographydependency. And I'd add analgcolumn so a future algorithm change becomes an epoch record chained over the old head instead of a migration.2. Shared vs per-ledger keys. My instinct is one per-install keypair. Per-ledger keys felt safer to me too at first, but I couldn't find a real threat-model win: compromise and rotation are install-scoped either way. The isolation that matters seems to come from domain separation instead, binding the ledger name and schema version into every signature and keeping the chains independent, so no row can be lifted from one ledger into another. If we go that way I'd want rotation semantics pinned up front (new key signs a rotation record over the old head, old key ids stay verifiable), otherwise
key_idis just a column.3. Additive columns vs sidecar. I'd lean additive, nullable. One appended row is one atomic signed unit, where a sidecar means a join on every verify and a two-table write with torn-write windows. My suggestion would be to start the chain with a genesis row that commits a digest and count of the existing unsigned tail, and to treat any NULL signature at or after that genesis as a verification failure, never "legacy." That's the bit I'd most want to get right, since it's the hole where a resident attacker inserts rows dressed up as old ones.
Then there are two things that cut across all three, and these I really don't want to settle solo: the chain's real job is being one anchorable commitment to the whole history (it doesn't stop someone holding the key, so anchoring the head out of the attacker's reach is the real endgame), and I think we need to design sanctioned truncation now so the planned decision-log pruning doesn't read as tampering later. Those two are exactly the kind of thing I'd love your read on.
You're assigned, so this one's yours to help shape, and nothing above is locked. I'm very open to talking any of it through, and Discord's the best place for the deep end: https://discord.gg/Sfy5XGNqty. Really appreciate all the work you've put into this one.
Originally posted by: nighshift-labs
The threat-model distinction makes sense: the value of Ed25519 here is exportable verification, not same-user key compromise. I’d pin the smallest first slice as: one per-install Ed25519 keypair,
key_id+alg, domain-separated canonical redacted rows, independent per-ledger chains, nullable additive columns, and a genesis/rotation record before touching pruning.Before I implement, can you confirm four acceptance points so I don’t bake in the wrong history semantics: (1) does a genesis digest commit the pre-signature unsigned tail, with any NULL signature after genesis reported as
legacy_unsigned; (2) should sanctioned truncation be an explicit signed checkpoint row that resets the chain head; (3) should signing failure preserve today’s best-effort decision behavior while rejecting only the new signed row; and (4) should--verifysupport stable JSON as well as human output? If those are right, I can prepare the isolated signer/canonicalization/verification slice first, without touching execution or pruning.Originally posted by: nighshift-labs
I turned the current discussion into a bounded v1 acceptance matrix. My proposed defaults are: Ed25519 behind a signer/verifier seam; one per-install keypair outside SQLite with exportable public verification; domain-separated canonical redacted rows; independent decision/policy chains; nullable additive signature columns; explicit signed genesis and rotation records; and stable human + JSON verification output.
Before implementing, I still need four history choices pinned:
legacy_unsigned), or only rows appended after genesis?signing_failed?--verify --jsoninclude row-level failures and the last valid head, or only aggregate status/reason?I would keep verification/reporting outside the decision path and sign only the already-redacted representation. If these defaults fit, your answers to the four history points are enough to start the isolated signer/canonicalization/verifier slice.
Originally posted by: fu351
@nighshift-labs sorry for the slow reply, and thank you for pinning the matrix down this tightly, it made the four calls easy. Your defaults all fit. The four:
Genesis covers the unsigned tail as one commitment (digest plus row count of everything before it). Rows before genesis verify as
legacy_unsigned, reported but not a failure. Any NULL signature at or after genesis is a verification failure, never legacy. That's the one place I'd differ from your wording, since a NULL after genesis is where a resident attacker would insert a row dressed up as old.Yes, sanctioned truncation is an explicit signed checkpoint row, but it chains over the previous head rather than resetting it: it commits the digest and count of the rows being removed plus the head they hung off, so verification stays continuous across the cut. One thing to know:
doberman decision-log-prunelanded in [#461] this week (resolved rows only, by age or row budget). Once signing is on, that command has to write the checkpoint, and a prune without one should read as tampering.Yes. Logging never alters a decision (§9 in the repo manual), so the decision goes ahead as today. The row is still written, marked
signing_failedexplicitly rather than left as a bare NULL, and--verifyreports it as a failure, not a skip. An honest gap beats a silent one.Both: aggregate status and reason, plus row-level failures and the last valid head. Follow the shared JSON output contract in docs/CLI.md (#396) so it lines up with the other
--jsoncommands.Go ahead with the isolated signer/canonicalization/verifier slice first, as you scoped it.
Related
Tickets:
#461