diff --git a/SPEC.md b/SPEC.md index 5a3ebbf..a4023c0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -332,14 +332,42 @@ lines separated by a blank line. \n \n \n +[revoc:\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:\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): @@ -347,6 +375,7 @@ encoding matters for signature verification). +revoc: ← present when revoc artifact exists — diff --git a/go/issuer/log/log.go b/go/issuer/log/log.go index bb428ee..4bd8a52 100644 --- a/go/issuer/log/log.go +++ b/go/issuer/log/log.go @@ -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) @@ -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 } @@ -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, @@ -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) } diff --git a/go/shared/checkpoint/checkpoint.go b/go/shared/checkpoint/checkpoint.go index 9d1c11d..5c4f481 100644 --- a/go/shared/checkpoint/checkpoint.go +++ b/go/shared/checkpoint/checkpoint.go @@ -14,6 +14,7 @@ import ( "crypto/ed25519" "crypto/sha256" "encoding/base64" + "encoding/hex" "fmt" "strconv" "strings" @@ -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: +// +// 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 { diff --git a/go/verifier/verify/verify.go b/go/verifier/verify/verify.go index c24bd28..96329e6 100644 --- a/go/verifier/verify/verify.go +++ b/go/verifier/verify/verify.go @@ -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 } diff --git a/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java b/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java index 33354a9..41e1f95 100644 --- a/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java +++ b/java/src/main/java/com/peculiarventures/mtaqr/issuer/Issuer.java @@ -90,8 +90,9 @@ private record LogEntry(long index, byte[] tbs, byte[] entryHash) {} private record Batch(List entries, byte[] root) {} private record SignedCheckpoint( - long treeSize, byte[] rootHash, byte[] body, byte[] issuerSig, - List cosigs) {} + long treeSize, byte[] rootHash, + byte[] body, byte[] issuerSig, List cosigs, + byte[] plainBody, byte[] plainSig, List plainCosigs) {} private record WitnessCosig(byte[] keyId, long timestamp, byte[] signature) {} @@ -269,23 +270,43 @@ private CompletableFuture 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 cosigs = new ArrayList<>(); + List 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) { @@ -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 cosigs0 = ckpt.cosigs(); + List cosigs0 = ckpt.plainCosigs(); out.write(cosigs0.size() & 0xff); for (WitnessCosig c : cosigs0) { out.writeBytes(c.keyId()); @@ -536,6 +557,23 @@ public static byte[] computeRootFromProof(byte[] start, int idx, int treeSize, L // --- Checkpoint --- + /** Checkpoint body with 4th extension line: revoc:\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); diff --git a/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java b/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java index 64f4396..aad7389 100644 --- a/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java +++ b/java/src/main/java/com/peculiarventures/mtaqr/verifier/Verifier.java @@ -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) diff --git a/rust/src/issuer/mod.rs b/rust/src/issuer/mod.rs index 4380880..4bd774a 100644 --- a/rust/src/issuer/mod.rs +++ b/rust/src/issuer/mod.rs @@ -77,11 +77,14 @@ struct Batch { } struct SignedCheckpoint { - tree_size: u64, - root_hash: Vec, - body: Vec, - issuer_sig: Vec, - cosigs: Vec<(Vec, u64, Vec)>, // (key_id, timestamp, sig) + tree_size: u64, + root_hash: Vec, + body: Vec, // 4-line if revoc committed, else 3-line + plain_body: Vec, // always 3-line; used for Mode 0 embedding + issuer_sig: Vec, // sig over body + plain_sig: Vec, // sig over plain_body (for Mode 0) + cosigs: Vec<(Vec, u64, Vec)>, // cosigs over body + plain_cosigs: Vec<(Vec, u64, Vec)>, // cosigs over plain_body } struct State { @@ -279,22 +282,41 @@ impl Issuer { (state.origin_id, total_entries(&state), root) }; - let body = checkpoint_body(&self.cfg.origin, tree_size, &parent_root); + // Commit revocation state into the checkpoint body so witnesses attest to it. + let revoc_snap: Option = { + let s = self.state.lock().await; + s.latest_rev_artifact.clone() + }; + let body = if let Some(ref art) = revoc_snap { + checkpoint_body_with_revoc(&self.cfg.origin, tree_size, &parent_root, art.as_bytes()) + } else { + checkpoint_body(&self.cfg.origin, tree_size, &parent_root) + }; let issuer_sig = self.signer.sign(&body).await?; - let ts = unix_now(); + // Plain 3-line body sigs — embedded in Mode 0 payloads. + let plain_body = checkpoint_body(&self.cfg.origin, tree_size, &parent_root); + let plain_sig = if plain_body == body { issuer_sig.clone() } + else { self.signer.sign(&plain_body).await? }; + let ts = unix_now(); - let cosigs: Vec<(Vec, u64, Vec)> = { + let (cosigs, plain_cosigs): (Vec<_>, Vec<_>) = { let state = self.state.lock().await; state.witnesses.iter().map(|w| { - let msg = cosignature_message(&body, ts); - let sig = w.signing.sign(&msg).to_bytes().to_vec(); - (w.key_id.to_vec(), ts, sig) - }).collect() + use ed25519_dalek::Signer as _; + let c = { let msg = cosignature_message(&body, ts); + (w.key_id.to_vec(), ts, w.signing.sign(&msg).to_bytes().to_vec()) }; + let p = if plain_body == body { c.clone() } else { + let msg = cosignature_message(&plain_body, ts); + (w.key_id.to_vec(), ts, w.signing.sign(&msg).to_bytes().to_vec()) + }; + (c, p) + }).unzip() }; let mut state = self.state.lock().await; state.latest_ckpt = Some(SignedCheckpoint { - tree_size, root_hash: parent_root, body, issuer_sig, cosigs, + tree_size, root_hash: parent_root, body, plain_body, + issuer_sig, plain_sig, cosigs, plain_cosigs, }); // Build revocation artifact alongside every checkpoint. state.latest_rev_artifact = Some(build_revocation_artifact( @@ -412,15 +434,11 @@ fn build_payload( let mut rh = [0u8; 32]; rh.copy_from_slice(&ckpt.root_hash); - let issuer_sig = ckpt.issuer_sig.clone(); - let cosigs: Vec<(Vec, u64, Vec)> = state.witnesses.iter() - .map(|w| { - let ts = unix_now(); - let msg = cosignature_message(&ckpt.body, ts); - use ed25519_dalek::Signer as DalekSigner; - let sig: ed25519_dalek::Signature = w.signing.sign(&msg); - (w.key_id.to_vec(), ts, sig.to_bytes().to_vec()) - }).collect(); + // Mode 0 embeds the checkpoint inline. The verifier reconstructs the + // checkpoint body as a plain 3-line body from (origin, treeSize, rootHash). + // Use pre-computed plain_sig and plain_cosigs from the checkpoint. + let issuer_sig = ckpt.plain_sig.clone(); + let cosigs = ckpt.plain_cosigs.clone(); let mut buf = encode_payload( global_idx, ckpt.tree_size, state.origin_id, @@ -591,6 +609,17 @@ pub(crate) fn checkpoint_body(origin: &str, tree_size: u64, root_hash: &[u8]) -> format!("{origin}\n{tree_size}\n{b64}\n").into_bytes() } +/// Checkpoint body with a 4th extension line committing to the revocation artifact. +pub(crate) fn checkpoint_body_with_revoc( + origin: &str, tree_size: u64, root_hash: &[u8], revoc_artifact: &[u8] +) -> Vec { + let mut body = checkpoint_body(origin, tree_size, root_hash); + let hash = Sha256::digest(revoc_artifact); + let ext = format!("revoc:{}\n", hex::encode(&hash[..])); + body.extend_from_slice(ext.as_bytes()); + body +} + pub(crate) fn cosignature_message(body: &[u8], timestamp: u64) -> Vec { let header = format!("cosignature/v1\ntime {timestamp}\n"); let mut msg = header.into_bytes(); diff --git a/rust/src/verifier/mod.rs b/rust/src/verifier/mod.rs index 83856e2..75eb252 100644 --- a/rust/src/verifier/mod.rs +++ b/rust/src/verifier/mod.rs @@ -431,14 +431,16 @@ impl Verifier { let body: Vec = (note[..blank].to_string() + "\n").into_bytes(); let rest = ¬e[blank + 2..]; - // Parse body + // Parse body — c2sp.org/tlog-checkpoint: 3 mandatory lines + optional extensions. let body_str = std::str::from_utf8(&body)?; - let lines: Vec<&str> = body_str.trim_end_matches('\n').splitn(3, '\n').collect(); - // Per c2sp.org/tlog-checkpoint: three mandatory lines plus optional extension lines. + let lines: Vec<&str> = body_str.trim_end_matches('\n').split('\n').collect(); if lines.len() < 3 { return Err(anyhow!("checkpoint body must have at least 3 lines, got {}", lines.len())); } let note_origin = lines[0]; let tree_size: u64 = lines[1].parse()?; let root_hash = B64.decode(lines[2])?; + // Extract optional revoc: extension line (line 4+). + let _revoc_hash: Option<&str> = lines.get(3) + .and_then(|l| l.strip_prefix("revoc:")); if note_origin != trust.origin { return Err(anyhow!("origin mismatch: {:?}", note_origin)); diff --git a/ts/sdk/src/checkpoint.ts b/ts/sdk/src/checkpoint.ts index 9f774ea..204eb4b 100644 --- a/ts/sdk/src/checkpoint.ts +++ b/ts/sdk/src/checkpoint.ts @@ -49,6 +49,30 @@ export function checkpointBody( return new TextEncoder().encode(`${origin}\n${treeSize}\n${rootHashB64}\n`); } +/** + * Checkpoint body with a 4th extension line committing to the revocation artifact. + * The 4th line is `revoc:\n`. + * Per c2sp.org/tlog-checkpoint, extension lines are part of the authenticated + * content — all signatures (issuer + witness cosigs) cover this line. + */ +export function checkpointBodyWithRevoc( + origin: string, + treeSize: bigint | number, + rootHash: Uint8Array, + revocArtifact: Uint8Array | string, +): Uint8Array { + const base = checkpointBody(origin, treeSize, rootHash); + const artBytes = typeof revocArtifact === "string" + ? new TextEncoder().encode(revocArtifact) : revocArtifact; + const hash = Buffer.from( + createHash("sha256").update(artBytes).digest() + ).toString("hex"); + const ext = new TextEncoder().encode(`revoc:${hash}\n`); + const combined = new Uint8Array(base.length + ext.length); + combined.set(base); combined.set(ext, base.length); + return combined; +} + /** Sign a checkpoint body. Returns 64-byte Ed25519 signature. */ export function signCheckpointBody(body: Uint8Array, privKeySeed: Uint8Array): Uint8Array { return new Uint8Array(nodeSign(null, Buffer.from(body), seedToPrivKey(privKeySeed))); diff --git a/ts/sdk/src/issuer.ts b/ts/sdk/src/issuer.ts index 66ce7ea..08484b6 100644 --- a/ts/sdk/src/issuer.ts +++ b/ts/sdk/src/issuer.ts @@ -15,7 +15,7 @@ import { encodeTbs, encodeNullTbs, decodeTbs, DataAssertionEntry, Claims } from import { Cascade } from "./cascade.js"; import { entryHash, inclusionProof, computeRoot } from "./merkle.js"; import { - checkpointBody, signCheckpointBody, signCosignature, + checkpointBody, checkpointBodyWithRevoc, signCheckpointBody, signCosignature, noteKeyId, witnessKeyId, computeOriginId, pubKeyFromSeed, generateSeed, } from "./checkpoint.js"; import { @@ -274,7 +274,12 @@ export class Issuer { private async publishCheckpoint(): Promise { const parentRoot = computeRoot(this.batchRoots()); const treeSize = this.totalEntries(); - const body = checkpointBody(this.origin, BigInt(treeSize), parentRoot); + // Commit revocation state into the checkpoint body so witnesses attest to it. + const revocArt = this.latestRevArtifact; + const body = revocArt + ? checkpointBodyWithRevoc(this.origin, BigInt(treeSize), parentRoot, + new TextEncoder().encode(revocArt)) + : checkpointBody(this.origin, BigInt(treeSize), parentRoot); const issuerSig = await this.signer.sign(body); const ts = BigInt(Math.floor(Date.now() / 1000)); const cosigs: WitnessCosig[] = this.witnesses.map(w => { diff --git a/ts/sdk/src/verifier.ts b/ts/sdk/src/verifier.ts index d560c48..84a2845 100644 --- a/ts/sdk/src/verifier.ts +++ b/ts/sdk/src/verifier.ts @@ -349,6 +349,9 @@ export class Verifier { const rest = note.slice(blankIdx + 2); const { origin, treeSize, rootHash } = parseCheckpointBody(body); + // Extract optional revoc: extension line for auditability. + const _revocHashHex = new TextDecoder().decode(body) + .split("\n").find(l => l.startsWith("revoc:"))?.slice("revoc:".length) ?? null; if (origin !== trust.origin) { throw new Error(`origin mismatch: got "${origin}" want "${trust.origin}"`); }