19, 'user_id' => null, 'user_email' => 100, 'ip_address' => 45, 'event_type' => 50, 'object_type' => 50, 'object_id' => null, 'summary' => null, 'details' => null, 'country' => 2, 'city' => 80, 'device_class' => 10, 'browser' => 40, 'os' => 40, ); /** Integer columns, cast the way MySQL returns them. */ private const INTEGERS = array( 'user_id', 'object_id' ); /** What MySQL's TEXT column holds: bytes, not characters. */ private const TEXT_BYTES = 65535; /** * The `chain_prev` of the first row of a chain. Derived from the key, so * a forger without it cannot start a chain of their own that verifies, * and from the epoch (the floor a restart set, 0 for the first chain) so * that a restarted chain does not reuse the first chain's genesis: * `chain_prev` is UNIQUE, and a second claim on the same value is how the * database refuses a fork. * * @param string $key Signing key. * @param int $epoch The floor of the current chain. */ public static function genesis( string $key, int $epoch = 0 ): string { return hash_hmac( 'sha256', 0 === $epoch ? 'genesis' : 'genesis|' . $epoch, $key ); } /** * A row as the database will hold it. * * The digest is computed over THIS, and THIS is what is inserted, so the * value read back at verification time is the value that was sealed even * where the database would have altered the original: an overlong * varchar is silently truncated outside strict mode, invalid UTF-8 and * (on a 3-byte charset) four-byte characters are stripped or replaced. * Any of those, un-normalised, would read as tampering on the first * emoji. * * @param array $row Column => value; missing columns are NULL. * @param bool $four_bytes Whether the table's charset stores four-byte characters (utf8mb4). * @return array Exactly COLUMNS, in order. */ public static function normalise( array $row, bool $four_bytes = true ): array { $out = array(); foreach ( self::COLUMNS as $column => $limit ) { $value = $row[ $column ] ?? null; if ( null === $value ) { $out[ $column ] = null; continue; } if ( in_array( $column, self::INTEGERS, true ) ) { $out[ $column ] = (int) $value; continue; } $value = self::utf8( (string) $value, $four_bytes ); if ( null !== $limit ) { $value = mb_substr( $value, 0, $limit, 'UTF-8' ); } elseif ( 'summary' === $column ) { $value = mb_strcut( $value, 0, self::TEXT_BYTES, 'UTF-8' ); } $out[ $column ] = $value; } return $out; } /** * The HMAC over a normalised row. * * Each column is written as a type marker and, for a value, its byte * length before it: NULL is `~`, a value is `=:`. Length * prefixes make the encoding unambiguous without an escape rule, so a * value holding a separator, a newline or something that looks like the * next column cannot move a boundary (the adversarial tests carry * exactly those). NULL and the empty string differ, as they do in the * database. * * @param string $key Signing key. * @param array $row A normalise() result. */ public static function content( string $key, array $row ): string { $message = ''; foreach ( array_keys( self::COLUMNS ) as $column ) { $value = $row[ $column ] ?? null; if ( null === $value ) { $message .= '~|'; continue; } $value = (string) $value; $message .= '=' . strlen( $value ) . ':' . $value . '|'; } return hash_hmac( 'sha256', 'content|' . $message, $key ); } /** * A row's chain hash. * * @param string $key Signing key. * @param string $prev The previous row's chain hash (or genesis). * @param string $content This row's content digest. */ public static function link( string $key, string $prev, string $content ): string { return hash_hmac( 'sha256', 'link|' . $prev . '|' . $content, $key ); } /** * A signature over a small piece of chain state (the head, the cut * ledger, the floor), bound to what the state is so one kind's signature * cannot stand in for another's. * * @param string $key Signing key. * @param string $kind What is being signed. * @param string $state A canonical string of the state. */ public static function sign( string $key, string $kind, string $state ): string { return hash_hmac( 'sha256', 'sign|' . $kind . '|' . strlen( $state ) . ':' . $state, $key ); } /** * Constant-time comparison; false for anything that is not two strings. * * @param mixed $expected The recomputed value. * @param mixed $actual The stored value. */ public static function same( $expected, $actual ): bool { return is_string( $expected ) && is_string( $actual ) && hash_equals( $expected, $actual ); } /** * A 64-character lowercase hex digest. * * @param mixed $value Candidate. */ public static function is_digest( $value ): bool { return is_string( $value ) && 1 === preg_match( '/\A[0-9a-f]{64}\z/D', $value ); } /** * Valid UTF-8, with four-byte characters removed when the charset cannot * hold them. * * @param string $value Raw value. * @param bool $four_bytes Whether four-byte characters are stored. */ private static function utf8( string $value, bool $four_bytes ): string { if ( 1 !== preg_match( '//u', $value ) ) { $value = (string) mb_convert_encoding( $value, 'UTF-8', 'UTF-8' ); } if ( ! $four_bytes ) { $value = (string) preg_replace( '/[\x{10000}-\x{10FFFF}]/u', '', $value ); } return $value; } }