diff --git a/half-aggregation.mediawiki b/half-aggregation.mediawiki
index 5667181..af29ada 100644
--- a/half-aggregation.mediawiki
+++ b/half-aggregation.mediawiki
@@ -69,18 +69,17 @@ Moreover, they came up with an elegant approach to incremental aggregation that
=== Specification ===
-The specification is written in [https://github.com/hacspec/hacspec hacspec], a language for formal specifications and a subset of rust.
-It can be found in the [[hacspec-halfagg/src/halfagg.rs|hacspec-halfagg directory]].
-Note that the specification depends the hacspec library and a [https://github.com/hacspec/hacspec/pull/244 hacspec implementation of BIP 340].
+The specification is written in Python and can be found in the [[py-halfagg/halfagg.py|py-halfagg directory]]
+Note that the specification depends on the [https://github.com/secp256k1lab/secp256k1lab secp256k1lab library].
=== Test Vectors ===
-Preliminary test vectors are provided in [[hacspec-halfagg/tests/tests.rs|tests.rs]].
-The specification can be executed with the test vectors by running cargo test in the [[hacspec-halfagg|hacspec-halfagg directory]] (cargo is the [https://doc.rust-lang.org/stable/cargo/ rust package manager]).
+Test vectors are provided in [[py-halfagg/vectors/|vectors]] and are generated with a [[py-halfagg/gen_test_vectors.py|generator script]].
+The specification can be executed with the test vectors by running [[py-halfagg/run_test_vectors.py|the run_test_vectors script]].
=== Pseudocode ===
-The following pseudocode is ''not'' a specification but is only intended to augment the actual hacspec [[#specification|specification]].
+The following pseudocode is ''not'' a specification but is only intended to augment the actual Python [[#specification|specification]].
==== Notation ====
diff --git a/py-halfagg/gen_test_vectors.py b/py-halfagg/gen_test_vectors.py
new file mode 100644
index 0000000..16be0ae
--- /dev/null
+++ b/py-halfagg/gen_test_vectors.py
@@ -0,0 +1,648 @@
+#!/usr/bin/env python3
+"""
+Generate test vectors for Schnorr signature half-aggregation.
+
+Usage:
+ python gen_test_vectors.py
+
+Outputs:
+ - vectors/test_vectors_aggregate.csv
+ - vectors/test_vectors_incaggregate.csv
+ - vectors/test_vectors_verify.csv
+"""
+
+import csv
+import os
+from pathlib import Path
+import sys
+from typing import List, Tuple
+
+from secp256k1lab.secp256k1 import GE, FE
+from secp256k1lab.util import bytes_from_int, int_from_bytes
+from secp256k1lab.bip340 import pubkey_gen, schnorr_sign, schnorr_verify
+from halfagg import Aggregate, IncAggregate, hashHalfAgg_randomizer
+
+
+ROOT = Path(__file__).resolve().parent
+VECTORS_DIR = ROOT / "vectors"
+AGGREGATE_VECTORS = VECTORS_DIR / 'test_vectors_aggregate.csv'
+INCAGGREGATE_VECTORS = VECTORS_DIR / 'test_vectors_incaggregate.csv'
+VERIFY_VECTORS = VECTORS_DIR / 'test_vectors_verify.csv'
+
+n = GE.ORDER
+p = FE.SIZE
+
+
+def create_signature(index: int) -> Tuple[bytes, bytes, bytes]:
+ """Create a deterministic (pubkey, message, signature) triple."""
+ sk = bytes([index + 1] * 32)
+ pk = pubkey_gen(sk)
+ msg = bytes([index + 2] * 32)
+ aux = bytes([index + 3] * 32)
+ sig = schnorr_sign(msg, sk, aux)
+ return pk, msg, sig
+
+
+def format_list(items: List[bytes], sep: str = ";") -> str:
+ """Format a list of bytes as semicolon-separated hex strings."""
+ if not items:
+ return ""
+ return sep.join(item.hex().upper() for item in items)
+
+
+def gen_aggregate_vectors(f):
+ """Generate test vectors for Aggregate function."""
+ writer = csv.writer(f)
+ writer.writerow((
+ "index",
+ "pubkeys",
+ "messages",
+ "signatures",
+ "expected_result",
+ "expected_aggsig",
+ "comment"
+ ))
+
+ idx = 0
+ sigs = [create_signature(i) for i in range(5)]
+
+ # Success: Empty aggregation
+ aggsig_empty = Aggregate([])
+ writer.writerow((
+ idx, "", "", "",
+ "TRUE",
+ aggsig_empty.hex().upper(),
+ "Empty signature list"
+ ))
+ idx += 1
+
+ # Success: Single signature
+ pk0, msg0, sig0 = sigs[0]
+ aggsig_1 = Aggregate([(pk0, msg0, sig0)])
+ writer.writerow((
+ idx,
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ sig0.hex().upper(),
+ "TRUE",
+ aggsig_1.hex().upper(),
+ "Single signature"
+ ))
+ idx += 1
+
+ # Success: Two signatures
+ pk1, msg1, sig1 = sigs[1]
+ pms_2 = [(pk0, msg0, sig0), (pk1, msg1, sig1)]
+ aggsig_2 = Aggregate(pms_2)
+ writer.writerow((
+ idx,
+ format_list([pk0, pk1]),
+ format_list([msg0, msg1]),
+ format_list([sig0, sig1]),
+ "TRUE",
+ aggsig_2.hex().upper(),
+ "Two signatures"
+ ))
+ idx += 1
+
+ # Success: Three signatures
+ pk2, msg2, sig2 = sigs[2]
+ pms_3 = [(pk0, msg0, sig0), (pk1, msg1, sig1), (pk2, msg2, sig2)]
+ aggsig_3 = Aggregate(pms_3)
+ writer.writerow((
+ idx,
+ format_list([pk0, pk1, pk2]),
+ format_list([msg0, msg1, msg2]),
+ format_list([sig0, sig1, sig2]),
+ "TRUE",
+ aggsig_3.hex().upper(),
+ "Three signatures"
+ ))
+ idx += 1
+
+ # Success: Five signatures
+ pms_5 = [sigs[i] for i in range(5)]
+ aggsig_5 = Aggregate(pms_5)
+ writer.writerow((
+ idx,
+ format_list([s[0] for s in pms_5]),
+ format_list([s[1] for s in pms_5]),
+ format_list([s[2] for s in pms_5]),
+ "TRUE",
+ aggsig_5.hex().upper(),
+ "Five signatures"
+ ))
+ idx += 1
+
+ # Success: Strange aggregation - individual invalid sigs that are valid
+ # in aggregate
+ pk_a, msg_a, sig_a = sigs[0]
+ pk_b, msg_b, sig_b = sigs[1]
+ pms_valid = [(pk_a, msg_a, sig_a), (pk_b, msg_b, sig_b)]
+ aggsig_valid = Aggregate(pms_valid)
+
+ pmr = []
+ z = []
+ for i in range(2):
+ pk, msg, sig = pms_valid[i]
+ pmr.append((pk, msg, sig[:32]))
+ z.append(hashHalfAgg_randomizer(pmr, i))
+
+ sagg = int_from_bytes(aggsig_valid[64:96])
+ s1_new = 0x123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0 % n
+ s0_new = ((sagg - z[1] * s1_new) * pow(z[0], -1, n)) % n
+
+ sig_a_invalid = sig_a[:32] + bytes_from_int(s0_new)
+ assert not schnorr_verify(msg_a, pk_a, sig_a_invalid), "sig_a_invalid should not verify"
+ sig_b_invalid = sig_b[:32] + bytes_from_int(s1_new)
+ assert not schnorr_verify(msg_b, pk_b, sig_b_invalid), "sig_b_invalid should not verify"
+
+ pms_strange = [(pk_a, msg_a, sig_a_invalid), (pk_b, msg_b, sig_b_invalid)]
+ aggsig_strange = Aggregate(pms_strange)
+
+ writer.writerow((
+ idx,
+ format_list([pk_a, pk_b]),
+ format_list([msg_a, msg_b]),
+ format_list([sig_a_invalid, sig_b_invalid]),
+ "TRUE",
+ aggsig_strange.hex().upper(),
+ "Strange aggregation - invalid individual sigs, valid aggregate"
+ ))
+ idx += 1
+
+ # Failure: Signature with s = n
+ invalid_sig_n = sig0[0:32] + bytes_from_int(n)
+ writer.writerow((
+ idx,
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ invalid_sig_n.hex().upper(),
+ "FALSE",
+ "",
+ "Signature s = n (at boundary)"
+ ))
+ idx += 1
+
+ # Failure: Signature with s > n
+ invalid_sig_gt_n = sig0[0:32] + bytes_from_int(n + 1000)
+ writer.writerow((
+ idx,
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ invalid_sig_gt_n.hex().upper(),
+ "FALSE",
+ "",
+ "Signature s > n"
+ ))
+ idx += 1
+
+ # Failure: Signature with s = 2^256 - 1
+ invalid_sig_max = sig0[0:32] + bytes([0xff] * 32)
+ writer.writerow((
+ idx,
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ invalid_sig_max.hex().upper(),
+ "FALSE",
+ "",
+ "Signature s = 2^256 - 1"
+ ))
+ idx += 1
+
+
+def gen_incaggregate_vectors(f):
+ """Generate test vectors for IncAggregate function."""
+ writer = csv.writer(f)
+ writer.writerow((
+ "index",
+ "aggsig",
+ "pm_aggd_pubkeys",
+ "pm_aggd_messages",
+ "pms_pubkeys",
+ "pms_messages",
+ "pms_signatures",
+ "expected_result",
+ "expected_aggsig",
+ "comment"
+ ))
+
+ idx = 0
+ sigs = [create_signature(i) for i in range(4)]
+ pk0, msg0, sig0 = sigs[0]
+ pk1, msg1, sig1 = sigs[1]
+ pk2, msg2, sig2 = sigs[2]
+
+ # Success: Increment empty aggregate with single signature
+ empty_aggsig = bytes([0] * 32)
+ result_0 = IncAggregate(empty_aggsig, [], [(pk0, msg0, sig0)])
+ writer.writerow((
+ idx,
+ empty_aggsig.hex().upper(),
+ "", "",
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ sig0.hex().upper(),
+ "TRUE",
+ result_0.hex().upper(),
+ "Add single signature to empty aggregate"
+ ))
+ idx += 1
+
+ # Success: Increment single-sig aggregate with another signature
+ aggsig_1 = Aggregate([(pk0, msg0, sig0)])
+ result_1 = IncAggregate(aggsig_1, [(pk0, msg0)], [(pk1, msg1, sig1)])
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ pk1.hex().upper(),
+ msg1.hex().upper(),
+ sig1.hex().upper(),
+ "TRUE",
+ result_1.hex().upper(),
+ "Add second signature to single-sig aggregate"
+ ))
+ idx += 1
+
+ # Success: Add two signatures at once
+ result_2 = IncAggregate(aggsig_1, [(pk0, msg0)], [(pk1, msg1, sig1), (pk2, msg2, sig2)])
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ format_list([pk1, pk2]),
+ format_list([msg1, msg2]),
+ format_list([sig1, sig2]),
+ "TRUE",
+ result_2.hex().upper(),
+ "Add two signatures at once"
+ ))
+ idx += 1
+
+ # Success: Increment with empty new signatures (no change)
+ result_3 = IncAggregate(aggsig_1, [(pk0, msg0)], [])
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "", "", "",
+ "TRUE",
+ result_3.hex().upper(),
+ "Add no new signatures (unchanged)"
+ ))
+ idx += 1
+
+ # Failure: Wrong aggsig length (too long)
+ wrong_len_aggsig = bytes([0] * 64)
+ writer.writerow((
+ idx,
+ wrong_len_aggsig.hex().upper(),
+ "", "",
+ "", "", "",
+ "FALSE",
+ "",
+ "Aggregate signature wrong length"
+ ))
+ idx += 1
+
+ # Failure: Empty aggsig
+ writer.writerow((
+ idx,
+ "",
+ "", "",
+ "", "", "",
+ "FALSE",
+ "",
+ "Aggregate signature empty"
+ ))
+ idx += 1
+
+ # Failure: Existing aggsig with s = n
+ invalid_aggsig_s = bytes_from_int(n)
+ writer.writerow((
+ idx,
+ invalid_aggsig_s.hex().upper(),
+ "", "",
+ "", "", "",
+ "FALSE",
+ "",
+ "Existing aggregate has s = n"
+ ))
+ idx += 1
+
+ # Failure: New signature with s = n
+ invalid_new_sig = sig0[0:32] + bytes_from_int(n)
+ writer.writerow((
+ idx,
+ empty_aggsig.hex().upper(),
+ "", "",
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ invalid_new_sig.hex().upper(),
+ "FALSE",
+ "",
+ "New signature has s = n"
+ ))
+ idx += 1
+
+
+def gen_verify_vectors(f):
+ """Generate test vectors for VerifyAggregate function."""
+ writer = csv.writer(f)
+ writer.writerow((
+ "index",
+ "aggsig",
+ "pubkeys",
+ "messages",
+ "expected_result",
+ "comment"
+ ))
+
+ idx = 0
+ sigs = [create_signature(i) for i in range(5)]
+ pk0, msg0, sig0 = sigs[0]
+ pk1, msg1, sig1 = sigs[1]
+ pk2, msg2, sig2 = sigs[2]
+
+ # Success: Empty signature list
+ writer.writerow((
+ idx,
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ "", "",
+ "TRUE",
+ "Empty signature list"
+ ))
+ idx += 1
+
+ # Success: Single signature
+ writer.writerow((
+ idx,
+ "B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F",
+ "1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F",
+ "0202020202020202020202020202020202020202020202020202020202020202",
+ "TRUE",
+ "Single signature"
+ ))
+ idx += 1
+
+ # Success: Two signatures
+ writer.writerow((
+ idx,
+ "B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8A3AFBDB45A6A34BF7C8C00F1B6D7E7D375B54540F13716C87B62E51E2F4F22FFBF8913EC53226A34892D60252A7052614CA79AE939986828D81D2311957371AD",
+ "1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;462779AD4AAD39514614751A71085F2F10E1C7A593E4E030EFB5B8721CE55B0B",
+ "0202020202020202020202020202020202020202020202020202020202020202;0505050505050505050505050505050505050505050505050505050505050505",
+ "TRUE",
+ "Two signatures"
+ ))
+ idx += 1
+
+ # Success: Three signatures
+ aggsig_3 = Aggregate([(pk0, msg0, sig0), (pk1, msg1, sig1), (pk2, msg2, sig2)])
+ writer.writerow((
+ idx,
+ aggsig_3.hex().upper(),
+ format_list([pk0, pk1, pk2]),
+ format_list([msg0, msg1, msg2]),
+ "TRUE",
+ "Three signatures"
+ ))
+ idx += 1
+
+ # Success: Five signatures
+ pms_5 = [sigs[i] for i in range(5)]
+ aggsig_5 = Aggregate(pms_5)
+ writer.writerow((
+ idx,
+ aggsig_5.hex().upper(),
+ format_list([s[0] for s in pms_5]),
+ format_list([s[1] for s in pms_5]),
+ "TRUE",
+ "Five signatures"
+ ))
+ idx += 1
+
+ # Failure: Public key not on curve
+ aggsig_1 = Aggregate([(pk0, msg0, sig0)])
+ invalid_x = 0x4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D # From BIP340
+ invalid_pk = bytes_from_int(invalid_x)
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ invalid_pk.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Public key not on curve"
+ ))
+ idx += 1
+
+ # Failure: Public key is zero
+ zero_pk = bytes([0] * 32)
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ zero_pk.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Public key is zero"
+ ))
+ idx += 1
+
+ # Failure: Public key >= field size
+ pk_too_large = bytes_from_int(p)
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk_too_large.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Public key >= field size"
+ ))
+ idx += 1
+
+ # Failure: R value not on curve
+ invalid_r = bytes_from_int(invalid_x)
+ invalid_r_aggsig = invalid_r + sig0[32:64]
+ writer.writerow((
+ idx,
+ invalid_r_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "R value not on curve"
+ ))
+ idx += 1
+
+ # Failure: R value is zero
+ zero_r_aggsig = bytes([0] * 32) + sig0[32:64]
+ writer.writerow((
+ idx,
+ zero_r_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "R value is zero"
+ ))
+ idx += 1
+
+ # Failure: R value >= field size
+ r_too_large_aggsig = bytes_from_int(p + 100) + sig0[32:64]
+ writer.writerow((
+ idx,
+ r_too_large_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "R value >= field size"
+ ))
+ idx += 1
+
+ # Failure: Aggregate s = n
+ s_n_aggsig = sig0[0:32] + bytes_from_int(n)
+ writer.writerow((
+ idx,
+ s_n_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Aggregate s = n"
+ ))
+ idx += 1
+
+ # Failure: Aggregate s > n
+ s_gt_n_aggsig = sig0[0:32] + bytes_from_int(n + 1000)
+ writer.writerow((
+ idx,
+ s_gt_n_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Aggregate s > n"
+ ))
+ idx += 1
+
+ # Failure: Aggregate s = 2^256 - 1
+ max_s_aggsig = sig0[0:32] + bytes([0xff] * 32)
+ writer.writerow((
+ idx,
+ max_s_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Aggregate s = 2^256 - 1"
+ ))
+ idx += 1
+
+ # Failure: Wrong message
+ wrong_msg = bytes([0xff] * 32)
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk0.hex().upper(),
+ wrong_msg.hex().upper(),
+ "FALSE",
+ "Wrong message"
+ ))
+ idx += 1
+
+ # Failure: Wrong public key
+ writer.writerow((
+ idx,
+ aggsig_1.hex().upper(),
+ pk1.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Wrong public key"
+ ))
+ idx += 1
+
+ # Failure: Tampered signature
+ tampered_aggsig = aggsig_1[:-1] + bytes([(aggsig_1[-1] + 1) % 256])
+ writer.writerow((
+ idx,
+ tampered_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Tampered signature (bit flip)"
+ ))
+ idx += 1
+
+ # Failure: Swapped order
+ aggsig_2 = Aggregate([(pk0, msg0, sig0), (pk1, msg1, sig1)])
+ writer.writerow((
+ idx,
+ aggsig_2.hex().upper(),
+ format_list([pk1, pk0]),
+ format_list([msg1, msg0]),
+ "FALSE",
+ "Swapped order"
+ ))
+ idx += 1
+
+ # Failure: s = n - 1 (valid range, wrong signature)
+ s_n_minus_1_aggsig = sig0[0:32] + bytes_from_int(n - 1)
+ writer.writerow((
+ idx,
+ s_n_minus_1_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "s = n-1 (valid range, wrong sig)"
+ ))
+ idx += 1
+
+ # Failure: s = 0 (valid range, wrong signature)
+ s_zero_aggsig = sig0[0:32] + bytes([0] * 32)
+ writer.writerow((
+ idx,
+ s_zero_aggsig.hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "s = 0 (valid range, wrong sig)"
+ ))
+ idx += 1
+
+ # Failure: Aggsig too short
+ writer.writerow((
+ idx,
+ sig0[0:31].hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Aggregate signature too short"
+ ))
+ idx += 1
+
+ # Failure: Aggsig too long
+ writer.writerow((
+ idx,
+ (aggsig_1 + bytes([0] * 32)).hex().upper(),
+ pk0.hex().upper(),
+ msg0.hex().upper(),
+ "FALSE",
+ "Aggregate signature too long"
+ ))
+ idx += 1
+
+
+if __name__ == "__main__":
+ os.makedirs(VECTORS_DIR, exist_ok=True)
+
+ print(f"Generating {AGGREGATE_VECTORS}")
+ with open(AGGREGATE_VECTORS, "w", newline='', encoding="utf-8") as f:
+ gen_aggregate_vectors(f)
+
+ print(f"Generating {INCAGGREGATE_VECTORS}")
+ with open(INCAGGREGATE_VECTORS, "w", newline='', encoding="utf-8") as f:
+ gen_incaggregate_vectors(f)
+
+ print(f"Generating {VERIFY_VECTORS}")
+ with open(VERIFY_VECTORS, "w", newline='', encoding="utf-8") as f:
+ gen_verify_vectors(f)
+
+ print("Done.")
diff --git a/py-halfagg/halfagg.py b/py-halfagg/halfagg.py
new file mode 100644
index 0000000..5b7b262
--- /dev/null
+++ b/py-halfagg/halfagg.py
@@ -0,0 +1,212 @@
+#!/usr/bin/env python3
+"""
+Schnorr signature half-aggregation reference implementation
+
+WARNING: This implementation is for demonstration purposes only and _not_ to
+be used in production environments.
+"""
+
+from secp256k1lab.secp256k1 import (
+ G,
+ GE,
+ Scalar,
+)
+from secp256k1lab.util import (
+ bytes_from_int,
+ int_from_bytes,
+ tagged_hash,
+)
+
+
+# Group order
+n = GE.ORDER
+
+def Aggregate(pms):
+ """
+ Aggregates an array of triples (public key, message, signature) into a single aggregate signature.
+
+ :param pms: An array of triples (public key, message, signature).
+ :return: The aggregate signature.
+ """
+
+ # Let aggsig = bytes(0)
+ aggsig = bytes([0] * 32)
+
+ pm_aggd = []
+ if not VerifyAggregate(aggsig, pm_aggd):
+ raise
+
+ # Return IncAggregate(aggsig, pms0..u-1); fail if that fails.
+ return IncAggregate(aggsig, pm_aggd, pms)
+
+
+# IncAggregate(aggsig, pm_aggd0..v-1, pms_to_agg0..u-1)
+def IncAggregate(aggsig, pm_aggd, pms_to_agg):
+ """
+ Incrementally aggregates an additional array of triples (public key, message, signature)
+ into an existing aggregate signature.
+
+ :param aggsig: A byte array representing the aggregate signature.
+ :param pm_aggd: An array of tuples (public key, message).
+ :param pms_to_agg: An array of triples (public key, message, signature).
+ :return: The new aggregate signature.
+ """
+
+ # Fail if v + u ≥ 2^16
+ v = len(pm_aggd)
+ u = len(pms_to_agg)
+ if v + u >= 2**16:
+ raise ValueError("v + u must be less than 2^16")
+
+ # Fail if len(aggsig) ≠ 32 * (v + 1)
+ if len(aggsig) != 32 * (v + 1):
+ raise ValueError("Length of aggsig must be 32 * (v + 1)")
+
+ r_values = []
+ pmr_to_agg = []
+
+ # For i = 0 .. v-1:
+ for i in range(v):
+ # Let (pki, mi) = pm_aggdi
+ (pki, mi) = pm_aggd[i]
+
+ # Let ri = aggsig[i⋅32:(i+1)⋅32]
+ ri = aggsig[i * 32:(i + 1) * 32]
+ r_values.append(ri)
+
+ pmr_to_agg.append((pki, mi, ri))
+
+ z_values = []
+ s_values = []
+
+ # For i = v .. v+u-1:
+ for i in range(v, v + u):
+ # Let (pki, mi, sigi) = pms_to_aggi-v
+ (pki, mi, sigi) = pms_to_agg[i - v]
+
+ # Let ri = sigi[0:32]
+ ri = sigi[0:32]
+ r_values.append(ri)
+
+ # Let si = int(sigi[32:64]); fail if si ≥ n
+ si = int_from_bytes(sigi[32:64])
+ if si >= n:
+ raise ValueError("si must be less than n")
+ s_values.append(si)
+
+ # If i = 0:
+ # Let zi = 1
+ # Else:
+ # Let zi = int(hashHalfAgg/randomizer(r0 || pk0 || m0 || ... || ri || pki || mi)) mod n
+ pmr_to_agg += [(pki, mi, sigi[0:32]) for (pki, mi, sigi) in pms_to_agg]
+ z_values.append(hashHalfAgg_randomizer(pmr_to_agg, i))
+
+ # Let s = int(aggsig[(v⋅32:(v+1)⋅32]) + zv⋅sv + ... + zv+u-1⋅sv+u-1 mod n
+ s = int_from_bytes(aggsig[v * 32:(v + 1) * 32])
+ if s >= n:
+ raise ValueError("s must be less than n")
+
+ for i in range(u):
+ s = (s + z_values[i] * s_values[i]) % n
+
+ # Return r0 || ... || rv+u-1 || bytes(s)
+ return b''.join(r_values) + bytes_from_int(s)
+
+
+# VerifyAggregate(aggsig, pm_aggd0..u-1)
+def VerifyAggregate(aggsig, pm_aggd):
+ """
+ Verifies an aggregate signature against an array of public key and message tuples.
+
+ :param aggsig: A byte array representing the aggregate signature.
+ :param pm_aggd: An array of tuples (public key, message).
+ :return: Boolean indicating whether the verification is successful.
+ """
+
+ # Fail if u ≥ 216
+ u = len(pm_aggd)
+ if u >= 2**16:
+ raise ValueError("u must be less than 2^16")
+
+ # Fail if len(aggsig) ≠ 32 * (u + 1)
+ if len(aggsig) != 32 * (u + 1):
+ raise ValueError("Length of aggsig must be 32 * (u + 1)")
+
+ z_values = []
+ R_values = []
+ P_values = []
+ e_values = []
+ r_values = []
+
+ # For i = 0 .. u-1:
+ for i in range(u):
+ # Let (pki, mi) = pm_aggdi
+ (pki, mi) = pm_aggd[i]
+
+ # Let Pi = lift_x(int(pki)); fail if that fails
+ try:
+ Pi = GE.lift_x(int_from_bytes(pki))
+ except ValueError:
+ return False
+ P_values.append(Pi)
+
+ # Let ri = aggsig[i⋅32:(i+1)⋅32]
+ ri = aggsig[i * 32:(i + 1) * 32]
+ # Let Ri = lift_x(int(ri)); fail if that fails
+ try:
+ Ri = GE.lift_x(int_from_bytes(ri))
+ except ValueError:
+ return False
+ R_values.append(Ri)
+ r_values.append(ri)
+
+ # Let ei = int(hashBIP0340/challenge(bytes(ri) || pki || mi)) mod n
+ ei = int_from_bytes(hashBIP0340_challenge(ri, pki, mi)) % n
+ e_values.append(ei)
+
+ # If i = 0:
+ # Let zi = 1
+ # Else:
+ # Let zi = int(hashHalfAgg/randomizer(r0 || pk0 || m0 || ... || ri || pki || mi)) mod n
+ pmr = [(pki, mi, ri) for (pki, mi), ri in zip(pm_aggd, r_values)]
+ z_values.append(hashHalfAgg_randomizer(pmr, i))
+
+ # Let s = int(aggsig[u⋅32:(u+1)⋅32]); fail if s ≥ n
+ s = int_from_bytes(aggsig[u * 32:(u + 1) * 32])
+ if s >= n:
+ return False
+ s = Scalar.from_int_checked(s)
+
+ # Fail if s⋅G ≠ z0⋅(R0 + e0⋅P0) + ... + zu-1⋅(Ru-1 + eu-1⋅Pu-1)
+ lhs = s * G
+ rhs = GE()
+ for i in range(u):
+ e = Scalar.from_int_checked(e_values[i])
+ P = P_values[i]
+ R = R_values[i]
+ rhsi = R + e * P
+
+ z = Scalar.from_int_checked(z_values[i])
+ rhsi = z * rhsi
+
+ rhs = rhs + rhsi
+
+ return lhs == rhs
+
+
+def hashBIP0340_challenge(sig, pubkey, msg):
+ return tagged_hash("BIP0340/challenge", sig + pubkey + msg)
+
+
+def hashHalfAgg_randomizer(pmr, index):
+ if index == 0:
+ return 1
+
+ random_input = bytes()
+ for i in range(index + 1):
+ (pki, mi, ri) = pmr[i]
+ random_input += ri
+ random_input += pki
+ random_input += mi
+
+ return int_from_bytes(tagged_hash("HalfAgg/randomizer", random_input)) % n
diff --git a/py-halfagg/run_test_vectors.py b/py-halfagg/run_test_vectors.py
new file mode 100644
index 0000000..9a588e6
--- /dev/null
+++ b/py-halfagg/run_test_vectors.py
@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""
+Run test vectors for Schnorr signature half-aggregation.
+
+Usage:
+ python run_test_vectors.py
+
+Expects CSV files generated by gen_test_vectors.py in vectors/ directory.
+"""
+
+import csv
+import os
+from pathlib import Path
+import sys
+from typing import List, Tuple
+
+from halfagg import Aggregate, IncAggregate, VerifyAggregate
+
+
+ROOT = Path(__file__).resolve().parent
+VECTORS_DIR = ROOT / "vectors"
+AGGREGATE_VECTORS = VECTORS_DIR / 'test_vectors_aggregate.csv'
+INCAGGREGATE_VECTORS = VECTORS_DIR / 'test_vectors_incaggregate.csv'
+VERIFY_VECTORS = VECTORS_DIR / 'test_vectors_verify.csv'
+
+
+def parse_hex_list(hex_str: str) -> List[bytes]:
+ if not hex_str:
+ return []
+ return [bytes.fromhex(h) for h in hex_str.split(';')]
+
+
+def parse_pms(pks_str: str, msgs_str: str, sigs_str: str) -> List[Tuple[bytes, bytes, bytes]]:
+ pks = parse_hex_list(pks_str)
+ msgs = parse_hex_list(msgs_str)
+ sigs = parse_hex_list(sigs_str)
+ if not pks:
+ return []
+ return list(zip(pks, msgs, sigs))
+
+
+def parse_pm(pks_str: str, msgs_str: str) -> List[Tuple[bytes, bytes]]:
+ pks = parse_hex_list(pks_str)
+ msgs = parse_hex_list(msgs_str)
+ if not pks:
+ return []
+ return list(zip(pks, msgs))
+
+
+def run_aggregate_tests() -> List[str]:
+ """Run Aggregate test vectors. Returns list of failed test descriptions."""
+ failures = []
+
+ with open(AGGREGATE_VECTORS, newline='', encoding='utf-8') as f:
+ reader = csv.reader(f)
+ next(reader)
+
+ for row in reader:
+ index, pks_str, msgs_str, sigs_str, expected_result, expected_aggsig, comment = row
+ pms = parse_pms(pks_str, msgs_str, sigs_str)
+
+ if expected_result == "FALSE":
+ try:
+ Aggregate(pms)
+ failures.append(f"Aggregate #{index}: {comment}")
+ except:
+ pass
+ else:
+ try:
+ actual = Aggregate(pms)
+ if actual != bytes.fromhex(expected_aggsig):
+ failures.append(f"Aggregate #{index}: {comment}")
+ except:
+ failures.append(f"Aggregate #{index}: {comment}")
+
+ return failures
+
+
+def run_incaggregate_tests() -> List[str]:
+ """Run IncAggregate test vectors. Returns list of failed test descriptions."""
+ failures = []
+
+ with open(INCAGGREGATE_VECTORS, newline='', encoding='utf-8') as f:
+ reader = csv.reader(f)
+ next(reader)
+
+ for row in reader:
+ (index, aggsig_str, pm_pks_str, pm_msgs_str,
+ pms_pks_str, pms_msgs_str, pms_sigs_str,
+ expected_result, expected_aggsig, comment) = row
+
+ aggsig = bytes.fromhex(aggsig_str) if aggsig_str else bytes()
+ pm_aggd = parse_pm(pm_pks_str, pm_msgs_str)
+ pms_to_agg = parse_pms(pms_pks_str, pms_msgs_str, pms_sigs_str)
+
+ if expected_result == "FALSE":
+ try:
+ IncAggregate(aggsig, pm_aggd, pms_to_agg)
+ failures.append(f"IncAggregate #{index}: {comment}")
+ except:
+ pass
+ else:
+ try:
+ actual = IncAggregate(aggsig, pm_aggd, pms_to_agg)
+ if actual != bytes.fromhex(expected_aggsig):
+ failures.append(f"IncAggregate #{index}: {comment}")
+ except:
+ failures.append(f"IncAggregate #{index}: {comment}")
+
+ return failures
+
+
+def run_verify_tests() -> List[str]:
+ """Run VerifyAggregate test vectors. Returns list of failed test descriptions."""
+ failures = []
+
+ with open(VERIFY_VECTORS, newline='', encoding='utf-8') as f:
+ reader = csv.reader(f)
+ next(reader)
+
+ for row in reader:
+ index, aggsig_str, pks_str, msgs_str, expected_result, comment = row
+
+ aggsig = bytes.fromhex(aggsig_str)
+ pm = parse_pm(pks_str, msgs_str)
+
+ try:
+ result = VerifyAggregate(aggsig, pm)
+ expected = (expected_result == "TRUE")
+ if result != expected:
+ failures.append(f"VerifyAggregate #{index}: {comment}")
+ except:
+ if expected_result == "TRUE":
+ failures.append(f"VerifyAggregate #{index}: {comment}")
+
+ return failures
+
+
+def run_count_limit_tests() -> List[str]:
+ """Run count limit tests. Returns list of failed test descriptions."""
+ failures = []
+
+ big_pms = [(bytes([1] * 32), bytes([2] * 32), bytes([3] * 64)) for _ in range(0x10000)]
+ big_pm = [(bytes([1] * 32), bytes([2] * 32)) for _ in range(0x10000)]
+
+ try:
+ Aggregate(big_pms)
+ failures.append("CountLimit: Aggregate with 2^16 signatures")
+ except:
+ pass
+
+ try:
+ IncAggregate(bytes([0] * 32), big_pm, [])
+ failures.append("CountLimit: IncAggregate with 2^16 existing")
+ except:
+ pass
+
+ try:
+ IncAggregate(bytes([0] * 32), [], big_pms)
+ failures.append("CountLimit: IncAggregate with 2^16 new")
+ except:
+ pass
+
+ try:
+ VerifyAggregate(bytes([0] * 32), big_pm)
+ failures.append("CountLimit: VerifyAggregate with 2^16")
+ except:
+ pass
+
+ return failures
+
+
+def main():
+ for filename in [AGGREGATE_VECTORS, INCAGGREGATE_VECTORS, VERIFY_VECTORS]:
+ if not os.path.exists(filename):
+ print(f"ERROR: Missing {filename}. Run gen_test_vectors.py first.")
+ sys.exit(1)
+
+ failures = []
+ failures.extend(run_aggregate_tests())
+ failures.extend(run_incaggregate_tests())
+ failures.extend(run_verify_tests())
+ failures.extend(run_count_limit_tests())
+
+ if failures:
+ print("FAILED:")
+ for f in failures:
+ print(f" {f}")
+ sys.exit(1)
+ else:
+ print("All tests have passed.")
+ sys.exit(0)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/py-halfagg/vectors/test_vectors_aggregate.csv b/py-halfagg/vectors/test_vectors_aggregate.csv
new file mode 100644
index 0000000..bdedae7
--- /dev/null
+++ b/py-halfagg/vectors/test_vectors_aggregate.csv
@@ -0,0 +1,10 @@
+index,pubkeys,messages,signatures,expected_result,expected_aggsig,comment
+0,,,,TRUE,0000000000000000000000000000000000000000000000000000000000000000,Empty signature list
+1,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,Single signature
+2,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F;C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9233428AEB77468852520B0256DE84562A29629194DAEAC45444C7711CD379E57,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9DF1D0EE03A7E5D274A857BF080C8BD3B5244BC1A9D31F660B8E22F309260FF26,Two signatures
+3,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;531FE6068134503D2723133227C867AC8FA6C83C537E9A44C3C5BDBDCB1FE337,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303;0404040404040404040404040404040404040404040404040404040404040404,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F;C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9233428AEB77468852520B0256DE84562A29629194DAEAC45444C7711CD379E57;F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F923A17EA0CD56ED3A824AA14BC67839CED465BA5269700F600EDFC3DAAC8A6B069,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F92F69F0DB595934BE97771DEF114962A9277861BFD5858D2E364DCCF30266F8CD4,Three signatures
+4,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;531FE6068134503D2723133227C867AC8FA6C83C537E9A44C3C5BDBDCB1FE337;462779AD4AAD39514614751A71085F2F10E1C7A593E4E030EFB5B8721CE55B0B;62C0A046DACCE86DDD0343C6D3C7C79C2208BA0D9C9CF24A6D046D21D21F90F7,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303;0404040404040404040404040404040404040404040404040404040404040404;0505050505050505050505050505050505050505050505050505050505050505;0606060606060606060606060606060606060606060606060606060606060606,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F;C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9233428AEB77468852520B0256DE84562A29629194DAEAC45444C7711CD379E57;F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F923A17EA0CD56ED3A824AA14BC67839CED465BA5269700F600EDFC3DAAC8A6B069;A3AFBDB45A6A34BF7C8C00F1B6D7E7D375B54540F13716C87B62E51E2F4F22FF3E57DF859581383FA53C564BC72EC8A60145C502E91B0B508A6F1BD7961FC45D;C1319F3743A9B8A5A30408FA90E96B3A8BB3FD4FF5B790935C465DDC68859B2935B87C950EB399CCCD956D5BE6327B164D4C4080671A6B2CB39FD6A755C5987E,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F92A3AFBDB45A6A34BF7C8C00F1B6D7E7D375B54540F13716C87B62E51E2F4F22FFC1319F3743A9B8A5A30408FA90E96B3A8BB3FD4FF5B790935C465DDC68859B293BCB7A264BF18758B66172F5A56AF98A0CEDAF944D094EE396A941A09F10015A,Five signatures
+5,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8A63C71B6F2AE3FFC8734B47B77A4F5F6C0274D7654BEC2C0D75E69A94892C04A;C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9DF1D0EE03A7E5D274A857BF080C8BD3B5244BC1A9D31F660B8E22F309260FF26,"Strange aggregation - invalid individual sigs, valid aggregate"
+6,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,FALSE,,Signature s = n (at boundary)
+7,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364529,FALSE,,Signature s > n
+8,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,FALSE,,Signature s = 2^256 - 1
diff --git a/py-halfagg/vectors/test_vectors_incaggregate.csv b/py-halfagg/vectors/test_vectors_incaggregate.csv
new file mode 100644
index 0000000..41f9298
--- /dev/null
+++ b/py-halfagg/vectors/test_vectors_incaggregate.csv
@@ -0,0 +1,9 @@
+index,aggsig,pm_aggd_pubkeys,pm_aggd_messages,pms_pubkeys,pms_messages,pms_signatures,expected_result,expected_aggsig,comment
+0,0000000000000000000000000000000000000000000000000000000000000000,,,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,Add single signature to empty aggregate
+1,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766,0303030303030303030303030303030303030303030303030303030303030303,C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9233428AEB77468852520B0256DE84562A29629194DAEAC45444C7711CD379E57,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9DF1D0EE03A7E5D274A857BF080C8BD3B5244BC1A9D31F660B8E22F309260FF26,Add second signature to single-sig aggregate
+2,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;531FE6068134503D2723133227C867AC8FA6C83C537E9A44C3C5BDBDCB1FE337,0303030303030303030303030303030303030303030303030303030303030303;0404040404040404040404040404040404040404040404040404040404040404,C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9233428AEB77468852520B0256DE84562A29629194DAEAC45444C7711CD379E57;F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F923A17EA0CD56ED3A824AA14BC67839CED465BA5269700F600EDFC3DAAC8A6B069,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F92F69F0DB595934BE97771DEF114962A9277861BFD5858D2E364DCCF30266F8CD4,Add two signatures at once
+3,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,,,,TRUE,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,Add no new signatures (unchanged)
+4,00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000,,,,,,FALSE,,Aggregate signature wrong length
+5,,,,,,,FALSE,,Aggregate signature empty
+6,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,,,,,,FALSE,,Existing aggregate has s = n
+7,0000000000000000000000000000000000000000000000000000000000000000,,,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,FALSE,,New signature has s = n
diff --git a/py-halfagg/vectors/test_vectors_verify.csv b/py-halfagg/vectors/test_vectors_verify.csv
new file mode 100644
index 0000000..46d20b3
--- /dev/null
+++ b/py-halfagg/vectors/test_vectors_verify.csv
@@ -0,0 +1,23 @@
+index,aggsig,pubkeys,messages,expected_result,comment
+0,0000000000000000000000000000000000000000000000000000000000000000,,,TRUE,Empty signature list
+1,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,TRUE,Single signature
+2,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8A3AFBDB45A6A34BF7C8C00F1B6D7E7D375B54540F13716C87B62E51E2F4F22FFBF8913EC53226A34892D60252A7052614CA79AE939986828D81D2311957371AD,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;462779AD4AAD39514614751A71085F2F10E1C7A593E4E030EFB5B8721CE55B0B,0202020202020202020202020202020202020202020202020202020202020202;0505050505050505050505050505050505050505050505050505050505050505,TRUE,Two signatures
+3,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F92F69F0DB595934BE97771DEF114962A9277861BFD5858D2E364DCCF30266F8CD4,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;531FE6068134503D2723133227C867AC8FA6C83C537E9A44C3C5BDBDCB1FE337,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303;0404040404040404040404040404040404040404040404040404040404040404,TRUE,Three signatures
+4,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9F62B58B1908197B6D732F0564AD2AD5C6E438D24C65AF758DA9ACCCCCF5B3F92A3AFBDB45A6A34BF7C8C00F1B6D7E7D375B54540F13716C87B62E51E2F4F22FFC1319F3743A9B8A5A30408FA90E96B3A8BB3FD4FF5B790935C465DDC68859B293BCB7A264BF18758B66172F5A56AF98A0CEDAF944D094EE396A941A09F10015A,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F;4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;531FE6068134503D2723133227C867AC8FA6C83C537E9A44C3C5BDBDCB1FE337;462779AD4AAD39514614751A71085F2F10E1C7A593E4E030EFB5B8721CE55B0B;62C0A046DACCE86DDD0343C6D3C7C79C2208BA0D9C9CF24A6D046D21D21F90F7,0202020202020202020202020202020202020202020202020202020202020202;0303030303030303030303030303030303030303030303030303030303030303;0404040404040404040404040404040404040404040404040404040404040404;0505050505050505050505050505050505050505050505050505050505050505;0606060606060606060606060606060606060606060606060606060606060606,TRUE,Five signatures
+5,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Public key not on curve
+6,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,0000000000000000000000000000000000000000000000000000000000000000,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Public key is zero
+7,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Public key >= field size
+8,4A298DACAE57395A15D0795DDBFD1DCB564DA82B0F269BC70A74F8220429BA1D0E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,R value not on curve
+9,00000000000000000000000000000000000000000000000000000000000000000E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,R value is zero
+10,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC930E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,R value >= field size
+11,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Aggregate s = n
+12,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364529,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Aggregate s > n
+13,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Aggregate s = 2^256 - 1
+14,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF,FALSE,Wrong message
+15,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F,4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Wrong public key
+16,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C2260,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Tampered signature (bit flip)
+17,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8C9D9B1CCE8C0A99AA79F56185358CD82B7649D6132DAACB31612C7C9B14CE3A9DF1D0EE03A7E5D274A857BF080C8BD3B5244BC1A9D31F660B8E22F309260FF26,4D4B6CD1361032CA9BD2AEB9D900AA4D45D9EAD80AC9423374C451A7254D0766;1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0303030303030303030303030303030303030303030303030303030303030303;0202020202020202020202020202020202020202020202020202020202020202,FALSE,Swapped order
+18,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA8FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364140,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,"s = n-1 (valid range, wrong sig)"
+19,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80000000000000000000000000000000000000000000000000000000000000000,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,"s = 0 (valid range, wrong sig)"
+20,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4A,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Aggregate signature too short
+21,B070AAFCEA439A4F6F1BBFC2EB66D29D24B0CAB74D6B745C3CFB009CC8FE4AA80E066C34819936549FF49B6FD4D41EDFC401A367B87DDD59FEE38177961C225F0000000000000000000000000000000000000000000000000000000000000000,1B84C5567B126440995D3ED5AABA0565D71E1834604819FF9C17F5E9D5DD078F,0202020202020202020202020202020202020202020202020202020202020202,FALSE,Aggregate signature too long