cloud'; public const DIRECTION_TO_AGENT = 'cloud->agent'; public const MAX_SKEW_SECONDS = 300; public static function canonical( string $direction, string $method, string $path, string $site_id, string $key_id, int $ts, string $nonce, string $body ): string { return implode( "\n", array( 'OPERANSE-HMAC-SHA256', $direction, strtoupper( $method ), $path, $site_id, $key_id, (string) $ts, $nonce, hash( 'sha256', $body ) ) ); } public static function sign( string $secret, string $direction, string $method, string $path, string $site_id, string $key_id, int $ts, string $nonce, string $body ): string { return self::b64url( hash_hmac( 'sha256', self::canonical( $direction, $method, $path, $site_id, $key_id, $ts, $nonce, $body ), $secret, true ) ); } public static function header( string $secret, string $direction, string $method, string $path, string $site_id, string $key_id, string $body, ?int $ts = null, ?string $nonce = null ): string { $ts = $ts ?? time(); $nonce = $nonce ?? self::nonce(); $sig = self::sign( $secret, $direction, $method, $path, $site_id, $key_id, $ts, $nonce, $body ); return sprintf( '%s site=%s,kid=%s,ts=%d,nonce=%s,sig=%s', self::SCHEME, $site_id, $key_id, $ts, $nonce, $sig ); } /** * @return array{site:string,kid:string,ts:int,nonce:string,sig:string}|null */ public static function parse( ?string $header ): ?array { if ( $header === null || $header === '' ) { return null; } $space = strpos( $header, ' ' ); if ( $space === false || substr( $header, 0, $space ) !== self::SCHEME ) { return null; } $out = array(); foreach ( explode( ',', trim( substr( $header, $space + 1 ) ) ) as $part ) { if ( ! preg_match( '/^([a-z]+)=([A-Za-z0-9_\-=]+)$/', trim( $part ), $m ) ) { return null; } $out[ $m[1] ] = $m[2]; } if ( ! isset( $out['site'], $out['kid'], $out['ts'], $out['nonce'], $out['sig'] ) || ! ctype_digit( $out['ts'] ) ) { return null; } $nl = strlen( $out['nonce'] ); if ( $nl < 16 || $nl > 64 ) { return null; } return array( 'site' => $out['site'], 'kid' => $out['kid'], 'ts' => (int) $out['ts'], 'nonce' => $out['nonce'], 'sig' => $out['sig'], ); } /** * @param array{site:string,kid:string,ts:int,nonce:string,sig:string} $parts * @return string|null null when valid, otherwise 'skew'|'signature' */ public static function verify( string $secret, array $parts, string $direction, string $method, string $path, string $body, ?int $now = null ): ?string { $now = $now ?? time(); if ( abs( $now - $parts['ts'] ) > self::MAX_SKEW_SECONDS ) { return 'skew'; } $expected = self::sign( $secret, $direction, $method, $path, $parts['site'], $parts['kid'], $parts['ts'], $parts['nonce'], $body ); return hash_equals( $expected, $parts['sig'] ) ? null : 'signature'; } public static function nonce(): string { return self::b64url( random_bytes( 16 ) ); } public static function b64url( string $bin ): string { return rtrim( strtr( base64_encode( $bin ), '+/', '-_' ), '=' ); } }