cashless2ecash

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

commit 15a1fecc1a177c79bfe7dcfa462810f7d09fad1b
parent dfdf9bbb7c928827928f3855ff5afea91daa28b9
Author: Joel-Haeberli <haebu@rubigen.ch>
Date:   Fri,  5 Apr 2024 23:23:36 +0200

code: cli for managing providers and terminals

Diffstat:
M.gitignore | 1+
Mbruno/c2ec/(LOCAL-BIA) Payment Confirmation.bru | 20++++++++++++++++++--
Mbruno/c2ec/(LOCAL-BIA) Register Withdrawal.bru | 18++++++++++++++++--
Mbruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru | 7++++++-
Mc2ec/auth.go | 25+++++++++++++++++++++++++
Mc2ec/bank-integration.go | 7+++----
Ac2ec/base32-taler.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Ac2ec/base32-taler_test.go | 21+++++++++++++++++++++
Mc2ec/c2ec-config.yaml | 6++++--
Mc2ec/config.go | 11++++++++---
Mc2ec/go.mod | 4+++-
Mc2ec/go.sum | 6++++++
Mc2ec/main.go | 8++++----
Mc2ec/model.go | 7+++++++
Mc2ec/wire-gateway.go | 1+
Acli/README | 16++++++++++++++++
Acli/cli.go | 254+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acli/codec.go | 53+++++++++++++++++++++++++++++++++++++++++++++++++++++
Acli/db.go | 21+++++++++++++++++++++
Acli/go.mod | 15+++++++++++++++
Acli/go.sum | 30++++++++++++++++++++++++++++++
Adocs/content/implementation/c2ec-db.tex | 16++++++++++++++++
Mdocs/content/implementation/c2ec.tex | 2+-
Mdocs/thesis.pdf | 0
Aspecs/api-c2ec.rst | 342+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
25 files changed, 920 insertions(+), 20 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -3,6 +3,7 @@ schemaspy/Makefile infra/ bruno/ c2ec/c2ec +cli/c2ec-cli LocalMakefile diff --git a/bruno/c2ec/(LOCAL-BIA) Payment Confirmation.bru b/bruno/c2ec/(LOCAL-BIA) Payment Confirmation.bru @@ -5,7 +5,23 @@ meta { } post { - url: http://localhost:8081/c2ec/withdrawal-operation/WOPID - body: none + url: http://localhost:8081/c2ec/withdrawal-operation/WOPID/payment + body: json auth: none } + +body:json { + { + "provider_transaction_id": "", + "amount": { + "currency": "CHF", + "value": "", + "fraction": "" + }, + "fees": { + "currency": "CHF", + "value": "", + "fraction": "" + } + } +} diff --git a/bruno/c2ec/(LOCAL-BIA) Register Withdrawal.bru b/bruno/c2ec/(LOCAL-BIA) Register Withdrawal.bru @@ -5,7 +5,21 @@ meta { } post { - url: http://localhost:8081/c2ec/withdrawal-operation - body: none + url: http://localhost:8081/c2ec/withdrawal-operation/ + body: json auth: none } + +body:json { + { + "status": "pending", + "amount": { + "currency": "CHF", + "value": "", + "fraction": "" + }, + "sender_wire": "payto://wallee-transaction/asdfsadf", + "wire_types": ["wallee-transaction"], + "reserve_public_key": "" + } +} diff --git a/bruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru b/bruno/c2ec/(LOCAL-BIA) Withdrawal Status.bru @@ -5,7 +5,12 @@ meta { } get { - url: http://localhost:8081/c2ec/withdrawal-operation/ + url: http://localhost:8081/c2ec/withdrawal-operation/WOPID?long_poll_ms=5000&old_state=pending body: none auth: none } + +query { + long_poll_ms: 5000 + old_state: pending +} diff --git a/c2ec/auth.go b/c2ec/auth.go @@ -1,7 +1,12 @@ package main import ( + "crypto/rand" + "encoding/base64" + "errors" "net/http" + + "golang.org/x/crypto/argon2" ) const AUTHORIZATION_HEADER = "Authorization" @@ -54,3 +59,23 @@ func isAllowed(req *http.Request) bool { // a wopid and // func handleTokenRequest() { // } + +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 @@ -99,8 +99,7 @@ func handleWithdrawalRegistration(res http.ResponseWriter, req *http.Request) { // read and validate the wopid path parameter wopid := req.PathValue(WOPID_PARAMETER) - if _, ok := any(wopid).(WithdrawalIdentifier); !ok { - + if !WopidValid(wopid) { if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ TypeUri: TALER_URI_PROBLEM_PREFIX + "/C2EC_INVALID_PATH_PARAMETER", @@ -168,7 +167,7 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { // read and validate the wopid path parameter wopid := req.PathValue(WOPID_PARAMETER) - if _, ok := any(wopid).(WithdrawalIdentifier); !ok { + if !WopidValid(wopid) { if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ @@ -234,7 +233,7 @@ func handleWithdrawalStatus(res http.ResponseWriter, req *http.Request) { func handlePaymentNotification(res http.ResponseWriter, req *http.Request) { wopid := req.PathValue(WOPID_PARAMETER) - if _, ok := any(wopid).(WithdrawalIdentifier); !ok { + if !WopidValid(wopid) { if wopid == "" { err := WriteProblem(res, HTTP_BAD_REQUEST, &RFC9457Problem{ diff --git a/c2ec/base32-taler.go b/c2ec/base32-taler.go @@ -0,0 +1,49 @@ +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 @@ -0,0 +1,21 @@ +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 @@ -13,5 +13,7 @@ db: password: "local" database: "postgres" providers: - - "Wallee" - - "Simulation" + - name: "Wallee" + credentials-password: "secret" + - name: "Simulation" + credentials-password: "secret" diff --git a/c2ec/config.go b/c2ec/config.go @@ -7,9 +7,9 @@ import ( ) type C2ECConfig struct { - Server C2ECServerConfig `yaml:"c2ec"` - Database C2ECDatabseConfig `yaml:"db"` - Providers []string `yaml:"providers"` + Server C2ECServerConfig `yaml:"c2ec"` + Database C2ECDatabseConfig `yaml:"db"` + Providers []C2ECProviderConfig `yaml:"providers"` } type C2ECServerConfig struct { @@ -30,6 +30,11 @@ type C2ECDatabseConfig struct { Database string `yaml:"database"` } +type C2ECProviderConfig struct { + Name string `yaml:"name"` + CredentialsPassword string `yaml:"credentials-password"` +} + func Parse(path string) (*C2ECConfig, error) { f, err := os.Open(path) diff --git a/c2ec/go.mod b/c2ec/go.mod @@ -10,12 +10,14 @@ require ( ) 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.17.0 // 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,3 +1,5 @@ +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 +33,12 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs 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= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/c2ec/main.go b/c2ec/main.go @@ -107,18 +107,18 @@ func setupAttestors(cfg *C2ECConfig) error { return errors.New("setup database first") } - for _, providerName := range cfg.Providers { + for _, provider := range cfg.Providers { - p, err := DB.GetTerminalProviderByName(providerName) + p, err := DB.GetTerminalProviderByName(provider.Name) if err != nil { return err } if p == nil { if cfg.Server.IsProd || cfg.Server.StrictAttestors { - panic("no provider entry for " + providerName) + panic("no provider entry for " + provider.Name) } else { - fmt.Println("non-strict attestor initialization. skipping", providerName) + fmt.Println("non-strict attestor initialization. skipping", provider) continue } } diff --git a/c2ec/model.go b/c2ec/model.go @@ -49,6 +49,13 @@ 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 +} + type ErrorDetail struct { // Numeric error code unique to the condition. diff --git a/c2ec/wire-gateway.go b/c2ec/wire-gateway.go @@ -155,6 +155,7 @@ func transfer(res http.ResponseWriter, req *http.Request) { return } + // prepare the body to add or compare. body := make([]byte, req.ContentLength) _, err = req.Body.Read(body) if err != nil { diff --git a/cli/README b/cli/README @@ -0,0 +1,15 @@ +# C2EC Management CLI + +This allows adding Providers and Terminals to the database using the command line. + +Before using the commands which connect to the database, you first need to connect to the database (can be done within the CLI using the `db` command). + +It will take care of generating the access tokens for the terminal and hashing the authorization key of the provider backend. + +## Build + +`go build .` + +## Run + +`./c2ec-cli` +\ No newline at end of file diff --git a/cli/cli.go b/cli/cli.go @@ -0,0 +1,254 @@ +package main + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "os" + "strconv" + "strings" + + "github.com/jackc/pgx/v5" + "golang.org/x/crypto/argon2" +) + +const ACTION_HELP = "h" +const ACTION_REGISTER_PROVIDER = "rp" +const ACTION_REGISTER_TERMINAL = "rt" +const ACTION_CONNECT_DB = "db" +const ACTION_QUIT = "q" + +// format of wallee backend credentials. +type WalleeCredentials struct { + SpaceId int `json:"spaceId"` + UserId int `json:"userId"` + ApplicationUserKey string `json:"application-user-key"` +} + +var DB *pgx.Conn + +// enter database credentials (host, port, database, username, password) +// register terminal -> read password, hash, save to database +// register provider -> read wallee, read space(id), read userid, read password, hash what needs to be hashed, save to database + +func main() { + fmt.Println("What do you want to do?") + showHelp() + for { + err := dispatchCommand(read("Type command (term in brackets): ")) + if err != nil { + fmt.Println("Error occured:", err.Error()) + } + } +} + +func registerWalleeProvider() error { + + if DB == nil { + return errors.New("connect to the database first (cmd: db)") + } + + name := "Wallee" + paytotargettype := "wallee-transaction" + backendUrl := read("Wallee backend base url: ") + spaceIdStr := read("Wallee Space Id: ") + spaceId, err := strconv.Atoi(spaceIdStr) + if err != nil { + return err + } + userIdStr := read("Wallee User Id: ") + userId, err := strconv.Atoi(userIdStr) + if err != nil { + return err + } + key := read("Wallee Application User Key: ") + hashedKey, err := pbkdf(key) + if err != nil { + return err + } + + creds, err := NewJsonCodec[WalleeCredentials]().EncodeToBytes(&WalleeCredentials{ + SpaceId: spaceId, + UserId: userId, + ApplicationUserKey: hashedKey, + }) + if err != nil { + return err + } + credsEncoded := base64.StdEncoding.EncodeToString(creds) + + rows, err := DB.Query( + context.Background(), + INSERT_PROVIDER, + name, + paytotargettype, + backendUrl, + credsEncoded, + ) + if err != nil { + return err + } + defer rows.Close() + + return nil +} + +func registerWalleeTerminal() error { + + if DB == nil { + return errors.New("connect to the database first (cmd: db)") + } + + description := read("Description (location, inventory identifier, etc.): ") + providerName := read("Provider Name: ") + + rows, err := DB.Query( + context.Background(), + GET_PROVIDER_BY_NAME, + providerName, + ) + if err != nil { + return err + } + defer rows.Close() + + p, err := pgx.CollectOneRow(rows, pgx.RowToAddrOfStructByName[Provider]) + if err != nil { + return err + } + + accessToken := make([]byte, 32) + _, err = rand.Read(accessToken) + if err != nil { + return err + } + + accessTokenBase64 := base64.StdEncoding.EncodeToString(accessToken) + fmt.Println("GENERATED ACCESS-TOKEN:", accessTokenBase64) + + _, err = DB.Query( + context.Background(), + INSERT_TERMINAL, + accessToken, + description, + p.ProviderTerminalID, + ) + if err != nil { + return err + } + defer rows.Close() + + return nil +} + +func connectDatabase() error { + + u := read("Username: ") + pw := read("Password: ") + h := read("Host: ") + ps := read("Port: ") + p, err := strconv.Atoi(ps) + if err != nil { + return err + } + d := read("Database: ") + + connstring := PostgresConnectionString(u, pw, h, p, d) + dbCfg, err := pgx.ParseConfig(connstring) + if err != nil { + return err + } + + DB, err = pgx.ConnectConfig(context.Background(), dbCfg) + if err != nil { + return err + } + return nil +} + +func showHelp() error { + + fmt.Println("register wallee provider (", ACTION_REGISTER_PROVIDER, ")") + fmt.Println("register wallee terminal (", ACTION_REGISTER_TERMINAL, ")") + fmt.Println("connect database (", ACTION_CONNECT_DB, ")") + fmt.Println("show help (", ACTION_HELP, ")") + fmt.Println("quit (", ACTION_QUIT, ")") + return nil +} + +func quit() error { + fmt.Println("bye...") + os.Exit(0) + return nil +} + +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 +} + +func PostgresConnectionString( + username string, + password string, + host string, + port int, + database string, +) string { + return fmt.Sprintf( + "postgres://%s:%s@%s:%d/%s", + username, + password, + host, + port, + database, + ) +} + +func dispatchCommand(cmd string) error { + + var err error + switch cmd { + case ACTION_HELP: + err = showHelp() + case ACTION_QUIT: + err = quit() + case ACTION_CONNECT_DB: + err = connectDatabase() + case ACTION_REGISTER_PROVIDER: + err = registerWalleeProvider() + case ACTION_REGISTER_TERMINAL: + err = registerWalleeTerminal() + default: + fmt.Println("unknown action") + } + return err +} + +func read(prefix string) string { + reader := bufio.NewReader(os.Stdin) + fmt.Print(prefix) + inp, err := reader.ReadString('\n') + if err != nil { + fmt.Println(err.Error()) + return "" + } + return strings.Trim(inp, "\n") +} diff --git a/cli/codec.go b/cli/codec.go @@ -0,0 +1,53 @@ +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]) 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/cli/db.go b/cli/db.go @@ -0,0 +1,21 @@ +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" + +type Provider struct { + ProviderTerminalID int64 `db:"provider_id"` + Name string `db:"name"` + PaytoTargetType string `db:"payto_target_type"` + BackendBaseURL string `db:"backend_base_url"` + BackendCredentials string `db:"backend_credentials"` +} + +type Terminal struct { + TerminalID int64 `db:"terminal_id"` + AccessToken []byte `db:"access_token"` + Active bool `db:"active"` + Description string `db:"description"` + ProviderID int64 `db:"provider_id"` +} diff --git a/cli/go.mod b/cli/go.mod @@ -0,0 +1,15 @@ +module c2ec-cli + +go 1.22.1 + +require ( + github.com/jackc/pgx/v5 v5.5.5 + golang.org/x/crypto v0.22.0 +) + +require ( + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect + golang.org/x/sys v0.19.0 // indirect + golang.org/x/text v0.14.0 // indirect +) diff --git a/cli/go.sum b/cli/go.sum @@ -0,0 +1,30 @@ +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= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= +github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.5.5 h1:amBjrZVmksIdNjxGW/IiIMzxMKZFelXbUoPNb+8sjQw= +github.com/jackc/pgx/v5 v5.5.5/go.mod h1:ez9gk+OAat140fv9ErkZDYFWmXLfV+++K0uAOiwgm1A= +github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +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.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= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/docs/content/implementation/c2ec-db.tex b/docs/content/implementation/c2ec-db.tex @@ -0,0 +1,15 @@ +\subsection{Database} + +The Database is implemented using Postgresql. This database is also used by other Taler components and therefore is a good fit. + +Besides the standard SQL features to insert and select data, Postgres also comes with handy features like LISTEN and NOTIFY. + +This allows the implementation of neat pub/sub models allowing better performance and separation of concerns. + +\subsubsection{Schema} + +\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 diff --git a/docs/content/implementation/c2ec.tex b/docs/content/implementation/c2ec.tex @@ -1,5 +1,5 @@ \section{C2EC} -\subsection{Database} +\include{content/implementation/c2ec-db} \subsection{Server} diff --git a/docs/thesis.pdf b/docs/thesis.pdf Binary files differ. diff --git a/specs/api-c2ec.rst b/specs/api-c2ec.rst @@ -0,0 +1,342 @@ +.. + This file is part of GNU TALER. + + Copyright (C) 2014-2024 Taler Systems SA + + TALER 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 2.1, or (at your option) any later version. + + TALER 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + + @author Joel Häberli + +=========================== +The C2EC RESTful API +=========================== + +.. note:: + + **This API is experimental and not yet implemented** + +This chapter describe the APIs that third party providers need to integrate to allow +withdrawals through indirect payment channels like credit cards or ATM. + +.. contents:: Table of Contents + +-------------- +Authentication +-------------- + +Terminals which authenticate against the C2EC API must provide their respective +access token. Therefore they provide a ``Authorization: Bearer $ACCESS_TOKEN`` header, +where `$ACCESS_TOKEN`` is a secret authentication token configured by the exchange and +must begin with the RFC 8959 prefix. + +---------------------------- +Configuration of C2EC +---------------------------- + +.. http:get:: /config + + Return the protocol version and configuration information about the C2EC API. + + **Response:** + + :http:statuscode:`200 OK`: + The exchange responds with a `C2ECConfig` object. This request should + virtually always be successful. + + **Details:** + + .. ts:def:: C2ECConfig + + interface C2ECConfig { + // Name of the API. + name: "taler-c2ec"; + + // libtool-style representation of the C2EC protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + version: string; + } + +----------------------------- +Withdrawing using C2EC +----------------------------- + +Withdrawals with a C2EC are based on withdrawal operations which register a withdrawal identifier +(nonce) at the C2EC component. The provider must first create a unique identifier for the withdrawal +operation (the ``WOPID``) to interact with the withdrawal operation and eventually withdraw using the wallet. + +.. http:post:: /withdrawal-operation + + Register a `WOPID` belonging to a reserve public key. + + **Request:** + + .. ts:def:: C2ECWithdrawRegistration + + interface C2ECWithdrawRegistration { + // Maps a nonce generated by the provider to a reserve public key generated by the wallet. + wopid: ShortHashCode; + + // Reserve public key generated by the wallet. + // According to TALER_ReservePublicKeyP (https://docs.taler.net/core/api-common.html#cryptographic-primitives) + reserve_pub_key: EddsaPublicKey; + + // Optional amount for the withdrawal. + amount?: Amount; + + // Id of the terminal of the provider requesting a withdrawal by nonce. + // Assigned by the exchange. + terminal_id: SafeUint64; + } + + **Response:** + + :http:statuscode:`204 No content`: + The withdrawal was successfully registered. + :http:statuscode:`400 Bad request`: + The ``WithdrawRegistration`` request was malformed or contained invalid parameters. + :http:statuscode:`500 Internal Server error`: + The registration of the withdrawal failed due to server side issues. + +.. http:get:: /withdrawal-operation/$WOPID + + Query information about a withdrawal operation, identified by the ``WOPID``. + + **Request:** + + :query long_poll_ms: + *Optional.* If specified, the bank will wait up to ``long_poll_ms`` + milliseconds for operationt state to be different from ``old_state`` before sending the HTTP + response. A client must never rely on this behavior, as the bank may + return a response immediately. + :query old_state: + *Optional.* Default to "pending". + + **Response:** + + :http:statuscode:`200 Ok`: + The withdrawal was found and is returned in the response body as ``C2ECWithdrawalStatus``. + :http:statuscode:`404 Not found`: + C2EC does not have a withdrawal registered with the specified ``WOPID``. + + **Details** + + .. ts:def:: C2ECWithdrawalStatus + + interface C2ECWithdrawalStatus { + // Current status of the operation + // pending: the operation is pending parameters selection (exchange and reserve public key) + // selected: the operations has been selected and is pending confirmation + // aborted: the operation has been aborted + // confirmed: the transfer has been confirmed and registered by the bank + // Since protocol v1. + status: "pending" | "selected" | "aborted" | "confirmed"; + + // Amount that will be withdrawn with this operation + // (raw amount without fee considerations). + amount: Amount; + + // A refund address as ``payto`` URI. This address shall be used + // in case a refund must be done. Only not-null if the status + // is "confirmed" or "aborted" + sender_wire?: string; + + // Reserve public key selected by the exchange, + // only non-null if ``status`` is ``selected`` or ``confirmed``. + // Since protocol v1. + selected_reserve_pub?: string; + } + + +.. http:post:: /withdrawal-operation/$WOPID + + Notifies C2EC about an executed payment for a specific withdrawal. + + **Request:** + + .. ts:def:: C2ECPaymentNotification + + interface C2ECPaymentNotification { + + // Unique identifier of the provider transaction. + provider_transaction_id: string; + + // Specifies the amount which was payed to the provider (without fees). + // This amount shall be put into the reserve linked to by the withdrawal id. + amount: Amount; + + // Fees associated with the payment. + fees: Amount; + } + + **Response:** + + :http:statuscode:`204 No content`: + C2EC received the ``C2ECPaymentNotification`` successfully and will further process + the withdrawal. + :http:statuscode:`400 Bad request`: + The ``C2ECPaymentNotification`` request was malformed or contained invalid parameters. + :http:statuscode:`404 Not found`: + C2EC does not have a withdrawal registered with the specified ``WOPID``. + :http:statuscode:`500 Internal Server error`: + The ``C2ECPaymentNotification`` could not be processed due to server side issues. + + +-------------- +Taler Wire Gateway +-------------- + +C2EC implements the wire gateway API in order to check for incoming transactions and +let the exchange get proofs of payments. This will allow the C2EC componente to add reserves +and therefore allow the withdrawal of the money. C2EC does not entirely implement all endpoints, +because the it is not needed for the case of C2EC. The endpoints not implemented are not described +further. They will be available but respond with 400 http error code. + +.. http:get:: /config + + Return the protocol version and configuration information about the bank. + This specification corresponds to ``current`` protocol being version **0**. + + **Response:** + + :http:statuscode:`200 OK`: + The exchange responds with a `WireConfig` object. This request should + virtually always be successful. + + **Details:** + + .. ts:def:: WireConfig + + interface WireConfig { + // Name of the API. + name: "taler-wire-gateway"; + + // libtool-style representation of the Bank protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + version: string; + + // Currency used by this gateway. + currency: string; + + // URN of the implementation (needed to interpret 'revision' in version). + // @since v0, may become mandatory in the future. + implementation?: string; + } + +.. http:post:: /transfer + + This API allows the exchange to make a transaction, typically to a merchant. The bank account + of the exchange is not included in the request, but instead derived from the user name in the + authentication header and/or the request base URL. + + To make the API idempotent, the client must include a nonce. Requests with the same nonce + are rejected unless the request is the same. + + **Request:** + + .. ts:def:: TransferRequest + + interface TransferRequest { + // Nonce to make the request idempotent. Requests with the same + // ``request_uid`` that differ in any of the other fields + // are rejected. + request_uid: HashCode; + + // Amount to transfer. + amount: Amount; + + // Base URL of the exchange. Shall be included by the bank gateway + // in the appropriate section of the wire transfer details. + exchange_base_url: string; + + // Wire transfer identifier chosen by the exchange, + // used by the merchant to identify the Taler order(s) + // associated with this wire transfer. + wtid: ShortHashCode; + + // The recipient's account identifier as a payto URI. + credit_account: string; + } + + **Response:** + + :http:statuscode:`200 OK`: + The request has been correctly handled, so the funds have been transferred to + the recipient's account. The body is a `TransferResponse`. + :http:statuscode:`400 Bad request`: + Request malformed. The bank replies with an `ErrorDetail` object. + :http:statuscode:`401 Unauthorized`: + Authentication failed, likely the credentials are wrong. + :http:statuscode:`404 Not found`: + The endpoint is wrong or the user name is unknown. The bank replies with an `ErrorDetail` object. + :http:statuscode:`409 Conflict`: + A transaction with the same ``request_uid`` but different transaction details + has been submitted before. + + **Details:** + + .. ts:def:: TransferResponse + + interface TransferResponse { + // Timestamp that indicates when the wire transfer will be executed. + // In cases where the wire transfer gateway is unable to know when + // the wire transfer will be executed, the time at which the request + // has been received and stored will be returned. + // The purpose of this field is for debugging (humans trying to find + // the transaction) as well as for taxation (determining which + // time period a transaction belongs to). + timestamp: Timestamp; + + // Opaque ID of the transaction that the bank has made. + row_id: SafeUint64; + } + +.. http:get:: /history/incoming + + **Request:** + + :query start: *Optional.* + Row identifier to explicitly set the *starting point* of the query. + :query delta: + The *delta* value that determines the range of the query. + :query long_poll_ms: *Optional.* If this parameter is specified and the + result of the query would be empty, the bank will wait up to ``long_poll_ms`` + milliseconds for new transactions that match the query to arrive and only + then send the HTTP response. A client must never rely on this behavior, as + the bank may return a response immediately or after waiting only a fraction + of ``long_poll_ms``. + + **Response:** + + .. ts:def:: IncomingReserveTransaction + + interface IncomingReserveTransaction { + type: "RESERVE"; + + // Opaque identifier of the returned record. + row_id: SafeUint64; + + // Date of the transaction. + date: Timestamp; + + // Amount transferred. + amount: Amount; + + // Payto URI to identify the sender of funds. + debit_account: string; + + // The reserve public key extracted from the transaction details. + reserve_pub: EddsaPublicKey; + + } +