Skip to content

Commit af4dddc

Browse files
committed
fix(checksig-census): preserve over-capacity ECDSA evidence
1 parent 4b4775d commit af4dddc

4 files changed

Lines changed: 332 additions & 24 deletions

File tree

tools/checksig-census/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -440,6 +440,14 @@ ECDSA pubkey; 2 empty ECDSA signature; 3 missing ECDSA transaction data;
440440
6 missing Schnorr transaction data; 7 Schnorr sighash failure; 8 empty
441441
Tapscript Schnorr signature skipped before `CheckSchnorrSignature`.
442442

443+
The on-disk 224-byte layout is fixed: `pubkey_len` preserves the low 8 bits of
444+
the source public-key stack element. A semantic reader may accept `pubkey_len >
445+
65` only for the exact native reason-1 pre-verification reject shape: `outcome
446+
== 2`, `reject_reason == 1`, `op_kind` in 1..4, `sig_version` in {0, 1},
447+
`der_len == 0`, `sighash_type == 0`, and all `sighash`, `der_sig`, `pubkey`,
448+
and padding bytes zero. Every other over-capacity record keeps the existing
449+
`pubkey_len ... exceeds 65` rejection.
450+
443451
**Journal** (magic `BRSJRN1\0`, 56 bytes each):
444452
spend_txid[32], input_index u32, checksig_ops u32, checkmultisig_ops u32,
445453
ecdsa_verify_calls u32, ecdsa_verify_ok u32, verdict u8, pad[3].

tools/checksig-census/analyze.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,26 @@ def __init__(self, raw: bytes) -> None:
340340
self.pubkey,
341341
_pad1,
342342
) = unpacked
343+
# Exact over-capacity ECDSA reject shape accepted by both BRSREC1 readers.
344+
# Native reason-1 records may preserve the original (>=66) pubkey_len only
345+
# for outcome 2 with a null payload and unchanged padding.
346+
_is_exact_over_capacity_reject = (
347+
self.outcome == 2
348+
and self.reject_reason == 1
349+
and 1 <= self.op_kind <= 4
350+
and self.sig_version in (0, 1)
351+
and self.der_len == 0
352+
and self.sighash_type == 0
353+
and _pad0 == 0
354+
and _pad1 == b"\x00" * 7
355+
and self.sighash == b"\x00" * 32
356+
and self.der_sig == b"\x00" * 72
357+
and self.pubkey == b"\x00" * 65
358+
)
359+
if self.pubkey_len > 65 and not _is_exact_over_capacity_reject:
360+
raise AnalyzerError(
361+
f"CTX-OPERATIONS: record pubkey_len {self.pubkey_len} exceeds 65"
362+
)
343363
# ── Canonical field-range validation ──
344364
if self.op_kind > 5:
345365
raise AnalyzerError(
@@ -416,10 +436,6 @@ def __init__(self, raw: bytes) -> None:
416436
effective_der_len = 72
417437
else:
418438
effective_der_len = self.der_len
419-
if self.pubkey_len > 65:
420-
raise AnalyzerError(
421-
f"CTX-OPERATIONS: record pubkey_len {self.pubkey_len} exceeds 65"
422-
)
423439
# ── Padding must be all-zero ──
424440
if _pad0 != 0:
425441
raise AnalyzerError("CTX-OPERATIONS: record _pad0 is not all-zero")

tools/checksig-census/bare-secp/src/main.rs

Lines changed: 203 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,38 @@ const COUNTER_NAMES: [&str; 24] = [
5656

5757
// ── Entry point ────────────────────────────────────────────────────────────
5858

59+
fn record_to_bare_input(
60+
rec: &ParsedRecord,
61+
) -> Result<Option<libbitcoinkernel_sys::btck_bare_input>> {
62+
// Outcome-2 records are pre-verification rejects and carry no sighash.
63+
// They must be skipped before any capacity-indexed public-key slice.
64+
if rec.outcome == 2 {
65+
return Ok(None);
66+
}
67+
// parse_record already enforces these bounds for non-exempt records,
68+
// but the helper keeps the slice contract self-contained.
69+
if rec.der_len > 72 {
70+
bail!("record: der_len {} exceeds 72", rec.der_len);
71+
}
72+
if rec.pubkey_len > 65 {
73+
bail!("record: pubkey_len {} exceeds 65", rec.pubkey_len);
74+
}
75+
76+
let mut input = libbitcoinkernel_sys::btck_bare_input {
77+
sighash: [0u8; 32],
78+
der_sig: [0u8; 72],
79+
pubkey: [0u8; 65],
80+
der_len: rec.der_len,
81+
pubkey_len: rec.pubkey_len,
82+
expected: rec.outcome, // 1=true, 0=false
83+
pad: [0u8; 4],
84+
};
85+
input.sighash.copy_from_slice(&rec.sighash);
86+
input.der_sig[..rec.der_len as usize].copy_from_slice(&rec.der_sig[..rec.der_len as usize]);
87+
input.pubkey[..rec.pubkey_len as usize].copy_from_slice(&rec.pubkey[..rec.pubkey_len as usize]);
88+
Ok(Some(input))
89+
}
90+
5991
fn main() -> Result<()> {
6092
let args = Args::parse(std::env::args_os().skip(1))?;
6193

@@ -70,24 +102,10 @@ fn main() -> Result<()> {
70102
let mut inputs: Vec<libbitcoinkernel_sys::btck_bare_input> = Vec::with_capacity(records.len());
71103
let mut rejected: u64 = 0;
72104
for rec in &records {
73-
if rec.outcome == 2 {
74-
rejected += 1;
75-
continue;
105+
match record_to_bare_input(rec)? {
106+
None => rejected += 1,
107+
Some(input) => inputs.push(input),
76108
}
77-
let mut input = libbitcoinkernel_sys::btck_bare_input {
78-
sighash: [0u8; 32],
79-
der_sig: [0u8; 72],
80-
pubkey: [0u8; 65],
81-
der_len: rec.der_len,
82-
pubkey_len: rec.pubkey_len,
83-
expected: rec.outcome, // 1=true, 0=false
84-
pad: [0u8; 4],
85-
};
86-
input.sighash.copy_from_slice(&rec.sighash);
87-
input.der_sig[..rec.der_len as usize].copy_from_slice(&rec.der_sig[..rec.der_len as usize]);
88-
input.pubkey[..rec.pubkey_len as usize]
89-
.copy_from_slice(&rec.pubkey[..rec.pubkey_len as usize]);
90-
inputs.push(input);
91109
}
92110
let expected_true_count: u64 = inputs.iter().filter(|inp| inp.expected == 1).count() as u64;
93111

@@ -351,17 +369,58 @@ struct ParsedRecord {
351369
pubkey_len: u8,
352370
outcome: u8,
353371
}
372+
373+
/// Exact over-capacity ECDSA reject shape accepted by the bare-secp reader.
374+
/// Native reason-1 records may preserve the original (>=66) pubkey_len only
375+
/// for outcome 2 with a null payload and unchanged padding.
376+
fn is_exempt_over_capacity_ecdsa_reject(buf: &[u8; RECORD_SIZE]) -> bool {
377+
if !(1..=4).contains(&buf[40]) {
378+
return false;
379+
} // op_kind
380+
if !(0..=1).contains(&buf[41]) {
381+
return false;
382+
} // sig_version
383+
if buf[42] != 2 {
384+
return false;
385+
} // outcome
386+
if buf[43] != 0 {
387+
return false;
388+
} // der_len
389+
if buf[45] != 0 {
390+
return false;
391+
} // sighash_type
392+
if buf[46] != 1 {
393+
return false;
394+
} // reject_reason
395+
if buf[47] != 0 {
396+
return false;
397+
} // _pad0
398+
if buf[48..80] != [0; 32] {
399+
return false;
400+
} // sighash
401+
if buf[80..152] != [0; 72] {
402+
return false;
403+
} // der_sig
404+
if buf[152..217] != [0; 65] {
405+
return false;
406+
} // pubkey
407+
if buf[217..224] != [0; 7] {
408+
return false;
409+
} // _pad1
410+
true
411+
}
412+
354413
fn parse_record(buf: &[u8; RECORD_SIZE], index: u64) -> Result<ParsedRecord> {
355414
let outcome = buf[42];
356415
let der_len = buf[43];
357416
let pubkey_len = buf[44];
358417

418+
if pubkey_len > 65 && !is_exempt_over_capacity_ecdsa_reject(buf) {
419+
bail!("record {index}: pubkey_len {pubkey_len} exceeds 65");
420+
}
359421
if der_len > 72 {
360422
bail!("record {index}: der_len {der_len} exceeds 72");
361423
}
362-
if pubkey_len > 65 {
363-
bail!("record {index}: pubkey_len {pubkey_len} exceeds 65");
364-
}
365424
if outcome > 2 {
366425
bail!("record {index}: outcome {outcome} exceeds 2");
367426
}
@@ -548,4 +607,128 @@ mod tests {
548607
};
549608
assert_eq!(rc, -1);
550609
}
610+
611+
fn preserved_over_capacity_row() -> [u8; RECORD_SIZE] {
612+
let mut buf = [0u8; RECORD_SIZE];
613+
let prefix: [u8; 48] = [
614+
0xcf, 0x42, 0xbd, 0x87, 0xb9, 0x98, 0x25, 0x95, 0xbf, 0x2d, 0x35, 0x4c, 0x5f, 0x75,
615+
0x8c, 0x14, 0x4d, 0x66, 0x33, 0x01, 0xce, 0xfc, 0x3b, 0x31, 0xad, 0x31, 0x32, 0xf8,
616+
0x7f, 0x49, 0x18, 0xd1, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00,
617+
0x02, 0x00, 0x42, 0x00, 0x01, 0x00,
618+
];
619+
buf[..48].copy_from_slice(&prefix);
620+
buf
621+
}
622+
623+
#[test]
624+
fn parse_accepts_preserved_over_capacity_ecdsa_reject() {
625+
let buf = preserved_over_capacity_row();
626+
let rec = parse_record(&buf, 0).unwrap();
627+
assert_eq!(rec.outcome, 2);
628+
assert_eq!(rec.der_len, 0);
629+
assert_eq!(rec.pubkey_len, 66);
630+
assert_eq!(rec.sighash, [0; 32]);
631+
assert_eq!(rec.der_sig, [0; 72]);
632+
assert_eq!(rec.pubkey, [0; 65]);
633+
}
634+
635+
#[test]
636+
fn parse_rejects_over_capacity_near_misses() {
637+
let base = preserved_over_capacity_row();
638+
let mutations: [(usize, u8, &str); 19] = [
639+
(40, 0, "op_kind=0"),
640+
(40, 5, "op_kind=5"),
641+
(41, 2, "sig_version=2"),
642+
(41, 3, "sig_version=3"),
643+
(42, 0, "outcome=0"),
644+
(42, 1, "outcome=1"),
645+
(43, 1, "der_len=1"),
646+
(43, 73, "der_len=73"),
647+
(45, 1, "sighash_type=1"),
648+
(46, 0, "reject_reason=0"),
649+
(46, 2, "reject_reason=2"),
650+
(46, 3, "reject_reason=3"),
651+
(46, 4, "reject_reason=4"),
652+
(46, 5, "reject_reason=5"),
653+
(46, 6, "reject_reason=6"),
654+
(46, 7, "reject_reason=7"),
655+
(46, 8, "reject_reason=8"),
656+
(47, 1, "nonzero _pad0"),
657+
(80, 1, "nonzero der_sig byte"),
658+
];
659+
for (offset, value, label) in mutations {
660+
let mut buf = base;
661+
buf[offset] = value;
662+
let err = match parse_record(&buf, 0) {
663+
Err(e) => e.to_string(),
664+
Ok(_) => panic!("{label} should have failed"),
665+
};
666+
assert!(
667+
err.contains("pubkey_len 66 exceeds 65"),
668+
"{label} should fail with length error, got: {err}"
669+
);
670+
}
671+
672+
for offset in [48, 79, 80, 151, 152, 216, 217, 223] {
673+
let mut buf = base;
674+
buf[offset] = 1;
675+
let err = match parse_record(&buf, 0) {
676+
Err(e) => e.to_string(),
677+
Ok(_) => panic!("byte {offset} should have failed"),
678+
};
679+
assert!(
680+
err.contains("pubkey_len 66 exceeds 65"),
681+
"byte {offset} should fail with length error, got: {err}"
682+
);
683+
}
684+
}
685+
686+
#[test]
687+
fn load_records_accepts_preserved_over_capacity_reject() {
688+
// Build a minimal BRSREC1 fixture with the exact over-capacity row.
689+
let path = std::env::temp_dir().join(format!(
690+
"checksig_bare_secp_preserved_row_{}.bin",
691+
std::process::id()
692+
));
693+
let mut file = std::fs::File::create(&path).unwrap();
694+
std::io::Write::write_all(&mut file, RECORD_MAGIC).unwrap();
695+
std::io::Write::write_all(&mut file, &1u64.to_le_bytes()).unwrap();
696+
std::io::Write::write_all(&mut file, &preserved_over_capacity_row()).unwrap();
697+
drop(file);
698+
699+
let records = load_records(&path).unwrap();
700+
assert_eq!(records.len(), 1);
701+
assert_eq!(records[0].outcome, 2);
702+
assert_eq!(records[0].pubkey_len, 66);
703+
704+
std::fs::remove_file(&path).ok();
705+
}
706+
707+
#[test]
708+
fn record_to_bare_input_skips_over_capacity_outcome_two() {
709+
// The exact over-capacity reason-1 record is outcome 2, so the
710+
// production helper must return None before any capacity-indexed
711+
// public-key slice. Removing the outcome-2 guard would make this
712+
// test panic because pubkey_len (66) exceeds the fixed 65-byte array.
713+
let rec = parse_record(&preserved_over_capacity_row(), 0).unwrap();
714+
assert_eq!(rec.outcome, 2);
715+
assert!(rec.pubkey_len > 65);
716+
let input = record_to_bare_input(&rec).unwrap();
717+
assert!(
718+
input.is_none(),
719+
"outcome-2 record must be skipped before copy"
720+
);
721+
}
722+
723+
#[test]
724+
fn record_to_bare_input_accepts_ordinary_record() {
725+
// Ordinary outcome-1 records are converted to a real FFI input.
726+
let buf = valid_buf();
727+
let rec = parse_record(&buf, 0).unwrap();
728+
assert_eq!(rec.outcome, 1);
729+
let input = record_to_bare_input(&rec).unwrap().unwrap();
730+
assert_eq!(input.expected, 1);
731+
assert_eq!(input.der_len, 72);
732+
assert_eq!(input.pubkey_len, 65);
733+
}
551734
}

0 commit comments

Comments
 (0)