/…`. * 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\n\n\n") * * @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; } }