Add HTTP Signature Verification

merge-requests/5/head
Terence Eden 2024-02-25 14:42:50 +00:00
rodzic cf07fe0b58
commit 8a7544e5bb
2 zmienionych plików z 171 dodań i 4 usunięć

Wyświetl plik

@ -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

168
index.php
Wyświetl plik

@ -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();