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:
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)
+}