From c7dd17f03de975547f3f106101c13c7af6a4c169 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Wed, 30 Sep 2026 12:56:22 +0100 Subject: [PATCH 1/2] feat(sdk): gate sealed bids on encoding and tlock commitment before commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SDK validated only size and encoding, so a blob that decoded but was committed to the wrong value would reach the chain and never open. The commitment check existed only in the post-reveal receipt verifier. tlock now owns the sealed-payload wire format (payload.ts): parseSealedPayload reads a tlock ciphertext's structure — armor, age v1 header, `-> tlock ` stanza, MAC line — without decrypting or touching the network, and reports the round, chain hash, and implied plaintext length. commitmentMatches in commitment.ts is the shared acceptance rule both sides call. validateSealedBid composes three layers: encoding, length (32-byte commitment, 48-byte be16(value)||nonce preimage), and — when the caller supplies the optional `binding` of value/nonce/round — the re-derived commitment. commit() runs it before any contract call. Errors never carry the bid value. Also fixes three pre-existing defects found on the way: - options.encoding was accepted and documented but never read; the test for it had been failing on main and was not in the package test script. - Empty auditor blobs were rejected, though sealBid emits one whenever no identity is disclosed and the contract takes auditor_blob as optional Bytes. - A wrong-width commitment reported both invalid_commitment_length and commitment_mismatch. Swaps are caught when the caller supplies the binding of the seal it holds. A self-consistent but wrong value/nonce pair still passes, since detecting that would require decrypting — that limit is documented by a named test. Tests use real sealBid output via a stub Drand client, so the gate is checked against the sealer it must agree with. sdk 110/110, tlock 46/46. --- packages/sdk/package.json | 2 +- packages/sdk/src/client.test.ts | 13 +- packages/sdk/src/client.ts | 39 ++- packages/sdk/src/encrypted-blob.test.ts | 316 +++++++++++++++++++++++ packages/sdk/src/encrypted-blob.ts | 219 +++++++++++++++- packages/sdk/src/index.ts | 3 + packages/sdk/src/testing/seal-fixture.ts | 71 +++++ packages/tlock/package.json | 4 +- packages/tlock/src/commitment.test.ts | 45 ++++ packages/tlock/src/commitment.ts | 23 ++ packages/tlock/src/index.ts | 16 ++ packages/tlock/src/payload.test.ts | 300 +++++++++++++++++++++ packages/tlock/src/payload.ts | 250 ++++++++++++++++++ 13 files changed, 1261 insertions(+), 40 deletions(-) create mode 100644 packages/sdk/src/testing/seal-fixture.ts create mode 100644 packages/tlock/src/payload.test.ts create mode 100644 packages/tlock/src/payload.ts diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 8f2a6890..3cde3929 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -24,7 +24,7 @@ ".": "./src/index.ts" }, "scripts": { - "test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/ids.test.ts src/mainnet-readiness.test.ts", + "test": "node --import tsx --test src/client.test.ts src/encoding.test.ts src/errors.test.ts src/encrypted-blob.test.ts src/ids.test.ts src/mainnet-readiness.test.ts", "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index 89797aed..36c7d4ea 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -10,6 +10,7 @@ import type { SubmitSignedTransactionParams, TransactionSubmitter, } from "./submitter.js"; +import { sealFixture, fixtureBinding } from "./testing/seal-fixture.js"; const BASE_CONFIG = { rpcUrl: "https://example.com", @@ -90,15 +91,17 @@ describe("SubRosaClient source configuration", () => { it("rejects commit without a bidder source using a typed error", async () => { const client = new SubRosaClient(BASE_CONFIG); + // A real seal: commit() runs the sealed-bid gate before it resolves the + // bidder source, so a placeholder blob would fail on its own terms and + // this test would stop being about the source check. + const sealed = await sealFixture(); + await assert.rejects( client.commit({ roundId: 1, - sealed: { - commitment: new Uint8Array(32), - ciphertext: new Uint8Array([0x61, 0x67, 0x65]), // non-empty - auditorBlob: new Uint8Array(1), // non-empty - }, + sealed, escrow: 1n, + binding: fixtureBinding(), }), (error: unknown) => { assert.ok(error instanceof SubRosaClientConfigError); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index c4a6fff5..cc6d10e7 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -23,7 +23,8 @@ import { import { toHex } from "@sub-rosa/tlock"; import type { SealedBid } from "@sub-rosa/tlock"; import type { RoundReceipt } from "./receipt.js"; -import { validateEncryptedBlob } from "./encrypted-blob.js"; +import { assertSealedBid } from "./encrypted-blob.js"; +import type { SealedBidBinding } from "./encrypted-blob.js"; import { networkFingerprint } from "./receipt.js"; import type { TransactionSubmitter } from "./submitter.js"; import { @@ -100,6 +101,16 @@ export interface CommitParams { escrow: bigint; /** Bidder address. Default: the configured signer's public key. */ bidder?: string; + /** + * The value, nonce, and Drand round the seal was produced from. + * + * Supplying it makes `commit` verify the seal against them before submitting + * — same acceptance rule the sealer works to, so a blob that decodes but + * commits to the wrong value is rejected here instead of becoming an + * on-chain commitment the contract can never open. The value is never logged + * or included in the resulting error. + */ + binding?: SealedBidBinding; } export interface RevealParams { @@ -276,26 +287,12 @@ export class SubRosaClient { } async commit(params: CommitParams): Promise { - // Validate encrypted blobs before submitting — catches size/encoding - // issues early, before paying gas for an on-chain revert (PayloadTooLarge). - const ciphertextResult = validateEncryptedBlob( - params.sealed.ciphertext, - "ciphertext", - ); - if (!ciphertextResult.valid) { - throw new SubRosaClientConfigError( - ciphertextResult.issues.map((i) => i.message).join("; "), - ); - } - const auditorBlobResult = validateEncryptedBlob( - params.sealed.auditorBlob, - "auditor_blob", - ); - if (!auditorBlobResult.valid) { - throw new SubRosaClientConfigError( - auditorBlobResult.issues.map((i) => i.message).join("; "), - ); - } + // Gate the seal before submitting. Size/encoding defects surface here + // instead of as an on-chain PayloadTooLarge revert, and — when the caller + // passes the value/nonce/round it sealed from — a blob whose commitment + // does not match never reaches the chain, where it would be committed and + // never open. + assertSealedBid(params.sealed, params.binding); const bidder = params.bidder ?? this.#requireSource("bidder"); const tx = await this.contract.commit({ diff --git a/packages/sdk/src/encrypted-blob.test.ts b/packages/sdk/src/encrypted-blob.test.ts index 72abfcf4..a2350bf9 100644 --- a/packages/sdk/src/encrypted-blob.test.ts +++ b/packages/sdk/src/encrypted-blob.test.ts @@ -6,17 +6,34 @@ // - Do not log raw blob contents. // - Keep limits conservative and configurable only if the codebase already // has config patterns. +// +// The `validateSealedBid` section is the bid acceptance rule. Its fixtures are +// real `sealBid` output — produced offline against a stub Drand client that +// serves quicknet's static public key — so the gate is checked against the +// sealer it has to agree with rather than against a hand-written stand-in. import { test } from "node:test"; import assert from "node:assert/strict"; +import { QUICKNET_HASH, commitment, generateAuditorKeypair, type SealedBid } from "@sub-rosa/tlock"; +import { SubRosaClientConfigError } from "./errors.js"; import { validateEncryptedBlob, + validateSealedBid, + assertSealedBid, MAX_CIPHERTEXT_BYTES, MAX_AUDITOR_BLOB_BYTES, tryDecodeHex, tryDecodeBase64, + type SealedBidBinding, } from "./encrypted-blob.js"; +import { + BID_NONCE, + BID_ROUND, + BID_VALUE, + sealFixture, + fixtureBinding, +} from "./testing/seal-fixture.js"; // ── Helpers ────────────────────────────────────────────────────────────── @@ -329,3 +346,302 @@ test("custom maxBytes can be more permissive than default", () => { }); assert.equal(result.valid, true); }); + +// ── Sealed-bid acceptance gate ──────────────────────────────────────────── +// +// The gate a bid has to pass before it is committed. Three layers, matching +// the sealer: the blob is well-formed tlock ciphertext, its lengths are the +// ones the contract expects, and its commitment is re-derived from the +// supplied value and nonce with tlock's own helper. + +/** Assert rejection and return the issue codes, for readable assertions. */ +function rejectionCodes(sealed: SealedBid, binding?: SealedBidBinding): string[] { + const result = validateSealedBid(sealed, binding); + assert.equal(result.valid, false, "expected the seal to be rejected"); + return result.issues.map((issue) => issue.code); +} + +// ── A real seal is accepted ─────────────────────────────────────────────── + +test("accepts a blob produced by sealBid, with its value, nonce, and round", async () => { + const sealed = await sealFixture(); + const result = validateSealedBid(sealed, fixtureBinding()); + + assert.equal(result.valid, true, JSON.stringify(result.issues)); + assert.deepEqual(result.issues, []); + assertSealedBid(sealed, fixtureBinding()); +}); + +test("accepts a sealBid blob carrying a selective-disclosure auditor blob", async () => { + const keypair = generateAuditorKeypair(); + const sealed = await sealFixture({ + identity: new TextEncoder().encode("GBIDDER...alice"), + auditorPublicKey: keypair.publicKey, + }); + assert.ok(sealed.auditorBlob.length > 0); + + const result = validateSealedBid(sealed, fixtureBinding()); + assert.equal(result.valid, true, JSON.stringify(result.issues)); +}); + +test("accepts a sealBid blob with an empty auditor blob (no identity disclosed)", async () => { + // `sealBid` emits an empty auditor blob when the bidder discloses nothing, + // and the contract takes `auditor_blob` as optional Bytes. + const sealed = await sealFixture(); + assert.equal(sealed.auditorBlob.length, 0); + assert.equal(validateSealedBid(sealed, fixtureBinding()).valid, true); +}); + +test("accepts the structural checks alone, without a value, nonce, or round", async () => { + const sealed = await sealFixture(); + const result = validateSealedBid(sealed); + assert.equal(result.valid, true, JSON.stringify(result.issues)); +}); + +// ── Truncated blobs are rejected ────────────────────────────────────────── + +test("rejects a truncated ciphertext before commit", async () => { + const sealed = await sealFixture(); + const truncated = { ...sealed, ciphertext: sealed.ciphertext.slice(0, 120) }; + assert.deepEqual(rejectionCodes(truncated, fixtureBinding()), [ + "invalid_sealed_payload", + ]); +}); + +test("rejects a ciphertext whose armor footer was cut off", async () => { + const sealed = await sealFixture(); + const text = new TextDecoder().decode(sealed.ciphertext); + const stripped = new TextEncoder().encode(text.replace(/-----END AGE ENCRYPTED FILE-----\n?$/, "")); + assert.ok(rejectionCodes({ ...sealed, ciphertext: stripped }, fixtureBinding()).includes( + "invalid_sealed_payload", + )); +}); + +test("rejects an empty ciphertext", async () => { + const sealed = await sealFixture(); + assert.ok(rejectionCodes({ ...sealed, ciphertext: new Uint8Array(0) }, fixtureBinding()).includes( + "empty_blob", + )); +}); + +test("rejects an oversized ciphertext", async () => { + const sealed = await sealFixture(); + const padded = new Uint8Array(MAX_CIPHERTEXT_BYTES + 1).fill(0x61); + assert.ok(rejectionCodes({ ...sealed, ciphertext: padded }, fixtureBinding()).includes( + "blob_too_large", + )); +}); + +test("rejects a blob that is not a tlock payload at all", async () => { + const sealed = await sealFixture(); + const codes = rejectionCodes( + { ...sealed, ciphertext: new TextEncoder().encode("age-encryption.org/v1\nnot armored\n") }, + fixtureBinding(), + ); + assert.ok(codes.includes("invalid_sealed_payload")); +}); + +// ── Length ──────────────────────────────────────────────────────────────── + +test("rejects a commitment that is not 32 bytes", async () => { + const sealed = await sealFixture(); + assert.deepEqual( + rejectionCodes({ ...sealed, commitment: sealed.commitment.slice(0, 31) }, fixtureBinding()), + ["invalid_commitment_length"], + ); + assert.deepEqual( + rejectionCodes({ ...sealed, commitment: new Uint8Array(33) }, fixtureBinding()), + ["invalid_commitment_length"], + ); +}); + +test("rejects a nonce that is not 32 bytes", async () => { + const sealed = await sealFixture(); + assert.deepEqual( + rejectionCodes(sealed, fixtureBinding({ nonce: new Uint8Array(31) })), + ["invalid_nonce_length"], + ); +}); + +test("rejects an oversized auditor blob", async () => { + const sealed = await sealFixture(); + const codes = rejectionCodes( + { ...sealed, auditorBlob: new Uint8Array(MAX_AUDITOR_BLOB_BYTES + 1) }, + fixtureBinding(), + ); + assert.ok(codes.includes("blob_too_large")); +}); + +// ── A commitment that does not match the value is rejected ──────────────── + +test("rejects a blob whose commitment does not match the value", async () => { + const sealed = await sealFixture(); + // The caller believes they bid 1 more stroop than they sealed. + const codes = rejectionCodes(sealed, fixtureBinding({ value: BID_VALUE + 1n })); + assert.deepEqual(codes, ["commitment_mismatch"]); +}); + +test("rejects a blob whose commitment does not match the nonce", async () => { + const sealed = await sealFixture(); + assert.deepEqual( + rejectionCodes(sealed, fixtureBinding({ nonce: new Uint8Array(32).fill(0x99) })), + ["commitment_mismatch"], + ); +}); + +test("rejects a swapped blob: one bidder's ciphertext with another's commitment", async () => { + const aliceNonce = new Uint8Array(32).fill(1); + const bobNonce = new Uint8Array(32).fill(2); + const alice = await sealFixture({ value: 700n, nonce: aliceNonce }); + const bob = await sealFixture({ value: 900n, nonce: bobNonce }); + const swapped: SealedBid = { ...alice, commitment: bob.commitment }; + + // Alice's ciphertext is structurally perfect, so the encoding and length + // layers pass it. Only re-deriving the commitment from the value and nonce + // the caller actually sealed catches the substitution. + assert.equal(validateSealedBid(swapped).valid, true); + assert.deepEqual( + rejectionCodes(swapped, { value: 700n, nonce: aliceNonce, round: BID_ROUND }), + ["commitment_mismatch"], + ); +}); + +test("documents the swap boundary: a self-consistent wrong binding is not detectable", async () => { + const aliceNonce = new Uint8Array(32).fill(1); + const bobNonce = new Uint8Array(32).fill(2); + const alice = await sealFixture({ value: 700n, nonce: aliceNonce }); + const bob = await sealFixture({ value: 900n, nonce: bobNonce }); + const swapped: SealedBid = { ...alice, commitment: bob.commitment }; + + // Honest limit of an offline gate. If the caller hands the gate bob's value + // and nonce, the commitment does match them, and the blob passes — because + // the only way to notice that alice's ciphertext is hiding something else is + // to decrypt it, and the seal is timelocked. This is not a gap the check can + // close; it is the guarantee the seal is built on. What the gate does + // guarantee is that a *self-consistent* value/nonce/commitment triple is what + // gets committed, so the reveal can only fail for the contract's own reasons. + assert.equal( + validateSealedBid(swapped, { value: 900n, nonce: bobNonce, round: BID_ROUND }).valid, + true, + ); + // The caller's own inputs are the attack surface, so they are echoed nowhere + // even when the blob is rejected: the mismatch message names neither side. + const result = validateSealedBid(swapped, { value: 1n, nonce: aliceNonce, round: BID_ROUND }); + assert.equal(result.valid, false); + assert.ok(!result.issues.some((i) => i.message.includes("700"))); + assert.ok(!result.issues.some((i) => i.message.includes("900"))); +}); + +test("rejects another round's commitment substituted for this bid's", async () => { + const mine = await sealFixture({ value: 700n, nonce: new Uint8Array(32).fill(1) }); + const other = await sealFixture({ value: 700n, nonce: new Uint8Array(32).fill(1), round: BID_ROUND + 1 }); + // Same value and nonce, so only the round differs — the commitment is equal. + assert.deepEqual([...mine.commitment], [...other.commitment]); + assert.deepEqual( + rejectionCodes({ ...mine, commitment: other.commitment.slice().reverse() }, { + value: 700n, + nonce: new Uint8Array(32).fill(1), + round: BID_ROUND, + }), + ["commitment_mismatch"], + ); +}); + +// ── Cross-round blobs are rejected ──────────────────────────────────────── + +test("rejects a blob sealed to a different drand round", async () => { + const sealed = await sealFixture(); + assert.deepEqual(rejectionCodes(sealed, fixtureBinding({ round: BID_ROUND + 1 })), [ + "round_mismatch", + ]); + assert.deepEqual(rejectionCodes(sealed, fixtureBinding({ round: 1 })), ["round_mismatch"]); +}); + +test("rejects a blob bound to a different drand chain", async () => { + const sealed = await sealFixture(); + assert.deepEqual(rejectionCodes(sealed, fixtureBinding({ chainHash: "a".repeat(64) })), [ + "chain_mismatch", + ]); + // The contract verifies quicknet, so quicknet is the default expectation. + assert.equal(validateSealedBid(sealed, fixtureBinding({ chainHash: QUICKNET_HASH })).valid, true); +}); + +// ── The bid value never reaches the error ───────────────────────────────── + +test("the error text does not contain the fixture bid", async () => { + const sealed = await sealFixture(); + + // Every rejection path is exercised, and none of them may echo the value. + const rejected: Array<{ sealed: SealedBid; binding?: SealedBidBinding }> = [ + { sealed, binding: fixtureBinding({ value: BID_VALUE + 1n }) }, + { sealed, binding: fixtureBinding({ nonce: new Uint8Array(32).fill(0x99) }) }, + { sealed, binding: fixtureBinding({ nonce: new Uint8Array(31) }) }, + { sealed, binding: fixtureBinding({ round: BID_ROUND + 1 }) }, + { sealed, binding: fixtureBinding({ chainHash: "a".repeat(64) }) }, + { sealed: { ...sealed, ciphertext: sealed.ciphertext.slice(0, 120) }, binding: fixtureBinding() }, + { sealed: { ...sealed, commitment: sealed.commitment.slice(0, 31) }, binding: fixtureBinding() }, + ]; + + for (const { sealed: bad, binding } of rejected) { + const result = validateSealedBid(bad, binding); + assert.equal(result.valid, false, "expected rejection"); + for (const issue of result.issues) { + assert.ok( + !issue.message.includes(BID_VALUE.toString()), + `message leaked the bid: ${issue.message}`, + ); + assert.ok( + !issue.message.includes(BID_VALUE.toString(16)), + `message leaked the bid in hex: ${issue.message}`, + ); + } + } +}); + +test("assertSealedBid throws a typed error whose message omits the bid", async () => { + const sealed = await sealFixture(); + assert.throws( + () => assertSealedBid(sealed, fixtureBinding({ value: BID_VALUE + 1n })), + (error: unknown) => { + assert.ok(error instanceof SubRosaClientConfigError); + assert.match(error.message, /commitment does not match/); + assert.ok(!error.message.includes(BID_VALUE.toString())); + return true; + }, + ); +}); + +test("assertSealedBid reports every defect at once", async () => { + const sealed = await sealFixture(); + const codes = (() => { + try { + assertSealedBid( + { ...sealed, ciphertext: sealed.ciphertext.slice(0, 120) }, + fixtureBinding({ value: BID_VALUE + 1n }), + ); + return []; + } catch (error) { + assert.ok(error instanceof SubRosaClientConfigError); + return error.message.split("; "); + } + })(); + assert.ok(codes.length >= 2, "expected both the payload and the binding defect"); +}); + +// ── The gate agrees with tlock's own helper ─────────────────────────────── + +test("the accepted set is exactly the set tlock's commitment helper agrees with", async () => { + const sealed = await sealFixture(); + // The gate must not be a second, drifting definition of "this is the bid": + // it has to accept precisely when tlock derives the same H. + for (const value of [BID_VALUE - 1n, BID_VALUE, BID_VALUE + 1n]) { + const agrees = bytesEqual(sealed.commitment, commitment(value, BID_NONCE)); + const accepted = validateSealedBid(sealed, fixtureBinding({ value })).valid; + assert.equal(accepted, agrees, `value ${value}: accepted=${accepted} agrees=${agrees}`); + } +}); + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + return a.every((byte, i) => byte === b[i]); +} diff --git a/packages/sdk/src/encrypted-blob.ts b/packages/sdk/src/encrypted-blob.ts index 14ee093e..625a8a12 100644 --- a/packages/sdk/src/encrypted-blob.ts +++ b/packages/sdk/src/encrypted-blob.ts @@ -1,4 +1,4 @@ -// Encrypted blob validation — size, content-type, and encoding checks. +// Encrypted blob validation — size, content-type, encoding, and seal binding. // // The contract enforces a 4096-byte maximum for ciphertext (Soroban Temporary // storage limit). Auditor blobs have no on-chain limit beyond the general @@ -8,7 +8,29 @@ // // These validation functions give callers early, clear feedback before paying // gas for a contract call that would revert with PayloadTooLarge (error 33). +// +// `validateSealedBid` goes further and is the gate a bid should pass *before* +// commit. Size and encoding live in one package, the tlock commitment in +// another, and a blob can satisfy the first while failing the second — which is +// the dangerous case, because the contract happily stores a commitment it will +// never be able to open. One acceptance rule, shared with the sealer, avoids +// committing a seal that is dead on arrival. +import { + QUICKNET_HASH, + COMMITMENT_BYTES, + NONCE_BYTES, + ARMOR_LINE_WIDTH, + TLOCK_ARMOR_HEADER, + TLOCK_ARMOR_FOOTER, + AGE_VERSION, + TLOCK_STANZA_TYPE, + SEALED_BID_PLAINTEXT_BYTES, + commitmentMatches, + isSealedBidPayload, + parseSealedPayload, +} from "@sub-rosa/tlock"; +import type { SealedBid, SealedPayloadHeader } from "@sub-rosa/tlock"; import { SubRosaClientConfigError } from "./errors.js"; // ── Blob size limits (bytes) ───────────────────────────────────────────── @@ -195,16 +217,20 @@ export function validateEncryptedBlob( let byteLength: number; if (typeof blob === "string") { - // Try hex first, then base64. - const hexDecoded = tryDecodeHex(blob); - const b64Decoded = hexDecoded ? null : tryDecodeBase64(blob); - - if (hexDecoded) { - rawBytes = hexDecoded.bytes; - byteLength = hexDecoded.length; - } else if (b64Decoded) { - rawBytes = b64Decoded.bytes; - byteLength = b64Decoded.length; + // An explicit `encoding` is a claim about the blob, so it is held to: a + // string that is valid in the *other* encoding is still a mismatch. With + // no claim, hex is tried first and base64 second. + const encoding = options?.encoding; + const decoded = + encoding === "hex" + ? tryDecodeHex(blob) + : encoding === "base64" + ? tryDecodeBase64(blob) + : tryDecodeHex(blob) ?? tryDecodeBase64(blob); + + if (decoded) { + rawBytes = decoded.bytes; + byteLength = decoded.length; } else { // Not valid hex or base64. add( @@ -239,3 +265,174 @@ export function validateEncryptedBlob( return { valid: issues.length === 0, issues }; } + +// ── Sealed-bid acceptance gate ──────────────────────────────────────────── + +/** + * The plaintext a sealed bid was produced from. + * + * Supplying this is what upgrades `validateSealedBid` from a structural check + * to a full binding check: the commitment is re-derived with tlock's own + * helper and compared against the H that is about to be committed. + */ +export interface SealedBidBinding { + /** The value inside the seal. Never echoed in an error message. */ + value: bigint; + /** The 32-byte nonce inside the seal. */ + nonce: Uint8Array; + /** The Drand round R the seal is expected to be locked to. */ + round: number; + /** + * The Drand chain hash the seal is expected to be bound to. The contract + * verifies quicknet, so that is the default. + */ + chainHash?: string; +} + +/** Human-readable explanation for each sealed-payload rejection reason. */ +const PAYLOAD_REJECTIONS: Record = { + not_utf8: "ciphertext is not valid UTF-8 text", + excessive_padding: "ciphertext has more than 1024 bytes of padding around the armor", + missing_header: `ciphertext is missing the "${TLOCK_ARMOR_HEADER}" armor header`, + missing_footer: `ciphertext is missing the "${TLOCK_ARMOR_FOOTER}" armor footer`, + invalid_base64: "armored ciphertext is not valid base64", + line_too_long: `armored ciphertext has a base64 line wider than ${ARMOR_LINE_WIDTH} columns`, + missing_version: `ciphertext is not an ${AGE_VERSION} payload`, + missing_recipient: "ciphertext has no age recipient stanza", + not_tlock: `ciphertext recipient is not a "${TLOCK_STANZA_TYPE}" stanza`, + malformed_recipient: "ciphertext tlock stanza is malformed", + missing_mac: "ciphertext is missing its age MAC line", +}; + +/** + * Validate a `SealedBid` from `@sub-rosa/tlock` against the rules the sealer + * and the contract both work to, before it is committed on-chain. + * + * Three layers, in the order a blob can fail them: + * + * 1. **Encoding** — the ciphertext is a well-formed tlock payload: age armor + * intact, `age-encryption.org/v1` header, a `-> tlock ` + * recipient, and a MAC line. Truncation, a hex/base64 string where a raw + * blob belongs, and a blob that is some other age file all fail here. + * 2. **Length** — the ciphertext fits the contract's storage limit, the + * commitment is exactly 32 bytes, and the payload's implied plaintext length + * is the 48-byte `be16(value)‖nonce` preimage a bid always carries. + * 3. **Binding** (when `binding` is given) — the round and chain hash the seal + * declares are the ones it was sealed for, and the commitment equals + * `sha256(be16(value)‖nonce)` recomputed by tlock's own `commitmentMatches`. + * + * A blob that passes all three is one the contract can check at reveal. Error + * messages describe the defect and never carry the bid value or the plaintext. + */ +export function validateSealedBid( + sealed: SealedBid, + binding?: SealedBidBinding, +): BlobValidationResult { + const issues: BlobValidationIssue[] = []; + const add = (code: string, message: string) => issues.push({ code, message }); + + // ── 1. Encoding and size ──────────────────────────────────────────── + const ciphertext = validateEncryptedBlob(sealed?.ciphertext, "ciphertext"); + for (const issue of ciphertext.issues) { + add(issue.code, issue.message); + } + + // The contract takes `auditor_blob` as optional Bytes; `sealBid` emits an + // empty blob when the bidder discloses no identity. Validate the size when + // there is one, and stay quiet when there is not. + if (sealed?.auditorBlob && sealed.auditorBlob.length > 0) { + const auditorBlob = validateEncryptedBlob(sealed.auditorBlob, "auditor_blob"); + for (const issue of auditorBlob.issues) { + add(issue.code, issue.message); + } + } + + // ── 2. Length ─────────────────────────────────────────────────────── + let commitmentWidthOk = false; + if (!(sealed?.commitment instanceof Uint8Array)) { + add("invalid_commitment", "commitment must be a Uint8Array"); + } else if (sealed.commitment.length !== COMMITMENT_BYTES) { + add( + "invalid_commitment_length", + `commitment is ${sealed.commitment.length} bytes, expected ${COMMITMENT_BYTES}`, + ); + } else { + commitmentWidthOk = true; + } + + let header: SealedPayloadHeader | null = null; + if (sealed?.ciphertext instanceof Uint8Array && sealed.ciphertext.length > 0) { + const parsed = parseSealedPayload(sealed.ciphertext); + if (!parsed.ok) { + add( + "invalid_sealed_payload", + PAYLOAD_REJECTIONS[parsed.reason] ?? `ciphertext is not a tlock payload (${parsed.reason})`, + ); + } else { + header = parsed.header; + if (!isSealedBidPayload(parsed.header)) { + add( + "unexpected_plaintext_length", + `sealed payload holds ${parsed.header.plaintextBytes} plaintext bytes, expected ${SEALED_BID_PLAINTEXT_BYTES} (a 16-byte value plus a 32-byte nonce)`, + ); + } + } + } + + // ── 3. Binding to the value, nonce, and round ─────────────────────── + if (binding) { + const expectedChainHash = binding.chainHash ?? QUICKNET_HASH; + if (header) { + if (header.round !== binding.round) { + add( + "round_mismatch", + `sealed bid is locked to drand round ${header.round}, not round ${binding.round}`, + ); + } + if (header.chainHash !== expectedChainHash) { + add( + "chain_mismatch", + `sealed bid is bound to drand chain ${header.chainHash}, not the contract's chain`, + ); + } + } + + if (!(binding.nonce instanceof Uint8Array) || binding.nonce.length !== NONCE_BYTES) { + add( + "invalid_nonce_length", + `nonce is ${binding.nonce?.length ?? 0} bytes, expected ${NONCE_BYTES}`, + ); + } else if (commitmentWidthOk) { + // The shared rule: the same helper the sealer used to derive H. A + // wrong-width commitment already failed the length check, so it is not + // also reported as a mismatch. + if (!commitmentMatches(binding.value, binding.nonce, sealed.commitment as Uint8Array)) { + // The value stays out of the message on purpose — errors get logged. + add( + "commitment_mismatch", + "sealed bid commitment does not match sha256(be16(value) || nonce) of the supplied value and nonce; the contract would never open this seal", + ); + } + } + } + + return { valid: issues.length === 0, issues }; +} + +/** + * `validateSealedBid` as a throw, for call sites that gate a commit. + * Rejects with `SubRosaClientConfigError` listing every defect found, so a + * caller sees all of them at once rather than one per attempt. + */ +export function assertSealedBid( + sealed: SealedBid, + binding?: SealedBidBinding, +): void { + const result = validateSealedBid(sealed, binding); + if (!result.valid) { + throw new SubRosaClientConfigError( + result.issues.map((issue) => issue.message).join("; "), + ); + } +} + diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index ac678ec0..12b35b03 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -26,6 +26,8 @@ export type { TimeoutErrorParams } from "./errors.js"; export { validateEncryptedBlob, + validateSealedBid, + assertSealedBid, tryDecodeHex, tryDecodeBase64, MAX_CIPHERTEXT_BYTES, @@ -33,6 +35,7 @@ export { type BlobContentType, type BlobValidationIssue, type BlobValidationResult, + type SealedBidBinding, } from "./encrypted-blob.js"; export { MAINNET_ARTIFACTS, diff --git a/packages/sdk/src/testing/seal-fixture.ts b/packages/sdk/src/testing/seal-fixture.ts new file mode 100644 index 00000000..47b2410d --- /dev/null +++ b/packages/sdk/src/testing/seal-fixture.ts @@ -0,0 +1,71 @@ +// Offline sealed-bid fixtures for the SDK tests. +// +// A real seal, produced without a network. `timelockEncrypt` reads exactly one +// thing from the Drand client — `chain().info()` — and only to learn the +// chain's static public key, which is a constant of quicknet. Stubbing that +// call therefore yields genuine tlock ciphertext, so the pre-commit gate is +// tested against the real sealer instead of a hand-written stand-in that could +// drift from it. +// +// This is test-only scaffolding: it is not exported from the package index, and +// nothing in `src` outside `*.test.ts` imports it. + +import { + QUICKNET_HASH, + NONCE_BYTES, + sealBid, + type DrandClient, + type SealedBid, +} from "@sub-rosa/tlock"; +import type { SealedBidBinding } from "../encrypted-blob.js"; + +/** quicknet's real chain public key. */ +const QUICKNET_PUBLIC_KEY = + "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"; + +const offlineClient = { + chain: () => ({ + info: async () => ({ + hash: QUICKNET_HASH, + public_key: QUICKNET_PUBLIC_KEY, + schemeID: "bls-unchained-g1-rfc9380", + }), + }), +} as unknown as DrandClient; + +/** The default fixture bid. Distinctive, so tests can assert it never leaks. */ +export const BID_VALUE = 8_675_309n; +export const BID_NONCE = new Uint8Array(NONCE_BYTES).fill(0x2a); +export const BID_ROUND = 1_234_567; + +/** Seal a bid offline. Each call re-encrypts, so ciphertexts differ per call. */ +export function sealFixture(overrides?: { + value?: bigint; + nonce?: Uint8Array; + round?: number; + identity?: Uint8Array; + auditorPublicKey?: Uint8Array; +}): Promise { + return sealBid({ + value: overrides?.value ?? BID_VALUE, + nonce: overrides?.nonce ?? BID_NONCE, + round: overrides?.round ?? BID_ROUND, + client: offlineClient, + ...(overrides?.identity ? { identity: overrides.identity } : {}), + ...(overrides?.auditorPublicKey + ? { auditorPublicKey: overrides.auditorPublicKey } + : {}), + }); +} + +/** The binding a fixture seal is expected to satisfy. */ +export function fixtureBinding( + overrides?: Partial, +): SealedBidBinding { + return { + value: BID_VALUE, + nonce: BID_NONCE, + round: BID_ROUND, + ...overrides, + }; +} diff --git a/packages/tlock/package.json b/packages/tlock/package.json index c46d1912..1a4910d4 100644 --- a/packages/tlock/package.json +++ b/packages/tlock/package.json @@ -20,8 +20,8 @@ }, "main": "src/index.ts", "scripts": { - "test": "node --import tsx --test src/commitment.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/seal.test.ts src/freshness.test.ts", - "test:unit": "node --import tsx --test src/commitment.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/freshness.test.ts", + "test": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/seal.test.ts src/freshness.test.ts", + "test:unit": "node --import tsx --test src/commitment.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/freshness.test.ts", "test:seal": "node --import tsx --test src/seal.test.ts", "recover:identities": "node --import tsx src/recover-identities.cli.ts", "typecheck": "tsc --noEmit -p tsconfig.json" diff --git a/packages/tlock/src/commitment.test.ts b/packages/tlock/src/commitment.test.ts index e5f538ae..057a3615 100644 --- a/packages/tlock/src/commitment.test.ts +++ b/packages/tlock/src/commitment.test.ts @@ -4,10 +4,13 @@ import assert from "node:assert/strict"; import { beBytesToI128, commitment, + commitmentMatches, decodeBidPreimage, encodeBidPreimage, i128ToBeBytes, toHex, + COMMITMENT_BYTES, + NONCE_BYTES, } from "./commitment.js"; // Frozen vector shared with the Round contract's Rust test @@ -51,3 +54,45 @@ test("rejects out-of-range and malformed inputs", () => { assert.throws(() => encodeBidPreimage(1n, new Uint8Array(31))); assert.throws(() => decodeBidPreimage(new Uint8Array(47))); }); + +// ── commitmentMatches — the one acceptance rule for "is this H this bid" ─── + +test("commitmentMatches accepts the commitment the sealer derives for that value and nonce", () => { + const h = commitment(FROZEN_VALUE, FROZEN_NONCE); + assert.equal(h.length, COMMITMENT_BYTES); + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, h), true); + // The comparison is over exact bytes: H is an opaque digest, so reordering + // it yields a different value and must not be mistaken for a match. + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, h.slice().reverse()), false); +}); + +test("commitmentMatches rejects a swapped value, nonce, or commitment", () => { + const h = commitment(FROZEN_VALUE, FROZEN_NONCE); + // A different value with the same nonce. + assert.equal(commitmentMatches(FROZEN_VALUE + 1n, FROZEN_NONCE, h), false); + // The same value with a different nonce. + assert.equal(commitmentMatches(FROZEN_VALUE, new Uint8Array(32).fill(0x99), h), false); + // Another bid's commitment entirely. + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, commitment(701n, FROZEN_NONCE)), false); + // A single flipped bit. + const flipped = h.slice(); + flipped[0] ^= 0x01; + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, flipped), false); +}); + +test("commitmentMatches treats wrong-width inputs as a mismatch rather than throwing", () => { + const h = commitment(FROZEN_VALUE, FROZEN_NONCE); + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, h.slice(0, 31)), false); + assert.equal(commitmentMatches(FROZEN_VALUE, FROZEN_NONCE, new Uint8Array(0)), false); + assert.equal( + commitmentMatches(FROZEN_VALUE, new Uint8Array(31), h), + false, + `a nonce that is not ${NONCE_BYTES} bytes cannot produce this H`, + ); +}); + +test("commitmentMatches holds across the i128 boundary values", () => { + for (const value of [0n, 1n, -1n, FROZEN_VALUE, (1n << 126n), -(1n << 126n)]) { + assert.equal(commitmentMatches(value, FROZEN_NONCE, commitment(value, FROZEN_NONCE)), true); + } +}); diff --git a/packages/tlock/src/commitment.ts b/packages/tlock/src/commitment.ts index 35da9470..abe77770 100644 --- a/packages/tlock/src/commitment.ts +++ b/packages/tlock/src/commitment.ts @@ -10,6 +10,8 @@ import { sha256 } from "@noble/hashes/sha2.js"; export const VALUE_BYTES = 16; export const NONCE_BYTES = 32; export const PREIMAGE_BYTES = VALUE_BYTES + NONCE_BYTES; +/// H is a sha256 digest — the width the contract declares as BytesN<32>. +export const COMMITMENT_BYTES = 32; const I128_MAX = (1n << 127n) - 1n; const I128_MIN = -(1n << 127n); @@ -71,6 +73,27 @@ export function commitment(value: bigint, nonce: Uint8Array): Uint8Array { return sha256(encodeBidPreimage(value, nonce)); } +/// True when `expected` is the commitment for this value and nonce. +/// +/// The one acceptance rule for "is this commitment the seal of this bid". The +/// sealer derives H with `commitment` and the contract re-derives it at reveal; +/// every off-chain check that has to answer the same question — the SDK's +/// pre-commit gate, the receipt verifier — goes through here so no caller can +/// drift onto a different hash. Returns false for a wrong-width `expected` +/// rather than throwing, so a malformed commitment is just a mismatch. +export function commitmentMatches( + value: bigint, + nonce: Uint8Array, + expected: Uint8Array, +): boolean { + if (expected.length !== COMMITMENT_BYTES) return false; + if (nonce.length !== NONCE_BYTES) return false; + const actual = commitment(value, nonce); + let diff = 0; + for (let i = 0; i < COMMITMENT_BYTES; i++) diff |= actual[i] ^ expected[i]; + return diff === 0; +} + export function toHex(bytes: Uint8Array): string { return Array.from(bytes) .map((b) => b.toString(16).padStart(2, "0")) diff --git a/packages/tlock/src/index.ts b/packages/tlock/src/index.ts index 2688c409..caa9c84c 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -1,5 +1,6 @@ export { commitment, + commitmentMatches, encodeBidPreimage, decodeBidPreimage, i128ToBeBytes, @@ -9,8 +10,23 @@ export { VALUE_BYTES, NONCE_BYTES, PREIMAGE_BYTES, + COMMITMENT_BYTES, } from "./commitment.js"; +export { + parseSealedPayload, + isSealedBidPayload, + ARMOR_LINE_WIDTH, + TLOCK_ARMOR_HEADER, + TLOCK_ARMOR_FOOTER, + AGE_VERSION, + TLOCK_STANZA_TYPE, + SEALED_BID_PLAINTEXT_BYTES, + type SealedPayloadHeader, + type SealedPayloadParse, + type SealedPayloadRejection, +} from "./payload.js"; + export { generateAuditorKeypair, auditorPublicKey, diff --git a/packages/tlock/src/payload.test.ts b/packages/tlock/src/payload.test.ts new file mode 100644 index 00000000..ccd0d333 --- /dev/null +++ b/packages/tlock/src/payload.test.ts @@ -0,0 +1,300 @@ +// Sealed-payload wire-format tests. +// +// These are the structural half of the bid acceptance rule: what a tlock +// ciphertext declares about itself, read back without decrypting and without +// touching the network. The blobs come from the real `sealBid`, so the parser +// is checked against the sealer it has to agree with — an offline Drand client +// stands in for quicknet and returns the chain's static public key, which is +// the only thing `timelockEncrypt` needs from the network. + +import { test } from "node:test"; +import assert from "node:assert/strict"; + +import { QUICKNET_HASH, type DrandClient } from "./quicknet.js"; +import { sealBid, type SealedBid } from "./seal.js"; +import { commitment, NONCE_BYTES, PREIMAGE_BYTES } from "./commitment.js"; +import { + AGE_VERSION, + ARMOR_LINE_WIDTH, + TLOCK_ARMOR_FOOTER, + TLOCK_ARMOR_HEADER, + SEALED_BID_PLAINTEXT_BYTES, + isSealedBidPayload, + parseSealedPayload, +} from "./payload.js"; + +// The real quicknet chain public key. Only `chain().info()` is consulted during +// encryption, so a stub over these values produces genuine tlock output offline. +const QUICKNET_PUBLIC_KEY = + "83cf0f2896adee7eb8b5f01fcad3912212c437e0073e911fb90022d3e760183c8c4b450b6a0a6c3ac6a5776a2d1064510d1fec758c921cc22b0e17e63aaf4bcb5ed66304de9cf809bd274ca73bab4af5a6e9c76a4bc09e76eae8991ef5ece45a"; + +const offlineClient = { + chain: () => ({ + info: async () => ({ + hash: QUICKNET_HASH, + public_key: QUICKNET_PUBLIC_KEY, + schemeID: "bls-unchained-g1-rfc9380", + }), + }), +} as unknown as DrandClient; + +const ROUND = 1_234_567; +const VALUE = 8_675_309n; +const NONCE = new Uint8Array(NONCE_BYTES).fill(0x2a); + +/** Seal a bid offline. Each call re-encrypts, so ciphertexts differ. */ +async function sealFixture(overrides?: { + value?: bigint; + nonce?: Uint8Array; + round?: number; +}): Promise { + return sealBid({ + value: overrides?.value ?? VALUE, + nonce: overrides?.nonce ?? NONCE, + round: overrides?.round ?? ROUND, + client: offlineClient, + }); +} + +const armored = (sealed: SealedBid): string => + new TextDecoder().decode(sealed.ciphertext); + +const reasonOf = (ciphertext: Uint8Array | string): string | null => { + const parsed = parseSealedPayload(ciphertext); + return parsed.ok ? null : parsed.reason; +}; + +// ── Armor and age-payload plumbing (used to build malformed fixtures) ───── + +interface AgeParts { + /** Version line, recipient line, stanza body lines, and the MAC line. */ + header: string[]; + /** The binary body, which may itself contain newlines. */ + body: string; +} + +/** Decode the ASCII armor into the age payload it wraps. */ +function dearmor(text: string): string { + const inner = text + .slice(TLOCK_ARMOR_HEADER.length, text.length - TLOCK_ARMOR_FOOTER.length) + .split("\n") + .filter((line) => line.length > 0) + .join(""); + return Buffer.from(inner, "base64").toString("latin1"); +} + +/** Split an age payload into its header lines and its body. */ +function splitAge(payload: string): AgeParts { + const lines = payload.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("--- ")) { + return { header: lines.slice(0, i + 1), body: lines.slice(i + 1).join("\n") }; + } + } + throw new Error("fixture has no MAC line"); +} + +/** Re-wrap an age payload in the armor `tlock-js` writes. */ +function rearmor(payload: string): string { + const base64 = Buffer.from(payload, "latin1").toString("base64"); + const lines: string[] = []; + for (let i = 0; i < base64.length; i += ARMOR_LINE_WIDTH) { + lines.push(base64.slice(i, i + ARMOR_LINE_WIDTH)); + } + // `encodeArmor` puts a blank line before the footer when the last base64 + // line lands exactly on the armor width. + const footer = + lines[lines.length - 1].length === ARMOR_LINE_WIDTH + ? `\n${TLOCK_ARMOR_FOOTER}` + : TLOCK_ARMOR_FOOTER; + return `${TLOCK_ARMOR_HEADER}\n${lines.join("\n")}\n${footer}\n`; +} + +/** Rebuild a sealed blob with a different `-> tlock` recipient line. */ +function withRecipient(sealed: SealedBid, recipient: string): string { + return withHeaderLine(sealed, 1, recipient); +} + +/** Rebuild a sealed blob with the nth age-header line replaced. */ +function withHeaderLine(sealed: SealedBid, index: number, line: string): string { + const { header, body } = splitAge(dearmor(armored(sealed))); + header[index] = line; + return [header.join("\n"), body].join("\n"); +} + +// ── A real seal parses ──────────────────────────────────────────────────── + +test("a sealBid ciphertext parses into the round and chain it was sealed for", async () => { + const sealed = await sealFixture(); + const parsed = parseSealedPayload(sealed.ciphertext); + + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + assert.equal(parsed.header.round, ROUND); + assert.equal(parsed.header.chainHash, QUICKNET_HASH); + // The implied plaintext length is the 48-byte commitment preimage and + // nothing else — that is what ties this blob to a bid. + assert.equal(parsed.header.plaintextBytes, SEALED_BID_PLAINTEXT_BYTES); + assert.equal(parsed.header.plaintextBytes, PREIMAGE_BYTES); + assert.equal(isSealedBidPayload(parsed.header), true); +}); + +test("the same seal parses identically from raw bytes and from its armored text", async () => { + const sealed = await sealFixture(); + assert.deepEqual( + parseSealedPayload(sealed.ciphertext), + parseSealedPayload(armored(sealed)), + ); +}); + +test("a sealed payload is the age v1 shape the opener reads", async () => { + const sealed = await sealFixture(); + const { header } = splitAge(dearmor(armored(sealed))); + assert.equal(header[0], AGE_VERSION); + assert.equal(header[1], `-> tlock ${ROUND} ${QUICKNET_HASH}`); + assert.ok(header[header.length - 1].startsWith("--- ")); +}); + +test("sealing the same value and nonce twice yields the same commitment", async () => { + const a = await sealFixture(); + const b = await sealFixture(); + assert.deepEqual([...a.commitment], [...commitment(VALUE, NONCE)]); + assert.deepEqual([...a.commitment], [...b.commitment]); + // The file key and body nonce are fresh per seal, so the ciphertexts differ + // even though the commitment — the thing the contract checks — does not. + assert.notEqual(armored(a), armored(b)); +}); + +// ── Truncation and corruption ───────────────────────────────────────────── + +test("a ciphertext truncated mid-armor is rejected", async () => { + const sealed = await sealFixture(); + assert.equal(reasonOf(sealed.ciphertext.slice(0, 20)), "missing_header"); + assert.equal( + reasonOf(sealed.ciphertext.slice(0, sealed.ciphertext.length - 200)), + "missing_footer", + ); +}); + +test("a ciphertext with its armor stripped is rejected", async () => { + const sealed = await sealFixture(); + const inner = armored(sealed) + .slice(TLOCK_ARMOR_HEADER.length, armored(sealed).length - TLOCK_ARMOR_FOOTER.length) + .split("\n") + .filter((line) => line.length > 0) + .join(""); + assert.equal(reasonOf(inner), "missing_header"); +}); + +test("a ciphertext with its base64 cut short is rejected", async () => { + const sealed = await sealFixture(); + const lines = armored(sealed).split("\n"); + // One column short of a full line: base64 that no longer lands on a + // 4-character boundary, which is what a clipped copy of the blob looks like. + const clipped = [lines[0], lines[1].slice(0, ARMOR_LINE_WIDTH - 1), TLOCK_ARMOR_FOOTER, ""]; + assert.equal(reasonOf(clipped.join("\n")), "invalid_base64"); +}); + +test("a ciphertext with a corrupted base64 character is rejected", async () => { + const sealed = await sealFixture(); + const lines = armored(sealed).split("\n"); + lines[1] = "!".repeat(lines[1].length); + assert.equal(reasonOf(lines.join("\n")), "invalid_base64"); +}); + +test("a ciphertext that is not UTF-8 text is rejected", () => { + assert.equal(reasonOf(new Uint8Array([0xff, 0xfe, 0xfd, 0x00, 0x80])), "not_utf8"); +}); + +test("a ciphertext with an unknown age version is rejected", async () => { + const sealed = await sealFixture(); + const text = rearmor(withHeaderLine(sealed, 0, "age-encryption.org/v2")); + assert.equal(reasonOf(text), "missing_version"); +}); + +test("a ciphertext whose recipient is not a tlock stanza is rejected", async () => { + const sealed = await sealFixture(); + const text = rearmor(withRecipient(sealed, `-> X25519 ${ROUND} ${QUICKNET_HASH}`)); + assert.equal(reasonOf(text), "not_tlock"); +}); + +test("a ciphertext with a malformed tlock stanza is rejected", async () => { + const sealed = await sealFixture(); + // The round argument is not a number. + assert.equal(reasonOf(rearmor(withRecipient(sealed, "-> tlock not-a-round"))), "malformed_recipient"); + // The chain hash is not a 32-byte hex value. + assert.equal(reasonOf(rearmor(withRecipient(sealed, `-> tlock ${ROUND} deadbeef`))), "malformed_recipient"); + // Round 0 is genesis, which the encrypter refuses. + assert.equal(reasonOf(rearmor(withRecipient(sealed, `-> tlock 0 ${QUICKNET_HASH}`))), "malformed_recipient"); +}); + +test("a ciphertext with its MAC line removed is rejected", async () => { + const sealed = await sealFixture(); + const { header, body } = splitAge(dearmor(armored(sealed))); + header.length = header.length - 1; + assert.notEqual(reasonOf(rearmor([...header, body].join("\n"))), null); +}); + +test("a ciphertext padded with more than 1024 whitespace characters is rejected", async () => { + const sealed = await sealFixture(); + assert.equal(reasonOf(" ".repeat(1025) + armored(sealed)), "excessive_padding"); +}); + +test("leading and trailing whitespace within the armor limit is tolerated", async () => { + const sealed = await sealFixture(); + // `openBid` trims before decoding, so the parser must trim too. + assert.equal(parseSealedPayload(`\n${armored(sealed)}\n`).ok, true); +}); + +// ── Round and chain binding ─────────────────────────────────────────────── + +test("a seal reports the round it was sealed to, so cross-round blobs are detectable", async () => { + const early = await sealFixture({ round: 1_000_000 }); + const late = await sealFixture({ round: 9_000_000 }); + const a = parseSealedPayload(early.ciphertext); + const b = parseSealedPayload(late.ciphertext); + assert.equal(a.ok && a.header.round, 1_000_000); + assert.equal(b.ok && b.header.round, 9_000_000); +}); + +test("a seal reports the chain hash it is bound to", async () => { + const sealed = await sealFixture(); + const parsed = parseSealedPayload(rearmor(withRecipient(sealed, `-> tlock ${ROUND} ${"a".repeat(64)}`))); + assert.equal(parsed.ok, true); + assert.equal(parsed.ok && parsed.header.chainHash, "a".repeat(64)); + assert.notEqual(parsed.ok && parsed.header.chainHash, QUICKNET_HASH); +}); + +test("a swapped ciphertext still parses — the commitment check is what catches it", async () => { + // Two bidders, two seals. Taking one bidder's ciphertext with the other + // bidder's commitment leaves a structurally perfect blob, which is exactly + // why the commitment has to be re-derived rather than trusted. + const alice = await sealFixture({ value: 700n, nonce: new Uint8Array(32).fill(1) }); + const bob = await sealFixture({ value: 900n, nonce: new Uint8Array(32).fill(2) }); + const swapped = { ...alice, commitment: bob.commitment }; + + const parsed = parseSealedPayload(swapped.ciphertext); + assert.equal(parsed.ok, true); + assert.equal(parsed.ok && parsed.header.plaintextBytes, SEALED_BID_PLAINTEXT_BYTES); + assert.notDeepEqual([...swapped.commitment], [...commitment(700n, new Uint8Array(32).fill(1))]); +}); + +// ── Plaintext length ────────────────────────────────────────────────────── + +test("only a 48-byte plaintext counts as a bid seal", async () => { + const sealed = await sealFixture(); + const parsed = parseSealedPayload(sealed.ciphertext); + assert.equal(parsed.ok, true); + if (!parsed.ok) return; + + // The length is inferred from the body, so a payload carrying any other + // plaintext is a valid age file but not a valid bid seal. + assert.equal(isSealedBidPayload(parsed.header), true); + for (const bytes of [0, 47, 49, 96]) { + assert.equal( + isSealedBidPayload({ ...parsed.header, plaintextBytes: bytes }), + false, + `plaintextBytes=${bytes} must not pass`, + ); + } +}); diff --git a/packages/tlock/src/payload.ts b/packages/tlock/src/payload.ts new file mode 100644 index 00000000..3634916b --- /dev/null +++ b/packages/tlock/src/payload.ts @@ -0,0 +1,250 @@ +// Sealed-payload wire format — the exact bytes `sealBid` emits and `openBid` +// consumes, inspected without decrypting and without touching the network. +// +// A tlock ciphertext is UTF-8 text: an ASCII-armored age file whose single +// recipient stanza is `-> tlock `, followed by a `--- ` +// line and the binary body. Reading that structure back is what lets a caller +// tell a real seal from a truncated, swapped, or foreign-round blob *before* +// spending gas on a commit that the contract would accept but could never open. +// +// Blob encoding and the commitment hash are separate concerns that must share +// one acceptance rule: the commitment is sha256(be16(value)‖nonce) from +// commitment.ts, and this module describes the ciphertext that commitment is +// supposed to be hiding inside. Neither package gets to redefine the other's +// format, so the shape lives here, next to the sealer, and the SDK's +// pre-commit gate reads it from this module. + +import { PREIMAGE_BYTES } from "./commitment.js"; + +// ── Wire-format constants ──────────────────────────────────────────────── + +/** First line of the ASCII armor `tlock-js` writes around an age file. */ +export const TLOCK_ARMOR_HEADER = "-----BEGIN AGE ENCRYPTED FILE-----"; + +/** Last line of that armor. */ +export const TLOCK_ARMOR_FOOTER = "-----END AGE ENCRYPTED FILE-----"; + +/** The only age header version `tlock-js` reads or writes. */ +export const AGE_VERSION = "age-encryption.org/v1"; + +/** Recipient stanza type tlock uses for a drand timelock recipient. */ +export const TLOCK_STANZA_TYPE = "tlock"; + +/** + * Base64 line width inside the armor. Both `tlock-js` and the Go age + * implementation refuse to decode a payload whose lines are wider, so a blob + * that does not respect it is not something the opener will ever read. + */ +export const ARMOR_LINE_WIDTH = 64; + +/** + * Padding-attack guard carried over from the Go age implementation: no more + * than 1024 leading/trailing whitespace characters around the armor. + */ +const MAX_ARMOR_PADDING = 1024; + +/** + * Per-message overhead tlock-js adds to the age body: a 16-byte HKDF nonce + * plus the 16-byte ChaCha20-Poly1305 tag STREAM appends to the single chunk. + */ +const BODY_OVERHEAD_BYTES = 32; + +/** A drand chain hash is a 32-byte value in lowercase hex. */ +const CHAIN_HASH_RE = /^[0-9a-f]{64}$/; + +/** Standard (padded) base64 — the outer armor layer. */ +const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/; + +/** Unpadded base64 — the age stanza body inside the armor. */ +const UNPADDED_BASE64_RE = /^[A-Za-z0-9+/]+$/; + +// ── Parse result ────────────────────────────────────────────────────────── + +/** + * Machine-readable reason a sealed payload was rejected. Every value describes + * a structural defect; none of them carry plaintext. + */ +export type SealedPayloadRejection = + | "not_utf8" + | "excessive_padding" + | "missing_header" + | "missing_footer" + | "invalid_base64" + | "line_too_long" + | "missing_version" + | "missing_recipient" + | "not_tlock" + | "malformed_recipient" + | "missing_mac"; + +/** What a structurally valid sealed payload declares about itself. */ +export interface SealedPayloadHeader { + /** Drand round R the seal is locked to — the tlock stanza's first argument. */ + round: number; + /** Drand chain hash the seal is bound to — the tlock stanza's second argument. */ + chainHash: string; + /** Decoded bytes of the IBE ciphertext carried by the tlock stanza. */ + stanzaBodyBytes: number; + /** Decoded bytes of the whole age payload (header + MAC + body). */ + payloadBytes: number; + /** + * Decoded bytes of the sealed plaintext, inferred from the body length. + * A bid seal is exactly `PREIMAGE_BYTES` (48) — `be16(value)‖nonce`. + */ + plaintextBytes: number; +} + +export type SealedPayloadParse = + | { ok: true; header: SealedPayloadHeader } + | { ok: false; reason: SealedPayloadRejection }; + +const reject = (reason: SealedPayloadRejection): SealedPayloadParse => ({ + ok: false, + reason, +}); + +// ── Parser ──────────────────────────────────────────────────────────────── + +const utf8Decoder = new TextDecoder("utf-8", { fatal: true }); + +/** + * Inspect a tlock ciphertext's structure. + * + * Accepts the same input `openBid` accepts (raw bytes or the armored text) and + * reports what the seal declares — the drand round it is locked to, the chain + * hash it is bound to, and the plaintext length implied by the body. Nothing is + * decrypted and no key material is needed, so this is safe to run on any blob + * before it is committed, logged, or forwarded. + * + * A rejection means the blob is not a well-formed tlock payload: the armor is + * missing or truncated, the age header is not the version the opener reads, the + * recipient is not a tlock stanza, or the MAC line is absent. + */ +export function parseSealedPayload( + ciphertext: Uint8Array | string, +): SealedPayloadParse { + const text = typeof ciphertext === "string" ? ciphertext : decodeUtf8(ciphertext); + if (text === null) return reject("not_utf8"); + + // The opener trims the armor before decoding, so trimming here keeps this + // check aligned with what would actually be read back. + const trimmed = text.trim(); + if (text.length - trimmed.length > MAX_ARMOR_PADDING) { + return reject("excessive_padding"); + } + if (!trimmed.startsWith(TLOCK_ARMOR_HEADER)) return reject("missing_header"); + if (!trimmed.endsWith(TLOCK_ARMOR_FOOTER)) return reject("missing_footer"); + + const armored = trimmed.slice( + TLOCK_ARMOR_HEADER.length, + trimmed.length - TLOCK_ARMOR_FOOTER.length, + ); + const armoredLines = armored.split("\n"); + // A line wider than the armor width, or a final line exactly as wide, is the + // padding-attack shape both age implementations refuse to decode. + if (armoredLines.some((line) => line.length > ARMOR_LINE_WIDTH)) { + return reject("line_too_long"); + } + if (armoredLines[armoredLines.length - 1].length >= ARMOR_LINE_WIDTH) { + return reject("line_too_long"); + } + + // `encodeArmor` emits a blank line before the footer when the last base64 + // line lands exactly on the armor width, so blank lines carry no payload. + const base64 = armoredLines.filter((line) => line.length > 0).join(""); + if (base64.length % 4 !== 0 || !BASE64_RE.test(base64)) { + return reject("invalid_base64"); + } + const payload = Buffer.from(base64, "base64"); + + return parseAgePayload(payload); +} + +/** Decode UTF-8 bytes, or `null` when they are not valid UTF-8 text. */ +function decodeUtf8(bytes: Uint8Array): string | null { + try { + return utf8Decoder.decode(bytes); + } catch { + return null; + } +} + +/** + * Walk the age header: version line, the single tlock stanza, the MAC line. + * Everything after the MAC line's newline is the binary body, whose length + * pins down how many plaintext bytes the seal holds. + */ +function parseAgePayload(payload: Buffer): SealedPayloadParse { + // The header is pure ASCII, so latin1 keeps one byte per character and the + // offsets below stay byte-exact even though the body is arbitrary binary. + const lines = payload.toString("latin1").split("\n"); + let consumed = 0; + const take = (): string => { + const line = lines.shift() ?? ""; + consumed += line.length + 1; // +1 for the newline that joined it + return line; + }; + + if (take() !== AGE_VERSION) return reject("missing_version"); + + const stanza = take(); + if (!stanza.startsWith("-> ")) return reject("missing_recipient"); + const [type, ...args] = stanza.slice(3).split(" "); + if (type !== TLOCK_STANZA_TYPE) return reject("not_tlock"); + if (args.length !== 2) return reject("malformed_recipient"); + const [rawRound, chainHash] = args; + if (!/^[0-9]+$/.test(rawRound)) return reject("malformed_recipient"); + if (!CHAIN_HASH_RE.test(chainHash)) return reject("malformed_recipient"); + const round = Number(rawRound); + if (!Number.isSafeInteger(round) || round < 1) return reject("malformed_recipient"); + + // Read the stanza body up to the MAC line. The stanza may wrap over several + // 64-column lines; a line starting with `--- ` always ends it. + const stanzaLines: string[] = []; + for (;;) { + if (lines.length === 0) return reject("missing_mac"); + const line = lines[0]; + if (line.startsWith("--- ")) break; + stanzaLines.push(take()); + } + if (lines.length === 0) return reject("missing_mac"); + if (stanzaLines.length === 0) return reject("malformed_recipient"); + const stanzaBody = stanzaLines.join(""); + if (stanzaBody.length % 4 === 1 || !UNPADDED_BASE64_RE.test(stanzaBody)) { + return reject("malformed_recipient"); + } + + const macLine = take(); + if (!macLine.startsWith("--- ")) return reject("missing_mac"); + const mac = macLine.slice(4); + if (mac.length === 0 || !UNPADDED_BASE64_RE.test(mac)) return reject("missing_mac"); + + // The body starts after the newline that terminates the MAC line. When the + // payload ends on that line there is no body, and `lines` is already empty. + const bodyBytes = payload.length - (consumed - 1) - (lines.length > 0 ? 1 : 0); + const plaintextBytes = bodyBytes - BODY_OVERHEAD_BYTES; + + return { + ok: true, + header: { + round, + chainHash, + stanzaBodyBytes: Math.floor((stanzaBody.length / 4) * 3), + payloadBytes: payload.length, + plaintextBytes, + }, + }; +} + +/** + * The plaintext length a `sealBid` payload always has: the 48-byte commitment + * preimage `be16(value)‖nonce`, and nothing else. A sealed payload of any other + * implied length is not a bid seal — it is some other age payload wearing the + * same armor, and its commitment says nothing about it. + */ +export const SEALED_BID_PLAINTEXT_BYTES = PREIMAGE_BYTES; + +/** True when the payload's implied plaintext length is a bid preimage. */ +export function isSealedBidPayload(header: SealedPayloadHeader): boolean { + return header.plaintextBytes === SEALED_BID_PLAINTEXT_BYTES; +} From 3b3040d13bca3eacab6f1ba7ed9abb5281df0501 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Wed, 30 Sep 2026 14:14:04 +0100 Subject: [PATCH 2/2] fix(contract): pass asset_config to create_round in all test call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `create_round` requires a trailing `RoundAssetConfig`, but the 88ef67b migration left every test call site on the old 7-argument signature, so `cargo test -p sub-rosa-round` failed to compile with E0061 (wrong argument count) and E0425 (RoundAssetConfig and soroban_sdk::String never imported). Adds a `native_xlm` test helper returning a native XLM config — the default for tests that don't exercise SAC binding — and threads it through all nine call sites: five in test.rs, four in error_paths.rs. `create_round` stores the config without validating its fields, so a well-formed native config is enough for every existing test. Imports `RoundAssetConfig` from crate::types and `String` from soroban_sdk, and re-exports the helper for error_paths.rs to use. Verified by static analysis: every call site now passes 8 arguments, brace balance is clean, and Round is constructed only in lib.rs where the field is already populated. NOT verified by cargo test — no Rust toolchain is installed in this environment, so this needs a CI run to confirm. --- contracts/round/src/error_paths.rs | 8 ++++++-- contracts/round/src/test.rs | 24 ++++++++++++++++++++---- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/contracts/round/src/error_paths.rs b/contracts/round/src/error_paths.rs index e8b2545b..70c2c84b 100644 --- a/contracts/round/src/error_paths.rs +++ b/contracts/round/src/error_paths.rs @@ -10,8 +10,8 @@ use crate::types::{ClearingRule, DataKey, Error, Status}; use super::{ assert_try_create_round_err, assert_try_contract_err, b32, commit_bid, commitment, - drand_round, funded_bidder, open_round, real_sig, setup, setup_drand, Fixture, GENESIS, - PERIOD, VEC_ROUND, + drand_round, funded_bidder, native_xlm, open_round, real_sig, setup, setup_drand, Fixture, + GENESIS, PERIOD, VEC_ROUND, }; const MAX_BIDDERS: u32 = 500; @@ -217,6 +217,7 @@ fn error_path_commit_deadline_after_reveal() { &2_000, &2_500, &Bytes::from_array(&f.env, b"a"), + &native_xlm(&f.env), ), Error::CommitDeadlineAfterReveal, ); @@ -372,6 +373,7 @@ fn error_path_invalid_drand_signature() { &commit_deadline, &reveal_deadline, &Bytes::from_array(&f.env, b"auditor"), + &native_xlm(&f.env), ); let bidder = funded_bidder(&f, 1_000); commit_bid(&f, id, &bidder, 100, 100, 0x01); @@ -426,6 +428,7 @@ fn error_path_payload_too_large() { &1_500, &2_500, &oversized_bytes(&f.env, 1025), + &native_xlm(&f.env), ), Error::PayloadTooLarge, ); @@ -488,6 +491,7 @@ fn error_path_deadline_in_past() { &500, &2_500, &Bytes::from_array(&f.env, b"a"), + &native_xlm(&f.env), ), Error::DeadlineInPast, ); diff --git a/contracts/round/src/test.rs b/contracts/round/src/test.rs index 596af7eb..1eae9067 100644 --- a/contracts/round/src/test.rs +++ b/contracts/round/src/test.rs @@ -2,13 +2,13 @@ use soroban_sdk::{ testutils::{Address as _, Ledger}, - token, Address, Bytes, BytesN, ConversionError, Env, InvokeError, Vec, + token, Address, Bytes, BytesN, ConversionError, Env, InvokeError, String, Vec, }; use soroban_sdk::testutils::storage::Temporary as TemporaryStorageTest; use crate::drand; use crate::storage::{seal_ttl_for_reveal_deadline, TEMP_THRESHOLD}; -use crate::types::{ClearingRule, DataKey, Error, GlobalConfig, Status}; +use crate::types::{ClearingRule, DataKey, Error, GlobalConfig, RoundAssetConfig, Status}; use crate::{SubRosaRound, SubRosaRoundClient}; // ── Dummy fixture (no BLS) — only for tests that never call open_reveal ────── @@ -140,6 +140,7 @@ fn drand_round(f: &Fixture, operator: &Address, commit_deadline: u64, reveal_dea &commit_deadline, &reveal_deadline, &Bytes::from_array(&f.env, b"auditor"), + &native_xlm(&f.env), ) } @@ -159,6 +160,19 @@ fn b32(env: &Env, byte: u8) -> BytesN<32> { BytesN::from_array(env, &[byte; 32]) } +/// Native XLM asset config, the default for tests that don't exercise SAC +/// binding. `create_round` takes it on every call but does not itself validate +/// the fields, so tests that only need a well-formed round pass this. +pub fn native_xlm(env: &Env) -> RoundAssetConfig { + RoundAssetConfig { + asset_type: String::from_str(env, "native"), + contract_id: String::from_str(env, ""), + code: String::from_str(env, "XLM"), + decimals: 7, + issuer: String::from_str(env, ""), + } +} + fn open_round(f: &Fixture, operator: &Address) -> u64 { f.client.create_round( operator, @@ -168,6 +182,7 @@ fn open_round(f: &Fixture, operator: &Address) -> u64 { &1_500, &2_500, &Bytes::from_array(&f.env, b"auditor-pubkey"), + &native_xlm(&f.env), ) } @@ -294,7 +309,7 @@ fn create_round_rejects_commit_after_reveal() { let operator = Address::generate(&f.env); let res = f.client.try_create_round( &operator, &b32(&f.env, 1), &2_000, &ClearingRule::HighestBid, - &2_000, &2_500, &Bytes::from_array(&f.env, b"a"), + &2_000, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env), ); assert!(res.is_err()); } @@ -305,7 +320,7 @@ fn create_round_rejects_deadline_in_past() { let operator = Address::generate(&f.env); let res = f.client.try_create_round( &operator, &b32(&f.env, 1), &2_000, &ClearingRule::HighestBid, - &500, &2_500, &Bytes::from_array(&f.env, b"a"), + &500, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env), ); assert!(res.is_err()); } @@ -995,6 +1010,7 @@ fn full_lifecycle_real_drand_signature() { let id = f.client.create_round( &operator, &b32(&f.env, 0xAB), &VEC_ROUND, &ClearingRule::HighestBid, &commit_deadline, &reveal_deadline, &Bytes::from_array(&f.env, b"auditor"), + &native_xlm(&f.env), ); let alice = funded_bidder(&f, 1_000);