this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add preparsed-key benchmark tier for sig verification

isolates pure ECDSA math from SEC1 key decompression.
reveals Go's key parsing is ~17% of crypto-only time,
making the pure math gap 1.7x (not 1.5x).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

zzstoatzz 761f4ac6 49126ae1

+977 -7
+12 -7
README.md
··· 135 135 136 136 both enforce low-S normalization on both curves. 137 137 138 - ### two tiers 138 + ### three tiers 139 139 140 140 - **full pipeline**: CBOR decode → strip sig → re-encode → SHA-256 → ECDSA verify (what a relay actually does) 141 141 - **crypto-only**: SHA-256 → ECDSA verify with pre-computed unsigned bytes (isolates crypto cost from CBOR overhead) 142 + - **preparsed-key**: SHA-256 → ECDSA verify with pre-parsed public keys (isolates pure ECDSA math from SEC1 decompression) 142 143 143 144 ### results 144 145 ··· 146 147 147 148 | SDK | variant | verifies/sec (median) | entries | P-256 | secp256k1 | errors | 148 149 |-----|---------|--------:|-----:|-----:|-----:|-----:| 149 - | go ([indigo](https://github.com/bluesky-social/indigo)) | full pipeline | 15,128 | 3,072 | 0 | 3,072 | 0 | 150 - | go (indigo) | crypto-only | 14,047 | 3,072 | 0 | 3,072 | 0 | 151 - | zig ([zat](https://tangled.sh/@zzstoatzz.io/zat) + [k256](https://tangled.sh/@zzstoatzz.io/k256)) | full pipeline | 9,845 | 3,072 | 0 | 3,072 | 0 | 152 - | zig (zat + k256) | crypto-only | 9,818 | 3,072 | 0 | 3,072 | 0 | 150 + | go ([indigo](https://github.com/bluesky-social/indigo)) | full pipeline | 15,123 | 3,072 | 0 | 3,072 | 0 | 151 + | go (indigo) | crypto-only | 14,969 | 3,072 | 0 | 3,072 | 0 | 152 + | go (indigo) | preparsed-key | 17,501 | 3,072 | 0 | 3,072 | 0 | 153 + | zig ([zat](https://tangled.sh/@zzstoatzz.io/zat) + [k256](https://tangled.sh/@zzstoatzz.io/k256)) | full pipeline | 9,830 | 3,072 | 0 | 3,072 | 0 | 154 + | zig (zat + k256) | crypto-only | 9,827 | 3,072 | 0 | 3,072 | 0 | 155 + | zig (zat + k256) | preparsed-key | 10,327 | 3,072 | 0 | 3,072 | 0 | 156 + 157 + Go leads by ~1.5x at the full pipeline level. the preparsed-key tier isolates pure ECDSA math: Go's SEC1 decompression is relatively expensive (~17% of crypto-only time), so the pure math gap is actually **1.7x**. 153 158 154 - Go leads sig verification by ~1.5x. indigo uses [decred/dcrd](https://github.com/decred/dcrd/tree/master/dcrec/secp256k1) — a highly optimized secp256k1 implementation with specialized 10×26-bit field arithmetic and NAF point multiplication. k256 v0.0.2 uses the same 10×26-bit field representation, GLV endomorphism, precomputed base point tables, and Jacobian point arithmetic. the remaining gap is primarily stdlib scalar operations (~42% of verify time) and normalize overhead in the field arithmetic. 159 + indigo uses [decred/dcrd](https://github.com/decred/dcrd/tree/master/dcrec/secp256k1) — a highly optimized secp256k1 implementation with specialized 10×26-bit field arithmetic and NAF point multiplication. k256 v0.0.2 uses the same 10×26-bit field representation, GLV endomorphism, precomputed base point tables, and Jacobian point arithmetic. the remaining gap is a mix of stdlib scalar operations and normalize overhead in the Fe26 field arithmetic. 155 160 156 - the crypto-only vs full-pipeline numbers being nearly identical confirms ECDSA is the bottleneck, not CBOR re-encoding overhead. 161 + the crypto-only vs full-pipeline numbers being nearly identical confirms ECDSA is the bottleneck, not CBOR re-encoding overhead. the preparsed-key tier shows key parsing is a small but measurable cost — relevant for relay implementations that cache public keys per-DID. 157 162 158 163 ### why only zig + go 159 164
+426
go-sigs/main.go
··· 1 + // atproto signature verification benchmarks — go (indigo) 2 + // 3 + // loads a corpus of signed commits + public keys, verifies each using 4 + // indigo's crypto and repo packages. same work as a relay does per commit. 5 + package main 6 + 7 + import ( 8 + "bytes" 9 + "encoding/binary" 10 + "encoding/json" 11 + "fmt" 12 + "os" 13 + "path/filepath" 14 + "sort" 15 + "time" 16 + 17 + "github.com/bluesky-social/indigo/atproto/atcrypto" 18 + "github.com/bluesky-social/indigo/atproto/repo" 19 + ) 20 + 21 + const ( 22 + warmupPasses = 2 23 + measuredPasses = 5 24 + fixturesDir = "../fixtures" 25 + ) 26 + 27 + type corpusEntry struct { 28 + curveType uint8 // 0 = P-256, 1 = secp256k1 29 + signedBytes []byte // full signed commit CBOR 30 + pubkeyBytes []byte // compressed public key (33 bytes) 31 + } 32 + 33 + type corpusInfo struct { 34 + entries []corpusEntry 35 + totalBytes int 36 + p256Count int 37 + k256Count int 38 + } 39 + 40 + type passResult struct { 41 + verifies int 42 + errors int 43 + elapsed time.Duration 44 + } 45 + 46 + func main() { 47 + fmt.Println("\n=== go sig-verify benchmarks ===") 48 + fmt.Println() 49 + 50 + corpus, err := loadCorpus("sig-verify-corpus.bin") 51 + if err != nil { 52 + fmt.Printf("sig-verify-corpus.bin: SKIP (%v)\n", err) 53 + return 54 + } 55 + 56 + fmt.Printf("corpus: %d entries, %d bytes\n", len(corpus.entries), corpus.totalBytes) 57 + fmt.Printf(" P-256: %d, secp256k1: %d\n", corpus.p256Count, corpus.k256Count) 58 + fmt.Printf(" passes: %d warmup, %d measured\n\n", warmupPasses, measuredPasses) 59 + 60 + // sanity check first entry 61 + if len(corpus.entries) > 0 { 62 + ok := verifyFullPipeline(corpus.entries[0]) 63 + status := "OK" 64 + if !ok { 65 + status = "FAIL" 66 + } 67 + fmt.Printf("first entry: %s\n\n", status) 68 + } 69 + 70 + // tier 1: full pipeline 71 + fullResults := benchFullPipeline(corpus) 72 + 73 + // tier 2: crypto-only 74 + cryptoResults := benchCryptoOnly(corpus) 75 + 76 + // tier 3: preparsed-key 77 + preparsedResults := benchPreparsedKey(corpus) 78 + 79 + // write JSON 80 + writeJSON(corpus, fullResults, cryptoResults, preparsedResults) 81 + 82 + fmt.Println() 83 + } 84 + 85 + func benchFullPipeline(corpus *corpusInfo) []passResult { 86 + for i := 0; i < warmupPasses; i++ { 87 + for _, entry := range corpus.entries { 88 + _ = verifyFullPipeline(entry) 89 + } 90 + } 91 + 92 + results := make([]passResult, measuredPasses) 93 + for i := 0; i < measuredPasses; i++ { 94 + var verifies, errors int 95 + start := time.Now() 96 + for _, entry := range corpus.entries { 97 + if verifyFullPipeline(entry) { 98 + verifies++ 99 + } else { 100 + errors++ 101 + } 102 + } 103 + results[i] = passResult{ 104 + verifies: verifies, 105 + errors: errors, 106 + elapsed: time.Since(start), 107 + } 108 + } 109 + 110 + reportResult("sig-verify", corpus, results) 111 + return results 112 + } 113 + 114 + type precomputedEntry struct { 115 + curveType uint8 116 + unsignedBytes []byte 117 + sigBytes []byte 118 + pubkeyBytes []byte 119 + } 120 + 121 + func benchCryptoOnly(corpus *corpusInfo) []passResult { 122 + // pre-compute unsigned bytes 123 + var precomputed []precomputedEntry 124 + for _, entry := range corpus.entries { 125 + var commit repo.Commit 126 + if err := commit.UnmarshalCBOR(bytes.NewReader(entry.signedBytes)); err != nil { 127 + continue 128 + } 129 + unsignedBytes, err := commit.UnsignedBytes() 130 + if err != nil { 131 + continue 132 + } 133 + precomputed = append(precomputed, precomputedEntry{ 134 + curveType: entry.curveType, 135 + unsignedBytes: unsignedBytes, 136 + sigBytes: commit.Sig, 137 + pubkeyBytes: entry.pubkeyBytes, 138 + }) 139 + } 140 + 141 + for i := 0; i < warmupPasses; i++ { 142 + for _, entry := range precomputed { 143 + _ = verifyCryptoOnly(entry) 144 + } 145 + } 146 + 147 + results := make([]passResult, measuredPasses) 148 + for i := 0; i < measuredPasses; i++ { 149 + var verifies, errors int 150 + start := time.Now() 151 + for _, entry := range precomputed { 152 + if verifyCryptoOnly(entry) { 153 + verifies++ 154 + } else { 155 + errors++ 156 + } 157 + } 158 + results[i] = passResult{ 159 + verifies: verifies, 160 + errors: errors, 161 + elapsed: time.Since(start), 162 + } 163 + } 164 + 165 + reportResult("crypto-only", corpus, results) 166 + return results 167 + } 168 + 169 + type preparsedEntry struct { 170 + unsignedBytes []byte 171 + sigBytes []byte 172 + key atcrypto.PublicKey 173 + } 174 + 175 + func benchPreparsedKey(corpus *corpusInfo) []passResult { 176 + // pre-compute unsigned bytes AND parse keys 177 + var preparsed []preparsedEntry 178 + for _, entry := range corpus.entries { 179 + var commit repo.Commit 180 + if err := commit.UnmarshalCBOR(bytes.NewReader(entry.signedBytes)); err != nil { 181 + continue 182 + } 183 + unsignedBytes, err := commit.UnsignedBytes() 184 + if err != nil { 185 + continue 186 + } 187 + pubkey, err := parseKey(entry.curveType, entry.pubkeyBytes) 188 + if err != nil { 189 + continue 190 + } 191 + preparsed = append(preparsed, preparsedEntry{ 192 + unsignedBytes: unsignedBytes, 193 + sigBytes: commit.Sig, 194 + key: pubkey, 195 + }) 196 + } 197 + 198 + for i := 0; i < warmupPasses; i++ { 199 + for _, entry := range preparsed { 200 + _ = entry.key.HashAndVerify(entry.unsignedBytes, entry.sigBytes) == nil 201 + } 202 + } 203 + 204 + results := make([]passResult, measuredPasses) 205 + for i := 0; i < measuredPasses; i++ { 206 + var verifies, errors int 207 + start := time.Now() 208 + for _, entry := range preparsed { 209 + if entry.key.HashAndVerify(entry.unsignedBytes, entry.sigBytes) == nil { 210 + verifies++ 211 + } else { 212 + errors++ 213 + } 214 + } 215 + results[i] = passResult{ 216 + verifies: verifies, 217 + errors: errors, 218 + elapsed: time.Since(start), 219 + } 220 + } 221 + 222 + reportResult("preparsed-key", corpus, results) 223 + return results 224 + } 225 + 226 + func verifyFullPipeline(entry corpusEntry) bool { 227 + // 1. CBOR decode signed commit 228 + var commit repo.Commit 229 + if err := commit.UnmarshalCBOR(bytes.NewReader(entry.signedBytes)); err != nil { 230 + return false 231 + } 232 + 233 + // 2. strip sig, re-encode as unsigned CBOR 234 + unsignedBytes, err := commit.UnsignedBytes() 235 + if err != nil { 236 + return false 237 + } 238 + 239 + // 3. parse public key by curve type 240 + pubkey, err := parseKey(entry.curveType, entry.pubkeyBytes) 241 + if err != nil { 242 + return false 243 + } 244 + 245 + // 4. SHA-256 + ECDSA verify (HashAndVerify does both) 246 + return pubkey.HashAndVerify(unsignedBytes, commit.Sig) == nil 247 + } 248 + 249 + func verifyCryptoOnly(entry precomputedEntry) bool { 250 + pubkey, err := parseKey(entry.curveType, entry.pubkeyBytes) 251 + if err != nil { 252 + return false 253 + } 254 + return pubkey.HashAndVerify(entry.unsignedBytes, entry.sigBytes) == nil 255 + } 256 + 257 + func parseKey(curveType uint8, raw []byte) (atcrypto.PublicKey, error) { 258 + if curveType == 0 { 259 + return atcrypto.ParsePublicBytesP256(raw) 260 + } 261 + return atcrypto.ParsePublicBytesK256(raw) 262 + } 263 + 264 + func reportResult(name string, corpus *corpusInfo, results []passResult) { 265 + vpsValues := make([]float64, len(results)) 266 + totalVerifies := 0 267 + totalErrors := 0 268 + 269 + for i, r := range results { 270 + elapsedS := r.elapsed.Seconds() 271 + vpsValues[i] = float64(r.verifies) / elapsedS 272 + totalVerifies += r.verifies 273 + totalErrors += r.errors 274 + } 275 + 276 + sort.Float64s(vpsValues) 277 + 278 + minVps := vpsValues[0] 279 + medianVps := vpsValues[measuredPasses/2] 280 + maxVps := vpsValues[measuredPasses-1] 281 + 282 + _ = totalVerifies 283 + 284 + fmt.Printf("%-14s %10.0f verifies/sec entries=%d P-256=%d secp256k1=%d errors=%d\n", 285 + name, medianVps, len(corpus.entries), corpus.p256Count, corpus.k256Count, totalErrors) 286 + fmt.Printf("%-14s variance: min=%.0f median=%.0f max=%.0f verifies/sec\n", 287 + "", minVps, medianVps, maxVps) 288 + } 289 + 290 + // --- JSON output --- 291 + 292 + type benchJSON struct { 293 + Benchmark string `json:"benchmark"` 294 + SDK string `json:"sdk"` 295 + Variant string `json:"variant"` 296 + Corpus corpusJSON `json:"corpus"` 297 + Passes []passJSON `json:"passes"` 298 + MedianVPS float64 `json:"median_verifies_per_sec"` 299 + MinVPS float64 `json:"min_verifies_per_sec"` 300 + MaxVPS float64 `json:"max_verifies_per_sec"` 301 + } 302 + 303 + type corpusJSON struct { 304 + Entries int `json:"entries"` 305 + P256 int `json:"p256"` 306 + Secp256k1 int `json:"secp256k1"` 307 + } 308 + 309 + type passJSON struct { 310 + VerifiesPerSec float64 `json:"verifies_per_sec"` 311 + Errors int `json:"errors"` 312 + } 313 + 314 + func writeJSON(corpus *corpusInfo, fullResults, cryptoResults, preparsedResults []passResult) { 315 + writeJSONVariant(corpus, "full-pipeline", fullResults, "sig-verify-go.json") 316 + writeJSONVariant(corpus, "crypto-only", cryptoResults, "sig-verify-crypto-go.json") 317 + writeJSONVariant(corpus, "preparsed-key", preparsedResults, "sig-verify-preparsed-go.json") 318 + } 319 + 320 + func writeJSONVariant(corpus *corpusInfo, variant string, results []passResult, filename string) { 321 + vpsValues := make([]float64, len(results)) 322 + var passes []passJSON 323 + for i, r := range results { 324 + elapsedS := r.elapsed.Seconds() 325 + vps := float64(r.verifies) / elapsedS 326 + vpsValues[i] = vps 327 + passes = append(passes, passJSON{VerifiesPerSec: vps, Errors: r.errors}) 328 + } 329 + sort.Float64s(vpsValues) 330 + 331 + j := benchJSON{ 332 + Benchmark: "sig-verify", 333 + SDK: "go (indigo)", 334 + Variant: variant, 335 + Corpus: corpusJSON{ 336 + Entries: len(corpus.entries), 337 + P256: corpus.p256Count, 338 + Secp256k1: corpus.k256Count, 339 + }, 340 + Passes: passes, 341 + MedianVPS: vpsValues[measuredPasses/2], 342 + MinVPS: vpsValues[0], 343 + MaxVPS: vpsValues[measuredPasses-1], 344 + } 345 + 346 + data, err := json.MarshalIndent(j, "", " ") 347 + if err != nil { 348 + return 349 + } 350 + 351 + path := filepath.Join(fixturesDir, filename) 352 + _ = os.WriteFile(path, data, 0644) 353 + } 354 + 355 + // --- corpus loading --- 356 + 357 + func loadCorpus(name string) (*corpusInfo, error) { 358 + path := filepath.Join(fixturesDir, name) 359 + data, err := os.ReadFile(path) 360 + if err != nil { 361 + fmt.Printf("cannot open %s: %v\n", path, err) 362 + fmt.Println("run `just capture-sigs` first to generate corpus") 363 + return nil, err 364 + } 365 + 366 + if len(data) < 4 { 367 + return nil, fmt.Errorf("corpus too small") 368 + } 369 + 370 + entryCount := int(binary.BigEndian.Uint32(data[0:4])) 371 + entries := make([]corpusEntry, 0, entryCount) 372 + pos := 4 373 + totalBytes := 0 374 + p256Count := 0 375 + k256Count := 0 376 + 377 + for i := 0; i < entryCount; i++ { 378 + if pos+1 > len(data) { 379 + return nil, fmt.Errorf("truncated corpus") 380 + } 381 + curveType := data[pos] 382 + pos++ 383 + 384 + if pos+2 > len(data) { 385 + return nil, fmt.Errorf("truncated corpus") 386 + } 387 + signedLen := int(binary.BigEndian.Uint16(data[pos : pos+2])) 388 + pos += 2 389 + if pos+signedLen > len(data) { 390 + return nil, fmt.Errorf("truncated corpus") 391 + } 392 + signedBytes := data[pos : pos+signedLen] 393 + pos += signedLen 394 + 395 + if pos+2 > len(data) { 396 + return nil, fmt.Errorf("truncated corpus") 397 + } 398 + pubkeyLen := int(binary.BigEndian.Uint16(data[pos : pos+2])) 399 + pos += 2 400 + if pos+pubkeyLen > len(data) { 401 + return nil, fmt.Errorf("truncated corpus") 402 + } 403 + pubkeyBytes := data[pos : pos+pubkeyLen] 404 + pos += pubkeyLen 405 + 406 + entries = append(entries, corpusEntry{ 407 + curveType: curveType, 408 + signedBytes: signedBytes, 409 + pubkeyBytes: pubkeyBytes, 410 + }) 411 + 412 + totalBytes += 1 + 2 + signedLen + 2 + pubkeyLen 413 + if curveType == 0 { 414 + p256Count++ 415 + } else { 416 + k256Count++ 417 + } 418 + } 419 + 420 + return &corpusInfo{ 421 + entries: entries, 422 + totalBytes: totalBytes, 423 + p256Count: p256Count, 424 + k256Count: k256Count, 425 + }, nil 426 + }
+539
zig/src/bench_sigs.zig
··· 1 + //! signature verification benchmarks using zat 2 + //! 3 + //! loads a corpus of signed commits + public keys, then: 4 + //! 5 + //! tier 1 (full pipeline): CBOR decode → strip sig → re-encode → SHA-256 → ECDSA verify 6 + //! tier 2 (crypto-only): SHA-256 → ECDSA verify (unsigned bytes pre-computed) 7 + //! 8 + //! reports human-readable table + structured JSON. 9 + 10 + const std = @import("std"); 11 + const zat = @import("zat"); 12 + const k256 = @import("k256"); 13 + 14 + const Allocator = std.mem.Allocator; 15 + const cbor = zat.cbor; 16 + 17 + const warmup_passes: usize = 2; 18 + const measured_passes: usize = 5; 19 + const fixtures_dir = "../fixtures"; 20 + 21 + const CorpusEntry = struct { 22 + curve_type: u8, // 0 = P-256, 1 = secp256k1 23 + signed_bytes: []const u8, 24 + pubkey_bytes: []const u8, 25 + }; 26 + 27 + const CorpusInfo = struct { 28 + entries: []const CorpusEntry, 29 + total_bytes: usize, 30 + p256_count: usize, 31 + k256_count: usize, 32 + }; 33 + 34 + const PassResult = struct { 35 + verifies: usize, 36 + errors: usize, 37 + elapsed_ns: u64, 38 + }; 39 + 40 + pub fn main() !void { 41 + const allocator = std.heap.c_allocator; 42 + 43 + const corpus = try loadCorpus(allocator, fixtures_dir ++ "/sig-verify-corpus.bin"); 44 + 45 + std.debug.print("\n=== zat sig-verify benchmarks ===\n\n", .{}); 46 + std.debug.print("corpus: {d} entries, {d} bytes\n", .{ corpus.entries.len, corpus.total_bytes }); 47 + std.debug.print(" P-256: {d}, secp256k1: {d}\n", .{ corpus.p256_count, corpus.k256_count }); 48 + std.debug.print(" passes: {d} warmup, {d} measured\n\n", .{ warmup_passes, measured_passes }); 49 + 50 + // verify first entry to sanity-check 51 + { 52 + var arena = std.heap.ArenaAllocator.init(allocator); 53 + defer arena.deinit(); 54 + const ok = verifyFullPipeline(arena.allocator(), corpus.entries[0]); 55 + std.debug.print("first entry: {s}\n\n", .{if (ok) "OK" else "FAIL"}); 56 + } 57 + 58 + // tier 1: full pipeline 59 + const full_results = try benchFullPipeline(allocator, corpus); 60 + 61 + // tier 2: crypto-only (pre-compute unsigned bytes) 62 + const crypto_results = try benchCryptoOnly(allocator, corpus); 63 + 64 + // tier 3: preparsed-key (pre-compute unsigned bytes + parse keys) 65 + const preparsed_results = try benchPreparsedKey(allocator, corpus); 66 + 67 + // write JSON output 68 + try writeJson(allocator, corpus, full_results, crypto_results, preparsed_results); 69 + 70 + std.debug.print("\n", .{}); 71 + } 72 + 73 + fn benchFullPipeline(allocator: Allocator, corpus: CorpusInfo) ![measured_passes]PassResult { 74 + var arena = std.heap.ArenaAllocator.init(allocator); 75 + defer arena.deinit(); 76 + 77 + for (0..warmup_passes) |_| { 78 + for (corpus.entries) |entry| { 79 + _ = arena.reset(.retain_capacity); 80 + _ = verifyFullPipeline(arena.allocator(), entry); 81 + } 82 + } 83 + 84 + var pass_results: [measured_passes]PassResult = undefined; 85 + for (0..measured_passes) |pass| { 86 + var pass_verifies: usize = 0; 87 + var pass_errors: usize = 0; 88 + var timer = try std.time.Timer.start(); 89 + for (corpus.entries) |entry| { 90 + _ = arena.reset(.retain_capacity); 91 + if (verifyFullPipeline(arena.allocator(), entry)) { 92 + pass_verifies += 1; 93 + } else { 94 + pass_errors += 1; 95 + } 96 + } 97 + pass_results[pass] = .{ 98 + .verifies = pass_verifies, 99 + .errors = pass_errors, 100 + .elapsed_ns = timer.read(), 101 + }; 102 + } 103 + 104 + reportResult("sig-verify", corpus, &pass_results); 105 + return pass_results; 106 + } 107 + 108 + const PrecomputedEntry = struct { 109 + curve_type: u8, 110 + unsigned_bytes: []const u8, 111 + sig_bytes: []const u8, 112 + pubkey_bytes: []const u8, 113 + }; 114 + 115 + const P256Scheme = std.crypto.sign.ecdsa.EcdsaP256Sha256; 116 + const K256Scheme = k256.EcdsaSecp256k1Sha256; 117 + 118 + const ParsedKey = union(enum) { 119 + p256: P256Scheme.PublicKey, 120 + k256: K256Scheme.PublicKey, 121 + }; 122 + 123 + const PreparsedEntry = struct { 124 + unsigned_bytes: []const u8, 125 + sig_bytes: []const u8, 126 + key: ParsedKey, 127 + }; 128 + 129 + fn benchCryptoOnly(allocator: Allocator, corpus: CorpusInfo) ![measured_passes]PassResult { 130 + // pre-compute unsigned bytes for all entries 131 + var precomputed: std.ArrayListUnmanaged(PrecomputedEntry) = .{}; 132 + defer { 133 + for (precomputed.items) |p| { 134 + allocator.free(p.unsigned_bytes); 135 + allocator.free(p.sig_bytes); 136 + } 137 + precomputed.deinit(allocator); 138 + } 139 + 140 + for (corpus.entries) |entry| { 141 + var arena = std.heap.ArenaAllocator.init(allocator); 142 + defer arena.deinit(); 143 + 144 + const commit = cbor.decodeAll(arena.allocator(), entry.signed_bytes) catch continue; 145 + const sig_bytes = commit.getBytes("sig") orelse continue; 146 + const map_entries = switch (commit) { 147 + .map => |m| m, 148 + else => continue, 149 + }; 150 + 151 + var unsigned_entries: std.ArrayListUnmanaged(cbor.Value.MapEntry) = .{}; 152 + for (map_entries) |map_entry| { 153 + if (!std.mem.eql(u8, map_entry.key, "sig")) { 154 + unsigned_entries.append(arena.allocator(), map_entry) catch continue; 155 + } 156 + } 157 + 158 + const unsigned_value: cbor.Value = .{ .map = unsigned_entries.items }; 159 + const unsigned_bytes = cbor.encodeAlloc(arena.allocator(), unsigned_value) catch continue; 160 + 161 + try precomputed.append(allocator, .{ 162 + .curve_type = entry.curve_type, 163 + .unsigned_bytes = try allocator.dupe(u8, unsigned_bytes), 164 + .sig_bytes = try allocator.dupe(u8, sig_bytes), 165 + .pubkey_bytes = entry.pubkey_bytes, 166 + }); 167 + } 168 + 169 + // warmup 170 + for (0..warmup_passes) |_| { 171 + for (precomputed.items) |entry| { 172 + _ = verifyCryptoOnly(entry); 173 + } 174 + } 175 + 176 + var pass_results: [measured_passes]PassResult = undefined; 177 + for (0..measured_passes) |pass| { 178 + var pass_verifies: usize = 0; 179 + var pass_errors: usize = 0; 180 + var timer = try std.time.Timer.start(); 181 + for (precomputed.items) |entry| { 182 + if (verifyCryptoOnly(entry)) { 183 + pass_verifies += 1; 184 + } else { 185 + pass_errors += 1; 186 + } 187 + } 188 + pass_results[pass] = .{ 189 + .verifies = pass_verifies, 190 + .errors = pass_errors, 191 + .elapsed_ns = timer.read(), 192 + }; 193 + } 194 + 195 + reportResult("crypto-only", corpus, &pass_results); 196 + return pass_results; 197 + } 198 + 199 + fn benchPreparsedKey(allocator: Allocator, corpus: CorpusInfo) ![measured_passes]PassResult { 200 + // pre-compute unsigned bytes AND parse keys 201 + var preparsed: std.ArrayListUnmanaged(PreparsedEntry) = .{}; 202 + defer { 203 + for (preparsed.items) |p| { 204 + allocator.free(p.unsigned_bytes); 205 + allocator.free(p.sig_bytes); 206 + } 207 + preparsed.deinit(allocator); 208 + } 209 + 210 + for (corpus.entries) |entry| { 211 + var arena = std.heap.ArenaAllocator.init(allocator); 212 + defer arena.deinit(); 213 + 214 + const commit = cbor.decodeAll(arena.allocator(), entry.signed_bytes) catch continue; 215 + const sig_bytes = commit.getBytes("sig") orelse continue; 216 + const map_entries = switch (commit) { 217 + .map => |m| m, 218 + else => continue, 219 + }; 220 + 221 + var unsigned_entries: std.ArrayListUnmanaged(cbor.Value.MapEntry) = .{}; 222 + for (map_entries) |map_entry| { 223 + if (!std.mem.eql(u8, map_entry.key, "sig")) { 224 + unsigned_entries.append(arena.allocator(), map_entry) catch continue; 225 + } 226 + } 227 + 228 + const unsigned_value: cbor.Value = .{ .map = unsigned_entries.items }; 229 + const unsigned_bytes = cbor.encodeAlloc(arena.allocator(), unsigned_value) catch continue; 230 + 231 + const key: ParsedKey = if (entry.curve_type == 0) 232 + .{ .p256 = P256Scheme.PublicKey.fromSec1(entry.pubkey_bytes) catch continue } 233 + else 234 + .{ .k256 = K256Scheme.PublicKey.fromSec1(entry.pubkey_bytes) catch continue }; 235 + 236 + try preparsed.append(allocator, .{ 237 + .unsigned_bytes = try allocator.dupe(u8, unsigned_bytes), 238 + .sig_bytes = try allocator.dupe(u8, sig_bytes), 239 + .key = key, 240 + }); 241 + } 242 + 243 + // warmup 244 + for (0..warmup_passes) |_| { 245 + for (preparsed.items) |entry| { 246 + _ = verifyPreparsed(entry); 247 + } 248 + } 249 + 250 + var pass_results: [measured_passes]PassResult = undefined; 251 + for (0..measured_passes) |pass| { 252 + var pass_verifies: usize = 0; 253 + var pass_errors: usize = 0; 254 + var timer = try std.time.Timer.start(); 255 + for (preparsed.items) |entry| { 256 + if (verifyPreparsed(entry)) { 257 + pass_verifies += 1; 258 + } else { 259 + pass_errors += 1; 260 + } 261 + } 262 + pass_results[pass] = .{ 263 + .verifies = pass_verifies, 264 + .errors = pass_errors, 265 + .elapsed_ns = timer.read(), 266 + }; 267 + } 268 + 269 + reportResult("preparsed-key", corpus, &pass_results); 270 + return pass_results; 271 + } 272 + 273 + // --- verification --- 274 + 275 + fn verifyFullPipeline(allocator: Allocator, entry: CorpusEntry) bool { 276 + // 1. CBOR decode signed commit 277 + const commit = cbor.decodeAll(allocator, entry.signed_bytes) catch return false; 278 + 279 + // 2. extract sig 280 + const sig_bytes = commit.getBytes("sig") orelse return false; 281 + if (sig_bytes.len != 64) return false; 282 + 283 + // 3. strip sig, re-encode 284 + const map_entries = switch (commit) { 285 + .map => |m| m, 286 + else => return false, 287 + }; 288 + 289 + var unsigned_entries: std.ArrayListUnmanaged(cbor.Value.MapEntry) = .{}; 290 + for (map_entries) |map_entry| { 291 + if (!std.mem.eql(u8, map_entry.key, "sig")) { 292 + unsigned_entries.append(allocator, map_entry) catch return false; 293 + } 294 + } 295 + 296 + const unsigned_value: cbor.Value = .{ .map = unsigned_entries.items }; 297 + const unsigned_bytes = cbor.encodeAlloc(allocator, unsigned_value) catch return false; 298 + 299 + // 4+5. verify (SHA-256 + ECDSA inside stdlib) 300 + if (entry.curve_type == 0) { 301 + return verifyP256(unsigned_bytes, sig_bytes, entry.pubkey_bytes); 302 + } else { 303 + return verifySecp256k1(unsigned_bytes, sig_bytes, entry.pubkey_bytes); 304 + } 305 + } 306 + 307 + fn verifyCryptoOnly(entry: PrecomputedEntry) bool { 308 + if (entry.curve_type == 0) { 309 + return verifyP256(entry.unsigned_bytes, entry.sig_bytes, entry.pubkey_bytes); 310 + } else { 311 + return verifySecp256k1(entry.unsigned_bytes, entry.sig_bytes, entry.pubkey_bytes); 312 + } 313 + } 314 + 315 + fn verifyPreparsed(entry: PreparsedEntry) bool { 316 + if (entry.sig_bytes.len != 64) return false; 317 + switch (entry.key) { 318 + .p256 => |pk| { 319 + const sig = P256Scheme.Signature.fromBytes(entry.sig_bytes[0..64].*); 320 + if (isHighS(p256_half_order, sig.s)) return false; 321 + sig.verify(entry.unsigned_bytes, pk) catch return false; 322 + return true; 323 + }, 324 + .k256 => |pk| { 325 + const sig = K256Scheme.Signature.fromBytes(entry.sig_bytes[0..64].*); 326 + if (isHighS(secp256k1_half_order, sig.s)) return false; 327 + sig.verifyMsg(entry.unsigned_bytes, pk) catch return false; 328 + return true; 329 + }, 330 + } 331 + } 332 + 333 + fn verifyP256(message: []const u8, sig_bytes: []const u8, pubkey_bytes: []const u8) bool { 334 + const Scheme = std.crypto.sign.ecdsa.EcdsaP256Sha256; 335 + const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 336 + if (isHighS(p256_half_order, sig.s)) return false; 337 + const pk = Scheme.PublicKey.fromSec1(pubkey_bytes) catch return false; 338 + sig.verify(message, pk) catch return false; 339 + return true; 340 + } 341 + 342 + fn verifySecp256k1(message: []const u8, sig_bytes: []const u8, pubkey_bytes: []const u8) bool { 343 + const Scheme = k256.EcdsaSecp256k1Sha256; 344 + const sig = Scheme.Signature.fromBytes(sig_bytes[0..64].*); 345 + if (isHighS(secp256k1_half_order, sig.s)) return false; 346 + const pk = Scheme.PublicKey.fromSec1(pubkey_bytes) catch return false; 347 + sig.verifyMsg(message, pk) catch return false; 348 + return true; 349 + } 350 + 351 + fn isHighS(half_order: [32]u8, s: [32]u8) bool { 352 + for (0..32) |i| { 353 + if (s[i] < half_order[i]) return false; 354 + if (s[i] > half_order[i]) return true; 355 + } 356 + return false; 357 + } 358 + 359 + const p256_half_order = [32]u8{ 360 + 0x7f, 0xff, 0xff, 0xff, 0x80, 0x00, 0x00, 0x00, 361 + 0xa0, 0x75, 0x9b, 0xc5, 0xaa, 0x00, 0xe3, 0xb2, 362 + 0xde, 0x73, 0x7d, 0x56, 0xd3, 0x8b, 0xcf, 0x42, 363 + 0x79, 0xdc, 0xe5, 0x61, 0x7e, 0x31, 0x92, 0xa8, 364 + }; 365 + 366 + const secp256k1_half_order = [32]u8{ 367 + 0x7f, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 368 + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe, 369 + 0xba, 0xae, 0xdc, 0xe6, 0xaf, 0x48, 0xa0, 0x3b, 370 + 0xbf, 0xd2, 0x5e, 0x8c, 0xd0, 0x36, 0x41, 0x41, 371 + }; 372 + 373 + // --- reporting --- 374 + 375 + fn reportResult(name: []const u8, corpus: CorpusInfo, pass_results: []const PassResult) void { 376 + var vps_values: [measured_passes]f64 = undefined; 377 + var total_verifies: usize = 0; 378 + var total_errors: usize = 0; 379 + 380 + for (pass_results, 0..) |r, i| { 381 + const elapsed_s = @as(f64, @floatFromInt(r.elapsed_ns)) / 1_000_000_000.0; 382 + vps_values[i] = @as(f64, @floatFromInt(r.verifies)) / elapsed_s; 383 + total_verifies += r.verifies; 384 + total_errors += r.errors; 385 + } 386 + 387 + std.mem.sort(f64, &vps_values, {}, std.sort.asc(f64)); 388 + 389 + const min_vps = vps_values[0]; 390 + const max_vps = vps_values[measured_passes - 1]; 391 + const median_vps = vps_values[measured_passes / 2]; 392 + 393 + std.debug.print("{s: <14} {d:>10.0} verifies/sec entries={d} P-256={d} secp256k1={d} errors={d}\n", .{ 394 + name, 395 + median_vps, 396 + corpus.entries.len, 397 + corpus.p256_count, 398 + corpus.k256_count, 399 + total_errors, 400 + }); 401 + std.debug.print("{s: <14} variance: min={d:.0} median={d:.0} max={d:.0} verifies/sec\n", .{ 402 + "", 403 + min_vps, 404 + median_vps, 405 + max_vps, 406 + }); 407 + } 408 + 409 + fn writeJson( 410 + allocator: Allocator, 411 + corpus: CorpusInfo, 412 + full_results: [measured_passes]PassResult, 413 + crypto_results: [measured_passes]PassResult, 414 + preparsed_results: [measured_passes]PassResult, 415 + ) !void { 416 + const variants = [_]struct { name: []const u8, file: []const u8, results: [measured_passes]PassResult }{ 417 + .{ .name = "full-pipeline", .file = fixtures_dir ++ "/sig-verify-zig.json", .results = full_results }, 418 + .{ .name = "crypto-only", .file = fixtures_dir ++ "/sig-verify-crypto-zig.json", .results = crypto_results }, 419 + .{ .name = "preparsed-key", .file = fixtures_dir ++ "/sig-verify-preparsed-zig.json", .results = preparsed_results }, 420 + }; 421 + 422 + for (variants) |v| { 423 + const json = buildJsonVariant(allocator, v.name, corpus, &v.results) catch continue; 424 + defer allocator.free(json); 425 + const file = std.fs.cwd().createFile(v.file, .{}) catch continue; 426 + defer file.close(); 427 + file.writeAll(json) catch continue; 428 + } 429 + } 430 + 431 + fn buildJsonVariant( 432 + allocator: Allocator, 433 + variant: []const u8, 434 + corpus: CorpusInfo, 435 + pass_results: []const PassResult, 436 + ) ![]u8 { 437 + var vps_values: [measured_passes]f64 = undefined; 438 + for (pass_results, 0..) |r, i| { 439 + const elapsed_s = @as(f64, @floatFromInt(r.elapsed_ns)) / 1_000_000_000.0; 440 + vps_values[i] = @as(f64, @floatFromInt(r.verifies)) / elapsed_s; 441 + } 442 + std.mem.sort(f64, &vps_values, {}, std.sort.asc(f64)); 443 + 444 + var buf: std.ArrayListUnmanaged(u8) = .{}; 445 + errdefer buf.deinit(allocator); 446 + const w = buf.writer(allocator); 447 + 448 + try w.print( 449 + \\{{ 450 + \\ "benchmark": "sig-verify", 451 + \\ "sdk": "zig (zat)", 452 + \\ "variant": "{s}", 453 + \\ "corpus": {{ "entries": {d}, "p256": {d}, "secp256k1": {d} }}, 454 + \\ "passes": [ 455 + \\ 456 + , .{ variant, corpus.entries.len, corpus.p256_count, corpus.k256_count }); 457 + 458 + for (pass_results, 0..) |r, i| { 459 + const elapsed_s = @as(f64, @floatFromInt(r.elapsed_ns)) / 1_000_000_000.0; 460 + const vps = @as(f64, @floatFromInt(r.verifies)) / elapsed_s; 461 + try w.print( 462 + \\ {{ "verifies_per_sec": {d:.0}, "errors": {d} }} 463 + , .{ vps, r.errors }); 464 + if (i < pass_results.len - 1) try w.writeAll(","); 465 + try w.writeAll("\n"); 466 + } 467 + 468 + try w.print( 469 + \\ ], 470 + \\ "median_verifies_per_sec": {d:.0}, 471 + \\ "min_verifies_per_sec": {d:.0}, 472 + \\ "max_verifies_per_sec": {d:.0} 473 + \\}} 474 + \\ 475 + , .{ 476 + vps_values[measured_passes / 2], 477 + vps_values[0], 478 + vps_values[measured_passes - 1], 479 + }); 480 + 481 + return try buf.toOwnedSlice(allocator); 482 + } 483 + 484 + // --- corpus loading --- 485 + 486 + fn loadCorpus(allocator: Allocator, path: []const u8) !CorpusInfo { 487 + const file = std.fs.cwd().openFile(path, .{}) catch |err| { 488 + std.debug.print("cannot open {s}: {s}\n", .{ path, @errorName(err) }); 489 + std.debug.print("run `just capture-sigs` first to generate corpus\n", .{}); 490 + return err; 491 + }; 492 + defer file.close(); 493 + const data = try file.readToEndAlloc(allocator, 50 * 1024 * 1024); 494 + 495 + if (data.len < 4) return error.InvalidFormat; 496 + 497 + const entry_count = std.mem.readInt(u32, data[0..4], .big); 498 + var entries: std.ArrayListUnmanaged(CorpusEntry) = .{}; 499 + var pos: usize = 4; 500 + var total_bytes: usize = 0; 501 + var p256_count: usize = 0; 502 + var k256_count: usize = 0; 503 + 504 + for (0..entry_count) |_| { 505 + if (pos + 1 > data.len) return error.InvalidFormat; 506 + const curve_type = data[pos]; 507 + pos += 1; 508 + 509 + if (pos + 2 > data.len) return error.InvalidFormat; 510 + const signed_len = std.mem.readInt(u16, data[pos..][0..2], .big); 511 + pos += 2; 512 + if (pos + signed_len > data.len) return error.InvalidFormat; 513 + const signed_bytes = data[pos..][0..signed_len]; 514 + pos += signed_len; 515 + 516 + if (pos + 2 > data.len) return error.InvalidFormat; 517 + const pubkey_len = std.mem.readInt(u16, data[pos..][0..2], .big); 518 + pos += 2; 519 + if (pos + pubkey_len > data.len) return error.InvalidFormat; 520 + const pubkey_bytes = data[pos..][0..pubkey_len]; 521 + pos += pubkey_len; 522 + 523 + try entries.append(allocator, .{ 524 + .curve_type = curve_type, 525 + .signed_bytes = signed_bytes, 526 + .pubkey_bytes = pubkey_bytes, 527 + }); 528 + 529 + total_bytes += 1 + 2 + signed_len + 2 + pubkey_len; 530 + if (curve_type == 0) p256_count += 1 else k256_count += 1; 531 + } 532 + 533 + return .{ 534 + .entries = try entries.toOwnedSlice(allocator), 535 + .total_bytes = total_bytes, 536 + .p256_count = p256_count, 537 + .k256_count = k256_count, 538 + }; 539 + }