The Interledger Community 🌱

Andrew Hancox for Web Monetization for Moodle

Posted on

Building and using a Receipt Verifier Service

Introduction

In order to monetize a web-site it is likely that the publishers of the content will need to deny access to some or all content to users who are not working with a web-monetized browser.

The concepts behind this are out-lined in the webmonetization documentation here and in the interledger specification here, however the documenation is quite abstract and the examples are all based on JavaScript single page application so we felt it worth sharing our experience achieving the same goals with backend PHP in the stack.

Detecting browser monetization

You can see the javacript code in context over in GitHub here.
This file is an AMD module and the init function is run once the dom is fully loaded and Moodle core Javascript has been bootstrapped.

The first thing the init function does is look to see if the document.monetization object exists, if it does not then it will re-driect the user to a page directing them instructions on setting up a webmonetized browser.

        init: function (requiremonetization, wantsurl) {
            if (document.monetization) {
                DO SOME STUFF
            } else if (requiremonetization) {
                var redirecturl = M.cfg.wwwroot + '/local/webmonetization/failed.php?contextid=' + M.cfg.contextid;
                window.location.href = redirecturl;
            }
        }
    };
Enter fullscreen mode Exit fullscreen mode

If the browser does support web monetization then it binds a function to the monetizationprogress event that sends the receipt to a web service for validation, if the receipt is valid then the user is allowed to continue, otherwise they are redirected to an interstitial page where the user will wait until a valid receipt is submitted.

document.monetization.addEventListener('monetizationprogress', event => {
    var req = ajax.call([
        {
            methodname: 'local_webmonetization_handlereceipt', args: {
                receipt: event.detail.receipt,
                contextid: M.cfg.contextid
            }
        }
    ]);

    if (requiremonetization) {
        var redirecturl = M.cfg.wwwroot + '/local/webmonetization/interstitial.php?contextid=' + M.cfg.contextid;
        req[0].done(function (result) {
            if (result !== true) {
                window.location.href = redirecturl;
            } else if (wantsurl) {
                window.location.href = wantsurl;
            }
        }).fail(function () {
            window.location.href = redirecturl;
        });
    }
});
Enter fullscreen mode Exit fullscreen mode

The code for the web service can be found here.

$verifier = receiptverifier::get_receiptverifier();
$result = $verifier->verify($receipt);

receiptverifier::set_lastsessionverificationresult($result);
Enter fullscreen mode Exit fullscreen mode

You can see all it's doing is validating the receipt (more on this later...) and updating a property of the session that tracks the latest result. This gets checked before every page gets returned in the local_webmonetization_before_standard_html_head function contained in lib.php - if the user's most recent receipt failed to validate then the will get sent to an interstitial page to wait for a valid one, if their last page view detected that the browser is not web monetized then they will get sent to the page with instructions on how to proceed we mentioned earlier.

$forcepayment =
        !empty($paymentpointer->get('forcepayment'))
        &&
        !has_capability('local/webmonetization:ignoreforcepayment', $PAGE->context);

$lastverificationresult = receiptverifier::get_lastsessionverificationresult();
if (!$monetizationsetuppage && $forcepayment && !isset($lastverificationresult)) {
    redirect(new moodle_url('/local/webmonetization/interstitial.php', ['contextid' => $PAGE->context->id]));
} else if (!$monetizationsetuppage && $forcepayment && $lastverificationresult == false) {
    redirect(new moodle_url('/local/webmonetization/failed.php', ['contextid' => $PAGE->context->id]));
}
Enter fullscreen mode Exit fullscreen mode

Building a local receipt verifier

The reference implementation in the webmonetization documentation supplies a node application that will verify receipts however it has a few drawbacks:

  • Privacy or deployment issues as it requires you to either deploy an additional web application or proxy all of your webmonetization related webservice calls through a third party service
  • Scaling issues as it adds additional webservice calls to various parts of the process and so increases latency, bandwidth etc. on something that will be happening every few seconds for every active user on your site
  • Functionality compromises as it does not surface all of the information contained within the reciept, e.g. amount transferred

In order to avoid these limitations we have provided a receipt verifier within the Moodle plugin.

There are two components to this:

The proxy

The job of the proxy is to generate a nonce and secret and then add them to the request headers of the call to the payment pointer.

$rawnonce = $receipthandler->generate_receipt_nonce();
$nonce = base64_encode($rawnonce);
$secret = base64_encode($receipthandler->generate_receipt_secret($rawnonce));
$webmonetizationid = $_SERVER['HTTP_WEB_MONETIZATION_ID'];

$curl = new curl();
$headers = [
        'accept: application/spsp4+json',
        'content-type: application/spsp4+json',
        "Web-Monetization-Id: $webmonetizationid",
        "Receipt-Nonce: $nonce",
        "Receipt-Secret: $secret"
];

$curl->setHeader($headers);

$result = $curl->get($paymentpointer);
Enter fullscreen mode Exit fullscreen mode

It does this using the InterledgerPHP library:

public function generate_receipt_nonce(): string {
    return random_bytes(16);
}

public function generate_receipt_secret(string $nonce): string {
    $keygen = hash_hmac('sha256', utf8_encode(self::RECEIPT_SECRET_GENERATION_STRING), $this->receiptseed, true);
    return hash_hmac('sha256', $nonce, base64_encode($keygen), true);
}
Enter fullscreen mode Exit fullscreen mode

The receipt verifier

Ealier on we described JavaScript code that registers an event handler to catch the monetizationprogress event that is fired by the browser and forward the receipt the browser plugin supplies to a web service for validation. It is here that the receipt verifier gets used.

The first important thing is does is use the InterledgerPHP library to parse the receipt:

$receipt = $receipthandler->parse_receipt($binreceipt);
Enter fullscreen mode Exit fullscreen mode

The receipt is a binary blob that requires some relatively complex parsing as seen below - due to the potential volume of requests a response format such as JSON would create too much overhead.

$arrayofbytes = str_split($binaryreceipt);

if (count($arrayofbytes) !== 58) {
    throw new receiptexception('incorrect size');
}

$arrayofbinarybytes = [];
foreach ($arrayofbytes as $byte) {
    $arrayofbinarybytes[] = sprintf("%08b", ord($byte));
}

$version = bindec(array_slice($arrayofbinarybytes, 0, 1)[0]);

if ($version !== 1) {
    throw new receiptexception('unsupported receipt version');
}

$receipt = new receipt();
$receipt->nonce = implode('', array_slice($arrayofbytes, 1, 16));
$receipt->streamid = bindec(array_slice($arrayofbinarybytes, 17, 1)[0]);
$receipt->totalreceived = bindec(implode('', array_slice($arrayofbinarybytes, 18, 8)));
Enter fullscreen mode Exit fullscreen mode

The next step is to validate the receipt, since it was generated by the browser it could have been spoofed so we need to ensure that it corresponds to an actual open payment stream. The receipt consists of two parts, the first portion is the body, the second portion is a hash of the body created using the secret we shared with the system that runs our payment pointer (and the user's browser is unaware of). We can validate the receipt by calculating our own hash of the receipt's body with the share secret and confirming it matches.

$arrayofbytes = str_split($binaryreceipt);

$arrayofbinarybytes = [];
foreach ($arrayofbytes as $byte) {
    $arrayofbinarybytes[] = sprintf("%08b", ord($byte));
}

$nonce = implode('', array_slice($arrayofbytes, 1, 16));
$receiptbody = implode('', array_slice($arrayofbytes, 0, 26));
$receipthmac = implode('', array_slice($arrayofbytes, 26, 32));

$receiptsecret = $this->generate_receipt_secret($nonce);

$calculatedhmac = hash_hmac('sha256', $receiptbody, $receiptsecret, true);

if ($receipthmac != $calculatedhmac) {
    return false;
}

return true;
Enter fullscreen mode Exit fullscreen mode

Thanks for reading!

Building this element of the project was by far the most complicated aspect and we really hope this documentation will save other some time.

Top comments (0)