Add HTTP Signature Verification
rodzic
cf07fe0b58
commit
8a7544e5bb
|
@ -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
168
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();
|
||||
|
|
Ładowanie…
Reference in New Issue