diff --git a/README.md b/README.md index cf326ff..7ac7363 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ This is a single PHP file - and an `.htaccess file` - which acts as an extremely This is designed to be a lightweight educational tool to show you the basics of how ActivityPub works. -There are no tests, no checks, no security features, no header verifications, no containers, no gods, no masters. +There are no tests, no checks, no security features, no formal verifications, no containers, no gods, no masters. 1. Edit the `index.php` file to add a username, password, and keypair. 1. Upload `index.php` and `.htaccess` to the *root* directory of your domain. For example `test.example.com/`. It will not work in a subdirectory. @@ -14,7 +14,7 @@ There are no tests, no checks, no security features, no header verifications, no 1. Visit `https://test.example.com/.well-known/webfinger` and check that it shows a JSON file with your user's details. 1. Go to Mastodon or other Fediverse site and search for your user: `@username@test.example.com` 1. Follow your user. -1. Check your `/logs/` directory to see if the follow request was received. +1. Check your `/logs/` directory to see if the follow request was received correctly. 1. To post a message, visit `https://test.example.com/write` type in your message and password. Press the "Post Message" button. 1. Check social media to see if the message appears. @@ -26,13 +26,14 @@ There are no tests, no checks, no security features, no header verifications, no * Post files are saved as .json in the `/posts` directory. * Followers' details are saved as .json in the `/data/followers` directory. * This has sloppy support for linking #hashtags, https:// URls, and @ mentions. +* HTTP Message Signatures are verified. ## Requirements * PHP 8.3 (We live in the future now) * The [OpenSSL Extension](https://www.php.net/manual/en/book.openssl.php) (This is usually installed by default) * HTTPS certificate (Let's Encrypt is fine) -* 50MB free disk space (ActivityPub is a very "chatty" protocol. Expect lots of logs.) +* 100MB free disk space (ActivityPub is a very "chatty" protocol. Expect lots of logs.) * Docker, Node, MongoDB, Wayland, GLaDOS, React, LLM, Adobe Creative Cloud, Maven (Absolutely none of these!) ## Licence diff --git a/index.php b/index.php index 91917d7..6fef7a4 100644 --- a/index.php +++ b/index.php @@ -47,7 +47,8 @@ $postData = print_r( $_POST, true ); $getData = print_r( $_GET, true ); $filesData = print_r( $_FILES, true ); - $body = json_decode( file_get_contents( "php://input" ), true ); + $input = file_get_contents( "php://input" ); + $body = json_decode( $input, true ); $bodyData = print_r( $body, true ); $requestData = print_r( $_REQUEST, true ); $serverData = print_r( $_SERVER, true ); @@ -81,6 +82,10 @@ "Server Data: \n$serverData \n\n" ); + // Validate HTTP Message Signature + // This logs whether the signature was validated or not + if ( !verifyHTTPSignature() ) { die(); } + // Routing: // The .htaccess changes /whatever to /?path=whatever // This runs the function of the path requested. @@ -740,6 +745,167 @@ HTML; die(); } + // Verify the signature sent with the message. + // This is optional + // It is very confusing + function verifyHTTPSignature() { + global $input, $body, $server; + + // Get the headers send with the request + $headers = getallheaders(); + + // Validate the timestamp is within ±30 seconds + if ( !isset( $headers["Date"] ) ) { return null; } // No date set + $dateHeader = $headers["Date"]; + $headerDatetime = DateTime::createFromFormat('D, d M Y H:i:s T', $dateHeader); + $currentDatetime = new DateTime(); + + // Calculate the time difference in seconds + $timeDifference = abs($currentDatetime->getTimestamp() - $headerDatetime->getTimestamp()); + if ($timeDifference > 30) { + // Write a log detailing the error + $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); + // Filename for the log + $filename = "{$timestamp} Time Failure.txt"; + + // Save headers and request data to the timestamped file in the logs directory + if( ! is_dir( "logs" ) ) { mkdir( "logs"); } + + file_put_contents( "logs/{$filename}", + "Original Date:\n" . print_r( $dateHeader, true ) . "\n" . + "Local Date:\n" . print_r( $currentDatetime->format('D, d M Y H:i:s T'), true ) . "\n" + ); + return false; + } + + // Validate the Digest + // It is the hash of the raw input string, in binary, encoded as base64 + $digestString = $headers["Digest"]; + // Usually in the form `SHA-256=Ofv56Jm9rlowLR9zTkfeMGLUG1JYQZj0up3aRPZgT0c=` + // The Base64 encoding may have multiple `=` at the end. So split this at the first `=` + $digestData = explode( "=", $digestString, 2 ); + $digestAlgorithm = $digestData[0]; + $digestHash = $digestData[1]; + + // There might be many different hashing algorithms + // TODO: Find a way to transform these automatically + if ( "SHA-256" == $digestAlgorithm ) { + $digestAlgorithm = "sha256"; + } else if ( "SHA-512" == $digestAlgorithm ) { + $digestAlgorithm = "sha512"; + } + + // Manually calculate the digest based on the data sent + $digestCalculated = base64_encode( hash( $digestAlgorithm, $input, true ) ); + + // Does our calculation match what was sent? + if ( !( $digestCalculated == $digestHash ) ) { + // Write a log detailing the error + $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); + // Filename for the log + $filename = "{$timestamp} Digest Failure.txt"; + + // Save headers and request data to the timestamped file in the logs directory + if( ! is_dir( "logs" ) ) { mkdir( "logs"); } + + file_put_contents( "logs/{$filename}", + "Original Input:\n" . print_r( $input, true ) . "\n" . + "Original Digest:\n" . print_r( $digestString, true ) . "\n" . + "Calculated Digest:\n" . print_r( $digestCalculated, true ) . "\n" + ); + return false; + } + + // Examine the signature + $signatureHeader = $headers["Signature"]; + + // Extract key information from the Signature header + $signatureParts = []; + // Converts 'a=b,c=d e f' into ["a"=>"b", "c"=>"d e f"] + // word="text" + preg_match_all('/(\w+)="([^"]+)"/', $signatureHeader, $matches); + foreach ($matches[1] as $index => $key) { + $signatureParts[$key] = $matches[2][$index]; + } + + // Manually reconstruct the header string + $signatureHeaders = explode(" ", $signatureParts["headers"] ); + $signatureString = ""; + foreach ($signatureHeaders as $signatureHeader) { + if ( "(request-target)" == $signatureHeader ) { + $method = strtolower( $_SERVER["REQUEST_METHOD"] ); + $target = strtolower( $_SERVER["REQUEST_URI"] ); + $signatureString .= "(request-target): {$method} {$target}\n"; + } else if ( "host" == $signatureHeader ) { + $host = strtolower( $_SERVER["HTTP_HOST"] ); + $signatureString .= "host: {$host}\n"; + } else { + // In the HTTP header, the keys use Title Case + $signatureString .= "{$signatureHeader}: " . $headers[ ucwords( $signatureHeader, "-" ) ] . "\n"; + } + } + + // Remove trailing newline + $signatureString = trim( $signatureString ); + + // Get the Public Key + // The link to the key may be sent with the body, but is always sent in the Signature header. + $publicKeyURL = $signatureParts["keyId"]; + + // This is usually in the form `https://example.com/user/username#main-key` + // This is to differentiate if the user has multiple keys + // TODO: Check the actual key + // This request does not need to be signed. But it does need to specify that it wants a JSON response + $context = stream_context_create( + [ "http" => [ "header" => "Accept: application/activity+json" ] ] + ); + $userJSON = file_get_contents( $publicKeyURL, false, $context ); + $userData = json_decode( $userJSON, true ); + $publicKey = $userData["publicKey"]["publicKeyPem"]; + + // Get the remaining parts + $signature = base64_decode( $signatureParts["signature"] ); + $algorithm = $signatureParts["algorithm"]; + + // Finally! Calculate whether the signature is valid + // Returns 1 if verified, 0 if not, false or -1 if an error occurred + $verified = openssl_verify( + $signatureString, + $signature, + $publicKey, + $algorithm + ); + + // Convert to boolean + if ($verified === 1) { + $verified = true; + } elseif ($verified === 0) { + $verified = false; + } else { + $verified = null; + } + + // Write a log detailing the signature verification process + $timestamp = ( new DateTime() )->format( DATE_RFC3339_EXTENDED ); + // Filename for the log + $filename = "{$timestamp} Signature ". json_encode( $verified ) . ".txt"; + + // Save headers and request data to the timestamped file in the logs directory + if( ! is_dir( "logs" ) ) { mkdir( "logs"); } + + file_put_contents( "logs/{$filename}", + "Original Body:\n" . print_r( $body, true ) . "\n\n" . + "Original Headers:\n" . print_r( $headers, true ) . "\n\n" . + "Signature Headers:\n" . print_r( $signatureHeaders, true ) . "\n\n" . + "Calculated signatureString:\n" . print_r( $signatureString, true ) . "\n\n" . + "Calculated algorithm:\n" . print_r( $algorithm, true ) . "\n\n" . + "publicKeyURL:\n" . print_r( $publicKeyURL, true ) . "\n\n" . + "publicKey:\n" . print_r( $publicKey, true ) . "\n" + ); + + return $verified; + } + // "One to stun, two to kill, three to make sure" die(); die();