270 lines
8.1 KiB
PHP
270 lines
8.1 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* CashAddress encoding — the fork's replacement for the email address.
|
||
|
|
*
|
||
|
|
* Wherever WordPress core would have stored, validated or compared a
|
||
|
|
* `user_email`, Sirius Press stores, validates and compares one of these. The
|
||
|
|
* format is the Bitcoin Cash address spec: a human-readable prefix, a colon,
|
||
|
|
* and base32 of [version byte | hash160] with a 40-bit BCH checksum.
|
||
|
|
*
|
||
|
|
* Two prefixes matter in practice: `bitcoincash` for mainnet names and
|
||
|
|
* `bchtest` for chipnet, which is what the test instances and the registrar's
|
||
|
|
* staging flow use. Token-aware forms (types 2 and 3) decode too, because a
|
||
|
|
* name certificate lives at a token address and users copy whichever form
|
||
|
|
* their wallet showed them — but they normalise to the same hash160, so a
|
||
|
|
* user who signs up with one form and signs in with the other is the same
|
||
|
|
* person as far as the auth plugin is concerned.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||
|
|
|
||
|
|
final class SP_CashAddr {
|
||
|
|
|
||
|
|
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
|
||
|
|
|
||
|
|
const TYPE_P2PKH = 0;
|
||
|
|
const TYPE_P2SH = 1;
|
||
|
|
const TYPE_P2PKH_TOKEN = 2;
|
||
|
|
const TYPE_P2SH_TOKEN = 3;
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Encode a 20-byte hash160 as a CashAddress.
|
||
|
|
*
|
||
|
|
* @param string $prefix e.g. 'bitcoincash' or 'bchtest'.
|
||
|
|
* @param string $hash 20 raw bytes.
|
||
|
|
* @param int $type One of the TYPE_* constants.
|
||
|
|
* @return string 'prefix:payload', or '' on bad input.
|
||
|
|
*/
|
||
|
|
public static function encode( $prefix, $hash, $type = self::TYPE_P2PKH ) {
|
||
|
|
$prefix = strtolower( (string) $prefix );
|
||
|
|
if ( 20 !== strlen( $hash ) || ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
// Version byte: type in the high nibble, size code in the low bits.
|
||
|
|
// 20-byte hashes are size code 0, which is every address we mint.
|
||
|
|
$payload = chr( ( $type << 3 ) | 0 ) . $hash;
|
||
|
|
$data = self::convert_bits( self::bytes_to_array( $payload ), 8, 5, true );
|
||
|
|
if ( null === $data ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$checksum = self::checksum( $prefix, array_merge( $data, array_fill( 0, 8, 0 ) ) );
|
||
|
|
$out = '';
|
||
|
|
foreach ( array_merge( $data, $checksum ) as $v ) {
|
||
|
|
$out .= self::CHARSET[ $v ];
|
||
|
|
}
|
||
|
|
return $prefix . ':' . $out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Decode a CashAddress.
|
||
|
|
*
|
||
|
|
* Accepts the address with or without its prefix; a bare payload is tried
|
||
|
|
* against both known prefixes, because half the UIs in the ecosystem strip
|
||
|
|
* the prefix when they display an address and users paste what they see.
|
||
|
|
*
|
||
|
|
* @param string $addr
|
||
|
|
* @return array{prefix:string,type:int,hash:string}|null
|
||
|
|
*/
|
||
|
|
public static function decode( $addr ) {
|
||
|
|
$addr = trim( (string) $addr );
|
||
|
|
if ( '' === $addr ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if ( false !== strpos( $addr, ':' ) ) {
|
||
|
|
list( $prefix, $payload ) = explode( ':', strtolower( $addr ), 2 );
|
||
|
|
return self::decode_parts( $prefix, $payload );
|
||
|
|
}
|
||
|
|
foreach ( array( 'bitcoincash', 'bchtest', 'bchreg' ) as $prefix ) {
|
||
|
|
$r = self::decode_parts( $prefix, strtolower( $addr ) );
|
||
|
|
if ( null !== $r ) {
|
||
|
|
return $r;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function decode_parts( $prefix, $payload ) {
|
||
|
|
if ( ! preg_match( '/^[a-z0-9]{1,60}$/', $prefix ) || ! preg_match( '/^[' . self::CHARSET . ']{8,124}$/', $payload ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$values = array();
|
||
|
|
$len = strlen( $payload );
|
||
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
||
|
|
$pos = strpos( self::CHARSET, $payload[ $i ] );
|
||
|
|
if ( false === $pos ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$values[] = $pos;
|
||
|
|
}
|
||
|
|
$sum = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $values ) );
|
||
|
|
if ( 0 !== $sum ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$data = self::convert_bits( array_slice( $values, 0, count( $values ) - 8 ), 5, 8, false );
|
||
|
|
if ( null === $data || count( $data ) < 21 ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$version = $data[0];
|
||
|
|
$hash = self::array_to_bytes( array_slice( $data, 1 ) );
|
||
|
|
// Only the 20-byte size code is ever produced by a wallet in this
|
||
|
|
// ecosystem; anything else is a paste of something that is not an
|
||
|
|
// address we can check a signature against.
|
||
|
|
if ( 0 !== ( $version & 0x07 ) || 20 !== strlen( $hash ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return array(
|
||
|
|
'prefix' => $prefix,
|
||
|
|
'type' => ( $version >> 3 ) & 0x0f,
|
||
|
|
'hash' => $hash,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/** True when `$addr` is a well-formed CashAddress with a valid checksum. */
|
||
|
|
public static function is_valid( $addr ) {
|
||
|
|
return null !== self::decode( $addr );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Canonical form for storage and comparison: lowercase, prefixed, and
|
||
|
|
* always the non-token p2pkh spelling.
|
||
|
|
*
|
||
|
|
* Two spellings of the same key must not become two accounts, so every
|
||
|
|
* address is squashed to this before it is written to or compared against
|
||
|
|
* the users table.
|
||
|
|
*
|
||
|
|
* @return string '' when the input is not an address.
|
||
|
|
*/
|
||
|
|
public static function normalize( $addr ) {
|
||
|
|
$d = self::decode( $addr );
|
||
|
|
if ( null === $d ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$type = ( self::TYPE_P2SH === $d['type'] || self::TYPE_P2SH_TOKEN === $d['type'] )
|
||
|
|
? self::TYPE_P2SH
|
||
|
|
: self::TYPE_P2PKH;
|
||
|
|
return self::encode( $d['prefix'], $d['hash'], $type );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The address a compressed public key controls. */
|
||
|
|
public static function from_public_key( $pubkey_bin, $prefix = 'bitcoincash' ) {
|
||
|
|
if ( 33 !== strlen( $pubkey_bin ) && 65 !== strlen( $pubkey_bin ) ) {
|
||
|
|
return '';
|
||
|
|
}
|
||
|
|
$hash = hash( 'ripemd160', hash( 'sha256', $pubkey_bin, true ), true );
|
||
|
|
return self::encode( $prefix, $hash, self::TYPE_P2PKH );
|
||
|
|
}
|
||
|
|
|
||
|
|
/** A short, human-checkable form for UI: `bchtest:qr4…8ktm`. */
|
||
|
|
public static function shorten( $addr, $head = 8, $tail = 6 ) {
|
||
|
|
$addr = (string) $addr;
|
||
|
|
$pos = strpos( $addr, ':' );
|
||
|
|
$body = false === $pos ? $addr : substr( $addr, $pos + 1 );
|
||
|
|
$pre = false === $pos ? '' : substr( $addr, 0, $pos + 1 );
|
||
|
|
if ( strlen( $body ) <= $head + $tail + 1 ) {
|
||
|
|
return $addr;
|
||
|
|
}
|
||
|
|
return $pre . substr( $body, 0, $head ) . '…' . substr( $body, -$tail );
|
||
|
|
}
|
||
|
|
|
||
|
|
// --------------------------------------------------------------- checksum
|
||
|
|
|
||
|
|
private static function expand_prefix( $prefix ) {
|
||
|
|
$out = array();
|
||
|
|
$len = strlen( $prefix );
|
||
|
|
for ( $i = 0; $i < $len; $i++ ) {
|
||
|
|
$out[] = ord( $prefix[ $i ] ) & 0x1f;
|
||
|
|
}
|
||
|
|
$out[] = 0;
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function checksum( $prefix, $data ) {
|
||
|
|
$mod = self::poly_mod( array_merge( self::expand_prefix( $prefix ), $data ) );
|
||
|
|
$out = array();
|
||
|
|
for ( $i = 0; $i < 8; $i++ ) {
|
||
|
|
$out[] = ( $mod >> ( 5 * ( 7 - $i ) ) ) & 0x1f;
|
||
|
|
}
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The BCH checksum polynomial over GF(32).
|
||
|
|
*
|
||
|
|
* Works on 40-bit accumulators, so this needs a 64-bit PHP build. 32-bit
|
||
|
|
* PHP has been unsupported by WordPress-adjacent hosting for years; the
|
||
|
|
* core plugin's requirements check says so explicitly rather than letting
|
||
|
|
* addresses silently fail to validate here.
|
||
|
|
*/
|
||
|
|
private static function poly_mod( $values ) {
|
||
|
|
$c = 1;
|
||
|
|
foreach ( $values as $d ) {
|
||
|
|
$c0 = ( $c >> 35 ) & 0xff;
|
||
|
|
$c = ( ( $c & 0x07ffffffff ) << 5 ) ^ $d;
|
||
|
|
if ( $c0 & 0x01 ) {
|
||
|
|
$c ^= 0x98f2bc8e61;
|
||
|
|
}
|
||
|
|
if ( $c0 & 0x02 ) {
|
||
|
|
$c ^= 0x79b76d99e2;
|
||
|
|
}
|
||
|
|
if ( $c0 & 0x04 ) {
|
||
|
|
$c ^= 0xf33e5fb3c4;
|
||
|
|
}
|
||
|
|
if ( $c0 & 0x08 ) {
|
||
|
|
$c ^= 0xae2eabe2a8;
|
||
|
|
}
|
||
|
|
if ( $c0 & 0x10 ) {
|
||
|
|
$c ^= 0x1e4f43e470;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return $c ^ 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- bit fiddling
|
||
|
|
|
||
|
|
private static function bytes_to_array( $bin ) {
|
||
|
|
return array_values( unpack( 'C*', $bin ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function array_to_bytes( $arr ) {
|
||
|
|
$out = '';
|
||
|
|
foreach ( $arr as $v ) {
|
||
|
|
$out .= chr( $v & 0xff );
|
||
|
|
}
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Regroup a list of values from `$from` bits each to `$to` bits each.
|
||
|
|
*
|
||
|
|
* @param bool $pad Pad the tail (encoding) or require it to be zero (decoding).
|
||
|
|
* @return array|null
|
||
|
|
*/
|
||
|
|
private static function convert_bits( $values, $from, $to, $pad ) {
|
||
|
|
$acc = 0;
|
||
|
|
$bits = 0;
|
||
|
|
$out = array();
|
||
|
|
$max = ( 1 << $to ) - 1;
|
||
|
|
foreach ( $values as $v ) {
|
||
|
|
if ( $v < 0 || ( $v >> $from ) !== 0 ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
$acc = ( $acc << $from ) | $v;
|
||
|
|
$bits += $from;
|
||
|
|
while ( $bits >= $to ) {
|
||
|
|
$bits -= $to;
|
||
|
|
$out[] = ( $acc >> $bits ) & $max;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if ( $pad ) {
|
||
|
|
if ( $bits > 0 ) {
|
||
|
|
$out[] = ( $acc << ( $to - $bits ) ) & $max;
|
||
|
|
}
|
||
|
|
} elseif ( $bits >= $from || ( ( $acc << ( $to - $bits ) ) & $max ) ) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
}
|