diff --git a/morph/grid.config.json b/morph/grid.config.json index c6ee26422d3af52d7618ffc84add6fc825e3f4d4..1df905600e25c75423070c7aede376d95ba9e4e2 100644 --- a/morph/grid.config.json +++ b/morph/grid.config.json @@ -3,4 +3,8 @@ , "stripeSecretKeyPath": "../../PrivateStorageSecrets/stripe.secret" , "issuerDomain": "payments.privatestorage.io" , "letsEncryptAdminEmail": "jean-paul@privatestorage.io" +, "allowedChargeOrigins": [ + "https://privatestorage.io" + , "https://www.privatestorage.io" + ] } diff --git a/morph/issuer.nix b/morph/issuer.nix index 57ffd009d58064830a30d30af926263962dcc5d7..98d10d38cd49d1c0dda8a630ef7b622d5ea62284 100644 --- a/morph/issuer.nix +++ b/morph/issuer.nix @@ -3,6 +3,7 @@ , stripeSecretKeyPath , issuerDomain , letsEncryptAdminEmail +, allowedChargeOrigins , stateVersion , ... }: { @@ -26,13 +27,14 @@ services.private-storage-issuer = { enable = true; - # XXX This should be passed as a path. - ristrettoSigningKey = builtins.readFile (./.. + ristrettoSigningKeyPath); + tls = true; + ristrettoSigningKeyPath = ./.. + ristrettoSigningKeyPath; stripeSecretKeyPath = ./.. + stripeSecretKeyPath; database = "SQLite3"; databasePath = "/var/db/vouchers.sqlite3"; inherit letsEncryptAdminEmail; domain = issuerDomain; + inherit allowedChargeOrigins; }; system.stateVersion = stateVersion; diff --git a/morph/testing-grid.config.json b/morph/testing-grid.config.json index 46cf4ff25c5eb0c6dffdba01f774c7862e300aa3..eca13fe1dd76fb37ea0a7b900b725217d33b970e 100644 --- a/morph/testing-grid.config.json +++ b/morph/testing-grid.config.json @@ -1,6 +1,11 @@ { "publicStoragePort": 8898 , "ristrettoSigningKeyPath": "../../PrivateStorageSecrets/ristretto.signing-key" -, "stripeSecretKeyPath": "../../PrivateStorageSecrets/stripe.secret" +, "stripeSecretKeyPath": "../../PrivateStorageSecrets/privatestorageio-testing-stripe.secret" , "issuerDomain": "payments.privatestorage-staging.com" , "letsEncryptAdminEmail": "jean-paul@privatestorage.io" +, "allowedChargeOrigins": [ + "http://localhost:5000" + , "https://privatestorage-staging.com" + , "https://www.privatestorage-staging.com" + ] } diff --git a/nixos/modules/issuer.nix b/nixos/modules/issuer.nix index b22cb07f749d85daed41bd4dcd59652bf266a37c..a65eed5a3539f2d5ba67870782635a1ae7209582 100644 --- a/nixos/modules/issuer.nix +++ b/nixos/modules/issuer.nix @@ -41,12 +41,12 @@ in { algorithm or Ristretto for Ristretto-flavored PrivacyPass. ''; }; - services.private-storage-issuer.ristrettoSigningKey = lib.mkOption { + services.private-storage-issuer.ristrettoSigningKeyPath = lib.mkOption { default = null; - type = lib.types.str; + type = lib.types.path; description = '' - The Ristretto signing key to use. Required if the issuer is - ``Ristretto``. + The path to a file containing the Ristretto signing key to use. + Required if the issuer is ``Ristretto``. ''; }; services.private-storage-issuer.stripeSecretKeyPath = lib.mkOption { @@ -56,6 +56,27 @@ in { and payment management. ''; }; + services.private-storage-issuer.stripeEndpointDomain = lib.mkOption { + type = lib.types.str; + description = '' + The domain name for the Stripe API HTTP endpoint. + ''; + default = "api.stripe.com"; + }; + services.private-storage-issuer.stripeEndpointScheme = lib.mkOption { + type = lib.types.enum [ "HTTP" "HTTPS" ]; + description = '' + Whether to use HTTP or HTTPS for the Stripe API. + ''; + default = "HTTPS"; + }; + services.private-storage-issuer.stripeEndpointPort = lib.mkOption { + type = lib.types.int; + description = '' + The port number for the Stripe API HTTP endpoint. + ''; + default = 443; + }; services.private-storage-issuer.database = lib.mkOption { default = "Memory"; type = lib.types.enum [ "Memory" "SQLite3" ]; @@ -78,6 +99,14 @@ in { for the service's TLS certificate. ''; }; + services.private-storage-issuer.allowedChargeOrigins = lib.mkOption { + type = lib.types.listOf lib.types.str; + description = '' + The CORS "Origin" values which are allowed to submit charges to the + payment server. Note this is not currently enforced by the + PaymentServer. It just controls the CORS headers served. + ''; + }; }; config = @@ -116,7 +145,7 @@ in { issuerArgs = if cfg.issuer == "Trivial" then "--issuer Trivial" - else "--issuer Ristretto --signing-key ${cfg.ristrettoSigningKey}"; + else "--issuer Ristretto --signing-key-path ${cfg.ristrettoSigningKeyPath}"; databaseArgs = if cfg.database == "Memory" then "--database Memory" @@ -131,9 +160,18 @@ in { else # Only for automated testing. "--http-port 80"; - stripeArgs = "--stripe-key ${builtins.readFile cfg.stripeSecretKeyPath}"; + + prefixOption = s: "--cors-origin=" + s; + originStrings = map prefixOption cfg.allowedChargeOrigins; + originArgs = builtins.concatStringsSep " " originStrings; + + stripeArgs = + "--stripe-key-path ${cfg.stripeSecretKeyPath} " + + "--stripe-endpoint-domain ${cfg.stripeEndpointDomain} " + + "--stripe-endpoint-scheme ${cfg.stripeEndpointScheme} " + + "--stripe-endpoint-port ${toString cfg.stripeEndpointPort}"; in - "${cfg.package}/bin/PaymentServer-exe ${issuerArgs} ${databaseArgs} ${httpsArgs} ${stripeArgs}"; + "${cfg.package}/bin/PaymentServer-exe ${originArgs} ${issuerArgs} ${databaseArgs} ${httpsArgs} ${stripeArgs}"; }; # Certificate renewal. We must declare that we *require* it in our diff --git a/nixos/modules/tests/get-passes.py b/nixos/modules/tests/get-passes.py index d76ce3ccbf703bfabd08e120f84ed39063339297..96875713233b7c46abae0cfc3f0d946628bf8cc2 100755 --- a/nixos/modules/tests/get-passes.py +++ b/nixos/modules/tests/get-passes.py @@ -12,23 +12,25 @@ from json import dumps from time import sleep def main(): - clientAPIRoot, issuerAPIRoot = argv[1:] + if len(argv) != 4: + raise SystemExit( + "usage: %s <client api root> <issuer api root> <voucher>", + ) + clientAPIRoot, issuerAPIRoot, voucher = argv[1:] if not clientAPIRoot.endswith("/"): clientAPIRoot += "/" if not issuerAPIRoot.endswith("/"): issuerAPIRoot += "/" - # Construct a voucher that's acceptable to various parts of the system. - voucher = "a" * 44 - zkapauthz = clientAPIRoot + "storage-plugins/privatestorageio-zkapauthz-v1" - # Simulate a payment for a voucher. - post( - issuerAPIRoot + "v1/stripe/webhook", - dumps(charge_succeeded_json(voucher)), + # 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( @@ -64,141 +66,13 @@ def retry(description, f): raise ValueError("failed to {} after many tries".format(description)) -def charge_succeeded_json(voucher): - # This structure copy/pasted from Stripe webhook web interface. - base_payload = { - "id": "evt_1FKSX2DeTd13VRuuhPaUDA2f", - "object": "event", - "api_version": "2016-07-06", - "created": 1568910660, - "data": { - "object": { - "id": "ch_1FKSX2DeTd13VRuuG9BXbqji", - "object": "charge", - "amount": 999, - "amount_refunded": 0, - "application": None, - "application_fee": None, - "application_fee_amount": None, - "balance_transaction": "txn_1FKSX2DeTd13VRuuqO1CJZ1e", - "billing_details": { - "address": { - "city": None, - "country": None, - "line1": None, - "line2": None, - "postal_code": None, - "state": None - }, - "email": None, - "name": None, - "phone": None - }, - "captured": True, - "created": 1568910660, - "currency": "usd", - "customer": None, - "description": None, - "destination": None, - "dispute": None, - "failure_code": None, - "failure_message": None, - "fraud_details": { - }, - "invoice": None, - "livemode": False, - "metadata": { - "Voucher": None, - }, - "on_behalf_of": None, - "order": None, - "outcome": { - "network_status": "approved_by_network", - "reason": None, - "risk_level": "normal", - "risk_score": 44, - "seller_message": "Payment complete.", - "type": "authorized" - }, - "paid": True, - "payment_intent": None, - "payment_method": "card_1FKSX2DeTd13VRuus5VEjmjG", - "payment_method_details": { - "card": { - "brand": "visa", - "checks": { - "address_line1_check": None, - "address_postal_code_check": None, - "cvc_check": None - }, - "country": "US", - "exp_month": 9, - "exp_year": 2020, - "fingerprint": "HTJeRR4MXhAAkctF", - "funding": "credit", - "last4": "4242", - "three_d_secure": None, - "wallet": None - }, - "type": "card" - }, - "receipt_email": None, - "receipt_number": None, - "receipt_url": "https://pay.stripe.com/receipts/acct_198xN4DeTd13VRuu/ch_1FKSX2DeTd13VRuuG9BXbqji/rcpt_Fq8oAItSiNmcm0beiie6lUYin920E7a", - "refunded": False, - "refunds": { - "object": "list", - "data": [ - ], - "has_more": False, - "total_count": 0, - "url": "/v1/charges/ch_1FKSX2DeTd13VRuuG9BXbqji/refunds" - }, - "review": None, - "shipping": None, - "source": { - "id": "card_1FKSX2DeTd13VRuus5VEjmjG", - "object": "card", - "address_city": None, - "address_country": None, - "address_line1": None, - "address_line1_check": None, - "address_line2": None, - "address_state": None, - "address_zip": None, - "address_zip_check": None, - "brand": "Visa", - "country": "US", - "customer": None, - "cvc_check": None, - "dynamic_last4": None, - "exp_month": 9, - "exp_year": 2020, - "fingerprint": "HTJeRR4MXhAAkctF", - "funding": "credit", - "last4": "4242", - "metadata": { - }, - "name": None, - "tokenization_method": None - }, - "source_transfer": None, - "statement_descriptor": None, - "statement_descriptor_suffix": None, - "status": "succeeded", - "transfer_data": None, - "transfer_group": None - } - }, - "livemode": False, - "pending_webhooks": 2, - "request": "req_5ozjOIAcOkvVUK", - "type": "charge.succeeded" +def charge_json(voucher): + return { + "token": "tok_abcdef", + "voucher": voucher, + "amount": "100", + "currency": "USD", } - # Indicate the voucher the payment references. - base_payload["data"]["object"]["metadata"]["Voucher"] = voucher - return base_payload - if __name__ == '__main__': main() diff --git a/nixos/modules/tests/private-storage.nix b/nixos/modules/tests/private-storage.nix index 9028691fc8edb133b5c048291cd5f0741f6f4ea1..1fe55c1302b35aeb9e5645bbc1cb60053fc6e97c 100644 --- a/nixos/modules/tests/private-storage.nix +++ b/nixos/modules/tests/private-storage.nix @@ -9,16 +9,33 @@ let get-passes = ./get-passes.py; exercise-storage = ./exercise-storage.py; + # This is a test double of the Stripe API server. It is extremely simple. + # It barely knows how to respond to exactly the API endpoints we use, + # exactly how we use them. + stripe-api-double = ./stripe-api-double.py; + # The root URL of the Ristretto-flavored PrivacyPass issuer API. issuerURL = "http://issuer/"; + voucher = "xyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxyxy"; + # The issuer's signing key. Notionally, this is a secret key. This is only # the value for this system test though so I don't care if it leaks to the # world at large. - ristrettoSigningKey = "wumQAfSsJlQKDDSaFN/PZ3EbgBit8roVgfzllfCK2gQ="; - - # Ugh. - stripeSecretKey = "sk_test_blubblub"; + ristrettoSigningKeyPath = + let + key = "wumQAfSsJlQKDDSaFN/PZ3EbgBit8roVgfzllfCK2gQ="; + basename = "signing-key.private"; + in + pkgs.writeText basename key; + + stripeSecretKeyPath = + let + # Ugh. + key = "sk_test_blubblub"; + basename = "stripe.secret"; + in + pkgs.writeText basename key; # 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 @@ -42,6 +59,22 @@ let networking.firewall.enable = false; networking.dhcpcd.enable = false; }; + + # Return a Perl program fragment to 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. + # + # The program's output is piped to systemd-cat and the Perl fragment + # evaluates to success if the command exits with a success status. + runOnNode = node: argv: + let + command = builtins.concatStringsSep " " argv; + in + " + \$${node}->succeed('set -eo pipefail; ${command} | systemd-cat'); + # succeed() is not success but 1 is. + 1; + "; in # https://nixos.org/nixos/manual/index.html#sec-nixos-tests import <nixpkgs/nixos/tests/make-test.nix> { @@ -69,11 +102,13 @@ import <nixpkgs/nixos/tests/make-test.nix> { { imports = [ ../private-storage.nix ]; - services.private-storage.enable = true; - services.private-storage.publicIPv4 = "storage"; - services.private-storage.introducerFURL = introducerFURL; - services.private-storage.issuerRootURL = issuerURL; - services.private-storage.ristrettoSigningKeyPath = pkgs.writeText "signing-key.private" ristrettoSigningKey; + services.private-storage = { + enable = true; + publicIPv4 = "storage"; + introducerFURL = introducerFURL; + issuerRootURL = issuerURL; + inherit ristrettoSigningKeyPath; + }; } // networkConfig; # Operate an issuer as well. @@ -87,11 +122,41 @@ import <nixpkgs/nixos/tests/make-test.nix> { domain = "issuer"; tls = false; issuer = "Ristretto"; - inherit ristrettoSigningKey; - stripeSecretKeyPath = pkgs.writeText "stripe.secret" stripeSecretKey; + inherit ristrettoSigningKeyPath; letsEncryptAdminEmail = "user@example.invalid"; + allowedChargeOrigins = [ "http://unused.invalid" ]; + + inherit stripeSecretKeyPath; + stripeEndpointDomain = "api_stripe_com"; + stripeEndpointScheme = "HTTP"; + stripeEndpointPort = 80; }; } // networkConfig; + + # Also run a fake Stripe API endpoint server. Nodes in these tests run on + # a network without outside access so we can't easily use the real Stripe + # API endpoint and with this one we have greater control over the + # behavior, anyway, without all of the unintentional transient network + # errors that come from the public internet. These tests *aren't* meant + # to prove PaymentServer correctly interacts with the real Stripe API + # server so this is an unverified fake. The PaymentServer test suite + # needs to take care of any actual Stripe API integration testing. + "api_stripe_com" = + { config, pkgs, ... }: + let python = pkgs.python3.withPackages (ps: [ ps.twisted ]); + in networkConfig // { + environment.systemPackages = [ + python + pkgs.curl + ]; + + systemd.services."api.stripe.com" = { + enable = true; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + script = "${python}/bin/python ${stripe-api-double} tcp:80"; + }; + }; }; # Test the machines with a Perl program (sobbing). @@ -107,13 +172,7 @@ import <nixpkgs/nixos/tests/make-test.nix> { ); eval { - $introducer->succeed( - 'set -eo pipefail; ' . - '${run-introducer} /tmp/node.pem ${toString introducerPort} ${introducerFURL} | ' . - 'systemd-cat' - ); - # Signal success. :/ - 1; + ${runOnNode "introducer" [ run-introducer "/tmp/node.pem" (toString introducerPort) introducerFURL ]} } or do { my $error = $@ || 'Unknown failure'; my ($code, $log) = $introducer->execute('cat /tmp/stdout /tmp/stderr'); @@ -142,26 +201,37 @@ import <nixpkgs/nixos/tests/make-test.nix> { # # Storage appears to be working so try to get a client to speak with it. # - $client->succeed('set -eo pipefail; ${run-client} ${introducerFURL} ${issuerURL} | systemd-cat'); + ${runOnNode "client" [ run-client introducerFURL issuerURL ]} $client->waitForOpenPort(3456); - # Get some ZKAPs from the issuer. + # Make sure the fake Stripe API server is ready for requests. eval { - $client->succeed('set -eo pipefail; ${get-passes} http://127.0.0.1:3456 ${issuerURL} | systemd-cat'); - # succeed() is not success but 1 is. + $api_stripe_com->waitForUnit("api.stripe.com"); 1; + } or do { + my ($code, $log) = $api_stripe_com->execute('journalctl -u api.stripe.com'); + $api_stripe_com->log($log); + die $@; + }; + + # Get some ZKAPs from the issuer. + eval { + ${runOnNode "client" [ get-passes "http://127.0.0.1:3456" issuerURL voucher ]} } or do { my $error = $@ || 'Unknown failure'; my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr'); $client->log($log); + + # Dump the fake Stripe API server logs, too, since the error may arise + # from a PaymentServer/Stripe interaction. + my ($code, $log) = $api_stripe_com->execute('journalctl -u api.stripe.com'); + $api_stripe_com->log($log); die $@; }; # The client should be prepped now. Make it try to use some storage. eval { - $client->succeed('set -eo pipefail; ${exercise-storage} /tmp/client | systemd-cat'); - # nothing succeeds like ... 1. - 1; + ${runOnNode "client" [ exercise-storage "/tmp/client" ]} } or do { my $error = $@ || 'Unknown failure'; my ($code, $log) = $client->execute('cat /tmp/stdout /tmp/stderr'); diff --git a/nixos/modules/tests/stripe-api-double.py b/nixos/modules/tests/stripe-api-double.py new file mode 100755 index 0000000000000000000000000000000000000000..5b96c0befe2a9434560bdd357f3d8e8dc2a92bc5 --- /dev/null +++ b/nixos/modules/tests/stripe-api-double.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 + +from sys import stdout, argv +from json import dumps + +from twisted.internet.defer import Deferred +from twisted.internet.endpoints import serverFromString +from twisted.internet.task import react +from twisted.web.resource import Resource +from twisted.web.server import Site +from twisted.python.log import startLogging + +class Charges(Resource): + def render_POST(self, request): + voucher = request.args[b"metadata[Voucher]"][0].decode("utf-8") + card = request.args[b"card"][0].decode("utf-8") + amount = int(request.args[b"amount"][0]) + currency = request.args[b"currency"][0].decode("utf-8") + response = dumps(charge(card, amount, currency, {u"Voucher": voucher})) + return response.encode("utf-8") + +def main(reactor, listenEndpoint): + charges = Charges() + v1 = Resource() + v1.putChild(b"charges", charges) + root = Resource() + root.putChild(b"v1", v1) + + return serverFromString(reactor, listenEndpoint).listen( + Site(root), + ).addCallback( + lambda ignored: Deferred() + ) + +def charge(source, amount, currency, metadata): + return { + "id": "ch_1Fj8frBHXBAMm9bPkekylvAq", + "object": "charge", + "amount": amount, + "amount_refunded": 0, + "application": None, + "application_fee": None, + "application_fee_amount": None, + "balance_transaction": "txn_1Fj8fr2eZvKYlo2CC5JzIGj5", + "billing_details": { + "address": { + "city": None, + "country": None, + "line1": None, + "line2": None, + "postal_code": None, + "state": None + }, + "email": None, + "name": None, + "phone": None + }, + "captured": False, + "created": 1574792527, + "currency": currency, + "customer": None, + "description": None, + "dispute": None, + "disputed": False, + "failure_code": None, + "failure_message": None, + "fraud_details": {}, + "invoice": None, + "livemode": False, + "metadata": metadata, + "on_behalf_of": None, + "order": None, + "outcome": None, + "paid": True, + "payment_intent": None, + "payment_method": source, + "payment_method_details": {}, + "receipt_email": None, + "receipt_number": None, + "receipt_url": "https://pay.stripe.com/receipts/acct_1FhhxTBHXBAMm9bP/ch_1Fj8frBHXBAMm9bPkekylvAq/rcpt_GFdxYuDoGKfYgokh9YA11XhnYC7Gnxp", + "refunded": False, + "refunds": { + "object": "list", + "data": [], + "has_more": False, + "url": "/v1/charges/ch_1Fj8frBHXBAMm9bPkekylvAq/refunds" + }, + "review": None, + "shipping": None, + "source_transfer": None, + "statement_descriptor": None, + "statement_descriptor_suffix": None, + "status": "succeeded", + "transfer_data": None, + "transfer_group": None, + "source": source, + } + +if __name__ == '__main__': + startLogging(stdout) + react(main, argv[1:]) diff --git a/nixos/pkgs/zkapissuer-repo.nix b/nixos/pkgs/zkapissuer-repo.nix index af0eda33aeccbe01d71407123129330326d7b6ba..a252d3a9b31cc83ef879e1a1f9561f0480860d1f 100644 --- a/nixos/pkgs/zkapissuer-repo.nix +++ b/nixos/pkgs/zkapissuer-repo.nix @@ -4,6 +4,6 @@ in pkgs.fetchFromGitHub { owner = "PrivateStorageio"; repo = "PaymentServer"; - rev = "c5651f58ff564f00cfcdb4c73584817b9197f7a6"; - sha256 = "1gmx4c82h95lkmqdklak3kpj6gkpp57hwc309h4798sclgvp287b"; + rev = "1130b17e85392efd9f6be733308542b50bded1e3"; + sha256 = "1ivcy3xcakxs0yfvbnvizq9pchp15g2wdprh5r5rq4fkqk8k6nbf"; } \ No newline at end of file