diff --git a/admin/create-payment-link.sh b/admin/create-payment-link.sh new file mode 100644 index 0000000000000000000000000000000000000000..394bda70811bdcadd29f293f59fad9a30ccb9acb --- /dev/null +++ b/admin/create-payment-link.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KEY=$1 +shift + +DOMAIN=$1 +shift + +PRODUCT_ID=$( + curl https://api.stripe.com/v1/products \ + -u "${KEY}:" \ + -d "name=30 GiB-months" \ + -d "description=30 GiB-months of Private.Storage storage × time" \ + -d "statement_descriptor=PRIVATE STORAGE" \ + -d "url=https://${DOMAIN}/" | + jp --unquoted id + ) + +echo "Product: $PRODUCT_ID" + +PRICE_ID=$( + curl https://api.stripe.com/v1/prices \ + -u "${KEY}:" \ + -d "currency=USD" \ + -d "unit_amount=650" \ + -d "tax_behavior=exclusive" \ + -d "product=${PRODUCT_ID}" | + jp --unquoted id + ) + +echo "Price: $PRICE_ID" + +LINK_URL=$( + curl https://api.stripe.com/v1/payment_links \ + -u "${KEY}:" \ + -d "line_items[0][price]=${PRICE_ID}" \ + -d "line_items[0][quantity]=1" \ + -d "after_completion[type]"=redirect \ + -d "after_completion[redirect][url]"="https://${DOMAIN}/payment/success" | + jp --unquoted url + ) + +echo "Payment link: $LINK_URL" diff --git a/admin/create-webhook.sh b/admin/create-webhook.sh new file mode 100644 index 0000000000000000000000000000000000000000..eacc3308e4c1e29a02372a69868a79daf435657a --- /dev/null +++ b/admin/create-webhook.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +set -euo pipefail + +KEY=$1 +shift + +DOMAIN=$1 +shift + +curl \ + https://api.stripe.com/v1/webhook_endpoints \ + -u "${KEY}:" \ + -d url="https://payments.${DOMAIN}/v1/stripe/webhook" \ + -d "enabled_events[]"="checkout.session.completed" diff --git a/docs/ops/README.rst b/docs/ops/README.rst index 8026e1673b0dbed3708b1c4b0e7b600c049fabde..109b29d30eb60564c3305c6b97b0d19468dbd3a8 100644 --- a/docs/ops/README.rst +++ b/docs/ops/README.rst @@ -10,3 +10,4 @@ This contains documentation regarding running PrivateStorageio. monitoring generating-keys backup-recovery + stripe diff --git a/docs/ops/stripe.rst b/docs/ops/stripe.rst new file mode 100644 index 0000000000000000000000000000000000000000..17a471590bcb2678635293489e17b5e189a3f848 --- /dev/null +++ b/docs/ops/stripe.rst @@ -0,0 +1,19 @@ +Stripe +====== + +We use Stripe for payment processing. +We have test-mode keys for use in staging and live-mode keys for use in production. + +There is "payment link" state in Stripe to facilitate the payment workflow. +This was created with ``admin/create-payment-link.sh`` +(once for test-mode and once for live-mode). + +The payment links can be found in PrivateStorageOps. +They are the values of the stage3 variables ``stripe_payment_link_staging`` and ``stripe_payment_link_production``. + +There is also "webhook" state in Stripe so that PaymentServer receives notification of payment. +This was created with ``admin/create-webhook.sh`` +(once for test-mode and once for live-mode). +The test-mode webhook is ``we_1LxwKnBHXBAMm9bPDJXJNcDN``. +The live-mode webhook is ``we_1LzioNA9OAm23rYOmAcp3V85``. +The webhook secrets can be found with the rest of the each grid's private keys in ``stripe.webhook-secret``. diff --git a/morph/grid/local/private-keys/stripe.webhook-secret b/morph/grid/local/private-keys/stripe.webhook-secret new file mode 100644 index 0000000000000000000000000000000000000000..a2f493f0cca4e60694b4d9177030da882b703f9e --- /dev/null +++ b/morph/grid/local/private-keys/stripe.webhook-secret @@ -0,0 +1 @@ +whsec_12121212121212121212121212121212121212 diff --git a/morph/lib/issuer.nix b/morph/lib/issuer.nix index 86c46a14762f0b9e1aba6602140bc0a2bc245e7c..69d44d3104d03553f384b2b2d2e98a34557d2d84 100644 --- a/morph/lib/issuer.nix +++ b/morph/lib/issuer.nix @@ -46,6 +46,14 @@ in { permissions = "0400"; action = ["sudo" "systemctl" "restart" "zkapissuer.service"]; }; + "stripe-webhook-secret-key" = { + destination = "/run/keys/stripe.webhook-secret-key"; + source = "${privateKeyPath}/stripe.webhook-secret"; + owner.user = "zkapissuer"; + owner.group = "zkapissuer"; + permissions = "0400"; + action = ["sudo" "systemctl" "restart" "zkapissuer.service"]; + }; }; }; services.private-storage-issuer = { @@ -53,6 +61,7 @@ in { tls = true; ristrettoSigningKeyPath = config.deployment.secrets.ristretto-signing-key.destination; stripeSecretKeyPath = config.deployment.secrets.stripe-secret-key.destination; + stripeWebhookSecretKeyPath = config.deployment.secrets.stripe-webhook-secret-key.destination; database = "SQLite3"; databasePath = "${config.fileSystems."zkapissuer-data".mountPoint}/vouchers.sqlite3"; inherit (config.grid) letsEncryptAdminEmail; diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix index b032100f1f20f04f174aa8f03faf5bf22a97b226..375f064c63c65b1b0b0c5fa94a121a4d68bba781 100644 --- a/nixos/modules/issuer.nix +++ b/nixos/modules/issuer.nix @@ -62,6 +62,13 @@ in { and payment management. ''; }; + services.private-storage-issuer.stripeWebhookSecretKeyPath = lib.mkOption { + type = lib.types.path; + description = '' + The path to a file containing a Stripe "webhook" secret key to use for + charge and payment management. + ''; + }; services.private-storage-issuer.stripeEndpointDomain = lib.mkOption { type = lib.types.str; description = '' @@ -216,6 +223,7 @@ in { stripeArgs = "--stripe-key-path ${cfg.stripeSecretKeyPath} " + + "--stripe-webhook-key-path ${cfg.stripeWebhookSecretKeyPath} " + "--stripe-endpoint-domain ${cfg.stripeEndpointDomain} " + "--stripe-endpoint-scheme ${cfg.stripeEndpointScheme} " + "--stripe-endpoint-port ${toString cfg.stripeEndpointPort}"; diff --git a/nixos/pkgs/zkapissuer/repo.json b/nixos/pkgs/zkapissuer/repo.json index 2963aafabef1503608195f7903fe202f11dd70a8..bdfc12089d9559492340898369770062d2cb3137 100644 --- a/nixos/pkgs/zkapissuer/repo.json +++ b/nixos/pkgs/zkapissuer/repo.json @@ -1,8 +1,8 @@ { "owner": "PrivateStorageio", "repo": "PaymentServer", - "rev": "b1bd39ffb7269e5334bf068263733472b27b38ef", + "rev": "a82ccda4ea604ef983a8bb73616b48d9a6ea16ac", "branch": "main", "outputHashAlgo": "sha512", - "outputHash": "1jncsdyz83xphairk4rx47wrlkdn7s48dk8l3mnimal4h4x7zb34lyx2anm1nhxm7s52jv28rv4mvggc2dhsa21dsqhga3qggcpfi2m" + "outputHash": "07iwsnfn1avkyl05592vx3n51dlwdd0asym0qb9857bh2xbgdmzl5pdx70dwrgvdad19fjnphqsga7x9b649q0l9xq13rln6j9aljl0" } \ No newline at end of file diff --git a/nixos/tests/get-passes.py b/nixos/tests/get-passes.py index 63b59e5700e0fa0c659f04df8fa889a0501f125f..206e8900e496f7b08967fb11715043fedeaa3f5d 100755 --- a/nixos/tests/get-passes.py +++ b/nixos/tests/get-passes.py @@ -12,14 +12,21 @@ from json import dumps from time import sleep def main(): - if len(argv) != 5: + if len(argv) == 4: + # If no issuer is given then we just won't make the charge request. + # This is useful for following the webhook-based workflow. + clientAPIRoot, clientAPITokenPath, voucher = argv[1:] + issuerAPIRoot = None + elif len(argv) == 5: + clientAPIRoot, clientAPITokenPath, issuerAPIRoot, voucher = argv[1:] + else: raise SystemExit( - "usage: %s <client api root> <client api token path> <issuer api root> <voucher>", + "usage: %s <client api root> <client api token path> [<issuer api root>] <voucher>", ) - clientAPIRoot, clientAPITokenPath, issuerAPIRoot, voucher = argv[1:] + if not clientAPIRoot.endswith("/"): clientAPIRoot += "/" - if not issuerAPIRoot.endswith("/"): + if issuerAPIRoot is not None and not issuerAPIRoot.endswith("/"): issuerAPIRoot += "/" zkapauthz = clientAPIRoot + "storage-plugins/privatestorageio-zkapauthz-v1" @@ -27,15 +34,16 @@ def main(): with open(clientAPITokenPath) as p: clientAPIToken = p.read().strip() - # Submit a charge to the issuer (which is also the PaymentServer). - charge_response = post( - issuerAPIRoot + "v1/stripe/charge", - dumps(charge_json(voucher)), - headers={ - "content-type": "application/json", - }, - ) - charge_response.raise_for_status() + if issuerAPIRoot is not None: + # Submit a charge to the issuer (which is also the PaymentServer). + charge_response = post( + issuerAPIRoot + "v1/stripe/charge", + dumps(charge_json(voucher)), + headers={ + "content-type": "application/json", + }, + ) + charge_response.raise_for_status() # Tell the client to redeem the voucher. response = put( diff --git a/nixos/tests/private-storage.nix b/nixos/tests/private-storage.nix index e02f9c49550d7fa4994c3d6099eda1e9374a278c..b593a18ef84947bdfe299e1ec6388987f4296491 100644 --- a/nixos/tests/private-storage.nix +++ b/nixos/tests/private-storage.nix @@ -40,6 +40,9 @@ let in pkgs.writeText basename key; + stripeWebhookSecretKey = "whsec_e302402f2f4b5d8241fe494cd693464345bf28c4d7312516d6c1ce69cd0c1e1d"; + stripeWebhookSecretKeyPath = pkgs.writeText "stripe-webhook.secret" stripeWebhookSecretKey; + # Here are the preconstructed secrets which we can assign to the introducer. # This is a lot easier than having the introducer generate them and then # discovering and configuring the other nodes with them. @@ -122,7 +125,7 @@ in { letsEncryptAdminEmail = "user@example.invalid"; allowedChargeOrigins = [ "http://unused.invalid" ]; - inherit stripeSecretKeyPath; + inherit stripeSecretKeyPath stripeWebhookSecretKeyPath; stripeEndpointDomain = "api_stripe_com"; stripeEndpointScheme = "HTTP"; stripeEndpointPort = 80; @@ -159,7 +162,7 @@ in { testScript = ourpkgs.lib.testing.makeTestScript { testpath = ./test_privatestorage.py; kwargs = { - inherit sshPrivateKeyFile pemFile introducerPort introducerFURL issuerURL ristrettoPublicKey voucher tokenCount; + inherit sshPrivateKeyFile pemFile introducerPort introducerFURL issuerURL ristrettoPublicKey voucher tokenCount stripeWebhookSecretKey; # Supply some helper programs to help the tests stay a bit higher level. run_introducer = ./run-introducer.py; run_client = ./run-client.py; diff --git a/nixos/tests/test_privatestorage.py b/nixos/tests/test_privatestorage.py index df2aeb9372042d9368736579090e70ab90097f43..0429ac9257f7b587f4efd1d1ecc53375ed9ee4d3 100644 --- a/nixos/tests/test_privatestorage.py +++ b/nixos/tests/test_privatestorage.py @@ -1,4 +1,8 @@ -def runOnNode(node, argv): +import hmac +from shlex import quote +from time import time + +def runOnNode(node, argvs): """ Run a shell command on one of the nodes. The first argument is the name of the node. The second is a list of the argv to run. @@ -6,12 +10,13 @@ def runOnNode(node, argv): The program's output is piped to systemd-cat and the python fragment evaluates to success if the command exits with a success status. """ - try: - node.succeed('set -eo pipefail; {} | systemd-cat'.format(" ".join(argv))) - except Exception as e: - code, output = node.execute('cat /tmp/stdout /tmp/stderr') - introducer.log(output) - raise + for argv in argvs: + try: + node.succeed('set -eo pipefail; {} | systemd-cat'.format(" ".join(map(quote, argv)))) + except Exception as e: + code, output = node.execute('cat /tmp/stdout /tmp/stderr') + node.log(output) + raise def ssh(username, sshPrivateKeyFile, hostname): """ @@ -19,10 +24,144 @@ def ssh(username, sshPrivateKeyFile, hostname): host. """ return [ - "cp", sshPrivateKeyFile, "/tmp/ssh_key", ";", - "chmod", "0400", "/tmp/ssh_key", ";", - "ssh", "-oStrictHostKeyChecking=no", "-i", "/tmp/ssh_key", - "{username}@{hostname}".format(username=username, hostname=hostname), ":", + ["cp", sshPrivateKeyFile, "/tmp/ssh_key"], + ["chmod", "0400", "/tmp/ssh_key"], + ["ssh", "-oStrictHostKeyChecking=no", "-i", "/tmp/ssh_key", + "{username}@{hostname}".format(username=username, hostname=hostname), ":"], + ] + +def checkout_session_completed(voucher: str) -> str: + """ + Return a request body string which represents the payment completed event + for the given voucher. + """ + return """\ +{ + "id": "evt_1LxcsdBHXBAMm9bPSq6UWAZe", + "object": "event", + "api_version": "2019-11-05", + "created": 1666903247, + "data": { + "object": { + "id": "cs_test_a1kWLWGoXZPa6ywyVnuib8DPA3BqXCWZX5UEjLfKh7gLjdZy2LD3F5mEp3", + "object": "checkout.session", + "after_expiration": null, + "allow_promotion_codes": null, + "amount_subtotal": 3000, + "amount_total": 3000, + "automatic_tax": { + "enabled": false, + "status": null + }, + "billing_address_collection": null, + "cancel_url": "https://httpbin.org/post", + "client_reference_id": "%(voucher)s", + "consent": null, + "consent_collection": null, + "created": 1666903243, + "currency": "usd", + "customer": "cus_Mh0u62xtelUehD", + "customer_creation": "always", + "customer_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "stripe@example.com", + "name": null, + "phone": null, + "tax_exempt": "none", + "tax_ids": [ + + ] + }, + "customer_email": null, + "display_items": [ + { + "amount": 1500, + "currency": "usd", + "custom": { + "description": "comfortable cotton t-shirt", + "images": null, + "name": "t-shirt" + }, + "quantity": 2, + "type": "custom" + } + ], + "expires_at": 1666989643, + "livemode": false, + "locale": null, + "metadata": { + }, + "mode": "payment", + "payment_intent": "pi_3LxcsZBHXBAMm9bP1daBGoPV", + "payment_link": null, + "payment_method_collection": "always", + "payment_method_options": { + }, + "payment_method_types": [ + "card" + ], + "payment_status": "paid", + "phone_number_collection": { + "enabled": false + }, + "recovered_from": null, + "setup_intent": null, + "shipping": null, + "shipping_address_collection": null, + "shipping_options": [ + + ], + "shipping_rate": null, + "status": "complete", + "submit_type": null, + "subscription": null, + "success_url": "https://httpbin.org/post", + "total_details": { + "amount_discount": 0, + "amount_shipping": 0, + "amount_tax": 0 + }, + "url": null + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "checkout.session.completed" +} +""" % dict(voucher=voucher) + +def stripe_signature(key: str, body: str) -> str: + """ + Construct a valid value for the ``Stripe-Signature`` header item. + """ + timestamp = int(time()) + v1 = hmac.new(key.encode("utf-8"), f"{timestamp}.{body}".encode("utf-8"), "sha256").hexdigest() + return f"t={timestamp},v1={v1}" + +def pay_for_voucher(url: str, webhook_secret, voucher: str) -> list[str]: + """ + Return a command to run to report to the issuer that payment for the given + voucher has been received. + """ + body = checkout_session_completed(voucher) + return [ + "curl", + "-X", "POST", + "--header", "content-type: application/json; charset=utf-8", + "--header", f"stripe-signature: {stripe_signature(webhook_secret, body)}", + "--data-binary", body, + url + "v1/stripe/webhook", ] def test( @@ -36,6 +175,7 @@ def test( introducerFURL, issuerURL, ristrettoPublicKey, + stripeWebhookSecretKey, voucher, tokenCount, ): @@ -69,8 +209,7 @@ def test( # Set up a Tahoe-LAFS introducer. introducer.copy_from_host(pemFile, '/tmp/node.pem') - - runOnNode(introducer, [run_introducer, "/tmp/node.pem", str(introducerPort), introducerFURL]) + runOnNode(introducer, [[run_introducer, "/tmp/node.pem", str(introducerPort), introducerFURL]]) # # Get a Tahoe-LAFS storage server up. @@ -96,10 +235,13 @@ def test( # Make sure the issuer is ready to accept connections. issuer.wait_for_open_port(80) + # Pretend to be Stripe and report that our voucher has been paid for. + runOnNode(issuer, [pay_for_voucher("http://localhost/", stripeWebhookSecretKey, voucher)]) + # # Storage appears to be working so try to get a client to speak with it. # - runOnNode(client, [run_client, "/tmp/client", introducerFURL, issuerURL, ristrettoPublicKey, str(tokenCount)]) + runOnNode(client, [[run_client, "/tmp/client", introducerFURL, issuerURL, ristrettoPublicKey, str(tokenCount)]]) client.wait_for_open_port(3456) # Make sure the fake Stripe API server is ready for requests. @@ -112,13 +254,12 @@ def test( # Get some ZKAPs from the issuer. try: - runOnNode(client, [ + runOnNode(client, [[ get_passes, "http://127.0.0.1:3456", "/tmp/client/private/api_auth_token", - issuerURL, voucher, - ]) + ]]) except: # Dump the fake Stripe API server logs, too, since the error may arise # from a PaymentServer/Stripe interaction. @@ -129,19 +270,19 @@ def test( raise # The client should be prepped now. Make it try to use some storage. - runOnNode(client, [exercise_storage, "/tmp/client"]) + runOnNode(client, [[exercise_storage, "/tmp/client"]]) # It should be possible to restart the storage service without the # storage node fURL changing. furlfile = '/var/db/tahoe-lafs/storage/private/storage-plugin.privatestorageio-zkapauthz-v1.furl' before = storage.execute('cat ' + furlfile) - runOnNode(storage, ["systemctl", "restart", "tahoe.storage"]) + runOnNode(storage, [["systemctl", "restart", "tahoe.storage"]]) after = storage.execute('cat ' + furlfile) if (before != after): raise Exception('fURL changes after storage node restart') # The client should actually still work, too. - runOnNode(client, [exercise_storage, "/tmp/client"]) + runOnNode(client, [[exercise_storage, "/tmp/client"]]) # The issuer metrics should be accessible from the monitoring network. issuer.execute('ifconfig lo:fauxvpn 172.23.23.2/24')