diff --git a/contracts/round/src/error_paths.rs b/contracts/round/src/error_paths.rs index fb91a903..97d7184d 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, sac_asset_config, 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; @@ -218,7 +218,7 @@ fn error_path_commit_deadline_after_reveal() { &2_000, &2_500, &Bytes::from_array(&f.env, b"a"), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ), Error::CommitDeadlineAfterReveal, ); @@ -374,7 +374,7 @@ fn error_path_invalid_drand_signature() { &commit_deadline, &reveal_deadline, &Bytes::from_array(&f.env, b"auditor"), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ); let bidder = funded_bidder(&f, 1_000); commit_bid(&f, id, &bidder, 100, 100, 0x01); @@ -429,7 +429,7 @@ fn error_path_payload_too_large() { &1_500, &2_500, &oversized_bytes(&f.env, 1025), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ), Error::PayloadTooLarge, ); @@ -492,7 +492,7 @@ fn error_path_deadline_in_past() { &500, &2_500, &Bytes::from_array(&f.env, b"a"), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ), Error::DeadlineInPast, ); diff --git a/contracts/round/src/test.rs b/contracts/round/src/test.rs index c8c4d71a..8a000fac 100644 --- a/contracts/round/src/test.rs +++ b/contracts/round/src/test.rs @@ -2,7 +2,7 @@ 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; @@ -140,7 +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"), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ) } @@ -160,19 +160,16 @@ fn b32(env: &Env, byte: u8) -> BytesN<32> { BytesN::from_array(env, &[byte; 32]) } -fn sac_asset_config(env: &Env) -> RoundAssetConfig { +/// 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: soroban_sdk::String::from_str(env, "sac"), - contract_id: soroban_sdk::String::from_str( - env, - "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABSC4", - ), - code: soroban_sdk::String::from_str(env, "USDC"), + asset_type: String::from_str(env, "native"), + contract_id: String::from_str(env, ""), + code: String::from_str(env, "XLM"), decimals: 7, - issuer: soroban_sdk::String::from_str( - env, - "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", - ), + issuer: String::from_str(env, ""), } } @@ -186,7 +183,7 @@ fn open_round(f: &Fixture, operator: &Address) -> u64 { &1_500, &2_500, &Bytes::from_array(&f.env, b"auditor-pubkey"), - &asset_config, + &native_xlm(&f.env), ) } @@ -313,8 +310,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"), - &sac_asset_config(&f.env), + &2_000, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env), ); assert!(res.is_err()); } @@ -325,8 +321,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"), - &sac_asset_config(&f.env), + &500, &2_500, &Bytes::from_array(&f.env, b"a"), &native_xlm(&f.env), ); assert!(res.is_err()); } @@ -1107,7 +1102,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"), - &sac_asset_config(&f.env), + &native_xlm(&f.env), ); let alice = funded_bidder(&f, 1_000); diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index af0dd010..8efde17d 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -14,6 +14,7 @@ import type { SubmitSignedTransactionParams, TransactionSubmitter, } from "./submitter.js"; +import { sealFixture, fixtureBinding } from "./testing/seal-fixture.js"; const BASE_CONFIG = { rpcUrl: "https://example.com", @@ -237,15 +238,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 aa14f296..6ce8e51b 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -28,8 +28,9 @@ import { } from "@sub-rosa/round-bindings/event-snapshot"; import { toHex } from "@sub-rosa/tlock"; import type { SealedBid } from "@sub-rosa/tlock"; -import type { RoundReceipt, RoundReceiptEvent } from "./receipt.js"; -import { validateEncryptedBlob } from "./encrypted-blob.js"; +import type { RoundReceipt } from "./receipt.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 { @@ -39,6 +40,7 @@ import { type PreflightResult, } from "./preflight.js"; import { + SubRosaAssetValidationError, SubRosaClientConfigError, SubRosaPaginationError, SubRosaMissingReturnValueError, @@ -107,17 +109,30 @@ export interface SubRosaClientConfig { * If not provided, no asset validation is performed. */ assetConfig?: import("./asset-config.js").AssetConfig; - /** * @internal Testing hook: inject a mock Soroban RPC server for simulation. */ _server?: rpc.Server; - /** Optional passkey session to bind commits and client operations to. */ - session?: PasskeySessionBinding; } export type ClearingRuleTag = ClearingRule["tag"]; +/** + * The contract's `RoundAssetConfig` (contracts/round/src/types.rs). + * + * Declared here rather than imported because the generated bindings in this + * tree predate the `asset_config` argument on `create_round`; the shape mirrors + * the Rust struct exactly. Once the bindings are regenerated this type and the + * accompanying cast at the call site can both be dropped. + */ +interface RoundAssetConfig { + asset_type: string; + contract_id: string; + code: string; + decimals: number; + issuer: string; +} + export interface CreateRoundParams { /** sha256 (or any opaque 32-byte ref) of the off-chain item description. */ itemRef: Uint8Array; @@ -145,8 +160,16 @@ export interface CommitParams { escrow: bigint; /** Bidder address. Default: the configured signer's public key. */ bidder?: string; - /** Optional passkey session to bind this commit to. */ - session?: PasskeySessionBinding; + /** + * 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 { @@ -424,8 +447,25 @@ export class SubRosaClient { const clearing_rule = { tag: params.clearingRule ?? "HighestBid", values: undefined, - } as ClearingRule; - const assetConfig = this.#buildAssetConfig(params); + } as ClearingRule; + // Build asset config for the round + let assetConfig: RoundAssetConfig = { + asset_type: "native", + contract_id: "", + code: "XLM", + decimals: 7, + issuer: "", + }; + if (params.assetConfig) { + const { type, code, contractId, issuer, decimals } = params.assetConfig; + assetConfig = { + asset_type: type, + contract_id: contractId || "", + code: code || "XLM", + decimals: decimals ?? 7, + issuer: issuer || "", + }; + } const tx = await this.#validatedContractCall(() => this.contract.create_round({ @@ -437,42 +477,19 @@ export class SubRosaClient { reveal_deadline: toBigInt(params.revealDeadline), auditor_pubkey: toBuffer(params.auditorPubkey), asset_config: assetConfig, - }), + } as Parameters[0]), ); return this.#sendUnwrap(tx); } async commit(params: CommitParams): Promise { - const session = params.session ?? this.#session; - if (session) { - validatePasskeySession(session, { - contractId: this.contractId, - networkPassphrase: this.networkPassphrase, - account: params.bidder ?? this.#source, - }); - } - - // 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); // Validate asset config matches the round's expected asset if (this.#assetConfig) { diff --git a/packages/sdk/src/encrypted-blob.test.ts b/packages/sdk/src/encrypted-blob.test.ts index 5249c9d4..63870b7c 100644 --- a/packages/sdk/src/encrypted-blob.test.ts +++ b/packages/sdk/src/encrypted-blob.test.ts @@ -7,17 +7,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 ────────────────────────────────────────────────────────────── @@ -395,6 +412,305 @@ 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]); +} + test("forced hex does not fall back to base64", () => { const result = validateEncryptedBlob("/w==", "ciphertext", { encoding: "hex" }); assert.equal(result.valid, false); diff --git a/packages/sdk/src/encrypted-blob.ts b/packages/sdk/src/encrypted-blob.ts index 046c2978..dfe0a6ed 100644 --- a/packages/sdk/src/encrypted-blob.ts +++ b/packages/sdk/src/encrypted-blob.ts @@ -1,5 +1,5 @@ // SPDX-License-Identifier: MIT -// 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 @@ -9,7 +9,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) ───────────────────────────────────────────── @@ -209,12 +231,10 @@ export function validateEncryptedBlob( b64Decoded = hexDecoded ? null : tryDecodeBase64(blob); } - if (hexDecoded) { - rawBytes = hexDecoded.bytes; - byteLength = hexDecoded.length; - } else if (b64Decoded) { - rawBytes = b64Decoded.bytes; - byteLength = b64Decoded.length; + const decoded = hexDecoded ?? b64Decoded; + if (decoded) { + rawBytes = decoded.bytes; + byteLength = decoded.length; } else { // Not valid hex or base64. add( @@ -249,3 +269,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 bdbc4098..fb4df41e 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -64,6 +64,8 @@ export { export { validateEncryptedBlob, + validateSealedBid, + assertSealedBid, tryDecodeHex, tryDecodeBase64, MAX_CIPHERTEXT_BYTES, @@ -71,6 +73,7 @@ export { type BlobContentType, type BlobValidationIssue, type BlobValidationResult, + type SealedBidBinding, } from "./encrypted-blob.js"; export { MAINNET_ARTIFACTS, diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 51ed5608..90f46da1 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -41,6 +41,7 @@ const EXPECTED_EXPORTS = [ "assertMainnetConfirmed", "assertMicroAmounts", "assertReadinessForExecute", + "assertSealedBid", "classifyRoundStatus", "contractErrorCode", "createOzChannelsSubmitter", @@ -82,7 +83,7 @@ const EXPECTED_EXPORTS = [ "validateAssetConfigs", "validateEncryptedBlob", "validateContractNetwork", - "validatePasskeySession", + "validateSealedBid", "verifyReceipt", "verifyReceiptEvents", "verifySettledRoundProof", 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 e8e4f2b0..4ef0c588 100644 --- a/packages/tlock/package.json +++ b/packages/tlock/package.json @@ -20,9 +20,9 @@ }, "main": "src/index.ts", "scripts": { - "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 src/quicknet.test.ts src/validate.test.ts", + "test": "node --import tsx --test src/commitment.test.ts src/ciphertext.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 src/quicknet.test.ts src/validate.test.ts", "test:quicknet": "node --import tsx --test src/quicknet.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 src/quicknet.test.ts src/validate.test.ts", + "test:unit": "node --import tsx --test src/commitment.test.ts src/ciphertext.test.ts src/payload.test.ts src/auditor.test.ts src/auditor-recovery-cli.test.ts src/bls.test.ts src/freshness.test.ts src/quicknet.test.ts src/validate.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/ciphertext.test.ts b/packages/tlock/src/ciphertext.test.ts new file mode 100644 index 00000000..506814cb --- /dev/null +++ b/packages/tlock/src/ciphertext.test.ts @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: MIT +// Sealed-ciphertext 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 "./ciphertext.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/ciphertext.ts b/packages/tlock/src/ciphertext.ts new file mode 100644 index 00000000..303631d9 --- /dev/null +++ b/packages/tlock/src/ciphertext.ts @@ -0,0 +1,251 @@ +// SPDX-License-Identifier: MIT +// Sealed-ciphertext 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; +} diff --git a/packages/tlock/src/commitment.test.ts b/packages/tlock/src/commitment.test.ts index f4199ff4..6207e5a8 100644 --- a/packages/tlock/src/commitment.test.ts +++ b/packages/tlock/src/commitment.test.ts @@ -5,12 +5,15 @@ import assert from "node:assert/strict"; import { beBytesToI128, commitment, + commitmentMatches, decodeBidPreimage, encodeBidPreimage, fromHex, i128ToBeBytes, isValidHex, toHex, + COMMITMENT_BYTES, + NONCE_BYTES, } from "./commitment.js"; // Frozen vector shared with the Round contract's Rust test @@ -55,6 +58,48 @@ test("rejects out-of-range and malformed inputs", () => { 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); + } +}); + test("fromHex decodes lowercase, uppercase, and prefixed values", () => { const expected = [0xab, 0xcd, 0xef]; assert.deepEqual([...fromHex("abcdef")], expected); diff --git a/packages/tlock/src/commitment.ts b/packages/tlock/src/commitment.ts index de3cee8f..a7f8ab87 100644 --- a/packages/tlock/src/commitment.ts +++ b/packages/tlock/src/commitment.ts @@ -11,6 +11,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); @@ -72,6 +74,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 d0e027a1..8d336132 100644 --- a/packages/tlock/src/index.ts +++ b/packages/tlock/src/index.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 Sub Rosa contributors export { commitment, + commitmentMatches, encodeBidPreimage, decodeBidPreimage, i128ToBeBytes, @@ -11,8 +12,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 "./ciphertext.js"; + export { generateAuditorKeypair, auditorPublicKey,