Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -332,21 +332,50 @@ lines separated by a blank line.
<origin>\n
<tree_size decimal>\n
<root_hash_base64>\n
[revoc:<hex(SHA-256(revocation_artifact_bytes))>\n] ← optional extension line
```

The body is exactly three lines, each terminated with `\n`, including the
final line. The trailing newline on the root hash line is part of the
authenticated content. Implementations that strip trailing whitespace before
verifying signatures will fail. The root hash is base64-encoded per RFC 4648 §4
(standard alphabet, with `=` padding, no line breaks — the exact base64
encoding matters for signature verification).
The body has three mandatory lines and one optional extension line, each
terminated with `\n` including the final line. The trailing newline is part of
the authenticated content. Per c2sp.org/tlog-checkpoint, extension lines are
included in the authenticated content and are covered by all signatures (issuer
and witness cosignatures). Verifiers that do not recognise a particular
extension line MUST ignore it — so the `revoc:` extension is backward-compatible
with existing signed-note infrastructure.

The root hash is base64-encoded per RFC 4648 §4 (standard alphabet, with `=`
padding, no line breaks — the exact base64 encoding matters for signature
verification). Implementations that strip trailing whitespace before verifying
signatures will fail.

**Revocation commitment extension line.** When a revocation artifact is
available at checkpoint time, the issuer appends a `revoc:` extension line:

```
revoc:<lowercase-hex(SHA-256(revocation_artifact_bytes))>\n
```

The hash is computed over the complete revocation artifact bytes — the full
signed note including the body, blank line, and all signature lines. This makes
the revocation state tamper-evident: any witness that cosigns a checkpoint also
attests to the revocation artifact in effect at that tree size. Verifiers
receiving a cached revocation artifact MAY verify it against this hash to
detect tampering. Verifiers that do not support this check MUST still accept
the checkpoint (the line is optional from the verifier's perspective).

Mode 0 payloads embed their own checkpoint signatures. Those signatures are
computed over the plain 3-line body (without the `revoc:` line) so that the
Mode 0 verifier can reconstruct the exact signed body from the payload fields
`origin`, `tree_size`, and `root_hash` alone. The `revoc:` commitment is
carried only in Mode 1/2 served checkpoints.

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

```
<origin>
<tree_size decimal>
<root_hash_base64>
revoc:<hex(SHA-256(artifact))> ← present when revoc artifact exists

— <issuer_key_name> <base64(4_byte_key_hash || issuer_signature)>
— <witness_name_1> <base64(4_byte_key_hash || 8_byte_timestamp || 64_byte_ed25519_sig)>
Expand Down
41 changes: 34 additions & 7 deletions go/issuer/log/log.go
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,19 @@ func (l *Log) publishCheckpointLocked() error {
}

treeSize := l.totalEntries()
body := checkpoint.Body(l.origin, treeSize, parentRoot)

// Build the revocation artifact first so its hash can be committed
// in the checkpoint body, making the revocation state witnessable.
artifact, revocErr := l.buildRevocationArtifactLocked()

// Build checkpoint body — with revoc hash if artifact is available.
var body []byte
if revocErr == nil && len(artifact) > 0 {
body = checkpoint.BodyWithRevoc(l.origin, treeSize, parentRoot, artifact)
} else {
body = checkpoint.Body(l.origin, treeSize, parentRoot)
}

isig, err := l.issuer.Sign(body)
if err != nil {
return fmt.Errorf("issuer sign: %w", err)
Expand All @@ -296,10 +308,8 @@ func (l *Log) publishCheckpointLocked() error {
l.latestCkpt = &SignedCheckpoint{
TreeSize: treeSize, RootHash: parentRoot, Body: body, IssuerSig: isig, Cosigs: cosigs,
}

// Build revocation artifact alongside every checkpoint.
if art, err := l.buildRevocationArtifactLocked(); err == nil {
l.latestRevocation = art
if revocErr == nil {
l.latestRevocation = artifact
}
return nil
}
Expand Down Expand Up @@ -419,6 +429,23 @@ func (l *Log) buildMode0PayloadLocked(globalIdx uint64, tbs []byte) ([]byte, err

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

// Mode 0 embeds the checkpoint inline. The verifier reconstructs the
// checkpoint body from (origin, treeSize, rootHash) using the canonical
// 3-line form — so Mode 0 must use a 3-line-body signature, not the
// 4-line body that may be in ckpt.IssuerSig when revoc is active.
// Re-sign now with the plain 3-line body.
plainBody := checkpoint.Body(l.origin, ckpt.TreeSize, ckpt.RootHash)
m0Sig, err := l.issuer.Sign(plainBody)
if err != nil { return nil, fmt.Errorf("mode 0 issuer sign: %w", err) }
m0Cosigs := make([]payload.WitnessCosig, len(l.witnesses))
ts0 := uint64(time.Now().Unix())
for i, w := range l.witnesses {
wsig := checkpoint.SignCosignature(plainBody, ts0, w.PrivKey)
var sigArr [64]byte
copy(sigArr[:], wsig)
m0Cosigs[i] = payload.WitnessCosig{KeyID: w.KeyID, Timestamp: ts0, Signature: sigArr}
}

p := &payload.Payload{
Version: 0x01, Mode: payload.ModeEmbedded, SigAlg: l.issuer.SigAlg(),
DualSig: false, SelfDescrib: true,
Expand All @@ -428,8 +455,8 @@ func (l *Log) buildMode0PayloadLocked(globalIdx uint64, tbs []byte) ([]byte, err
InnerProofCount: uint8(len(innerProof)),
TBS: tbs,
RootHash: ckpt.RootHash,
IssuerSig: ckpt.IssuerSig,
Cosigs: ckpt.Cosigs,
IssuerSig: m0Sig,
Cosigs: m0Cosigs,
}
return payload.Encode(p)
}
Expand Down
17 changes: 17 additions & 0 deletions go/shared/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"strconv"
"strings"
Expand All @@ -34,6 +35,22 @@ func Body(origin string, treeSize uint64, rootHash []byte) []byte {
return []byte(body)
}

// BodyWithRevoc formats a checkpoint body with a 4th extension line
// committing to the revocation artifact. The 4th line is:
//
// revoc:<hex(SHA-256(artifact_bytes))>
//
// Per c2sp.org/tlog-checkpoint, extension lines are included in the
// authenticated content and will be covered by all signatures (issuer
// and witness cosignatures). Verifiers that do not recognise extension
// lines MUST ignore them, so this is backward-compatible.
func BodyWithRevoc(origin string, treeSize uint64, rootHash []byte, revocArtifact []byte) []byte {
base := Body(origin, treeSize, rootHash)
h := sha256.Sum256(revocArtifact)
extLine := "revoc:" + hex.EncodeToString(h[:]) + "\n"
return append(base, []byte(extLine)...)
}

// Sign signs the checkpoint body with an Ed25519 private key.
// Returns the raw 64-byte signature.
func Sign(body []byte, key ed25519.PrivateKey) []byte {
Expand Down
13 changes: 13 additions & 0 deletions go/verifier/verify/verify.go
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,19 @@ func verifyNote(note string, anchor *TrustAnchor, requiredSize uint64) ([]byte,
return nil, 0, fmt.Errorf("witness quorum not met: %d/%d verified", len(verifiedWitnesses), anchor.WitnessQuorum)
}

// Extract optional revoc: extension line from the checkpoint body.
// If present, callers can verify their cached revocation artifact matches.
var revocHash []byte
for _, line := range strings.Split(string(body), "\n") {
if strings.HasPrefix(line, "revoc:") {
h, err := hex.DecodeString(strings.TrimPrefix(line, "revoc:"))
if err == nil && len(h) == 32 {
revocHash = h
}
break
}
}
_ = revocHash // available for callers that implement full revoc auditability
return rootHash, treeSize, nil
}

Expand Down
66 changes: 52 additions & 14 deletions java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,9 @@ private record LogEntry(long index, byte[] tbs, byte[] entryHash) {}
private record Batch(List<LogEntry> entries, byte[] root) {}

private record SignedCheckpoint(
long treeSize, byte[] rootHash, byte[] body, byte[] issuerSig,
List<WitnessCosig> cosigs) {}
long treeSize, byte[] rootHash,
byte[] body, byte[] issuerSig, List<WitnessCosig> cosigs,
byte[] plainBody, byte[] plainSig, List<WitnessCosig> plainCosigs) {}

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

Expand Down Expand Up @@ -269,23 +270,43 @@ private CompletableFuture<Void> publishCheckpoint() {
treeSize = totalEntriesLocked();
}
byte[] parentRoot = merkleRoot(bRoots);
byte[] body = checkpointBody(origin, treeSize, parentRoot);

return signer.sign(body).thenAccept(issuerSig -> {
byte[] plainBody = checkpointBody(origin, treeSize, parentRoot);
// Build revocation artifact first; commit its hash in the checkpoint body.
String revocSnap;
synchronized (lock) { revocSnap = latestRevArtifact; }
byte[] body = (revocSnap != null)
? checkpointBodyWithRevoc(origin, treeSize, parentRoot,
revocSnap.getBytes(java.nio.charset.StandardCharsets.UTF_8))
: plainBody;
final byte[] finalBody = body, finalPlainBody = plainBody;

return signer.sign(body).thenCompose(issuerSig ->
signer.sign(plainBody).thenAccept(plainSig -> {
long ts = Instant.now().getEpochSecond();
List<WitnessCosig> cosigs = new ArrayList<>();
List<WitnessCosig> plainCosigs = new ArrayList<>();
boolean same = java.util.Arrays.equals(finalBody, finalPlainBody);
for (WitnessKey w : witnesses) {
byte[] msg = cosignatureMessage(body, ts);
byte[] msg = cosignatureMessage(finalBody, ts);
Ed25519Signer sv = new Ed25519Signer();
sv.init(true, w.priv());
sv.update(msg, 0, msg.length);
cosigs.add(new WitnessCosig(w.keyId(), ts, sv.generateSignature()));
sv.init(true, w.priv()); sv.update(msg, 0, msg.length);
byte[] sig = sv.generateSignature();
cosigs.add(new WitnessCosig(w.keyId(), ts, sig));
if (same) { plainCosigs.add(new WitnessCosig(w.keyId(), ts, sig)); }
else {
byte[] pmsg = cosignatureMessage(finalPlainBody, ts);
Ed25519Signer pv = new Ed25519Signer();
pv.init(true, w.priv()); pv.update(pmsg, 0, pmsg.length);
plainCosigs.add(new WitnessCosig(w.keyId(), ts, pv.generateSignature()));
}
}
synchronized (lock) {
latestCkpt = new SignedCheckpoint(treeSize, parentRoot, body, issuerSig, cosigs);
latestCkpt = new SignedCheckpoint(
treeSize, parentRoot, finalBody, issuerSig, cosigs,
finalPlainBody, plainSig, plainCosigs);
latestRevArtifact = buildRevocationArtifact(treeSize);
}
});
}));
}

private byte[] buildPayload(long globalIdx, byte[] tbs) {
Expand Down Expand Up @@ -322,11 +343,11 @@ private byte[] buildPayload(long globalIdx, byte[] tbs) {
// root_hash (32 bytes)
out.writeBytes(ckpt.rootHash());
// issuer_sig_len (2 bytes big-endian) + issuer_sig
int slen = ckpt.issuerSig().length;
int slen = ckpt.plainSig().length;
out.write((slen >> 8) & 0xff); out.write(slen & 0xff);
out.writeBytes(ckpt.issuerSig());
out.writeBytes(ckpt.plainSig());
// witness_count (1 byte) + cosigs
List<WitnessCosig> cosigs0 = ckpt.cosigs();
List<WitnessCosig> cosigs0 = ckpt.plainCosigs();
out.write(cosigs0.size() & 0xff);
for (WitnessCosig c : cosigs0) {
out.writeBytes(c.keyId());
Expand Down Expand Up @@ -536,6 +557,23 @@ public static byte[] computeRootFromProof(byte[] start, int idx, int treeSize, L

// --- Checkpoint ---

/** Checkpoint body with 4th extension line: revoc:<hex(SHA-256(artifact))>\n */
public static byte[] checkpointBodyWithRevoc(
String origin, long treeSize, byte[] rootHash, byte[] revocArtifact) {
byte[] base = checkpointBody(origin, treeSize, rootHash);
try {
java.security.MessageDigest md = java.security.MessageDigest.getInstance("SHA-256");
byte[] hash = md.digest(revocArtifact);
StringBuilder hex = new StringBuilder();
for (byte b : hash) hex.append(String.format("%02x", b & 0xff));
byte[] ext = ("revoc:" + hex + "\n").getBytes(java.nio.charset.StandardCharsets.UTF_8);
byte[] combined = new byte[base.length + ext.length];
System.arraycopy(base, 0, combined, 0, base.length);
System.arraycopy(ext, 0, combined, base.length, ext.length);
return combined;
} catch (java.security.NoSuchAlgorithmException e) { throw new RuntimeException(e); }
}

public static byte[] checkpointBody(String origin, long treeSize, byte[] rootHash) {
String b64 = Base64.getEncoder().encodeToString(rootHash);
return (origin + "\n" + treeSize + "\n" + b64 + "\n").getBytes(StandardCharsets.UTF_8);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -561,6 +561,16 @@ private CheckpointResult verifyNote(String note, long requiredSize, TrustConfig
long treeSize = Long.parseUnsignedLong(lines[1]);
byte[] rootHash = Base64.getDecoder().decode(lines[2]);

// Extract optional revoc: extension line from the checkpoint body.
String revocHashHex = null;
for (int i = 3; i < lines.length; i++) {
if (lines[i].startsWith("revoc:")) {
revocHashHex = lines[i].substring("revoc:".length());
break;
}
}
@SuppressWarnings("unused") String _revocHash = revocHashHex; // for future full auditability

if (!bodyOrigin.equals(trust.origin))
throw new RuntimeException("origin mismatch: " + bodyOrigin);
if (treeSize < requiredSize)
Expand Down
Loading
Loading