Skip to content

Commit 78858d7

Browse files
authored
feat: commit revocation artifact hash in checkpoint body (tamper-evident revocation)
feat: commit revocation artifact hash in checkpoint body (tamper-evident revocation)
2 parents adf9bce + 907563a commit 78858d7

11 files changed

Lines changed: 251 additions & 54 deletions

File tree

SPEC.md

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -332,21 +332,50 @@ lines separated by a blank line.
332332
<origin>\n
333333
<tree_size decimal>\n
334334
<root_hash_base64>\n
335+
[revoc:<hex(SHA-256(revocation_artifact_bytes))>\n] ← optional extension line
335336
```
336337

337-
The body is exactly three lines, each terminated with `\n`, including the
338-
final line. The trailing newline on the root hash line is part of the
339-
authenticated content. Implementations that strip trailing whitespace before
340-
verifying signatures will fail. The root hash is base64-encoded per RFC 4648 §4
341-
(standard alphabet, with `=` padding, no line breaks — the exact base64
342-
encoding matters for signature verification).
338+
The body has three mandatory lines and one optional extension line, each
339+
terminated with `\n` including the final line. The trailing newline is part of
340+
the authenticated content. Per c2sp.org/tlog-checkpoint, extension lines are
341+
included in the authenticated content and are covered by all signatures (issuer
342+
and witness cosignatures). Verifiers that do not recognise a particular
343+
extension line MUST ignore it — so the `revoc:` extension is backward-compatible
344+
with existing signed-note infrastructure.
345+
346+
The root hash is base64-encoded per RFC 4648 §4 (standard alphabet, with `=`
347+
padding, no line breaks — the exact base64 encoding matters for signature
348+
verification). Implementations that strip trailing whitespace before verifying
349+
signatures will fail.
350+
351+
**Revocation commitment extension line.** When a revocation artifact is
352+
available at checkpoint time, the issuer appends a `revoc:` extension line:
353+
354+
```
355+
revoc:<lowercase-hex(SHA-256(revocation_artifact_bytes))>\n
356+
```
357+
358+
The hash is computed over the complete revocation artifact bytes — the full
359+
signed note including the body, blank line, and all signature lines. This makes
360+
the revocation state tamper-evident: any witness that cosigns a checkpoint also
361+
attests to the revocation artifact in effect at that tree size. Verifiers
362+
receiving a cached revocation artifact MAY verify it against this hash to
363+
detect tampering. Verifiers that do not support this check MUST still accept
364+
the checkpoint (the line is optional from the verifier's perspective).
365+
366+
Mode 0 payloads embed their own checkpoint signatures. Those signatures are
367+
computed over the plain 3-line body (without the `revoc:` line) so that the
368+
Mode 0 verifier can reconstruct the exact signed body from the payload fields
369+
`origin`, `tree_size`, and `root_hash` alone. The `revoc:` commitment is
370+
carried only in Mode 1/2 served checkpoints.
343371

344372
**Full served note** (body + blank line + signature lines):
345373

346374
```
347375
<origin>
348376
<tree_size decimal>
349377
<root_hash_base64>
378+
revoc:<hex(SHA-256(artifact))> ← present when revoc artifact exists
350379
351380
— <issuer_key_name> <base64(4_byte_key_hash || issuer_signature)>
352381
— <witness_name_1> <base64(4_byte_key_hash || 8_byte_timestamp || 64_byte_ed25519_sig)>

go/issuer/log/log.go

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,19 @@ func (l *Log) publishCheckpointLocked() error {
278278
}
279279

280280
treeSize := l.totalEntries()
281-
body := checkpoint.Body(l.origin, treeSize, parentRoot)
281+
282+
// Build the revocation artifact first so its hash can be committed
283+
// in the checkpoint body, making the revocation state witnessable.
284+
artifact, revocErr := l.buildRevocationArtifactLocked()
285+
286+
// Build checkpoint body — with revoc hash if artifact is available.
287+
var body []byte
288+
if revocErr == nil && len(artifact) > 0 {
289+
body = checkpoint.BodyWithRevoc(l.origin, treeSize, parentRoot, artifact)
290+
} else {
291+
body = checkpoint.Body(l.origin, treeSize, parentRoot)
292+
}
293+
282294
isig, err := l.issuer.Sign(body)
283295
if err != nil {
284296
return fmt.Errorf("issuer sign: %w", err)
@@ -296,10 +308,8 @@ func (l *Log) publishCheckpointLocked() error {
296308
l.latestCkpt = &SignedCheckpoint{
297309
TreeSize: treeSize, RootHash: parentRoot, Body: body, IssuerSig: isig, Cosigs: cosigs,
298310
}
299-
300-
// Build revocation artifact alongside every checkpoint.
301-
if art, err := l.buildRevocationArtifactLocked(); err == nil {
302-
l.latestRevocation = art
311+
if revocErr == nil {
312+
l.latestRevocation = artifact
303313
}
304314
return nil
305315
}
@@ -419,6 +429,23 @@ func (l *Log) buildMode0PayloadLocked(globalIdx uint64, tbs []byte) ([]byte, err
419429

420430
allProof := append(innerProof, outerProof...)
421431

432+
// Mode 0 embeds the checkpoint inline. The verifier reconstructs the
433+
// checkpoint body from (origin, treeSize, rootHash) using the canonical
434+
// 3-line form — so Mode 0 must use a 3-line-body signature, not the
435+
// 4-line body that may be in ckpt.IssuerSig when revoc is active.
436+
// Re-sign now with the plain 3-line body.
437+
plainBody := checkpoint.Body(l.origin, ckpt.TreeSize, ckpt.RootHash)
438+
m0Sig, err := l.issuer.Sign(plainBody)
439+
if err != nil { return nil, fmt.Errorf("mode 0 issuer sign: %w", err) }
440+
m0Cosigs := make([]payload.WitnessCosig, len(l.witnesses))
441+
ts0 := uint64(time.Now().Unix())
442+
for i, w := range l.witnesses {
443+
wsig := checkpoint.SignCosignature(plainBody, ts0, w.PrivKey)
444+
var sigArr [64]byte
445+
copy(sigArr[:], wsig)
446+
m0Cosigs[i] = payload.WitnessCosig{KeyID: w.KeyID, Timestamp: ts0, Signature: sigArr}
447+
}
448+
422449
p := &payload.Payload{
423450
Version: 0x01, Mode: payload.ModeEmbedded, SigAlg: l.issuer.SigAlg(),
424451
DualSig: false, SelfDescrib: true,
@@ -428,8 +455,8 @@ func (l *Log) buildMode0PayloadLocked(globalIdx uint64, tbs []byte) ([]byte, err
428455
InnerProofCount: uint8(len(innerProof)),
429456
TBS: tbs,
430457
RootHash: ckpt.RootHash,
431-
IssuerSig: ckpt.IssuerSig,
432-
Cosigs: ckpt.Cosigs,
458+
IssuerSig: m0Sig,
459+
Cosigs: m0Cosigs,
433460
}
434461
return payload.Encode(p)
435462
}

go/shared/checkpoint/checkpoint.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"crypto/ed25519"
1515
"crypto/sha256"
1616
"encoding/base64"
17+
"encoding/hex"
1718
"fmt"
1819
"strconv"
1920
"strings"
@@ -34,6 +35,22 @@ func Body(origin string, treeSize uint64, rootHash []byte) []byte {
3435
return []byte(body)
3536
}
3637

38+
// BodyWithRevoc formats a checkpoint body with a 4th extension line
39+
// committing to the revocation artifact. The 4th line is:
40+
//
41+
// revoc:<hex(SHA-256(artifact_bytes))>
42+
//
43+
// Per c2sp.org/tlog-checkpoint, extension lines are included in the
44+
// authenticated content and will be covered by all signatures (issuer
45+
// and witness cosignatures). Verifiers that do not recognise extension
46+
// lines MUST ignore them, so this is backward-compatible.
47+
func BodyWithRevoc(origin string, treeSize uint64, rootHash []byte, revocArtifact []byte) []byte {
48+
base := Body(origin, treeSize, rootHash)
49+
h := sha256.Sum256(revocArtifact)
50+
extLine := "revoc:" + hex.EncodeToString(h[:]) + "\n"
51+
return append(base, []byte(extLine)...)
52+
}
53+
3754
// Sign signs the checkpoint body with an Ed25519 private key.
3855
// Returns the raw 64-byte signature.
3956
func Sign(body []byte, key ed25519.PrivateKey) []byte {

go/verifier/verify/verify.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,19 @@ func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) ([]byte,
498498
return nil, 0, fmt.Errorf("witness quorum not met: %d/%d verified", len(verifiedWitnesses), anchor.WitnessQuorum)
499499
}
500500

501+
// Extract optional revoc: extension line from the checkpoint body.
502+
// If present, callers can verify their cached revocation artifact matches.
503+
var revocHash []byte
504+
for _, line := range strings.Split(string(body), "\n") {
505+
if strings.HasPrefix(line, "revoc:") {
506+
h, err := hex.DecodeString(strings.TrimPrefix(line, "revoc:"))
507+
if err == nil && len(h) == 32 {
508+
revocHash = h
509+
}
510+
break
511+
}
512+
}
513+
_ = revocHash // available for callers that implement full revoc auditability
501514
return rootHash, treeSize, nil
502515
}
503516

java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,9 @@ private record LogEntry(long index, byte[] tbs, byte[] entryHash) {}
9090
private record Batch(List<LogEntry> entries, byte[] root) {}
9191

9292
private record SignedCheckpoint(
93-
long treeSize, byte[] rootHash, byte[] body, byte[] issuerSig,
94-
List<WitnessCosig> cosigs) {}
93+
long treeSize, byte[] rootHash,
94+
byte[] body, byte[] issuerSig, List<WitnessCosig> cosigs,
95+
byte[] plainBody, byte[] plainSig, List<WitnessCosig> plainCosigs) {}
9596

9697
private record WitnessCosig(byte[] keyId, long timestamp, byte[] signature) {}
9798

@@ -269,23 +270,43 @@ private CompletableFuture<Void> publishCheckpoint() {
269270
treeSize = totalEntriesLocked();
270271
}
271272
byte[] parentRoot = merkleRoot(bRoots);
272-
byte[] body = checkpointBody(origin, treeSize, parentRoot);
273-
274-
return signer.sign(body).thenAccept(issuerSig -> {
273+
byte[] plainBody = checkpointBody(origin, treeSize, parentRoot);
274+
// Build revocation artifact first; commit its hash in the checkpoint body.
275+
String revocSnap;
276+
synchronized (lock) { revocSnap = latestRevArtifact; }
277+
byte[] body = (revocSnap != null)
278+
? checkpointBodyWithRevoc(origin, treeSize, parentRoot,
279+
revocSnap.getBytes(java.nio.charset.StandardCharsets.UTF_8))
280+
: plainBody;
281+
final byte[] finalBody = body, finalPlainBody = plainBody;
282+
283+
return signer.sign(body).thenCompose(issuerSig ->
284+
signer.sign(plainBody).thenAccept(plainSig -> {
275285
long ts = Instant.now().getEpochSecond();
276286
List<WitnessCosig> cosigs = new ArrayList<>();
287+
List<WitnessCosig> plainCosigs = new ArrayList<>();
288+
boolean same = java.util.Arrays.equals(finalBody, finalPlainBody);
277289
for (WitnessKey w : witnesses) {
278-
byte[] msg = cosignatureMessage(body, ts);
290+
byte[] msg = cosignatureMessage(finalBody, ts);
279291
Ed25519Signer sv = new Ed25519Signer();
280-
sv.init(true, w.priv());
281-
sv.update(msg, 0, msg.length);
282-
cosigs.add(new WitnessCosig(w.keyId(), ts, sv.generateSignature()));
292+
sv.init(true, w.priv()); sv.update(msg, 0, msg.length);
293+
byte[] sig = sv.generateSignature();
294+
cosigs.add(new WitnessCosig(w.keyId(), ts, sig));
295+
if (same) { plainCosigs.add(new WitnessCosig(w.keyId(), ts, sig)); }
296+
else {
297+
byte[] pmsg = cosignatureMessage(finalPlainBody, ts);
298+
Ed25519Signer pv = new Ed25519Signer();
299+
pv.init(true, w.priv()); pv.update(pmsg, 0, pmsg.length);
300+
plainCosigs.add(new WitnessCosig(w.keyId(), ts, pv.generateSignature()));
301+
}
283302
}
284303
synchronized (lock) {
285-
latestCkpt = new SignedCheckpoint(treeSize, parentRoot, body, issuerSig, cosigs);
304+
latestCkpt = new SignedCheckpoint(
305+
treeSize, parentRoot, finalBody, issuerSig, cosigs,
306+
finalPlainBody, plainSig, plainCosigs);
286307
latestRevArtifact = buildRevocationArtifact(treeSize);
287308
}
288-
});
309+
}));
289310
}
290311

291312
private byte[] buildPayload(long globalIdx, byte[] tbs) {
@@ -322,11 +343,11 @@ private byte[] buildPayload(long globalIdx, byte[] tbs) {
322343
// root_hash (32 bytes)
323344
out.writeBytes(ckpt.rootHash());
324345
// issuer_sig_len (2 bytes big-endian) + issuer_sig
325-
int slen = ckpt.issuerSig().length;
346+
int slen = ckpt.plainSig().length;
326347
out.write((slen >> 8) & 0xff); out.write(slen & 0xff);
327-
out.writeBytes(ckpt.issuerSig());
348+
out.writeBytes(ckpt.plainSig());
328349
// witness_count (1 byte) + cosigs
329-
List<WitnessCosig> cosigs0 = ckpt.cosigs();
350+
List<WitnessCosig> cosigs0 = ckpt.plainCosigs();
330351
out.write(cosigs0.size() & 0xff);
331352
for (WitnessCosig c : cosigs0) {
332353
out.writeBytes(c.keyId());
@@ -536,6 +557,23 @@ public static byte[] computeRootFromProof(byte[] start, int idx, int treeSize, L
536557

537558
// --- Checkpoint ---
538559

560+
/** Checkpoint body with 4th extension line: revoc:<hex(SHA-256(artifact))>\n */
561+
public static byte[] checkpointBodyWithRevoc(
562+
String origin, long treeSize, byte[] rootHash, byte[] revocArtifact) {
563+
byte[] base = checkpointBody(origin, treeSize, rootHash);
564+
try {
565+
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
566+
byte[] hash = md.digest(revocArtifact);
567+
StringBuilder hex = new StringBuilder();
568+
for (byte b : hash) hex.append(String.format("%02x", b & 0xff));
569+
byte[] ext = ("revoc:" + hex + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8);
570+
byte[] combined = new byte[base.length + ext.length];
571+
System.arraycopy(base, 0, combined, 0, base.length);
572+
System.arraycopy(ext, 0, combined, base.length, ext.length);
573+
return combined;
574+
} catch (java.security.NoSuchAlgorithmException e) { throw new RuntimeException(e); }
575+
}
576+
539577
public static byte[] checkpointBody(String origin, long treeSize, byte[] rootHash) {
540578
String b64 = Base64.getEncoder().encodeToString(rootHash);
541579
return (origin + "\n" + treeSize + "\n" + b64 + "\n").getBytes(StandardCharsets.UTF_8);

java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,16 @@ private CheckpointResult verifyNote(String note, long requiredSize, TrustConfig
561561
long treeSize = Long.parseUnsignedLong(lines[1]);
562562
byte[] rootHash = Base64.getDecoder().decode(lines[2]);
563563

564+
// Extract optional revoc: extension line from the checkpoint body.
565+
String revocHashHex = null;
566+
for (int i = 3; i < lines.length; i++) {
567+
if (lines[i].startsWith("revoc:")) {
568+
revocHashHex = lines[i].substring("revoc:".length());
569+
break;
570+
}
571+
}
572+
@SuppressWarnings("unused") String _revocHash = revocHashHex; // for future full auditability
573+
564574
if (!bodyOrigin.equals(trust.origin))
565575
throw new RuntimeException("origin mismatch: " + bodyOrigin);
566576
if (treeSize < requiredSize)

0 commit comments

Comments
 (0)