cashless2ecash

cashless2ecash: pay with cards for digital cash (experimental)
Log | Files | Refs | README

commit 889a4e98136e73aaa16b2523d463c7d54e09ccb9
parent 15a1fecc1a177c79bfe7dcfa462810f7d09fad1b
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Sun,  7 Apr 2024 22:15:08 +0200

code: add logging, cli and simulation

Diffstat:
M.gitignore | 1+
Ac2ec/README | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/amount.go | 14+++++++-------
Mc2ec/attestor.go | 15++++++++++-----
Mc2ec/auth.go | 204++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Ac2ec/auth_test.go | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/bank-integration.go | 13++++++-------
Dc2ec/base32-taler.go | 49-------------------------------------------------
Dc2ec/base32-taler_test.go | 21---------------------
Mc2ec/c2ec-config.yaml | 5++++-
Ac2ec/c2ec-log.txt | 0
Mc2ec/config.go | 32+++++++++++++++++++++++++-------
Mc2ec/db.go | 6+++---
Mc2ec/db/0000-c2ec_schema.sql | 4++--
Mc2ec/go.mod | 3+--
Mc2ec/go.sum | 4----
Ac2ec/logger.go | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mc2ec/main.go | 25+++++++++++++++++--------
Mc2ec/model.go | 10+++++++---
Mc2ec/postgres.go | 33++++++++++++++++++++++++++++++---
Mc2ec/simulation-attestor.go | 6+++---
Mc2ec/simulation-client.go | 6++++++
Mc2ec/wallee-attestor.go | 11++++++-----
Mc2ec/wallee-client.go | 30+++++++++++++++++++++++++++---
Mcli/cli.go | 29+++++++++++++++++++++++++----
Mcli/db.go | 1+
Mdocs/content/implementation/c2ec-db.tex | 5+++--
Mdocs/thesis.pdf | 0
Asimulation/.vscode/launch.json | 14++++++++++++++
Asimulation/amount.go | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/c2ec-simulation | 0
Asimulation/codec.go | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/go.mod | 3+++
Asimulation/http-util.go | 359+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/main.go | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/model.go | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/sim-terminal.go | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asimulation/sim-wallet.go | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
38 files changed, 1488 insertions(+), 192 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -3,6 +3,7 @@ schemaspy/Makefile infra/ bruno/ c2ec/c2ec +c2ec/.vscode cli/c2ec-cli LocalMakefile diff --git a/c2ec/README b/c2ec/README @@ -0,0 +1,68 @@ +# C2EC + +The cashless2ecash (C2EC) component, allows withdrawing taler in an indirect +transaction. This means that the reserve is created if a trusted third party +called the `provider` attests, that the payment was successful and the withdrawal +can be executed. + +## Build, Config, Run + +### Build + +To build the executable, simply run `go build .` inside the `c2ec`-directory. + +### Config + +By default, the C2EC executable will look for a config file called +`c2ec-config.yaml` in the directory it is executed. If you want to run C2EC with +a config located somewhere else, supply the path to the config file as the first +argument on startup. + +### Running + +To run the application just execute the binary built during the build step above: + +`./c2ec` + +With non-default config: + +`./c2ec /my/custom/config/location.yaml` + +## Adding Providers and Terminals + +Adding providers and terminals belonging to them is a task which must be fulfilled +by the operator of the C2EC component (usually the operator of the Exchange). + +To add providers and terminal, see the [**c2ec-cli**](https://git.taler.net/cashless2ecash.git/tree/cli). + +## Simulation + +In testing mode, a Simulation Provider and terminal can be used to simulate the +flow of the withdrawal from the creation of the `WOPID` on the side of the +Terminal until the confirmation of the payment by the attestor. Since the +withdrawal itself is done by the wallet directly at the exchange, the simulation +does not capture this. + +### Add Simulation Provider + +Run the **c2ec-cli** and after connecting to the database using the `db` command, +run `rp`. It will ask you for four things: + +1. the name of the provider, which MUST be `Simulation` +2. the payto target type as registered in the [GANA](https://gana.gnunet.org/payto-payment-target-types/payto_payment_target_types.html). It is suggested to use `void`, which is meant for testing. +3. the provider's backend base url. Enter anything, will not be used by the Simulation +4. the provider's backend credentials. Enter anything, will not be used by the Simulation + +### Add Simulation Terminal + +Run the **c2ec-cli** and after connecting to the database using the `db` command, +run `rt`. It will ask you for two things: + +1. the description. Enter a description which makes sense. In the case of the simulation something like `this is a simulation terminal.` might make sense. In a real world case something like the location of the device, an identifier and other similar information might be supplied. +2. the name of the provider, which the device belongs to. In the simulation case this will MUST be `Simulation`. + +Be aware that a terminal can only be added, after the provdier it belongs to was added. + +### Run the Simulation + +To run the simulation, diff --git a/c2ec/amount.go b/c2ec/amount.go @@ -71,7 +71,7 @@ func NewAmount(currency string, value uint64, fraction uint64) Amount { // a and b must be of the same currency and a >= b func (a *Amount) Sub(b Amount) (*Amount, error) { if a.Currency != b.Currency { - return nil, errors.New("Currency mismatch!") + return nil, errors.New("currency mismatch") } v := a.Value f := a.Fraction @@ -81,7 +81,7 @@ func (a *Amount) Sub(b Amount) (*Amount, error) { } f -= b.Fraction if v < b.Value { - return nil, errors.New("Amount Overflow!") + return nil, errors.New("amount overflow") } v -= b.Value r := Amount{ @@ -97,14 +97,14 @@ func (a *Amount) Sub(b Amount) (*Amount, error) { // cause an overflow of the value func (a *Amount) Add(b Amount) (*Amount, error) { if a.Currency != b.Currency { - return nil, errors.New("Currency mismatch!") + return nil, errors.New("currency mismatch") } v := a.Value + b.Value + uint64(math.Floor((float64(a.Fraction)+float64(b.Fraction))/FractionalBase)) if v >= MaxAmountValue { - return nil, errors.New(fmt.Sprintf("Amount Overflow (%d > %d)!", v, MaxAmountValue)) + return nil, fmt.Errorf("amount overflow (%d > %d)", v, MaxAmountValue) } f := uint64((a.Fraction + b.Fraction) % FractionalBase) r := Amount{ @@ -121,7 +121,7 @@ func ParseAmount(s string) (*Amount, error) { parsed := re.FindStringSubmatch(s) if nil != err { - return nil, errors.New(fmt.Sprintf("invalid amount: %s", s)) + return nil, fmt.Errorf("invalid amount: %s", s) } tail := "0.0" if len(parsed) >= 4 { @@ -132,11 +132,11 @@ func ParseAmount(s string) (*Amount, error) { } value, err := strconv.ParseUint(parsed[2], 10, 64) if nil != err { - return nil, errors.New(fmt.Sprintf("Unable to parse value %s", parsed[2])) + return nil, fmt.Errorf("unable to parse value %s", parsed[2]) } fractionF, err := strconv.ParseFloat(tail, 64) if nil != err { - return nil, errors.New(fmt.Sprintf("Unable to parse fraction %s", tail)) + return nil, fmt.Errorf("unable to parse fraction %s", tail) } fraction := uint64(math.Round(fractionF * FractionalBase)) currency := parsed[1] diff --git a/c2ec/attestor.go b/c2ec/attestor.go @@ -18,7 +18,7 @@ type Attestor[T any] interface { // This will setup the attestor. Don't call this function // on your own. Instead, use RunAttestor which will setup // and run the Attestor for you. - Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *T, error) + Setup(p *Provider) (chan *T, error) // Listen for database event. If event is catched, // dispatch and kick off attestation. The notifications // will be sent through the notification channel. Since the @@ -34,18 +34,23 @@ func RunAttestor[T any]( ctx context.Context, attestor Attestor[T], provider *Provider, - cfg *C2ECDatabseConfig, ) (chan error, error) { if attestor == nil { return nil, errors.New("the attestor was null") } - if cfg == nil { - return nil, errors.New("the database config was null") + var providerCfg *C2ECProviderConfig + for _, p := range CONFIG.Providers { + if p.Name == provider.Name { + providerCfg = &p + } + } + if providerCfg == nil { + return nil, errors.New("provider is not configured in runtime configuration") } - notificationChannel, err := attestor.Setup(provider, cfg) + notificationChannel, err := attestor.Setup(provider) if err != nil { return nil, err } diff --git a/c2ec/auth.go b/c2ec/auth.go @@ -1,81 +1,179 @@ package main import ( - "crypto/rand" "encoding/base64" "errors" + "fmt" "net/http" + "strconv" + "strings" "golang.org/x/crypto/argon2" ) const AUTHORIZATION_HEADER = "Authorization" -const BEARER_TOKEN_PREFIX = "Bearer" +const BASIC_AUTH_PREFIX = "Basic " -// Authentication in C2EC requires following use cases: -// 1. Wallet authenticating itself using the Bank-Integration API -// 2. Provider authentication itself using the Bank-Integration API -// 3. Exchange Wirewatch component, using the Wire-Gateway API -// 3.1 The Wire-Gateway API specifies Basic-Auth (RFC7617) -// -// The Wire-Gateway API is the only API specification which makes -// prescriptions concerning the authenticaion. For simplicity, -// Basic-Auth will be applied to all client types (Exchange, Wallet, Providers) -// To distinguish what client type wants to request, a special format -// for the username type is created. -// -// use `PROVIDER-[PROVIDER_ID]:[PROVIDER_SECRET]` for provider clients -// use `WALLET:` +// Authenticates the Exchange against C2EC +// returns true if authentication was successful, otherwise false +// when not successful, the api shall return immediately +// The exchange is specified to use basic auth +func AuthenticateExchange(req *http.Request) bool { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + ba := fmt.Sprintf("%s:%s", CONFIG.Server.WireGateway.Username, CONFIG.Server.WireGateway.Password) + encoded := base64.StdEncoding.EncodeToString([]byte(ba)) + return encoded == basicAuth + } + return false +} + +// Authenticates a terminal against C2EC +// returns true if authentication was successful, otherwise false +// when not successful, the api shall return immediately // -// in case no prefix was specified, it is assumed that the request originates -// from the exchange. -func isAllowed(req *http.Request) bool { +// Terminals are authenticated using basic auth. +// The basic authorization header MUST be base64 encoded. +// The username part is the name of the provider (case sensitive) a '-' sign, followed +// by the id of the terminal, which is a number. +func AuthenticateTerminal(req *http.Request) bool { + + auth := req.Header.Get(AUTHORIZATION_HEADER) + if basicAuth, found := strings.CutPrefix(auth, BASIC_AUTH_PREFIX); found { + + decoded, err := base64.StdEncoding.DecodeString(basicAuth) + if err != nil { + return false + } + + username, password, err := parseBasicAuth(string(decoded)) + if err != nil { + return false + } + + provider, terminalId, err := parseTerminalUser(username) + if err != nil { + return false + } + LogInfo(fmt.Sprintf("req=%s by terminal with id=%d, provider=%s", req.RequestURI, terminalId, provider)) + terminal, err := DB.GetTerminalById(terminalId) + if err != nil { + return false + } + + if !terminal.Active { + LogWarn(fmt.Sprintf("request from inactive terminal. id=%d", terminalId)) + return false + } + + prvdr, err := DB.GetTerminalProviderByName(provider) + if err != nil { + LogWarn(fmt.Sprintf("failed requesting provider by name %s", err.Error())) + return false + } + + if terminal.ProviderId != prvdr.ProviderId { + LogWarn("terminal's provider id did not match provider id of supplied provider") + return false + } + + return ValidPassword(password, terminal.AccessToken) + } + + return false +} + +// find out how the wallet authenticates itself. +// returns true if authentication was successful, otherwise false +// when not successful, the api shall return immediately +func AuthenticateWallet(req *http.Request) bool { + + // Is this needed? Understand how the wallet authenticates itself at the exchange currently first. + // https://docs.taler.net/design-documents/049-auth.html#dd48-token + // https://docs.taler.net/core/api-corebank.html#authentication + // + // /accounts/$USERNAME/token + // + // The username in our case is the reserve public key + // registered for withdrawal. At the initial registration + // of the reserve public key we leverage a TOFU trust model. + // during the registration of the reserve public key a new + // access token will be created with a limited lifetime. + // The token will not be refreshable and become invalid + // only after a few minutes. Since the Wallet will register + // a wopid and return true +} - // auth := req.Header.Get(AUTHORIZATION_HEADER) - // token, found := strings.CutPrefix(auth, AUTHORIZATION_HEADER) - // if !found { - // // invalid token prefix - // return false - // } +func parseBasicAuth(basicAuth string) (string, string, error) { - // return strings.EqualFold(token, "") + parts := strings.Split(basicAuth, ":") + if len(parts) != 2 { + return "", "", errors.New("malformed basic auth") + } + return parts[0], parts[1], nil } -// Is this needed? Understand how the wallet authenticates itself at the exchange currently first. -// https://docs.taler.net/design-documents/049-auth.html#dd48-token -// https://docs.taler.net/core/api-corebank.html#authentication +// parses the username of the basic auth param of the terminal. +// the username has following format: // -// /accounts/$USERNAME/token +// [PROVIDER_NAME]-[TERMINAL_ID] +func parseTerminalUser(username string) (string, int, error) { + + parts := strings.Split(username, "-") + if len(parts) != 2 { + return "", -1, errors.New("malformed basic auth username") + } + + providerName := parts[0] + terminalId, err := strconv.Atoi(parts[1]) + if err != nil { + return "", -1, errors.New("malformed basic auth username") + } + + return providerName, terminalId, nil +} + +// takes a password and a base64 encoded password hash, including salt and checks +// the password supplied against it. +// the format of the password hash is expected to be the following: // -// The username in our case is the reserve public key -// registered for withdrawal. At the initial registration -// of the reserve public key we leverage a TOFU trust model. -// during the registration of the reserve public key a new -// access token will be created with a limited lifetime. -// The token will not be refreshable and become invalid -// only after a few minutes. Since the Wallet will register -// a wopid and -// func handleTokenRequest() { -// } - -func pbkdf(pw string) (string, error) { +// [32 BYTES HASH][16 BYTES SALT] = Bytes array with length of 48 bytes. +// +// returns true if password matches the password hash. Otherwise false. +func ValidPassword(pw string, base64EncodedHashAndSalt string) bool { - rfcTime := 3 - rfcMemory := 32 * 1024 - salt := make([]byte, 16) - _, err := rand.Read(salt) + hashedBytes := make([]byte, 48) + decodedLen, err := base64.StdEncoding.Decode(hashedBytes, []byte(base64EncodedHashAndSalt)) if err != nil { - return "", err + return false + } + + if decodedLen != 48 { + // malformed credentials + return false } + + salt := hashedBytes[32:48] + rfcTime := 3 + rfcMemory := 32 * 1024 key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) - keyAndSalt := make([]byte, 0, 48) - keyAndSalt = append(keyAndSalt, key...) - keyAndSalt = append(keyAndSalt, salt...) - if len(keyAndSalt) != 48 { - return "", errors.New("invalid password hash and salt") + if len(key) != 32 { + // length mismatch + return false + } + + for i := range key { + if key[i] != hashedBytes[i] { + // wrong password (application user key) + return false + } } - return base64.StdEncoding.EncodeToString(keyAndSalt), nil + + // password correct. + return true } diff --git a/c2ec/auth_test.go b/c2ec/auth_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "testing" + + "golang.org/x/crypto/argon2" +) + +func TestValidPassword(t *testing.T) { + + pw := "verygoodpassword" + hashedEncodedPw, err := pbkdf(pw) + if err != nil { + fmt.Println("pbkdf failed") + t.FailNow() + } + + if !ValidPassword(pw, hashedEncodedPw) { + fmt.Println("password check failed") + t.FailNow() + } +} + +// copied from the cli tool. this function is used to obtain a base64 encoded password hash. +func pbkdf(pw string) (string, error) { + + rfcTime := 3 + rfcMemory := 32 * 1024 + salt := make([]byte, 16) + _, err := rand.Read(salt) + if err != nil { + return "", err + } + key := argon2.Key([]byte(pw), salt, uint32(rfcTime), uint32(rfcMemory), 4, 32) + + keyAndSalt := make([]byte, 0, 48) + keyAndSalt = append(keyAndSalt, key...) + keyAndSalt = append(keyAndSalt, salt...) + if len(keyAndSalt) != 48 { + return "", errors.New("invalid password hash and salt") + } + return base64.StdEncoding.EncodeToString(keyAndSalt), nil +} diff --git a/c2ec/bank-integration.go b/c2ec/bank-integration.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "fmt" - "log" http "net/http" "strconv" "time" @@ -70,7 +69,7 @@ func bankIntegrationConfig(res http.ResponseWriter, req *http.Request) { serializedCfg, err := NewJsonCodec[BankIntegrationConfig]().EncodeToBytes(&cfg) if err != nil { - log.Default().Printf("failed serializing config: %s", err.Error()) + LogInfo(fmt.Sprintf("failed serializing config: %s", err.Error())) res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) return } @@ -100,6 +99,7 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) { // read and validate the wopid path parameter wopid := req.PathValue(WOPID_PARAMETER) if !WopidValid(wopid) { + LogWarn("wopid " + wopid + " not valid") if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER", @@ -139,8 +139,6 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) { // Get status of withdrawal associated with the given WOPID // -// # If the -// // Parameters: // - long_poll_ms (optional): // milliseconds to wait for state to change @@ -155,7 +153,6 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { if longPollMilliPtr, accepted := AcceptOptionalParamOrWriteResponse( "long_poll_ms", strconv.Atoi, req, res, ); accepted { - } else { if longPollMilliPtr != nil { longPollMilli = *longPollMilliPtr } else { @@ -163,12 +160,14 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { // no long polling (simple get) shouldStartLongPoll = false } + } else { + shouldStartLongPoll = false } // read and validate the wopid path parameter wopid := req.PathValue(WOPID_PARAMETER) if !WopidValid(wopid) { - + LogWarn("wopid " + wopid + " not valid") if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER", @@ -234,7 +233,7 @@ func handlePaymentNotification(res http.ResponseWriter, req *http.Request) { wopid := req.PathValue(WOPID_PARAMETER) if !WopidValid(wopid) { - + LogWarn("wopid " + wopid + " not valid") if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER", diff --git a/c2ec/base32-taler.go b/c2ec/base32-taler.go @@ -1,49 +0,0 @@ -package main - -import ( - "errors" - "strings" - "unicode" -) - -// just ported the function from the C code in Taler. -// source: [taler-exchange]/src/util/crypto_confirmation.c -func TalerBase32Decode(val string, buf []byte) (int, error) { - // 32 characters for decoding, using RFC 3548. - decTable := "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" - udata := buf - wpos := 0 - rpos := 0 - bits := 0 - vbit := 0 - - for rpos < len(val) || vbit >= 8 { - if rpos < len(val) && vbit < 8 { - c := val[rpos] - - if c == '=' { - // padding character - if rpos == len(val)-1 { - break // Ok, 1x '=' padding is allowed - } - if c == '=' && rpos+1 == len(val)-1 { - break // Ok, 2x '=' padding is allowed - } - return -1, errors.New("invalid padding") - } - p := strings.IndexByte(strings.ToUpper(decTable), byte(unicode.ToUpper(rune(c)))) - if p == -1 { - return -1, errors.New("invalid character") - } - bits = (bits << 5) | p - vbit += 5 - rpos++ - } - if vbit >= 8 { - udata[wpos] = byte((bits >> (vbit - 8)) & 0xFF) - wpos++ - vbit -= 8 - } - } - return wpos, nil -} diff --git a/c2ec/base32-taler_test.go b/c2ec/base32-taler_test.go @@ -1,21 +0,0 @@ -package main - -import ( - "fmt" - "testing" -) - -func TestBase32(t *testing.T) { - - val := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" - key := make([]byte, len(val)) - result, err := TalerBase32Decode(val, key) - if err != nil { - fmt.Println(err.Error()) - t.FailNow() - } - - fmt.Println(key) - - fmt.Println(result) -} diff --git a/c2ec/c2ec-config.yaml b/c2ec/c2ec-config.yaml @@ -6,6 +6,9 @@ c2ec: unix-socket-path: "c2ec.sock" fail-on-missing-attestors: false # forced if prod=true credit-account: "payto://iban/CH50030202099498" # this account must be specified at the providers backends as well + wire-gateway: + username: "wire" + password: "secret" db: host: "localhost" port: 5432 @@ -14,6 +17,6 @@ db: database: "postgres" providers: - name: "Wallee" - credentials-password: "secret" + credentials-password: "secret" # dummy terminal password: cNuEkK4iT4NpREqTbpYfZxpM0Skvvw+niJOT47QtM90= - name: "Simulation" credentials-password: "secret" diff --git a/c2ec/c2ec-log.txt b/c2ec/c2ec-log.txt diff --git a/c2ec/config.go b/c2ec/config.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "gopkg.in/yaml.v3" @@ -13,13 +14,19 @@ type C2ECConfig struct { } type C2ECServerConfig struct { - IsProd bool `yaml:"prod"` - Host string `yaml:"host"` - Port int `yaml:"port"` - UseUnixDomainSocket bool `yaml:"unix-domain-socket"` - UnixSocketPath string `yaml:"unix-socket-path"` - StrictAttestors bool `yaml:"fail-on-missing-attestors"` - CreditAccount string `yaml:"credit-account"` + IsProd bool `yaml:"prod"` + Host string `yaml:"host"` + Port int `yaml:"port"` + UseUnixDomainSocket bool `yaml:"unix-domain-socket"` + UnixSocketPath string `yaml:"unix-socket-path"` + StrictAttestors bool `yaml:"fail-on-missing-attestors"` + CreditAccount string `yaml:"credit-account"` + WireGateway C2ECWireGatewayConfig `yaml:"wire-gateway"` +} + +type C2ECWireGatewayConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` } type C2ECDatabseConfig struct { @@ -62,3 +69,14 @@ func Parse(path string) (*C2ECConfig, error) { return cfg, nil } + +func ConfigForProvider(name string) (*C2ECProviderConfig, error) { + + for _, provider := range CONFIG.Providers { + + if provider.Name == name { + return &provider, nil + } + } + return nil, errors.New("no such provider") +} diff --git a/c2ec/db.go b/c2ec/db.go @@ -37,7 +37,7 @@ const TRANSFER_FIELD_NAME_ID = "request_uid" const TRANSFER_FIELD_NAME_HASH = "request_hash" type Provider struct { - ProviderTerminalID int64 `db:"provider_id"` + ProviderId int64 `db:"provider_id"` Name string `db:"name"` PaytoTargetType string `db:"payto_target_type"` BackendBaseURL string `db:"backend_base_url"` @@ -46,10 +46,10 @@ type Provider struct { type Terminal struct { TerminalID int64 `db:"terminal_id"` - AccessToken []byte `db:"access_token"` + AccessToken string `db:"access_token"` Active bool `db:"active"` Description string `db:"description"` - ProviderID int64 `db:"provider_id"` + ProviderId int64 `db:"provider_id"` } type Withdrawal struct { diff --git a/c2ec/db/0000-c2ec_schema.sql b/c2ec/db/0000-c2ec_schema.sql @@ -57,7 +57,7 @@ COMMENT ON COLUMN provider.backend_credentials CREATE TABLE IF NOT EXISTS terminal ( terminal_id INT8 GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - access_token BYTEA CHECK (LENGTH(access_token)=32) NOT NULL, + access_token TEXT NOT NULL, active BOOLEAN NOT NULL DEFAULT TRUE, description TEXT, provider_id INT8 NOT NULL REFERENCES provider(provider_id) @@ -67,7 +67,7 @@ COMMENT ON TABLE terminal COMMENT ON COLUMN terminal.terminal_id IS 'Uniquely identifies a terminal'; COMMENT ON COLUMN terminal.access_token - IS 'The access token of the terminal used for authentication against the c2ec API'; + IS 'The access token of the terminal used for authentication against the c2ec API. It is hashed using a PBKDF.'; COMMENT ON COLUMN terminal.active IS 'Indicates if the terminal is active or deactivated'; COMMENT ON COLUMN terminal.description diff --git a/c2ec/go.mod b/c2ec/go.mod @@ -5,18 +5,17 @@ go 1.22.0 require ( github.com/jackc/pgx/v5 v5.5.5 github.com/jackc/pgxlisten v0.0.0-20230728233309-2632bad3185a + golang.org/x/crypto v0.22.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 ) require ( - github.com/carlmjohnson/crockford v0.23.1 // indirect github.com/google/go-cmp v0.5.9 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/rogpeppe/go-internal v1.6.1 // indirect - golang.org/x/crypto v0.22.0 // indirect golang.org/x/sync v0.1.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/text v0.14.0 // indirect diff --git a/c2ec/go.sum b/c2ec/go.sum @@ -1,5 +1,3 @@ -github.com/carlmjohnson/crockford v0.23.1 h1:ImVIp5KOZvHXpxaVGP78WanEPxzH0ZqqE4Nd9YuJAb4= -github.com/carlmjohnson/crockford v0.23.1/go.mod h1:+uz/aAJerF/noKb/fS4l9vl4bFexgpDjdVoL9XeKn/0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,8 +29,6 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= diff --git a/c2ec/logger.go b/c2ec/logger.go @@ -0,0 +1,61 @@ +package main + +import ( + "fmt" + "os" + "time" +) + +const LOG_PATH = "c2ec-log.txt" + +// LEVEL | TIME | MESSAGE +const LOG_PATTERN = "%d | %s | %s" +const TIME_FORMAT = "yyyy-MM-dd hh:mm:ss" + +type LogLevel int + +const ( + INFO LogLevel = iota + WARN + ERROR +) + +func LogError(err error) { + + go logAppendError(ERROR, err) +} + +func LogWarn(msg string) { + + go logAppend(WARN, msg) +} + +func LogInfo(msg string) { + + go logAppend(INFO, msg) +} + +func logAppendError(level LogLevel, err error) { + + logAppend(level, err.Error()) +} + +func logAppend(level LogLevel, msg string) { + + openAppendClose(fmt.Sprintf(LOG_PATTERN, level, time.Now().Format(time.UnixDate), msg)) +} + +func openAppendClose(s string) { + + // first try opening only append + f, err := os.OpenFile(LOG_PATH, os.O_APPEND|os.O_WRONLY, os.ModeAppend) + if err != nil || f == nil { + // if file does not yet exist, open with create flag. + f, err = os.OpenFile(LOG_PATH, os.O_CREATE|os.O_APPEND|os.O_WRONLY, os.ModeAppend) + if err != nil || f == nil { + panic("failed opening or creating log file") + } + } + f.WriteString(s + "\n") + f.Close() +} diff --git a/c2ec/main.go b/c2ec/main.go @@ -9,6 +9,7 @@ import ( "os" "os/signal" "syscall" + "time" ) const GET = "GET " @@ -19,6 +20,8 @@ const WIRE_GATEWAY_API = "/wire" const DEFAULT_C2EC_CONFIG_PATH = "c2ec-config.yaml" +var CONFIG C2ECConfig + var DB C2ECDatabase // This map contains all clients initialized during the @@ -39,6 +42,8 @@ var PROVIDER_CLIENTS = map[string]ProviderClient{} // 6. listen for incoming requests (as specified in config) func main() { + LogInfo(fmt.Sprintf("starting c2ec at %s", time.Now().Format(time.UnixDate))) + cfgPath := DEFAULT_C2EC_CONFIG_PATH if len(os.Args) > 1 && os.Args[1] != "" { cfgPath = os.Args[1] @@ -47,13 +52,17 @@ func main() { if err != nil { panic("unable to load config: " + err.Error()) } + if cfg == nil { + panic("config is nil") + } + CONFIG = *cfg - DB, err = setupDatabase(&cfg.Database) + DB, err = setupDatabase(&CONFIG.Database) if err != nil { panic("unable initialize datatbase: " + err.Error()) } - err = setupAttestors(cfg) + err = setupAttestors(&CONFIG) if err != nil { panic("unable initialize attestors: " + err.Error()) } @@ -68,9 +77,9 @@ func main() { Handler: router, } - if cfg.Server.UseUnixDomainSocket { + if CONFIG.Server.UseUnixDomainSocket { - socket, err := net.Listen("unix", cfg.Server.UnixSocketPath) + socket, err := net.Listen("unix", CONFIG.Server.UnixSocketPath) if err != nil { panic("failed listening on socket: " + err.Error()) } @@ -80,7 +89,7 @@ func main() { signal.Notify(c, os.Interrupt, syscall.SIGTERM) go func() { <-c - os.Remove(cfg.Server.UnixSocketPath) + os.Remove(CONFIG.Server.UnixSocketPath) os.Exit(1) }() @@ -89,7 +98,7 @@ func main() { } } else { - server.Addr = fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + server.Addr = fmt.Sprintf("%s:%d", CONFIG.Server.Host, CONFIG.Server.Port) if err = server.ListenAndServe(); err != nil { panic(err.Error()) } @@ -127,7 +136,7 @@ func setupAttestors(cfg *C2ECConfig) error { // Prevent simulation provider to be loaded in productive environments. if p.Name == "Simulation" { attestor := new(SimulationAttestor) - errs, err := RunAttestor(context.Background(), attestor, p, &cfg.Database) + errs, err := RunAttestor(context.Background(), attestor, p) if err != nil { return err } @@ -137,7 +146,7 @@ func setupAttestors(cfg *C2ECConfig) error { if p.Name == "Wallee" { attestor := new(WalleeAttestor) - errs, err := RunAttestor(context.Background(), attestor, p, &cfg.Database) + errs, err := RunAttestor(context.Background(), attestor, p) if err != nil { return err } else { diff --git a/c2ec/model.go b/c2ec/model.go @@ -1,6 +1,7 @@ package main import ( + "encoding/base64" "fmt" ) @@ -51,9 +52,12 @@ func ToWithdrawalOpStatus(s string) (WithdrawalOperationStatus, error) { func WopidValid(wopid string) bool { - buf := make([]byte, 32) - res, err := TalerBase32Decode(wopid, buf) - return err != nil && res == 32 + decoded, err := base64.URLEncoding.DecodeString(wopid) + LogInfo(fmt.Sprintf("decoded wopid=%s", string(decoded))) + if err != nil { + LogError(err) + } + return err != nil //&& len(wopidBytes) == 32 } type ErrorDetail struct { diff --git a/c2ec/postgres.go b/c2ec/postgres.go @@ -127,6 +127,7 @@ func (db *C2ECPostgres) RegisterWithdrawal( terminalId, ) if err != nil { + LogError(err) return err } res.Close() @@ -140,6 +141,7 @@ func (db *C2ECPostgres) GetWithdrawalByWopid(wopid string) (*Withdrawal, error) PS_GET_WITHDRAWAL_BY_WOPID, wopid, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -150,6 +152,7 @@ func (db *C2ECPostgres) GetWithdrawalByWopid(wopid string) (*Withdrawal, error) withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) if err != nil { + LogError(err) return nil, err } @@ -166,6 +169,7 @@ func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withd PS_GET_WITHDRAWAL_BY_PTID, tid, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -176,6 +180,7 @@ func (db *C2ECPostgres) GetWithdrawalByProviderTransactionId(tid string) (*Withd withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) if err != nil { + LogError(err) return nil, err } @@ -201,6 +206,7 @@ func (db *C2ECPostgres) NotifyPayment( fees, ) if err != nil { + LogError(err) return err } res.Close() @@ -213,6 +219,7 @@ func (db *C2ECPostgres) GetAttestableWithdrawals() ([]*Withdrawal, error) { db.ctx, PS_GET_UNCONFIRMED_WITHDRAWALS, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -223,6 +230,7 @@ func (db *C2ECPostgres) GetAttestableWithdrawals() ([]*Withdrawal, error) { withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) if err != nil { + LogError(err) return nil, err } @@ -248,6 +256,7 @@ func (db *C2ECPostgres) FinaliseWithdrawal( withdrawalId, ) if err != nil { + LogError(err) return err } res.Close() @@ -296,6 +305,7 @@ func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int) ([]*Withdr } if err != nil { + LogError(err) if row != nil { row.Close() } @@ -306,6 +316,7 @@ func (db *C2ECPostgres) GetConfirmedWithdrawals(start int, delta int) ([]*Withdr withdrawals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Withdrawal]) if err != nil { + LogError(err) return nil, err } @@ -320,6 +331,7 @@ func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error PS_GET_PROVIDER_BY_NAME, name, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -330,6 +342,7 @@ func (db *C2ECPostgres) GetTerminalProviderByName(name string) (*Provider, error provider, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Provider]) if err != nil { + LogError(err) return nil, err } @@ -348,6 +361,7 @@ func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType str PS_GET_PROVIDER_BY_PAYTO_TARGET_TYPE, paytoTargetType, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -358,6 +372,7 @@ func (db *C2ECPostgres) GetTerminalProviderByPaytoTargetType(paytoTargetType str provider, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Provider]) if err != nil { + LogError(err) return nil, err } @@ -376,6 +391,7 @@ func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) { PS_GET_TERMINAL_BY_ID, id, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -386,6 +402,7 @@ func (db *C2ECPostgres) GetTerminalById(id int) (*Terminal, error) { terminals, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Terminal]) if err != nil { + LogError(err) return nil, err } @@ -404,6 +421,7 @@ func (db *C2ECPostgres) GetTransferById(requestUid HashCode) (*Transfer, error) PS_GET_TRANSFER_BY_ID, requestUid, ); err != nil { + LogError(err) if row != nil { row.Close() } @@ -414,6 +432,7 @@ func (db *C2ECPostgres) GetTransferById(requestUid HashCode) (*Transfer, error) transfers, err := pgx.CollectRows(row, pgx.RowToAddrOfStructByName[Transfer]) if err != nil { + LogError(err) return nil, err } @@ -434,6 +453,7 @@ func (db *C2ECPostgres) AddTransfer(requestId HashCode, requestHash string) erro requestHash, ) if err != nil { + LogError(err) return err } res.Close() @@ -461,12 +481,19 @@ func (db *C2ECPostgres) ListenForWithdrawalStatusChange( select { case e := <-errs: + LogError(e) return "", e case <-ctx.Done(): - return "", errors.New("time exceeded") + err := ctx.Err() + msg := "context sent done signal while listening for status change" + if err != nil { + LogError(err) + } else { + LogWarn(msg) + } + return "", errors.New(msg) case n := <-pgNotification: - // TODO : Centralize Logging somehow - fmt.Println("received notification for channel", n.Channel, ":", n.Payload) + LogInfo(fmt.Sprintf("received notification for channel %s: %s", n.Channel, n.Payload)) return WithdrawalOperationStatus(n.Payload), nil } } diff --git a/c2ec/simulation-attestor.go b/c2ec/simulation-attestor.go @@ -12,16 +12,16 @@ import ( ) type SimulationAttestor struct { - Attestor[SimulationClient] + Attestor[*pgconn.Notification] listener *pgxlisten.Listener provider *Provider providerClient ProviderClient } -func (wa *SimulationAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgconn.Notification, error) { +func (wa *SimulationAttestor) Setup(p *Provider) (chan *pgconn.Notification, error) { - connectionString := PostgresConnectionString(cfg) + connectionString := PostgresConnectionString(&CONFIG.Database) dbCfg, err := pgx.ParseConfig(connectionString) if err != nil { diff --git a/c2ec/simulation-client.go b/c2ec/simulation-client.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "time" ) type SimulationTransaction struct { @@ -15,6 +16,9 @@ type SimulationClient struct { // toggle this to simulate failed transactions. AllowNextWithdrawal bool + + // simulates the provider client fetching attestation at the providers backend. + providerBackendAttestationDelayMs int } func (st *SimulationTransaction) AllowWithdrawal() bool { @@ -26,6 +30,7 @@ func (sc *SimulationClient) SetupClient(p *Provider) error { fmt.Println("setting up simulation client. probably not what you want in production") + sc.providerBackendAttestationDelayMs = 1000 // one second, might be a lot but for testing this is good. PROVIDER_CLIENTS["Simulation"] = sc return nil } @@ -33,6 +38,7 @@ func (sc *SimulationClient) SetupClient(p *Provider) error { func (sc *SimulationClient) GetTransaction(transactionId string) (ProviderTransaction, error) { fmt.Println("getting transaction from simulation provider") + time.Sleep(time.Duration(sc.providerBackendAttestationDelayMs) * time.Millisecond) st := new(SimulationTransaction) st.allow = sc.AllowNextWithdrawal return st, nil diff --git a/c2ec/wallee-attestor.go b/c2ec/wallee-attestor.go @@ -12,20 +12,20 @@ import ( ) type WalleeAttestor struct { - Attestor[WalleeClient] + Attestor[*pgconn.Notification] listener *pgxlisten.Listener provider *Provider providerClient ProviderClient } -func (wa *WalleeAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgconn.Notification, error) { +func (wa *WalleeAttestor) Setup(p *Provider) (chan *pgconn.Notification, error) { - connectionString := PostgresConnectionString(cfg) + connectionString := PostgresConnectionString(&CONFIG.Database) dbCfg, err := pgx.ParseConfig(connectionString) if err != nil { - panic(err.Error()) + return nil, err } wa.provider = p @@ -33,7 +33,7 @@ func (wa *WalleeAttestor) Setup(p *Provider, cfg *C2ECDatabseConfig) (chan *pgco wa.providerClient = new(WalleeClient) err = wa.providerClient.SetupClient(wa.provider) if err != nil { - panic(err.Error()) + return nil, err } notificationChannel := make(chan *pgconn.Notification, PAYMENT_NOTIFICATION_CHANNEL_BUFFER_SIZE) @@ -54,6 +54,7 @@ func (wa *WalleeAttestor) Listen( go func() { err := wa.listener.Listen(ctx) if err != nil { + LogError(err) errs <- err } close(notificationChannel) diff --git a/c2ec/wallee-client.go b/c2ec/wallee-client.go @@ -44,7 +44,12 @@ func (wt *WalleeTransaction) AllowWithdrawal() bool { func (w *WalleeClient) SetupClient(p *Provider) error { - creds, err := parseCredentials(p.BackendCredentials) + cfg, err := ConfigForProvider(p.Name) + if err != nil { + return err + } + + creds, err := parseCredentials(p.BackendCredentials, cfg) if err != nil { return err } @@ -111,9 +116,26 @@ func (w *WalleeClient) prepareWalleeHeaders(url string, method string) (map[stri return headers, nil } -func parseCredentials(raw string) (*WalleeCredentials, error) { +func parseCredentials(raw string, cfg *C2ECProviderConfig) (*WalleeCredentials, error) { + + credsJson := make([]byte, len(raw)) + _, err := base64.StdEncoding.Decode(credsJson, []byte(raw)) + if err != nil { + return nil, err + } + + creds, err := NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBuffer(credsJson)) + if err != nil { + return nil, err + } + + if !ValidPassword(cfg.CredentialsPassword, creds.ApplicationUserKey) { + return nil, errors.New("invalid application user key") + } - return NewJsonCodec[WalleeCredentials]().Decode(bytes.NewBufferString(raw)) + // correct application user key. + creds.ApplicationUserKey = cfg.CredentialsPassword + return creds, nil } // This function calculates the authentication token according @@ -152,6 +174,7 @@ func calculateWalleeAuthToken( key := make([]byte, base64.StdEncoding.DecodedLen(len(userKeyBase64))) _, err := base64.StdEncoding.Decode(key, []byte(userKeyBase64)) if err != nil { + LogError(err) return "", err } @@ -162,6 +185,7 @@ func calculateWalleeAuthToken( macer := hmac.New(sha512.New, key) _, err = macer.Write(authMsg) if err != nil { + LogError(err) return "", err } mac := make([]byte, 64) diff --git a/cli/cli.go b/cli/cli.go @@ -113,12 +113,12 @@ func registerWalleeTerminal() error { if err != nil { return err } - defer rows.Close() p, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Provider]) if err != nil { return err } + rows.Close() // release rows / connection accessToken := make([]byte, 32) _, err = rand.Read(accessToken) @@ -127,19 +127,40 @@ func registerWalleeTerminal() error { } accessTokenBase64 := base64.StdEncoding.EncodeToString(accessToken) - fmt.Println("GENERATED ACCESS-TOKEN:", accessTokenBase64) + + hashedAccessToken, err := pbkdf(accessTokenBase64) + if err != nil { + return err + } _, err = DB.Query( context.Background(), INSERT_TERMINAL, - accessToken, + hashedAccessToken, description, p.ProviderTerminalID, ) if err != nil { return err } - defer rows.Close() + rows.Close() + + rows, err = DB.Query( + context.Background(), + GET_LAST_INSERTED_TERMINAL, + ) + if err != nil { + return err + } + t, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Terminal]) + if err != nil { + return err + } + rows.Close() + + fmt.Println("Terminal-User-Id (used to identify terminal at the api. You want to note this):", "Wallee-"+strconv.Itoa(int(t.TerminalID))) + fmt.Println("GENERATED ACCESS-TOKEN (save it in your password manager. Can't be recovered!!):") + fmt.Println(accessTokenBase64) return nil } diff --git a/cli/db.go b/cli/db.go @@ -3,6 +3,7 @@ package main const INSERT_PROVIDER = "INSERT INTO c2ec.provider (name, payto_target_type, backend_base_url, backend_credentials) VALUES ($1,$2,$3,$4)" const INSERT_TERMINAL = "INSERT INTO c2ec.terminal (access_token, description, provider_id) VALUES ($1,$2,$3)" const GET_PROVIDER_BY_NAME = "SELECT * FROM c2ec.provider WHERE name=$1" +const GET_LAST_INSERTED_TERMINAL = "SELECT * FROM c2ec.terminal WHERE terminal_id = (SELECT MAX(terminal_id) FROM c2ec.terminal)" type Provider struct { ProviderTerminalID int64 `db:"provider_id"` diff --git a/docs/content/implementation/c2ec-db.tex b/docs/content/implementation/c2ec-db.tex @@ -8,8 +8,10 @@ This allows the implementation of neat pub/sub models allowing better performanc \subsubsection{Schema} +For the C2EC component the schema c2ec is created. It holds three tables and three triggers. + \subsubsection{Triggers} Two types of triggers are implemented. One type notifies listeners about the update of a withdrawal entry. The trigger runs every time an entry is created using INSERT statements and every time a change of the withdrawal status field is detected. -The trigger runs a Postgres function which will execute a NOTIFY statement using Postgres built-in function \textit{pg_notify}, which wraps the statement in a Postgres function allowing to be used more easy. -\ No newline at end of file +The trigger runs a Postgres function which will execute a NOTIFY statement using Postgres built-in function \textit{pg\_notify}, which wraps the statement in a Postgres function allowing to be used more easy. diff --git a/docs/thesis.pdf b/docs/thesis.pdf Binary files differ. diff --git a/simulation/.vscode/launch.json b/simulation/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + // Launch config docs: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "C2EC-Simulation", + "type": "go", + "request": "launch", + "mode": "auto", + "program": "." + } + ] +} +\ No newline at end of file diff --git a/simulation/amount.go b/simulation/amount.go @@ -0,0 +1,153 @@ +// This file is part of taler-go, the Taler Go implementation. +// Copyright (C) 2022 Martin Schanzenbach +// +// Taler Go is free software: you can redistribute it and/or modify it +// under the terms of the GNU Affero General Public License as published +// by the Free Software Foundation, either version 3 of the License, +// or (at your option) any later version. +// +// Taler Go is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// SPDX-License-Identifier: AGPL3.0-or-later + +package main + +import ( + "errors" + "fmt" + "math" + "regexp" + "strconv" + "strings" +) + +// The GNU Taler Amount object +type Amount struct { + + // The type of currency, e.g. EUR + Currency string `json:"currency"` + + // The value (before the ".") + Value uint64 `json:"value"` + + // The fraction (after the ".", optional) + Fraction uint64 `json:"fraction"` +} + +// The maximim length of a fraction (in digits) +const FractionalLength = 8 + +// The base of the fraction. +const FractionalBase = 1e8 + +// The maximum value +var MaxAmountValue = uint64(math.Pow(2, 52)) + +// Create a new amount from value and fraction in a currency +func NewAmount(currency string, value uint64, fraction uint64) Amount { + return Amount{ + Currency: currency, + Value: value, + Fraction: fraction, + } +} + +// Subtract the amount b from a and return the result. +// a and b must be of the same currency and a >= b +func (a *Amount) Sub(b Amount) (*Amount, error) { + if a.Currency != b.Currency { + return nil, errors.New("currency mismatch") + } + v := a.Value + f := a.Fraction + if a.Fraction < b.Fraction { + v -= 1 + f += FractionalBase + } + f -= b.Fraction + if v < b.Value { + return nil, errors.New("amount overflow") + } + v -= b.Value + r := Amount{ + Currency: a.Currency, + Value: v, + Fraction: f, + } + return &r, nil +} + +// Add b to a and return the result. +// Returns an error if the currencies do not match or the addition would +// cause an overflow of the value +func (a *Amount) Add(b Amount) (*Amount, error) { + if a.Currency != b.Currency { + return nil, errors.New("currency mismatch") + } + v := a.Value + + b.Value + + uint64(math.Floor((float64(a.Fraction)+float64(b.Fraction))/FractionalBase)) + + if v >= MaxAmountValue { + return nil, fmt.Errorf("amount overflow (%d > %d)", v, MaxAmountValue) + } + f := uint64((a.Fraction + b.Fraction) % FractionalBase) + r := Amount{ + Currency: a.Currency, + Value: v, + Fraction: f, + } + return &r, nil +} + +// Parses an amount string in the format <currency>:<value>[.<fraction>] +func ParseAmount(s string) (*Amount, error) { + re, err := regexp.Compile(`^\s*([-_*A-Za-z0-9]+):([0-9]+)\.?([0-9]+)?\s*$`) + parsed := re.FindStringSubmatch(s) + + if nil != err { + return nil, fmt.Errorf("invalid amount: %s", s) + } + tail := "0.0" + if len(parsed) >= 4 { + tail = "0." + parsed[3] + } + if len(tail) > FractionalLength+1 { + return nil, errors.New("fraction too long") + } + value, err := strconv.ParseUint(parsed[2], 10, 64) + if nil != err { + return nil, fmt.Errorf("unable to parse value %s", parsed[2]) + } + fractionF, err := strconv.ParseFloat(tail, 64) + if nil != err { + return nil, fmt.Errorf("unable to parse fraction %s", tail) + } + fraction := uint64(math.Round(fractionF * FractionalBase)) + currency := parsed[1] + a := NewAmount(currency, value, fraction) + return &a, nil +} + +// Check if this amount is zero +func (a *Amount) IsZero() bool { + return (a.Value == 0) && (a.Fraction == 0) +} + +// Returns the string representation of the amount: <currency>:<value>[.<fraction>] +// Omits trailing zeroes. +func (a *Amount) String() string { + v := strconv.FormatUint(a.Value, 10) + if a.Fraction != 0 { + f := strconv.FormatUint(a.Fraction, 10) + f = strings.TrimRight(f, "0") + v = fmt.Sprintf("%s.%s", v, f) + } + return fmt.Sprintf("%s:%s", a.Currency, v) +} diff --git a/simulation/c2ec-simulation b/simulation/c2ec-simulation Binary files differ. diff --git a/simulation/codec.go b/simulation/codec.go @@ -0,0 +1,58 @@ +// COPIED FROM C2EC +package main + +import ( + "bytes" + "encoding/json" + "io" +) + +type Codec[T any] interface { + HttpApplicationContentHeader() string + Encode(*T) (io.Reader, error) + EncodeToBytes(body *T) ([]byte, error) + Decode(io.Reader) (*T, error) +} + +type JsonCodec[T any] struct { + Codec[T] +} + +func NewJsonCodec[T any]() Codec[T] { + + return new(JsonCodec[T]) +} + +func (*JsonCodec[T]) HttpApplicationContentHeader() string { + return "application/json" +} + +func (*JsonCodec[T]) Encode(body *T) (io.Reader, error) { + + encodedBytes, err := json.Marshal(body) + if err != nil { + return nil, err + } + + return bytes.NewReader(encodedBytes), err +} + +func (c *JsonCodec[T]) EncodeToBytes(body *T) ([]byte, error) { + + reader, err := c.Encode(body) + if err != nil { + return make([]byte, 0), err + } + buf, err := io.ReadAll(reader) + if err != nil { + return make([]byte, 0), err + } + return buf, nil +} + +func (*JsonCodec[T]) Decode(reader io.Reader) (*T, error) { + + body := new(T) + err := json.NewDecoder(reader).Decode(body) + return body, err +} diff --git a/simulation/go.mod b/simulation/go.mod @@ -0,0 +1,3 @@ +module c2ec-simulation + +go 1.22.1 diff --git a/simulation/http-util.go b/simulation/http-util.go @@ -0,0 +1,359 @@ +// COPIED FROM C2EC +package main + +import ( + "bytes" + "errors" + "fmt" + "net/http" + "strings" +) + +const HTTP_GET = "GET" +const HTTP_POST = "POST" + +const HTTP_OK = 200 +const HTTP_NO_CONTENT = 204 +const HTTP_BAD_REQUEST = 400 +const HTTP_UNAUTHORIZED = 401 +const HTTP_NOT_FOUND = 404 +const HTTP_METHOD_NOT_ALLOWED = 405 +const HTTP_CONFLICT = 409 +const HTTP_INTERNAL_SERVER_ERROR = 500 + +const TALER_URI_PROBLEM_PREFIX = "taler://problem" + +type RFC9457Problem struct { + TypeUri string `json:"type"` + Title string `json:"title"` + Detail string `json:"detail"` + Instance string `json:"instance"` +} + +// Writes a problem as specified by RFC 9457 to +// the response. The problem is always serialized +// as JSON. +func WriteProblem(res http.ResponseWriter, status int, problem *RFC9457Problem) error { + + c := NewJsonCodec[RFC9457Problem]() + problm, err := c.EncodeToBytes(problem) + if err != nil { + return err + } + + res.WriteHeader(status) + res.Write(problm) + return nil +} + +// Function reads and validates a param of a request in the +// correct format according to the transform function supplied. +// When the transform fails, it returns false as second return +// value. This indicates the caller, that the request shall not +// be further processed and the handle must be returned by the +// caller. Since the parameter is optional, it can be null, even +// if the boolean return value is set to true. +func AcceptOptionalParamOrWriteResponse[T any]( + name string, + transform func(s string) (T, error), + req *http.Request, + res http.ResponseWriter, +) (*T, bool) { + + ptr, err := OptionalQueryParamOrError(name, transform, req) + if err != nil { + err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ + TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_REQUEST_QUERY_PARAMETER", + Title: "invalid request query parameter", + Detail: "the withdrawal status request parameter '" + name + "' is malformed (error: " + err.Error() + ")", + Instance: req.RequestURI, + }) + if err != nil { + res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) + } + return nil, false + } + + if ptr == nil { + err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{ + TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_REQUEST_QUERY_PARAMETER", + Title: "invalid request query parameter", + Detail: "the withdrawal status request parameter '" + name + "' resulted in a nil pointer)", + Instance: req.RequestURI, + }) + if err != nil { + res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) + } + return nil, false + } + + obj := *ptr + assertedObj, ok := any(obj).(T) + if !ok { + // this should generally not happen (due to the implementation) + err := WriteProblem(res, HTTP_INTERNAL_SERVER_ERROR, &RFC9457Problem{ + TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_FATAL_ERROR", + Title: "Fatal Error", + Detail: "Something strange happened. Probably not your fault.", + Instance: req.RequestURI, + }) + if err != nil { + res.WriteHeader(HTTP_INTERNAL_SERVER_ERROR) + } + return nil, false + } + return &assertedObj, true +} + +// The function parses a parameter of the query +// of the request. If the parameter is not present +// (empty string) it will not create an error and +// just return nil. +func OptionalQueryParamOrError[T any]( + name string, + transform func(s string) (T, error), + req *http.Request, +) (*T, error) { + + paramStr := req.URL.Query().Get(name) + if paramStr != "" { + + if t, err := transform(paramStr); err != nil { + return nil, err + } else { + return &t, nil + } + } + return nil, nil +} + +// Reads a generic argument struct from the requests +// body. It takes the codec as argument which is used to +// decode the struct from the request. If an error occurs +// nil and the error are returned. +func ReadStructFromBody[T any](req *http.Request, codec Codec[T]) (*T, error) { + + bodyBytes, err := ReadBody(req) + if err != nil { + return nil, err + } + + return codec.Decode(bytes.NewReader(bodyBytes)) +} + +// Reads the body of a request into a byte array. +// If the body is empty, an empty array is returned. +// If an error occurs while reading the body, nil and +// the respective error is returned. +func ReadBody(req *http.Request) ([]byte, error) { + + if req.ContentLength < 0 { + return make([]byte, 0), nil + } + + body := make([]byte, req.ContentLength) + _, err := req.Body.Read(body) + if err != nil { + return nil, err + } + return body, nil +} + +// Executes a GET request at the given url. +// Use FormatUrl for to build the url. +// Headers can be defined using the headers map. +func HttpGet[T any]( + url string, + headers map[string]string, + codec Codec[T], +) (*T, int, error) { + + req, err := http.NewRequest(HTTP_GET, url, bytes.NewBufferString("")) + if err != nil { + return nil, -1, err + } + + for k, v := range headers { + req.Header.Add(k, v) + } + req.Header.Add("Accept", codec.HttpApplicationContentHeader()) + + fmt.Printf("requesting %s\n", url) + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, -1, err + } + + if res.StatusCode > 299 { + errBody, err := NewJsonCodec[RFC9457Problem]().Decode(res.Body) + if err != nil { + fmt.Println("error happened on GET. Failed parsing error") + return nil, -1, err + } + fmt.Printf("Error (%d): %s (%s)", res.StatusCode, errBody.Title, errBody.Detail) + return nil, res.StatusCode, nil + } + + if codec == nil { + return nil, res.StatusCode, err + } else { + resBody, err := codec.Decode(res.Body) + return resBody, res.StatusCode, err + } +} + +// execute a POST request and parse response or retrieve error +func HttpPost2[T any, R any]( + req string, + body *T, + requestCodec Codec[T], + responseCodec Codec[R], +) (*R, int, error) { + + return HttpPost( + req, + nil, + nil, + body, + requestCodec, + responseCodec, + ) +} + +// execute a POST request and parse response or retrieve error +// path- and query-parameters can be set to add query and path parameters +func HttpPost[T any, R any]( + req string, + pathParams map[string]string, + queryParams map[string]string, + body *T, + requestCodec Codec[T], + responseCodec Codec[R], +) (*R, int, error) { + + url := FormatUrl(req, pathParams, queryParams) + fmt.Println("POST:", url) + + var res *http.Response + if body == nil { + if requestCodec == nil { + res, err := http.Post( + url, + "", + nil, + ) + + if err != nil { + return nil, -1, err + } + + return nil, res.StatusCode, nil + } else { + return nil, -1, errors.New("invalid arguments - body was not present but codec was defined") + } + } else { + if requestCodec == nil { + return nil, -1, errors.New("invalid arguments - body was present but no codec was defined") + } else { + + encodedBody, err := requestCodec.Encode(body) + if err != nil { + return nil, -1, err + } + + res, err = http.Post( + url, + requestCodec.HttpApplicationContentHeader(), + encodedBody, + ) + + if err != nil { + return nil, -1, err + } + + buf := make([]byte, res.ContentLength) + _, err = res.Body.Read(buf) + if err != nil { + fmt.Println("body after post:", string(buf)) + } + } + } + + if responseCodec == nil { + return nil, res.StatusCode, nil + } + + if res.StatusCode > 299 { + errBody, err := NewJsonCodec[RFC9457Problem]().Decode(res.Body) + if err != nil { + return nil, -1, err + } + fmt.Printf("Error (%d): %s (%s)", res.StatusCode, errBody.Title, errBody.Detail) + return nil, res.StatusCode, nil + } + + resBody, err := responseCodec.Decode(res.Body) + if err != nil { + return nil, -1, err + } + return resBody, res.StatusCode, err +} + +// builds request URL containing the path and query +// parameters of the respective parameter map. +func FormatUrl( + req string, + pathParams map[string]string, + queryParams map[string]string, +) string { + + return setUrlQuery(setUrlPath(req, pathParams), queryParams) +} + +// Sets the parameters which are part of the url. +// The function expects each parameter in the path to be prefixed +// using a ':'. The function handles url as follows: +// +// /some/:param/tobereplaced -> ':param' will be replaced with value. +// +// For replacements, the pathParams map must be supplied. The map contains +// the name of the parameter with the value mapped to it. +// The names MUST not contain the prefix ':'! +func setUrlPath( + req string, + pathParams map[string]string, +) string { + + if pathParams == nil || len(pathParams) < 1 { + return req + } + + var url = req + for k, v := range pathParams { + + if !strings.HasPrefix(k, "/") { + // prevent scheme postfix replacements + url = strings.Replace(url, ":"+k, v, 1) + } + } + return url +} + +func setUrlQuery( + req string, + queryParams map[string]string, +) string { + + if queryParams == nil || len(queryParams) < 1 { + return req + } + + var url = req + "?" + for k, v := range queryParams { + + url = strings.Join([]string{url, k, "=", v, "&"}, "") + } + + url, _ = strings.CutSuffix(url, "&") + return url +} diff --git a/simulation/main.go b/simulation/main.go @@ -0,0 +1,88 @@ +package main + +import ( + "fmt" + "os" +) + +const DISABLE_DELAYS = true + +const C2EC_BASE_URL = "http://localhost:8081" +const C2EC_BANK_BASE_URL = C2EC_BASE_URL + "/c2ec" +const C2EC_BANK_CONFIG_URL = C2EC_BANK_BASE_URL + "/config" +const C2EC_BANK_WITHDRAWAL_STATUS_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid" +const C2EC_BANK_WITHDRAWAL_REGISTRATION_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid" +const C2EC_BANK_WITHDRAWAL_PAYMENT_URL = C2EC_BANK_BASE_URL + "/withdrawal-operation/:wopid/payment" + +// simulates the terminal talking to its backend system and executing the payment. +const PROVIDER_BACKEND_PAYMENT_DELAY_MS = 1000 + +// simulates the provider client fetching attestation at the providers backend. +const PROVIDER_BACKEND_ATTESTATION_DELAY_MS = 1000 + +// simulates the user presenting his card to the terminal +const TERMINAL_ACCEPT_CARD_DELAY_MS = 5000 + +// simulates the user scanning the QR code presented at the terminal +const WALLET_SCAN_QR_CODE_DELAY_MS = 5000 + +// https://docs.taler.net/core/api-exchange.html#tsref-type-CurrencySpecification +type CurrencySpecification struct { + Name string `json:"name"` + Currency string `json:"currency"` + NumFractionalInputDigits int `json:"num_fractional_input_digits"` + NumFractionalNormalDigits int `json:"num_fractional_normal_digits"` + NumFractionalTrailingZeroDigits int `json:"num_fractional_trailing_zero_digits"` + AltUnitNames string `json:"alt_unit_names"` +} + +// https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankIntegrationConfig +type BankIntegrationConfig struct { + Name string `json:"name"` + Version string `json:"version"` + Implementation string `json:"implementation"` + Currency string `json:"currency"` + CurrencySpecification CurrencySpecification `json:"currency_specification"` +} + +type SimulatedPhysicalInteraction struct { + Msg string +} + +func main() { + + if !c2ecAlive() { + fmt.Println("start c2ec first.") + return + } + + kill := make(chan error) + toTerminal := make(chan *SimulatedPhysicalInteraction, 10) + toWallet := make(chan *SimulatedPhysicalInteraction, 10) + + // start simulated terminal + go Terminal(toTerminal, toWallet, kill) + + // start simulated wallet + go Wallet(toWallet, toTerminal, kill) + + for err := range kill { + if err == nil { + fmt.Print("simulation successful.") + os.Exit(0) + } + fmt.Println("simulation error: ", err.Error()) + os.Exit(1) + } +} + +func c2ecAlive() bool { + + cfg, status, err := HttpGet(C2EC_BANK_CONFIG_URL, map[string]string{}, NewJsonCodec[BankIntegrationConfig]()) + if err != nil || status != 200 { + return false + } + + fmt.Println("C2EC-Config:", cfg.Name, cfg.Version, cfg.Currency, cfg.CurrencySpecification.AltUnitNames) + return true +} diff --git a/simulation/model.go b/simulation/model.go @@ -0,0 +1,69 @@ +package main + +import ( + "fmt" +) + +// https://docs.taler.net/core/api-common.html#hash-codes +type WithdrawalIdentifier string + +// https://docs.taler.net/core/api-common.html#cryptographic-primitives +type EddsaPublicKey string + +// https://docs.taler.net/core/api-common.html#hash-codes +type HashCode string + +// https://docs.taler.net/core/api-common.html#hash-codes +type ShortHashCode string + +// https://docs.taler.net/core/api-common.html#timestamps +type Timestamp struct { + Ts int `json:"t_s"` +} + +// https://docs.taler.net/core/api-common.html#wadid +type WadId [6]uint32 + +// according to https://docs.taler.net/core/api-bank-integration.html#tsref-type-BankWithdrawalOperationStatus +type WithdrawalOperationStatus string + +const ( + PENDING WithdrawalOperationStatus = "pending" + SELECTED WithdrawalOperationStatus = "selected" + ABORTED WithdrawalOperationStatus = "aborted" + CONFIRMED WithdrawalOperationStatus = "confirmed" +) + +func ToWithdrawalOpStatus(s string) (WithdrawalOperationStatus, error) { + switch s { + case string(PENDING): + return PENDING, nil + case string(SELECTED): + return SELECTED, nil + case string(ABORTED): + return ABORTED, nil + case string(CONFIRMED): + return CONFIRMED, nil + default: + return "", fmt.Errorf("unknown withdrawal operation status '%s'", s) + } +} + +type C2ECWithdrawRegistration struct { + ReservePubKey EddsaPublicKey `json:"reserve_pub_key"` + TerminalId uint64 `json:"terminal_id"` +} + +type C2ECWithdrawalStatus struct { + Status WithdrawalOperationStatus `json:"status"` + Amount Amount `json:"amount"` + SenderWire string `json:"sender_wire"` + WireTypes []string `json:"wire_types"` + ReservePubKey EddsaPublicKey `json:"selected_reserve_pub"` +} + +type C2ECPaymentNotification struct { + ProviderTransactionId string `json:"provider_transaction_id"` + Amount Amount `json:"amount"` + Fees Amount `json:"fees"` +} diff --git a/simulation/sim-terminal.go b/simulation/sim-terminal.go @@ -0,0 +1,98 @@ +package main + +import ( + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "strconv" + "time" +) + +const TERMINAL_PROVIDER = "Simulation" + +// retrieved from the cli tool when added the terminal +const TERMINAL_USER_ID = TERMINAL_PROVIDER + "-1" + +// retrieved from the cli tool when added the terminal +const TERMINAL_ACCESS_TOKEN = "secret" + +const SIM_TERMINAL_LONG_POLL_MS_STR = "20000" // 20 seconds + +const QR_CODE_CONTENT_BASE = "taler://withdraw/localhost:8081/c2ec/" + +func Terminal(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysicalInteraction, kill chan error) { + + fmt.Println("Terminal idle... awaiting readiness message of sim-wallet") + <-in + + fmt.Println("Sim-Wallet ready, simulating QR-Code scan... sending withdrawal uri to wallet") + wopidBytes := make([]byte, 32) + _, err := rand.Read(wopidBytes) + if err != nil { + fmt.Println("failed creating the wopid:", err.Error(), "(ends simulation)") + kill <- err + } + + wopid := base64.URLEncoding.EncodeToString(wopidBytes) + fmt.Println("Generated Nonce (base64 url encoded):", wopid) + uri := QR_CODE_CONTENT_BASE + wopid + fmt.Println("Taler Withdrawal URI:", uri) + + // note for realworld implementation + // -> start long polling always before showing the QR code + awaitSelection := make(chan *C2ECWithdrawalStatus) + longPollFailed := make(chan error) + go func() { + // long poll for parameter selection notification by c2ec + + url := FormatUrl( + C2EC_BANK_WITHDRAWAL_STATUS_URL, + map[string]string{"wopid": wopid}, + map[string]string{"long_poll_ms": SIM_TERMINAL_LONG_POLL_MS_STR}, + ) + response, status, err := HttpGet( + url, + map[string]string{"Authorization": TerminalAuth()}, + NewJsonCodec[C2ECWithdrawalStatus](), + ) + if err != nil { + kill <- err + return + } + if status != 200 { + longPollFailed <- errors.New("status of withdrawal status response was " + strconv.Itoa(status)) + return + } + + awaitSelection <- response + }() + + out <- &SimulatedPhysicalInteraction{Msg: uri} + + for { + select { + case w := <-awaitSelection: + fmt.Println("selected parameter:", w.ReservePubKey) + fmt.Println("simulating user interaction. customer presents card. delay:", TERMINAL_ACCEPT_CARD_DELAY_MS) + if !DISABLE_DELAYS { + time.Sleep(time.Duration(TERMINAL_ACCEPT_CARD_DELAY_MS)) + } + fmt.Println("the card was tead by the terminal. simulating the payment using the providers backend. delay:", PROVIDER_BACKEND_PAYMENT_DELAY_MS) + if !DISABLE_DELAYS { + time.Sleep(time.Duration(PROVIDER_BACKEND_PAYMENT_DELAY_MS)) + } + // sending payment notification now... + + case f := <-longPollFailed: + fmt.Println("long-polling for selection failed... error:", err.Error()) + kill <- f + } + } +} + +func TerminalAuth() string { + + userAndPw := fmt.Sprintf("%s:%s", TERMINAL_USER_ID, TERMINAL_ACCESS_TOKEN) + return base64.StdEncoding.EncodeToString([]byte(userAndPw)) +} diff --git a/simulation/sim-wallet.go b/simulation/sim-wallet.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "crypto/rand" + "encoding/base32" + "errors" + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +const SIM_WALLET_LONG_POLL_MS_STR = "20000" // 20 seconds + +func Wallet(in chan *SimulatedPhysicalInteraction, out chan *SimulatedPhysicalInteraction, kill chan error) { + + fmt.Println("Wallet started. Signaling terminal readiness (this is simulation specific)") + out <- &SimulatedPhysicalInteraction{Msg: "wallet ready"} + + uriFromQrCode := <-in + if !DISABLE_DELAYS { + time.Sleep(time.Duration(WALLET_SCAN_QR_CODE_DELAY_MS) * time.Millisecond) + } + fmt.Println("simulated QR code scanning... scanned", uriFromQrCode) + wopid, err := parseTalerWithdrawUri(uriFromQrCode.Msg) + if err != nil { + fmt.Println("failed parsing taler withdraw uri. error:", err.Error()) + } + fmt.Println("Wallet parsed wopid:", wopid) + + // Register Withdrawal + registrationUrl := FormatUrl( + C2EC_BANK_WITHDRAWAL_REGISTRATION_URL, + map[string]string{"wopid": wopid}, + map[string]string{}, + ) + // TODO take terminal id from uri + parts := strings.Split(TERMINAL_USER_ID, "-") + tid, err := strconv.Atoi(parts[1]) + if err != nil { + kill <- err + } + + cdc := NewJsonCodec[C2ECWithdrawRegistration]() + body, err := cdc.EncodeToBytes(&C2ECWithdrawRegistration{ + ReservePubKey: EddsaPublicKey(simulateReservePublicKey()), + TerminalId: uint64(tid), + }) + if err != nil { + kill <- err + } + res, err := http.Post( + registrationUrl, + cdc.HttpApplicationContentHeader(), + bytes.NewBuffer(body), + ) + + if res.StatusCode != 204 { + fmt.Println("response status from registration:", res.StatusCode) + kill <- errors.New("failed registering the withdrawal parameters") + } + + fmt.Println("wallet sends withdrawal registration request with freshly generated public key.") + + // Start long poll for confirmed or abort + awaitConfirmationOrAbortion := make(chan *C2ECWithdrawalStatus) + longPollFailed := make(chan error) + go func() { + // long poll for parameter selection notification by c2ec + + url := FormatUrl( + C2EC_BANK_WITHDRAWAL_STATUS_URL, + map[string]string{"wopid": wopid}, + map[string]string{"long_poll_ms": SIM_WALLET_LONG_POLL_MS_STR}, + ) + response, status, err := HttpGet( + url, + map[string]string{"Authorization": TerminalAuth()}, + NewJsonCodec[C2ECWithdrawalStatus](), + ) + if err != nil { + kill <- err + return + } + if status != 200 { + longPollFailed <- errors.New("status of withdrawal status response was " + strconv.Itoa(status)) + return + } + + awaitConfirmationOrAbortion <- response + }() + + for { + select { + case w := <-awaitConfirmationOrAbortion: + fmt.Println("payment processed:", w.Status) + if w.Status == CONFIRMED { + fmt.Println("the exchange would now create the reserve and the wallet can withdraw the reserve") + os.Exit(0) + } + if w.Status == ABORTED { + fmt.Println("the withdrawal was aborted. c2ec cleans up withdrawal") + os.Exit(0) + } + case f := <-longPollFailed: + fmt.Println("long-polling for selection failed... error:", err.Error()) + kill <- f + } + } + +} + +// returns wopid. +func parseTalerWithdrawUri(s string) (string, error) { + + wopid, found := strings.CutPrefix(s, QR_CODE_CONTENT_BASE) + if !found { + return "", errors.New("invalid uri " + s) + } + return wopid, nil +} + +// creates format compliant reserve public key +func simulateReservePublicKey() string { + + mockedPubKey := make([]byte, 32) + _, err := rand.Read(mockedPubKey) + if err != nil { + return "" + } + return base32.HexEncoding.EncodeToString(mockedPubKey) +}