taldir

Directory service to resolve wallet mailboxes by messenger addresses
Log | Files | Refs | Submodules | README | LICENSE

commit f16b14fa205e9cb65ba3cbc6f3140079b504e60e
parent 5a70d6d172f2f9330276830f415e62ae32ae032c
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Wed,  6 Jul 2022 23:35:42 +0200

add tests

Diffstat:
Acmd/taldir-server/main_test.go | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/taldir-server/taldir.go | 539+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acmd/taldir-server/testdata/taldir-test.conf | 34++++++++++++++++++++++++++++++++++
Acmd/taldir-server/testdata/templates/validation_landing.html | 17+++++++++++++++++
4 files changed, 676 insertions(+), 0 deletions(-)

diff --git a/cmd/taldir-server/main_test.go b/cmd/taldir-server/main_test.go @@ -0,0 +1,86 @@ +// This file is part of gnunet-go, a GNUnet-implementation in Golang. +// Copyright (C) 2019-2022 Bernd Fix >Y< +// +// gnunet-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. +// +// gnunet-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_test + +import ( + "os" + "testing" + "net/http" + "net/http/httptest" + "crypto/sha512" + "bytes" + "taler.net/taldir/cmd/taldir-server" + "taler.net/taldir/util" +) + +var t main.Taldir + +var validRegisterRequest = []byte(` + { + "address": "abc@test", + "public_key": "000G006XE97PTWV3B7AJNCRQZA6BF26HPV3XZ07293FMY7KD4181946A90", + "inbox_url": "myinbox@xyz", + "duration": 23 + } +`) + +func TestMain(m *testing.M) { + t.Initialize("testdata/taldir-test.conf", true) + code := m.Run() + t.ClearDatabase() + os.Exit(code) +} + +func getHAddress(addr string) string { + h := sha512.New() + h.Write([]byte(addr)) + return util.EncodeBinaryToString(h.Sum(nil)) +} + +func TestNoEntry(s *testing.T) { + t.ClearDatabase() + + h_addr := getHAddress("jdoe@example.com") + req, _ := http.NewRequest("GET", "/" + h_addr, nil) + response := executeRequest(req) + + if http.StatusNotFound != response.Code { + s.Errorf("Expected response code %d. Got %d\n", http.StatusNotFound, response.Code) + } +} + +func executeRequest(req *http.Request) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + t.Router.ServeHTTP(rr, req) + return rr +} + +func TestRegisterRequest(s *testing.T) { + t.ClearDatabase() + + req, _ := http.NewRequest("POST", "/register/test", bytes.NewBuffer(validRegisterRequest)) + response := executeRequest(req) + + if http.StatusAccepted != response.Code { + s.Errorf("Expected response code %d. Got %d\n", http.StatusAccepted, response.Code) + } +} + + diff --git a/cmd/taldir-server/taldir.go b/cmd/taldir-server/taldir.go @@ -0,0 +1,539 @@ +// This file is part of gnunet-go, a GNUnet-implementation in Golang. +// Copyright (C) 2019-2022 Bernd Fix >Y< +// +// gnunet-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. +// +// gnunet-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 + +/* TODO + - ToS API (terms, privacy) with localizations + - Prettify QR code landing page + - Base32: Use gnunet-go module? (currently copied) + - OrderId processing + - Maintenance of database: When to delete expired validations? +*/ + +import ( + "os" + "os/exec" + "time" + "fmt" + "log" + "net/http" + "html/template" + "encoding/json" + "github.com/gorilla/mux" + "gorm.io/gorm" + "encoding/base64" + "taler.net/taldir/util" + "taler.net/taldir/gana" + "crypto/sha512" + "gorm.io/driver/postgres" + "gopkg.in/ini.v1" + "strings" + "github.com/skip2/go-qrcode" +) + +type Taldir struct { + + // The main router + Router *mux.Router + + // The main DB handle + Db *gorm.DB + + // Our configuration from the config.json + Cfg *ini.File + + // Map of supported validators as defined in the configuration + Validators map[string]bool + + // landing page + ValidationTpl *template.Template + + // The address salt + Salt string + + // Request frequency + RequestFrequency int64 +} + +type VersionResponse struct { + // libtool-style representation of the Merchant protocol version, see + // https://www.gnu.org/software/libtool/manual/html_node/Versioning.html#Versioning + // The format is "current:revision:age". + Version string `json:"version"` + + // Name of the protocol. + Name string `json:"name"` // "taler-directory" + + // Supported registration methods + Methods []Method `json:"methods"` + + // fee for one month of registration + MonthlyFee string `json:"monthly_fee"` + +} + +type Method struct { + + // Name of the method, e.g. "email" or "sms". + Name string `json:"name"` + + // per challenge fee + ChallengeFee string `json:"challenge_fee"` + +} + +type RateLimitedResponse struct { + + // Taler error code, TALER_EC_TALDIR_REGISTER_RATE_LIMITED. + Code int `json:"code"` + + // At what frequency are new registrations allowed. FIXME: In what? Currently: In microseconds + RequestFrequency int64 `json:"request_frequency"` + + // The human readable error message. + Hint string `json:"hint"` +} + +type RegisterMessage struct { + + // Address, in method-specific format + Address string `json:"address"` + + // Public key of the user to register + PublicKey string `json:"public_key"` + + // (HTTPS) endpoint URL for the inbox service for this address + Inbox string `json:"inbox_url"` + + // For how long should the registration last + Duration int64 `json:"duration"` + + // Order ID, if the client recently paid for this registration + // FIXME: As an optional field, maybe we want to parse this separately + // instead? + // Order_id string `json:"order_id"` +} + +// A mappind entry from the identity key hash to a wallet key +// The identity key hash is sha256(sha256(identity)|salt) where identity is +// one of the identity key types supported (e.g. email) +type Entry struct { + + // ORM + gorm.Model `json:"-"` + + // The salted hash (SHA512) of the hashed address (h_address) + HsAddress string `json:"-"` + + // (HTTPS) endpoint URL for the inbox service for this address + Inbox string `json:"inbox_url"` + + // Public key of the user to register in base32 + PublicKey string `json:"public_key"` + + // Time of (re)registration. In Unix epoch microseconds) + RegisteredAt int64 `json:"-"` + + // How long the registration lasts in microseconds + Duration int64 `json:"-"` +} + +// A validation is created when a registration for an entry is initiated. +// The validation stores the identity key (sha256(identity)) the secret +// validation reference. The validation reference is sent to the identity +// depending on the out-of-band chennel defined through the identity key type. +type Validation struct { + + // ORM + gorm.Model `json:"-"` + + // The hash (SHA512) of the address + HAddress string `json:"h_address"` + + // For how long should the registration last + Duration int64 `json:"duration"` + + // (HTTPS) endpoint URL for the inbox service for this address + Inbox string `json:"inbox_url"` + + // The activation code sent to the client + Code string `json:"activation_code"` + + // Public key of the user to register + PublicKey string `json:"public_key"` +} + +type ErrorDetail struct { + + // Numeric error code unique to the condition. + // The other arguments are specific to the error value reported here. + Code int `json:"code"` + + // Human-readable description of the error, i.e. "missing parameter", "commitment violation", ... + // Should give a human-readable hint about the error's nature. Optional, may change without notice! + Hint string `json:"hint,omitempty"` + + // Optional detail about the specific input value that failed. May change without notice! + Detail string `json:"detail,omitempty"` + + // Name of the parameter that was bogus (if applicable). + Parameter string `json:"parameter,omitempty"` + + // Path to the argument that was bogus (if applicable). + Path string `json:"path,omitempty"` + + // Offset of the argument that was bogus (if applicable). + Offset string `json:"offset,omitempty"` + + // Index of the argument that was bogus (if applicable). + Index string `json:"index,omitempty"` + + // Name of the object that was bogus (if applicable). + Object string `json:"object,omitempty"` + + // Name of the currency than was problematic (if applicable). + Currency string `json:"currency,omitempty"` + + // Expected type (if applicable). + TypeExpected string `json:"type_expected,omitempty"` + + // Type that was provided instead (if applicable). + TypeActual string `json:"type_actual,omitempty"` +} + +type ValidationConfirmation struct { + Solution string `json:"solution"` +} + +// Primary lookup function. +// Allows the caller to query a wallet key using the hash(!) of the +// identity, e.g. SHA512(<email address>) +func (t *Taldir) getSingleEntry(w http.ResponseWriter, r *http.Request){ + vars := mux.Vars(r) + var entry Entry + hs_address := saltHAddress(vars["h_address"], t.Salt) + var err = t.Db.First(&entry, "hs_address = ?", hs_address).Error + if err == nil { + w.Header().Set("Content-Type", "application/json") + resp, _ := json.Marshal(entry) + w.Write(resp) + return + } + w.WriteHeader(http.StatusNotFound) +} + +// Hashes an identity key (e.g. sha256(<email address>)) with a salt for +// Lookup and storage. +func saltHAddress(h_address string, salt string) string { + h := sha512.New() + h.Write([]byte(h_address)) + h.Write([]byte(salt)) + return util.EncodeBinaryToString(h.Sum(nil)) +} + +// Called by the registrant to validate the registration request. The reference ID was +// provided "out of band" using a validation method such as email or SMS +func (t *Taldir) validationRequest(w http.ResponseWriter, r *http.Request){ + vars := mux.Vars(r) + var entry Entry + var validation Validation + var confirm ValidationConfirmation + var errDetail ErrorDetail + if r.Body == nil { + http.Error(w, "No request body", 400) + return + } + err := json.NewDecoder(r.Body).Decode(&confirm) + if err != nil { + errDetail.Code = 1006 //TALER_EC_JSON_INVALID + errDetail.Hint = "Unable to parse JSON" + resp, _ := json.Marshal(errDetail) + w.WriteHeader(400) + w.Write(resp) + return + } + err = t.Db.First(&validation, "h_address = ?", vars["h_address"]).Error + if err != nil { + w.WriteHeader(http.StatusNotFound) + return + } + expectedSolution := util.GenerateSolution(validation.PublicKey, validation.Code) + if confirm.Solution != expectedSolution { + // FIXME how TF do we rate limit here?? + w.WriteHeader(http.StatusForbidden) + return + } + // FIXME: Expire validations somewhere? + err = t.Db.Delete(&validation).Error + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + entry.HsAddress = saltHAddress(validation.HAddress, t.Salt) + entry.Inbox = validation.Inbox + entry.Duration = validation.Duration + entry.RegisteredAt = time.Now().UnixMicro() + entry.PublicKey = validation.PublicKey + err = t.Db.First(&entry, "hs_address = ?", entry.HsAddress).Error + if err == nil { + t.Db.Save(&entry) + } else { + err = t.Db.Create(&entry).Error + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + } + w.WriteHeader(http.StatusNoContent) +} + + +func (t *Taldir) registerRequest(w http.ResponseWriter, r *http.Request){ + vars := mux.Vars(r) + var req RegisterMessage + var errDetail ErrorDetail + var validation Validation + var entry Entry + if r.Body == nil { + http.Error(w, "No request body", 400) + return + } + err := json.NewDecoder(r.Body).Decode(&req) + if err != nil { + errDetail.Code = gana.GENERIC_JSON_INVALID + errDetail.Hint = "Unable to parse JSON" + resp, _ := json.Marshal(errDetail) + w.WriteHeader(400) + w.Write(resp) + return + } + if !t.Validators[vars["method"]] { + errDetail.Code = gana.TALDIR_METHOD_NOT_SUPPORTED + errDetail.Hint = "Unsupported method" + errDetail.Detail = "Given method: " + vars["method"] + resp, _ := json.Marshal(errDetail) + w.WriteHeader(404) + w.Write(resp) + return + } + h := sha512.New() + h.Write([]byte(req.Address)) + validation.HAddress = util.EncodeBinaryToString(h.Sum(nil)) + // We first try if there is already an entry for this address which + // is still valid and the duration is not extended. + hs_address := saltHAddress(validation.HAddress, t.Salt) + err = t.Db.First(&entry, "hs_address = ?", hs_address).Error + if err != nil { + lastRegValidity := entry.RegisteredAt + entry.Duration + requestedValidity := time.Now().UnixMicro() + req.Duration + earliestReRegistration := entry.RegisteredAt + t.RequestFrequency + // Rate limit re-registrations. + if time.Now().UnixMicro() < earliestReRegistration { + w.WriteHeader(429) + rlResponse := RateLimitedResponse{ + Code: gana.TALDIR_REGISTER_RATE_LIMITED, + RequestFrequency: t.RequestFrequency, + Hint: "Registration rate limit reached", + } + jsonResp, _ := json.Marshal(rlResponse) + w.Write(jsonResp) + return + } + // Do not allow re-registrations with shorter duration. + if requestedValidity <= lastRegValidity { + w.WriteHeader(200) + // FIXME how to return how long it is already paid for?? + return + } + } + err = t.Db.First(&validation, "h_address = ?", validation.HAddress).Error + validation.Code = util.GenerateCode() + validation.Inbox = req.Inbox + validation.Duration = req.Duration + validation.PublicKey = req.PublicKey + if err == nil { + // FIXME: Validation already pending for this address + // How should we proceed here? Expire old validations? + log.Println("Validation for this address already exists") + err = t.Db.Save(&validation).Error + } else { + err = t.Db.Create(&validation).Error + } + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + fmt.Println("Address registration request created:", validation) + if !t.Cfg.Section("taldir-" + vars["method"]).HasKey("command") { + log.Fatal(err) + t.Db.Delete(&validation) + w.WriteHeader(500) + return + } + command := t.Cfg.Section("taldir-" + vars["method"]).Key("command").String() + path, err := exec.LookPath(command) + if err != nil { + log.Println(err) + t.Db.Delete(&validation) + w.WriteHeader(500) + return + } + out, err := exec.Command(path, req.Address, validation.Code).Output() + if err != nil { + log.Println(err) + t.Db.Delete(&validation) + w.WriteHeader(500) + return + } + w.WriteHeader(202) + fmt.Printf("Output from method script %s is %s\n", path, out) +} + +func notImplemented(w http.ResponseWriter, r *http.Request) { + return +} + +func (t *Taldir) configResponse(w http.ResponseWriter, r *http.Request) { + meths := []Method{} + i := 0 + for key, _ := range t.Validators { + var meth Method + meth.Name = key + meth.ChallengeFee = t.Cfg.Section("taldir-" + key).Key("challenge_fee").MustString("KUDOS:1") + i++ + meths = append(meths, meth) + } + cfg := VersionResponse{ + Version: "0:0:0", + Name: "taler-directory", + MonthlyFee: t.Cfg.Section("taldir").Key("monthly_fee").MustString("KUDOS:1"), + Methods: meths, + } + w.Header().Set("Content-Type", "application/json") + response, _ := json.Marshal(cfg) + w.Write(response) +} + +func (t *Taldir) validationPage(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + w.Header().Set("Content-Type", "text/html; charset=utf-8") + var walletLink string + walletLink = "taler://taldir/" + vars["h_address"] + "/" + vars["validation_code"] + "-wallet" + var png []byte + png, err := qrcode.Encode(walletLink, qrcode.Medium, 256) + if err != nil { + w.WriteHeader(500) + return + } + encodedPng := base64.StdEncoding.EncodeToString(png) + + fullData := map[string]interface{}{ + "QRCode": template.URL("data:image/png;base64," + encodedPng), + "WalletLink": template.URL(walletLink), + } + t.ValidationTpl.Execute(w, fullData) + return +} + +func (t *Taldir) ClearDatabase() { + t.Db.Where("1 = 1").Delete(&Entry{}) + t.Db.Where("1 = 1").Delete(&Validation{}) +} + +func (t *Taldir) setupHandlers() { + t.Router = mux.NewRouter().StrictSlash(true) + + /* ToS API */ + t.Router.HandleFunc("/terms", notImplemented).Methods("GET") + t.Router.HandleFunc("/privacy", notImplemented).Methods("GET") + + /* Config API */ + t.Router.HandleFunc("/config", t.configResponse).Methods("GET") + + + /* Registration API */ + t.Router.HandleFunc("/{h_address}", t.getSingleEntry).Methods("GET") + t.Router.HandleFunc("/register/{method}", t.registerRequest).Methods("POST") + t.Router.HandleFunc("/register/{h_address}/{validation_code}", t.validationPage).Methods("GET") + t.Router.HandleFunc("/{h_address}", t.validationRequest).Methods("POST") + +} + +func (t *Taldir) handleRequests() { + log.Fatal(http.ListenAndServe(t.Cfg.Section("taldir").Key("bind_to").MustString("localhost:11000"), t.Router)) +} + +func (t *Taldir) Initialize(cfgfile string, clearDb bool) { + _cfg, err := ini.Load(cfgfile) + if err != nil { + fmt.Printf("Failed to read config: %v", err) + os.Exit(1) + } + t.Cfg = _cfg + if t.Cfg.Section("taldir").Key("production").MustBool(false) { + fmt.Println("Production mode enabled") + } + + t.Validators = make(map[string]bool) + for _, a := range strings.Split(t.Cfg.Section("taldir").Key("validators").String(), " ") { + t.Validators[a] = true + } + t.Validators = make(map[string]bool) + for _, a := range strings.Split(t.Cfg.Section("taldir").Key("validators").String(), " ") { + t.Validators[a] = true + } + + psqlconn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable", + t.Cfg.Section("taldir-pq").Key("host").MustString("localhost"), + t.Cfg.Section("taldir-pq").Key("port").MustInt64(5432), + t.Cfg.Section("taldir-pq").Key("user").MustString("taldir"), + t.Cfg.Section("taldir-pq").Key("password").MustString("secret"), + t.Cfg.Section("taldir-pq").Key("db_name").MustString("taldir")) + _db, err := gorm.Open(postgres.Open(psqlconn), &gorm.Config{}) + if err != nil { + panic(err) + } + t.Db = _db + if err := t.Db.AutoMigrate(&Entry{}); err != nil { + panic(err) + } + if err := t.Db.AutoMigrate(&Validation{}); err != nil { + panic(err) + } + if clearDb { + t.ClearDatabase() + } + + validationLandingTplFile := t.Cfg.Section("taldir").Key("validation_landing").MustString("templates/validation_landing.html") + t.ValidationTpl, err = template.ParseFiles(validationLandingTplFile) + if err != nil { + fmt.Println(err) + } + t.Salt = os.Getenv("TALDIR_SALT") + if "" == t.Salt { + t.Salt = t.Cfg.Section("taldir").Key("salt").MustString("ChangeMe") + } + t.RequestFrequency = t.Cfg.Section("taldir").Key("request_frequency").MustInt64(1000) + t.setupHandlers() +} + +func (t *Taldir) Run() { + t.handleRequests() +} diff --git a/cmd/taldir-server/testdata/taldir-test.conf b/cmd/taldir-server/testdata/taldir-test.conf @@ -0,0 +1,34 @@ +[taldir] +production = false +validators = "twitter test" +host = "https://taldir.net" +bind_to = "localhost:11000" +salt = "ChangeMe" +monthly_fee = KUDOS:1 +request_frequency = 3 +validation_landing = testdata/templates/validation_landing.html + +[taldir-email] +sender = "taldir@taler.net" +challenge_fee = KUDOS:0.5 +command = validate_email.sh + +[taldir-phone] +challenge_fee = KUDOS:5 +requires_payment = true +command = validate_phone.sh + +[taldir-test] +challenge_fee = KUDOS:23 +command = taldir-validate-test + +[taldir-twitter] +challenge_fee = KUDOS:2 +command = taldir-validate-twitter + +[taldir-pq] +host = "localhost" +port = 5432 +user = "taldir" +password = "secret" +db_name = "taldir" diff --git a/cmd/taldir-server/testdata/templates/validation_landing.html b/cmd/taldir-server/testdata/templates/validation_landing.html @@ -0,0 +1,17 @@ +<!DOCTYPE html> +<html lang="en"> + <head> + <!-- Required meta tags --> + <meta charset="utf-8"> + <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> + <title>Validation Landing Page</title> + </head> + <body> + <div class="container"> + <h1>Scan this QR code with your Taler Wallet to complete your registration.</h1> + <a href="{{.WalletLink}}"> + <img src="{{.QRCode}}"/> + </a> + </div> + </body> +</html>