176 lines
6 KiB
PHP
176 lines
6 KiB
PHP
|
|
<?php
|
||
|
|
/**
|
||
|
|
* The two signing envelopes Sirius Press speaks.
|
||
|
|
*
|
||
|
|
* **BIP-137** — the "Bitcoin Signed Message:\n" scheme every BCH wallet has
|
||
|
|
* implemented for a decade. Login, registration and any other place a *human*
|
||
|
|
* proves who they are uses this, because it is the only format a user can
|
||
|
|
* produce without installing anything of ours: Electron Cash's Sign Message
|
||
|
|
* box, the Theseus wallet bridge and the in-page wallet all emit it, so a
|
||
|
|
* signature can be pasted in from whatever the person already runs.
|
||
|
|
*
|
||
|
|
* **BNS-SITE1** — the BNS gateway's own envelope for `PUT /api/site/<name>/…`.
|
||
|
|
* Single SHA-256 over a fixed line format, no magic prefix. Only the server
|
||
|
|
* ever produces these, using the stored publishing key, so no wallet has to
|
||
|
|
* understand it.
|
||
|
|
*
|
||
|
|
* Getting these two confused is the single most likely source of a
|
||
|
|
* "signature does not match" that looks like a key problem, so they live side
|
||
|
|
* by side here rather than being inlined at their call sites.
|
||
|
|
*
|
||
|
|
* @package SiriusPress
|
||
|
|
*/
|
||
|
|
|
||
|
|
defined( 'ABSPATH' ) || defined( 'SP_CLI' ) || exit;
|
||
|
|
|
||
|
|
require_once __DIR__ . '/class-sp-secp256k1.php';
|
||
|
|
require_once __DIR__ . '/class-sp-cashaddr.php';
|
||
|
|
|
||
|
|
final class SP_Message {
|
||
|
|
|
||
|
|
const MAGIC = "Bitcoin Signed Message:\n";
|
||
|
|
|
||
|
|
// ------------------------------------------------------------- BIP-137
|
||
|
|
|
||
|
|
/** Bitcoin-style varint length prefix. */
|
||
|
|
private static function var_int( $n ) {
|
||
|
|
if ( $n < 0xfd ) {
|
||
|
|
return chr( $n );
|
||
|
|
}
|
||
|
|
if ( $n <= 0xffff ) {
|
||
|
|
return "\xfd" . pack( 'v', $n );
|
||
|
|
}
|
||
|
|
return "\xfe" . pack( 'V', $n );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function var_str( $s ) {
|
||
|
|
return self::var_int( strlen( $s ) ) . $s;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The 32-byte digest a wallet actually signs for a text message.
|
||
|
|
*
|
||
|
|
* Double SHA-256 over varstr(magic) || varstr(message).
|
||
|
|
*/
|
||
|
|
public static function bip137_digest( $message ) {
|
||
|
|
$payload = self::var_str( self::MAGIC ) . self::var_str( (string) $message );
|
||
|
|
return hash( 'sha256', hash( 'sha256', $payload, true ), true );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Verify a wallet's message signature against a claimed address.
|
||
|
|
*
|
||
|
|
* The comparison is on the normalised address, so a user who registered
|
||
|
|
* with the plain form and signs with a token-aware wallet still matches.
|
||
|
|
* The network prefix is taken from the claimed address, which means a
|
||
|
|
* chipnet signature can never authenticate a mainnet account.
|
||
|
|
*
|
||
|
|
* @param string $message Exactly the text that was signed.
|
||
|
|
* @param string $sig_b64 Base64 of 65 bytes.
|
||
|
|
* @param string $address The CashAddress being claimed.
|
||
|
|
* @return bool
|
||
|
|
*/
|
||
|
|
public static function verify( $message, $sig_b64, $address ) {
|
||
|
|
$claimed = SP_CashAddr::normalize( $address );
|
||
|
|
if ( '' === $claimed ) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
$decoded = SP_CashAddr::decode( $claimed );
|
||
|
|
$sig = base64_decode( (string) $sig_b64, true );
|
||
|
|
if ( false === $sig || 65 !== strlen( $sig ) ) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
$pub = SP_Secp256k1::recover( $sig, self::bip137_digest( $message ) );
|
||
|
|
if ( '' === $pub ) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
$derived = SP_CashAddr::from_public_key( $pub, $decoded['prefix'] );
|
||
|
|
return '' !== $derived && hash_equals( $claimed, $derived );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Produce a BIP-137 signature. Used by tests and by WP-CLI helpers; the
|
||
|
|
* running site never signs on a user's behalf.
|
||
|
|
*
|
||
|
|
* @return string Base64 of 65 bytes.
|
||
|
|
* @throws Exception
|
||
|
|
*/
|
||
|
|
public static function sign( $message, $priv_bin ) {
|
||
|
|
return base64_encode( SP_Secp256k1::sign_recoverable( self::bip137_digest( $message ), $priv_bin ) );
|
||
|
|
}
|
||
|
|
|
||
|
|
// ----------------------------------------------------------- BNS-SITE1
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Digest for a gateway site write.
|
||
|
|
*
|
||
|
|
* Mirrors public-gateway.mjs exactly:
|
||
|
|
* sha256("BNS-SITE1\n<name>\n<path>\n<sha256hex(body)>\n<ts>")
|
||
|
|
*
|
||
|
|
* @param string $name Fully-qualified BCNR name, e.g. "example.bch".
|
||
|
|
* @param string $path Path inside the name's bucket, no leading slash.
|
||
|
|
* @param string $body Raw bytes being uploaded ('' for DELETE).
|
||
|
|
* @param int $ts Unix milliseconds.
|
||
|
|
*/
|
||
|
|
public static function site_digest( $name, $path, $body, $ts ) {
|
||
|
|
$body_hex = hash( 'sha256', $body );
|
||
|
|
return hash( 'sha256', "BNS-SITE1\n{$name}\n{$path}\n{$body_hex}\n{$ts}", true );
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* The `x-bns-sig` / `x-bns-ts` pair for a site write.
|
||
|
|
*
|
||
|
|
* @return array{'x-bns-sig':string,'x-bns-ts':string}
|
||
|
|
* @throws Exception
|
||
|
|
*/
|
||
|
|
public static function site_headers( $name, $path, $body, $priv_bin, $ts = null ) {
|
||
|
|
$ts = null === $ts ? (int) round( microtime( true ) * 1000 ) : (int) $ts;
|
||
|
|
$sig = SP_Secp256k1::sign_recoverable( self::site_digest( $name, $path, $body, $ts ), $priv_bin );
|
||
|
|
return array(
|
||
|
|
'x-bns-sig' => base64_encode( $sig ),
|
||
|
|
'x-bns-ts' => (string) $ts,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ------------------------------------------------------- canonical JSON
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Canonical JSON per DESIGN-signed-records-manifest.md: keys sorted at
|
||
|
|
* every level, no insignificant whitespace, non-ASCII \u-escaped, null
|
||
|
|
* members dropped.
|
||
|
|
*
|
||
|
|
* Only needed when this plugin writes a `_records.json` manifest — the
|
||
|
|
* export path signs raw bytes, not JSON. Kept here so there is exactly one
|
||
|
|
* canonicaliser in the codebase.
|
||
|
|
*/
|
||
|
|
public static function canonical_json( $value ) {
|
||
|
|
// JSON_UNESCAPED_SLASHES matches JavaScript's JSON.stringify, which the
|
||
|
|
// portal signs with; PHP would otherwise emit \/ and produce different
|
||
|
|
// bytes for the same manifest. Non-ASCII stays \u-escaped, which is
|
||
|
|
// json_encode's default and is what the spec asks for.
|
||
|
|
return json_encode( self::canonicalize( $value ), JSON_UNESCAPED_SLASHES );
|
||
|
|
}
|
||
|
|
|
||
|
|
private static function canonicalize( $value ) {
|
||
|
|
if ( is_object( $value ) ) {
|
||
|
|
$value = (array) $value;
|
||
|
|
}
|
||
|
|
if ( is_array( $value ) ) {
|
||
|
|
$is_list = ( array() === $value ) || array_keys( $value ) === range( 0, count( $value ) - 1 );
|
||
|
|
if ( $is_list ) {
|
||
|
|
return array_map( array( __CLASS__, 'canonicalize' ), $value );
|
||
|
|
}
|
||
|
|
$out = array();
|
||
|
|
foreach ( $value as $k => $v ) {
|
||
|
|
if ( null === $v ) {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
$out[ (string) $k ] = self::canonicalize( $v );
|
||
|
|
}
|
||
|
|
ksort( $out, SORT_STRING );
|
||
|
|
return $out;
|
||
|
|
}
|
||
|
|
return $value;
|
||
|
|
}
|
||
|
|
}
|