198 lines
8.7 KiB
PHP
198 lines
8.7 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* Addresses, derivation and the two signing envelopes.
|
||
|
|
*
|
||
|
|
* These are the pieces that decide who a user is. A bug here does not produce
|
||
|
|
* a crash — it produces an account attached to the wrong key, or a login that
|
||
|
|
* accepts a signature it should not. So the checks lean hard on cases where
|
||
|
|
* a plausible-looking shortcut would be wrong: a flipped checksum character, a
|
||
|
|
* token-aware address spelling, a signature over a slightly different message.
|
||
|
|
*
|
||
|
|
* Addresses and the derived wallet were produced with libauth, the same
|
||
|
|
* library the Sirius portal uses.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
require_once __DIR__ . '/bootstrap.php';
|
||
|
|
|
||
|
|
T::group( 'CashAddress' );
|
||
|
|
|
||
|
|
$mainnet = 'bitcoincash:qp63uahgrxged4z5jswyt5dn5v3lzsem6cy4spdc2h';
|
||
|
|
$chipnet = 'bchtest:qp63uahgrxged4z5jswyt5dn5v3lzsem6cq85x00dt';
|
||
|
|
$pub_one = SP_Secp256k1::public_key( hex2bin( str_repeat( '0', 63 ) . '1' ) );
|
||
|
|
|
||
|
|
T::is( SP_CashAddr::from_public_key( $pub_one, 'bitcoincash' ), $mainnet, 'mainnet address for key 1' );
|
||
|
|
T::is( SP_CashAddr::from_public_key( $pub_one, 'bchtest' ), $chipnet, 'chipnet address for the same key' );
|
||
|
|
|
||
|
|
T::ok( SP_CashAddr::is_valid( $mainnet ), 'a good address validates' );
|
||
|
|
T::ok( ! SP_CashAddr::is_valid( substr( $mainnet, 0, -1 ) . 'q' ), 'a corrupted checksum is rejected' );
|
||
|
|
T::ok( ! SP_CashAddr::is_valid( 'bitcoincash:notanaddressatall' ), 'nonsense is rejected' );
|
||
|
|
T::ok( ! SP_CashAddr::is_valid( '' ), 'an empty string is rejected' );
|
||
|
|
T::ok( ! SP_CashAddr::is_valid( 'walerikus@example.com' ), 'an email address is not an address' );
|
||
|
|
|
||
|
|
// The prefix is part of the checksum, so a mainnet payload under a testnet
|
||
|
|
// prefix must not validate. Without this, a chipnet signature could
|
||
|
|
// authenticate a mainnet account.
|
||
|
|
$payload = substr( $mainnet, strlen( 'bitcoincash:' ) );
|
||
|
|
T::ok( ! SP_CashAddr::is_valid( 'bchtest:' . $payload ), 'a mainnet payload does not validate under the chipnet prefix' );
|
||
|
|
|
||
|
|
$decoded = SP_CashAddr::decode( $mainnet );
|
||
|
|
T::is( $decoded['prefix'], 'bitcoincash', 'decode reports the prefix' );
|
||
|
|
T::is( $decoded['type'], SP_CashAddr::TYPE_P2PKH, 'decode reports the type' );
|
||
|
|
T::is( strlen( $decoded['hash'] ), 20, 'decode returns a 20-byte hash' );
|
||
|
|
|
||
|
|
// Token-aware and plain spellings of one key must resolve to one account.
|
||
|
|
$token = SP_CashAddr::encode( 'bitcoincash', $decoded['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN );
|
||
|
|
T::ok( $token !== $mainnet, 'the token-aware spelling differs' );
|
||
|
|
T::is( SP_CashAddr::normalize( $token ), $mainnet, 'the token-aware spelling normalises to the plain one' );
|
||
|
|
T::is( SP_CashAddr::normalize( strtoupper( $mainnet ) ), $mainnet, 'case does not create a second identity' );
|
||
|
|
T::is( SP_CashAddr::normalize( 'nonsense' ), '', 'normalising a non-address gives an empty string' );
|
||
|
|
|
||
|
|
// A bare payload, which is how half the ecosystem displays an address.
|
||
|
|
T::is( SP_CashAddr::normalize( $payload ), $mainnet, 'a prefix-less address is understood' );
|
||
|
|
|
||
|
|
T::group( 'BIP-39 and BIP-32' );
|
||
|
|
|
||
|
|
// Generated by libauth's BuiltInWallet; the address is what the Sirius portal
|
||
|
|
// shows for this phrase.
|
||
|
|
$phrase = 'trash key flip dawn impulse float medal rain sell hand neither hub';
|
||
|
|
$key = SP_HD::publishing_key( $phrase, 'bchtest' );
|
||
|
|
|
||
|
|
T::is( $key['address'], 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy', 'derives the address the portal wallet derives' );
|
||
|
|
T::is( strlen( $key['private'] ), 32, 'the private key is 32 bytes' );
|
||
|
|
T::is( strlen( $key['public'] ), 33, 'the public key is compressed' );
|
||
|
|
|
||
|
|
// Normalisation must not change the key: a phrase pasted with odd spacing or
|
||
|
|
// capitals is the same wallet, and treating it as a different one would look
|
||
|
|
// to the user like their funds had vanished.
|
||
|
|
T::is(
|
||
|
|
SP_HD::publishing_key( " Trash KEY flip dawn impulse\tfloat medal rain sell hand neither hub \n", 'bchtest' )['address'],
|
||
|
|
$key['address'],
|
||
|
|
'spacing and capitals do not change the wallet'
|
||
|
|
);
|
||
|
|
|
||
|
|
// A different path is a different wallet — this is the single most likely
|
||
|
|
// cause of an unexplained 403 from the gateway.
|
||
|
|
T::ok(
|
||
|
|
SP_HD::publishing_key( $phrase, 'bchtest', "m/44'/0'/0'/0/0" )['address'] !== $key['address'],
|
||
|
|
'a different derivation path gives a different address'
|
||
|
|
);
|
||
|
|
|
||
|
|
T::is( SP_HD::phrase_problem( $phrase ), '', 'a good phrase has no problem' );
|
||
|
|
T::ok( '' !== SP_HD::phrase_problem( 'too few words' ), 'a short phrase is reported' );
|
||
|
|
T::ok( '' !== SP_HD::phrase_problem( '' ), 'an empty phrase is reported' );
|
||
|
|
|
||
|
|
T::group( 'BIP-137 message signatures' );
|
||
|
|
|
||
|
|
$message = "Sign in to Example\n\nSite: https://example.test/\nNonce: 1.2.3";
|
||
|
|
|
||
|
|
// The digest a wallet computes. Checked against the Theseus wallet's own
|
||
|
|
// implementation (bundled-addons/aegis/lib/chain-bch.js).
|
||
|
|
T::is(
|
||
|
|
bin2hex( SP_Message::bip137_digest( 'SIRIUS-PRESS-LOGIN1' . "\n" . 'https://example.bch' . "\n" . 'bchtest:qrq05hk8hurcsjx0slw4yjknmlujfzme3vxjhtwpwy' . "\n" . 'abc123' . "\n" . '1758412800000' ) ),
|
||
|
|
'98d1b0e89586b933f1117fcd0dd167fe126ec5bb8e34f28685890f6030befe19',
|
||
|
|
'the message digest matches the Theseus wallet'
|
||
|
|
);
|
||
|
|
|
||
|
|
$signature = SP_Message::sign( $message, $key['private'] );
|
||
|
|
T::ok( SP_Message::verify( $message, $signature, $key['address'] ), 'a signature verifies against its own address' );
|
||
|
|
T::ok( ! SP_Message::verify( $message . ' ', $signature, $key['address'] ), 'a trailing space breaks verification' );
|
||
|
|
T::ok( ! SP_Message::verify( $message, $signature, $mainnet ), 'the signature does not verify against another address' );
|
||
|
|
T::ok( ! SP_Message::verify( $message, 'not base64 at all!!', $key['address'] ), 'garbage is not a signature' );
|
||
|
|
T::ok( ! SP_Message::verify( $message, base64_encode( 'short' ), $key['address'] ), 'a short signature is refused' );
|
||
|
|
T::ok( ! SP_Message::verify( $message, $signature, 'bitcoincash:notanaddress' ), 'an invalid claimed address is refused' );
|
||
|
|
|
||
|
|
// A token-aware address must still verify — a user may present either form.
|
||
|
|
T::ok(
|
||
|
|
SP_Message::verify(
|
||
|
|
$message,
|
||
|
|
$signature,
|
||
|
|
SP_CashAddr::encode( 'bchtest', SP_CashAddr::decode( $key['address'] )['hash'], SP_CashAddr::TYPE_P2PKH_TOKEN )
|
||
|
|
),
|
||
|
|
'the token-aware spelling of the signer verifies'
|
||
|
|
);
|
||
|
|
|
||
|
|
// A message longer than 252 bytes crosses the varint boundary in the BIP-137
|
||
|
|
// payload. Getting that wrong produces signatures no other wallet accepts.
|
||
|
|
$long = str_repeat( 'x', 300 );
|
||
|
|
T::ok(
|
||
|
|
SP_Message::verify( $long, SP_Message::sign( $long, $key['private'] ), $key['address'] ),
|
||
|
|
'a 300-byte message signs and verifies across the varint boundary'
|
||
|
|
);
|
||
|
|
|
||
|
|
T::group( 'BNS-SITE1 upload envelope' );
|
||
|
|
|
||
|
|
T::is(
|
||
|
|
bin2hex( SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 ) ),
|
||
|
|
hash( 'sha256', "BNS-SITE1\nexample.bch\nindex.html\n" . hash( 'sha256', '<h1>hi</h1>' ) . "\n1758412800000" ),
|
||
|
|
'the upload digest matches the gateway formula'
|
||
|
|
);
|
||
|
|
|
||
|
|
$headers = SP_Message::site_headers( 'example.bch', 'index.html', '<h1>hi</h1>', $key['private'], 1758412800000 );
|
||
|
|
T::is( $headers['x-bns-ts'], '1758412800000', 'the timestamp header is the one signed' );
|
||
|
|
T::is( strlen( base64_decode( $headers['x-bns-sig'], true ) ), 65, 'the signature header decodes to 65 bytes' );
|
||
|
|
|
||
|
|
// The gateway recovers the signer and compares it to the on-chain owner.
|
||
|
|
// Reproduce that here so a change to either side is caught.
|
||
|
|
T::is(
|
||
|
|
SP_CashAddr::from_public_key(
|
||
|
|
SP_Secp256k1::recover(
|
||
|
|
base64_decode( $headers['x-bns-sig'], true ),
|
||
|
|
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>hi</h1>', 1758412800000 )
|
||
|
|
),
|
||
|
|
'bchtest'
|
||
|
|
),
|
||
|
|
$key['address'],
|
||
|
|
'the gateway would recover the publishing address from the header'
|
||
|
|
);
|
||
|
|
|
||
|
|
// Changing any signed component must invalidate it.
|
||
|
|
T::ok(
|
||
|
|
SP_CashAddr::from_public_key(
|
||
|
|
SP_Secp256k1::recover(
|
||
|
|
base64_decode( $headers['x-bns-sig'], true ),
|
||
|
|
SP_Message::site_digest( 'example.bch', 'index.html', '<h1>TAMPERED</h1>', 1758412800000 )
|
||
|
|
),
|
||
|
|
'bchtest'
|
||
|
|
) !== $key['address'],
|
||
|
|
'altered upload bytes no longer recover the publishing address'
|
||
|
|
);
|
||
|
|
|
||
|
|
T::group( 'canonical JSON' );
|
||
|
|
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'b' => 1, 'a' => 2 ) ),
|
||
|
|
'{"a":2,"b":1}',
|
||
|
|
'keys are sorted'
|
||
|
|
);
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'a' => null, 'b' => 1 ) ),
|
||
|
|
'{"b":1}',
|
||
|
|
'null members are dropped'
|
||
|
|
);
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'z' => array( 'y' => 1, 'x' => 2 ) ) ),
|
||
|
|
'{"z":{"x":2,"y":1}}',
|
||
|
|
'nested keys are sorted too'
|
||
|
|
);
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'url' => 'https://a/b' ) ),
|
||
|
|
'{"url":"https://a/b"}',
|
||
|
|
'slashes are not escaped, matching JSON.stringify'
|
||
|
|
);
|
||
|
|
// Written as a concatenation so the expected value cannot itself be stored as
|
||
|
|
// a literal e-acute, which would make this assertion test nothing.
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'note' => 'caf' . chr( 0xc3 ) . chr( 0xa9 ) ) ),
|
||
|
|
'{"note":"caf' . chr( 92 ) . 'u00e9"}',
|
||
|
|
'non-ASCII is backslash-u escaped'
|
||
|
|
);
|
||
|
|
T::is(
|
||
|
|
SP_Message::canonical_json( array( 'list' => array( 3, 1, 2 ) ) ),
|
||
|
|
'{"list":[3,1,2]}',
|
||
|
|
'array order is preserved'
|
||
|
|
);
|
||
|
|
|
||
|
|
exit( T::summary() );
|