Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .php-cs-fixer.cache

Large diffs are not rendered by default.

43 changes: 25 additions & 18 deletions src/BLS/ProofOfPossession.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@

use ArkEcosystem\Crypto\BLS\Curves\G1;
use ArkEcosystem\Crypto\BLS\HashToCurve\G2HashToCurve;
use ArkEcosystem\Crypto\ByteBuffer\ByteBuffer;
use ArkEcosystem\Crypto\Utils\Address;
use InvalidArgumentException;

/**
* DST used for PoP signatures: "BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_".
*/
final class ProofOfPossession
{
public const POP_DST = 'BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_';
public const POP_DST = 'MAINSAIL_BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_';

/**
* Derives a BLS private key (32 bytes binary) from a BIP-39 mnemonic.
Expand Down Expand Up @@ -45,18 +44,22 @@ public static function privateKeyToPublicKey(string $privateKeyBytes): string
}

/**
* Builds the Proof of Possession for a given private key.
* Signs abi.encodePacked(uint256 chainId, address registrant, bytes pk) under POP_DST.
* The registrant must be the transaction sender, as the contract verifies against msg.sender.
*
* PoP = Sign(sk, Hash_G2(pk)) where Hash_G2 hashes the G1 public key bytes
* to a G2 point under POP_DST, then the G2 point is "signed" by multiplying
* by the private key scalar.
*
* @param string $privateKeyBytes 32-byte raw private key
* @throws InvalidArgumentException if the key is not exactly 32 bytes or is the zero scalar
* @return array{pk: string, pop: string} hex-encoded G1 pk (96 chars) and G2 pop (192 chars)
* @throws InvalidArgumentException
* @return array{pk: string, pop: string}
*/
public static function buildProofOfPossession(string $privateKeyBytes): array
public static function buildProofOfPossession(string $privateKeyBytes, int $chainId, string $registrantAddress): array
{
if ($chainId <= 0) {
throw new InvalidArgumentException('Invalid chain id: '.$chainId);
}

if (! Address::validate($registrantAddress) || ! Address::hasValidChecksum($registrantAddress)) {
throw new InvalidArgumentException('Invalid registrant address: '.$registrantAddress);
}

if (strlen($privateKeyBytes) !== 32) {
throw new InvalidArgumentException(
'BLS secret key must be exactly 32 bytes, got '.strlen($privateKeyBytes)
Expand All @@ -72,10 +75,14 @@ public static function buildProofOfPossession(string $privateKeyBytes): array
// G1 public key: [sk] * G1
$pk = G1::generator()->scalarMul($sk)->toCompressedBytes();

// Hash pk bytes to G2 under POP_DST
$messagePoint = G2HashToCurve::hashToG2($pk, self::POP_DST);
$message = ByteBuffer::new(0)
->writeUInt256($chainId)
->writeHex(substr($registrantAddress, 2))
->writeBytes($pk)
->toBinary();

$messagePoint = G2HashToCurve::hashToG2($message, self::POP_DST);

// Sign: PoP = [sk] * hash(pk)
$pop = $messagePoint->scalarMul($sk)->toCompressedBytes();

return [
Expand All @@ -89,8 +96,8 @@ public static function buildProofOfPossession(string $privateKeyBytes): array
*
* @return array{pk: string, pop: string}
*/
public static function fromMnemonic(string $mnemonic): array
public static function fromMnemonic(string $mnemonic, int $chainId, string $registrantAddress): array
{
return self::buildProofOfPossession(self::deriveBlsPrivateKey($mnemonic));
return self::buildProofOfPossession(self::deriveBlsPrivateKey($mnemonic), $chainId, $registrantAddress);
}
}
12 changes: 10 additions & 2 deletions src/Transactions/Builder/ValidatorRegistrationBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace ArkEcosystem\Crypto\Transactions\Builder;

use ArkEcosystem\Crypto\BLS\ProofOfPossession;
use ArkEcosystem\Crypto\Configuration\Network;
use ArkEcosystem\Crypto\Enums\ContractAddresses;
use ArkEcosystem\Crypto\Transactions\Types\AbstractTransaction;
use ArkEcosystem\Crypto\Transactions\Types\ValidatorRegistration;
Expand All @@ -19,9 +20,16 @@ public function __construct(?array $data = null)
$this->to(ContractAddresses::CONSENSUS->value);
}

public function validatorPassphrase(string $passphrase): self
/**
* The registrant address must be the one that signs this transaction.
*/
public function proofOfPossession(string $validatorPassphrase, string $registrantAddress, ?int $chainId = null): self
{
$pop = ProofOfPossession::fromMnemonic($passphrase);
$pop = ProofOfPossession::fromMnemonic(
$validatorPassphrase,
$chainId ?? Network::get()->chainId(),
$registrantAddress,
);

$this->transaction->data['validatorPublicKey'] = $pop['pk'];
$this->transaction->data['validatorProof'] = $pop['pop'];
Expand Down
12 changes: 10 additions & 2 deletions src/Transactions/Builder/ValidatorUpdateBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace ArkEcosystem\Crypto\Transactions\Builder;

use ArkEcosystem\Crypto\BLS\ProofOfPossession;
use ArkEcosystem\Crypto\Configuration\Network;
use ArkEcosystem\Crypto\Enums\ContractAddresses;
use ArkEcosystem\Crypto\Transactions\Types\AbstractTransaction;
use ArkEcosystem\Crypto\Transactions\Types\ValidatorUpdate;
Expand All @@ -18,9 +19,16 @@ public function __construct(?array $data = null)
$this->to(ContractAddresses::CONSENSUS->value);
}

public function validatorPassphrase(string $passphrase): self
/**
* The registrant address must be the one that signs this transaction.
*/
public function proofOfPossession(string $validatorPassphrase, string $registrantAddress, ?int $chainId = null): self
{
$pop = ProofOfPossession::fromMnemonic($passphrase);
$pop = ProofOfPossession::fromMnemonic(
$validatorPassphrase,
$chainId ?? Network::get()->chainId(),
$registrantAddress,
);

$this->transaction->data['validatorPublicKey'] = $pop['pk'];
$this->transaction->data['validatorProof'] = $pop['pop'];
Expand Down
12 changes: 12 additions & 0 deletions src/Utils/Address.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ public static function validate(string $address): bool
return preg_match('/^0x[a-fA-F0-9]{40}$/', $address) === 1;
}

/**
* All-lowercase addresses carry no EIP-55 checksum and pass.
*/
public static function hasValidChecksum(string $address): bool
{
if (strtolower($address) === $address) {
return true;
}

return self::toChecksumAddress($address) === $address;
}

/**
* Convert to checksum address.
*
Expand Down
9 changes: 5 additions & 4 deletions src/Utils/TransactionEncoder.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace ArkEcosystem\Crypto\Utils;

use ArkEcosystem\Crypto\BLS\ProofOfPossession;
use ArkEcosystem\Crypto\Configuration\Network;
use ArkEcosystem\Crypto\Enums\AbiFunction;
use ArkEcosystem\Crypto\Enums\ContractAbiType;
use Brick\Math\BigDecimal;
Expand Down Expand Up @@ -48,19 +49,19 @@ public static function usernameResignation(): string
);
}

public static function validatorRegistration(string $passphrase): string
public static function validatorRegistration(string $passphrase, string $registrantAddress, ?int $chainId = null): string
{
$pop = ProofOfPossession::fromMnemonic($passphrase);
$pop = ProofOfPossession::fromMnemonic($passphrase, $chainId ?? Network::get()->chainId(), $registrantAddress);

return (new AbiEncoder(ContractAbiType::CONSENSUS))->encodeFunctionCall(
AbiFunction::VALIDATOR_REGISTRATION->value,
[self::addHexPrefix($pop['pk']), self::addHexPrefix($pop['pop'])]
);
}

public static function validatorUpdate(string $passphrase): string
public static function validatorUpdate(string $passphrase, string $registrantAddress, ?int $chainId = null): string
{
$pop = ProofOfPossession::fromMnemonic($passphrase);
$pop = ProofOfPossession::fromMnemonic($passphrase, $chainId ?? Network::get()->chainId(), $registrantAddress);

return (new AbiEncoder(ContractAbiType::CONSENSUS))->encodeFunctionCall(
AbiFunction::VALIDATOR_UPDATE->value,
Expand Down
67 changes: 50 additions & 17 deletions tests/Unit/BLS/ProofOfPossessionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,58 +7,91 @@

const POP_SK_A_HEX = '67d53f170b908cabb9eb326c3c337762d59289a8fec79f7bc9254b584b73265c';
const POP_SK_B_HEX = '3325023a5e4e0069558c5bd9eb7eca78b4f4c7711b9b231d9263a8edc33bc510';
const POP_CHAIN_ID = 10_000;
const POP_REGISTRANT = '0x75545540230d5c3BEf023202d23CB74cFA723376';
const POP_PASSPHRASE = 'peasant list dentist thrive guide uncle announce city energy artist basket divert stool glow eternal stove length gun action slice type labor aunt unlock';

const EXPECTED_PK_A = 'a7e75af9dd4d868a41ad2f5a5b021d653e31084261724fb40ae2f1b1c31c778d3b9464502d599cf6720723ec5c68b59d';
const EXPECTED_POP_A = '878ad02e1f215d40722bd77a0148adb8dfaad4514157600a0a926cfc58589fa4e79d3d4d579cc4149237b8100efdcff110dd2a251c52543539d499c8f24b142da66d1dc19ec44b3d9c3f71112b2705e5557f932a36bd9cd9b3544ab0d9e6a677';
const EXPECTED_POP_A = 'a892e94d8ed6d0fe8792dcb31b7c5116a7d138ad4bbbd044780a7c314e86673e783850121dc34d0edfa2a2560c2f30a402f4fa5106ff71d5c69bc3027210ef90b3d3ae0a19ffc9f554b37aca72f3bb25788c3177514d94e041441ba9d029b3ba';
const EXPECTED_MNEMONIC_PK = 'a3b93d0149c9e0ee8c2e734b641d313040b8901fcddbf61a018ae2a4633da49f9b169c0bb6653dee4cdd7dac2631a935';
const EXPECTED_MNEMONIC_POP = 'b72a4b4601608029564c691c742bac7a089c355e9e4e3c60469bc429ca24a24f1a568377df210755c307939065ae8954125c823fea74ee3fe5fa9ecd5dfd4a33a540d1835acb086b968ca7b543d3258bb2afb7911fb1d2e560004d6e280c043c';
const EXPECTED_MNEMONIC_POP = 'a84ec0eb9ba99033ede2bf0c64e63a4b894b4ec865c8f25c9bd44a4d11e7e2f5e0643835bdef4b983d7577dc03539b370082fa7e537b0648182296b64df8027b8d35db7f87b430f1cc1c3af733fe97862fcf2fc8aea1cc6f819b38997bbd1a6e';

// -------------------------------------------------------------------------
// buildProofOfPossession
// -------------------------------------------------------------------------

it('returns a 48-byte pk and 96-byte pop', function () {
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
// hex strings: 96 chars = 48 bytes, 192 chars = 96 bytes
expect(strlen($result['pk']))->toBe(96)
->and(strlen($result['pop']))->toBe(192);
});

it('is deterministic for the same secret key', function () {
$a = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$b = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$a = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
$b = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
expect($a['pk'])->toBe($b['pk'])
->and($a['pop'])->toBe($b['pop']);
});

it('produces different pk and pop for different secret keys', function () {
$a = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$b = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_B_HEX));
$a = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
$b = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_B_HEX), POP_CHAIN_ID, POP_REGISTRANT);
expect($a['pk'])->not->toBe($b['pk'])
->and($a['pop'])->not->toBe($b['pop']);
});

it('pk matches deriveBlsPublicKey for the same secret key', function () {
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
$expected = ProofOfPossession::privateKeyToPublicKey(hex2bin(POP_SK_A_HEX));
expect($result['pk'])->toBe($expected);
});

it('matches the pinned test vector for SK_A', function () {
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX));
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
expect($result['pk'])->toBe(EXPECTED_PK_A)
->and($result['pop'])->toBe(EXPECTED_POP_A);
});

it('uses the MAINSAIL_ prefixed POP domain separation tag', function () {
expect(ProofOfPossession::POP_DST)->toBe('MAINSAIL_BLS_POP_BLS12381G2_XMD:SHA-256_SSWU_RO_POP_');
});

it('binds the pop to the chain id and registrant address', function () {
$bound = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, POP_REGISTRANT);
$otherChain = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), 11_812, POP_REGISTRANT);
$otherAddress = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, '0xBd6F65c58A46427AF4B257cBE231D0eD69eD5508');

expect($otherChain['pk'])->toBe($bound['pk'])
->and($otherAddress['pk'])->toBe($bound['pk'])
->and($otherChain['pop'])->not->toBe($bound['pop'])
->and($otherAddress['pop'])->not->toBe($bound['pop']);
});

it('accepts an all-lowercase registrant address', function () {
$result = ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, strtolower(POP_REGISTRANT));

expect($result['pop'])->toBe(EXPECTED_POP_A);
});

it('throws on an invalid registrant address', function () {
expect(fn () => ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, '0x1234'))->toThrow(InvalidArgumentException::class, 'Invalid registrant address')
->and(fn () => ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), POP_CHAIN_ID, '0x75545540230d5c3bEf023202d23CB74cFA723376'))->toThrow(InvalidArgumentException::class, 'Invalid registrant address');
});

it('throws on a non-positive chain id', function () {
expect(fn () => ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), 0, POP_REGISTRANT))->toThrow(InvalidArgumentException::class, 'Invalid chain id')
->and(fn () => ProofOfPossession::buildProofOfPossession(hex2bin(POP_SK_A_HEX), -1, POP_REGISTRANT))->toThrow(InvalidArgumentException::class, 'Invalid chain id');
});

it('throws on a secret key of wrong length', function () {
expect(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 31)))->toThrow(InvalidArgumentException::class)
->and(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 33)))->toThrow(InvalidArgumentException::class)
->and(fn () => ProofOfPossession::buildProofOfPossession(''))->toThrow(InvalidArgumentException::class);
expect(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 31), POP_CHAIN_ID, POP_REGISTRANT))->toThrow(InvalidArgumentException::class)
->and(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 33), POP_CHAIN_ID, POP_REGISTRANT))->toThrow(InvalidArgumentException::class)
->and(fn () => ProofOfPossession::buildProofOfPossession('', POP_CHAIN_ID, POP_REGISTRANT))->toThrow(InvalidArgumentException::class);
});

it('throws on the zero secret key', function () {
expect(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 32)))->toThrow(InvalidArgumentException::class);
expect(fn () => ProofOfPossession::buildProofOfPossession(str_repeat("\x00", 32), POP_CHAIN_ID, POP_REGISTRANT))->toThrow(InvalidArgumentException::class);
});

// -------------------------------------------------------------------------
Expand All @@ -76,14 +109,14 @@
});

it('matches the pinned pop for the same passphrase', function () {
$result = ProofOfPossession::fromMnemonic(POP_PASSPHRASE);
$result = ProofOfPossession::fromMnemonic(POP_PASSPHRASE, POP_CHAIN_ID, POP_REGISTRANT);
expect($result['pk'])->toBe(EXPECTED_MNEMONIC_PK)
->and($result['pop'])->toBe(EXPECTED_MNEMONIC_POP);
});

// -------------------------------------------------------------------------
// Table-driven derivation vectors
// Each entry: [mnemonic, private key (hex), public key (hex), proof of possession (hex)]
// Each entry: [mnemonic, registrant address, chain id, private key (hex), public key (hex), proof of possession (hex)]
// -------------------------------------------------------------------------

$blsDataset = [];
Expand All @@ -93,10 +126,10 @@
}
}

it('derives correct public key, private key, and pop for the given mnemonic', function (string $mnemonic, string $expectedSk, string $expectedPk, string $expectedPop) {
it('derives correct public key, private key, and pop for the given mnemonic', function (string $mnemonic, string $address, int $chainId, string $expectedSk, string $expectedPk, string $expectedPop) {
expect(bin2hex(EIP2333::deriveBlsPrivateKey($mnemonic)))->toBe($expectedSk);

$result = ProofOfPossession::fromMnemonic($mnemonic);
$result = ProofOfPossession::fromMnemonic($mnemonic, $chainId, $address);
expect($result['pk'])->toBe(substr($expectedPk, 2))
->and($result['pop'])->toBe(substr($expectedPop, 2));
})->with($blsDataset);
Loading
Loading