taldir

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

commit 1b6093863884b746dcae66c82fb3bf748feb4b78
parent 2378b2681b8c5dbee8ae6ebbc7bcb770bdf6aeda
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Thu,  7 Jul 2022 21:33:41 +0200

refactoring

Diffstat:
MMakefile | 4++--
Mcmd/taldir-cli/main.go | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mcmd/taldir-server/main.go | 24+++---------------------
Mcmd/taldir-server/main_test.go | 9+++++----
Dcmd/taldir-server/taldir.go | 634-------------------------------------------------------------------------------
Mgo.mod | 2+-
Apkg/taldir/taldir.go | 658+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Rutil/base32.go -> pkg/util/base32.go | 0
Rutil/helper.go -> pkg/util/helper.go | 0
9 files changed, 714 insertions(+), 663 deletions(-)

diff --git a/Makefile b/Makefile @@ -3,11 +3,11 @@ all: gana server cli SCRIPT_TARGET:=$(shell dirname $(shell go list -f '{{.Target}}' ./cmd/taldir-server)) gana: - mkdir -p gana + mkdir -p pkg/gana git submodule update --init --recursive git submodule sync --recursive cd contrib/gana/gnu-taler-error-codes && make taler_error_codes.go - cp contrib/gana/gnu-taler-error-codes/taler_error_codes.go gana/ + cp contrib/gana/gnu-taler-error-codes/taler_error_codes.go pkg/gana/ server: go build ./cmd/taldir-server diff --git a/cmd/taldir-cli/main.go b/cmd/taldir-cli/main.go @@ -23,7 +23,10 @@ import ( "os" "fmt" "flag" - "taler.net/taldir/util" + "bufio" + "log" + "taler.net/taldir/pkg/taldir" + "taler.net/taldir/pkg/util" "crypto/sha512" ) @@ -41,6 +44,11 @@ func main() { var codeFlag = flag.String("c", "", "Activation code") var pubkeyFlag = flag.String("p", "", "Public key") var addressFlag = flag.String("a", "", "Address") + var delEntryFlag = flag.Bool("d", false, "Delete Entry for the specificied address") + var delValidationFlag = flag.Bool("p", false, "Delete validation for the specificied address") + + var dropFlag = flag.Bool("D", false, "Drop all data in table (DANGEROUS!)") + var cfgFlag = flag.String("c", "", "Configuration file to use") flag.Parse() if *solveFlag { if len(*codeFlag) == 0 || len(*pubkeyFlag) == 0 { @@ -58,4 +66,40 @@ func main() { fmt.Println(generateLink(*addressFlag, *codeFlag)) os.Exit(0) } + cfgfile := "taldir.conf" + if len(*cfgFlag) != 0 { + cfgfile = *cfgFlag + } + t := taldir.Taldir{} + t.Initialize(cfgfile) + if *dropFlag { + fmt.Println("Really delete all data in database? [y/N]:") + reader := bufio.NewReader(os.Stdin) + char, _, err := reader.ReadRune() + + if err == nil { + fmt.Println(char) + if char == 'y' { + t.ClearDatabase() + } + os.Exit(0) + } + os.Exit(1) + } + if *delEntryFlag { + if len(*addressFlag) == 0 { + log.Fatal("No address provided!") + } + t.DeleteEntry(*addressFlag) + } + if *delValidationFlag { + if len(*addressFlag) == 0 { + log.Fatal("No address provided!") + } + t.DeleteValidation(*addressFlag) + } + + + t.Run() + } diff --git a/cmd/taldir-server/main.go b/cmd/taldir-server/main.go @@ -28,36 +28,18 @@ package main */ import ( - "os" - "fmt" - "bufio" "flag" + "taler.net/taldir/pkg/taldir" ) func main() { - var dropFlag = flag.Bool("D", false, "Drop all data in table (DANGEROUS!)") var cfgFlag = flag.String("c", "", "Configuration file to use") flag.Parse() cfgfile := "taldir.conf" if len(*cfgFlag) != 0 { cfgfile = *cfgFlag } - t := Taldir{} - clearDb := false - if *dropFlag { - fmt.Println("Really delete all data in database? [y/N]:") - reader := bufio.NewReader(os.Stdin) - char, _, err := reader.ReadRune() - - if err == nil { - fmt.Println(char) - if char == 'y' { - clearDb = true - } - os.Exit(0) - } - os.Exit(1) - } - t.Initialize(cfgfile, clearDb) + t := taldir.Taldir{} + t.Initialize(cfgfile) t.Run() } diff --git a/cmd/taldir-server/main_test.go b/cmd/taldir-server/main_test.go @@ -29,11 +29,12 @@ import ( "strings" "io/ioutil" "log" - "taler.net/taldir/cmd/taldir-server" - "taler.net/taldir/util" + "taler.net/taldir/pkg/taldir" + _ "taler.net/taldir/cmd/taldir-server" + "taler.net/taldir/pkg/util" ) -var t main.Taldir +var t taldir.Taldir var validRegisterRequest = []byte(` { @@ -55,7 +56,7 @@ var validRegisterRequestShort = []byte(` func TestMain(m *testing.M) { - t.Initialize("testdata/taldir-test.conf", true) + t.Initialize("testdata/taldir-test.conf") code := m.Run() t.ClearDatabase() os.Exit(code) diff --git a/cmd/taldir-server/taldir.go b/cmd/taldir-server/taldir.go @@ -1,634 +0,0 @@ -// This file is part of taldir, the Taler Directory implementation. -// Copyright (C) 2022 Martin Schanzenbach -// -// Taldir 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. -// -// Taldir 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 localizions - - ToS compression - - ToS etag - - 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" - "io/ioutil" - "mime" - "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" - "golang.org/x/text/language" -) - -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"` -} - -// matcher is a language.Matcher configured for all supported languages. -var langMatcher = language.NewMatcher([]language.Tag{ - language.BritishEnglish, - //language.Norwegian, - language.German, -}) - -// 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 { - log.Println("Entry for this address already exists..") - 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 - bytes := t.Cfg.Section("taldir").Key("activation_code_bytes").MustInt(16) - validation.Code = util.GenerateCode(bytes) - 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) termsResponse(w http.ResponseWriter, r *http.Request) { - fileType := t.Cfg.Section("taldir").Key("default_doc_filetype").MustString("text/markdown") - termsLocation := t.Cfg.Section("taldir").Key("default_tos_path").MustString("terms/") - for _, typ := range r.Header["Accept"] { - for _, a := range strings.Split(t.Cfg.Section("taldir").Key("supported_doc_filetypes").String(), " ") { - if typ == a { - fileType = a - } - } - } - - if len(r.Header.Get("Accept-Language")) != 0 { - acceptLangs, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) - for _, lang := range acceptLangs { - extensions, _ := mime.ExtensionsByType(fileType) - for _, ext := range extensions { - log.Printf("Trying %s\n", termsLocation + lang.String() + ext) - fileBytes, err := ioutil.ReadFile(termsLocation + lang.String() + ext) - if nil == err { - w.Header().Set("Content-Type", fileType) - w.Write(fileBytes) - return - } - } - } - w.WriteHeader(404) - return - } - // Default document in expected/default format - defaultLanguage := t.Cfg.Section("taldir").Key("default_doc_lang").MustString("en-US") - extensions, _ := mime.ExtensionsByType(fileType) - for _, ext := range extensions { - fileBytes, err := ioutil.ReadFile(termsLocation + defaultLanguage + ext) - if nil == err { - w.Header().Set("Content-Type", fileType) - w.Write(fileBytes) - return - } - } - w.WriteHeader(404) -} - -func (t *Taldir) privacyResponse(w http.ResponseWriter, r *http.Request) { - fileType := t.Cfg.Section("taldir").Key("default_doc_filetype").MustString("text/markdown") - termsLocation := t.Cfg.Section("taldir").Key("default_pp_path").MustString("privacy/") - for _, typ := range r.Header["Accept"] { - for _, a := range strings.Split(t.Cfg.Section("taldir").Key("supported_doc_filetypes").String(), " ") { - if typ == a { - fileType = a - } - } - } - - if len(r.Header.Get("Accept-Language")) != 0 { - acceptLangs, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) - for _, lang := range acceptLangs { - extensions, _ := mime.ExtensionsByType(fileType) - for _, ext := range extensions { - log.Printf("Trying %s\n", termsLocation + lang.String() + ext) - fileBytes, err := ioutil.ReadFile(termsLocation + lang.String() + ext) - if nil == err { - w.Header().Set("Content-Type", fileType) - w.Write(fileBytes) - return - } - } - } - w.WriteHeader(404) - return - } - // Default document in expected/default format - defaultLanguage := t.Cfg.Section("taldir").Key("default_doc_lang").MustString("en-US") - extensions, _ := mime.ExtensionsByType(fileType) - for _, ext := range extensions { - fileBytes, err := ioutil.ReadFile(termsLocation + defaultLanguage + ext) - if nil == err { - w.Header().Set("Content-Type", fileType) - w.Write(fileBytes) - return - } - } - w.WriteHeader(404) -} - - -func (t *Taldir) setupHandlers() { - t.Router = mux.NewRouter().StrictSlash(true) - - /* ToS API */ - t.Router.HandleFunc("/terms", t.termsResponse).Methods("GET") - t.Router.HandleFunc("/privacy", t.privacyResponse).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 - } - - 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_microseconds").MustInt64(1000) - t.setupHandlers() -} - -func (t *Taldir) Run() { - t.handleRequests() -} diff --git a/go.mod b/go.mod @@ -9,7 +9,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect - golang.org/x/text v0.3.7 // indirect + golang.org/x/text v0.3.7 gopkg.in/ini.v1 v1.66.4 gopkg.in/jcmturner/aescts.v1 v1.0.1 // indirect gopkg.in/jcmturner/dnsutils.v1 v1.0.1 // indirect diff --git a/pkg/taldir/taldir.go b/pkg/taldir/taldir.go @@ -0,0 +1,658 @@ +// This file is part of taldir, the Taler Directory implementation. +// Copyright (C) 2022 Martin Schanzenbach +// +// Taldir 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. +// +// Taldir 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 taldir + +/* TODO + - ToS API (terms, privacy) with localizions + - ToS compression + - ToS etag + - 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" + "io/ioutil" + "mime" + "net/http" + "html/template" + "encoding/json" + "github.com/gorilla/mux" + "gorm.io/gorm" + "encoding/base64" + "taler.net/taldir/pkg/util" + "taler.net/taldir/pkg/gana" + "crypto/sha512" + "gorm.io/driver/postgres" + "gopkg.in/ini.v1" + "strings" + "github.com/skip2/go-qrcode" + "golang.org/x/text/language" +) + +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"` +} + +// matcher is a language.Matcher configured for all supported languages. +var langMatcher = language.NewMatcher([]language.Tag{ + language.BritishEnglish, + //language.Norwegian, + language.German, +}) + +// 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 { + log.Println("Entry for this address already exists..") + 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 + bytes := t.Cfg.Section("taldir").Key("activation_code_bytes").MustInt(16) + validation.Code = util.GenerateCode(bytes) + 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) DeleteValidation(addr string) error { + + var validation Validation + h := sha512.New() + h.Write([]byte(addr)) + h_addr := util.EncodeBinaryToString(h.Sum(nil)) + err := t.Db.First(&validation, "h_address = ?", h_addr).Error + if nil == err { + err = t.Db.Delete(&validation).Error + } + return err +} + + +func (t *Taldir) DeleteEntry(addr string) error { + + var entry Entry + h := sha512.New() + h.Write([]byte(addr)) + h_addr := util.EncodeBinaryToString(h.Sum(nil)) + hs_address := saltHAddress(h_addr, t.Salt) + err := t.Db.First(&entry, "hs_address = ?", hs_address).Error + if nil == err { + err = t.Db.Delete(&entry).Error + } + return err +} + +func (t *Taldir) ClearDatabase() { + t.Db.Where("1 = 1").Delete(&Entry{}) + t.Db.Where("1 = 1").Delete(&Validation{}) +} + +func (t *Taldir) termsResponse(w http.ResponseWriter, r *http.Request) { + fileType := t.Cfg.Section("taldir").Key("default_doc_filetype").MustString("text/markdown") + termsLocation := t.Cfg.Section("taldir").Key("default_tos_path").MustString("terms/") + for _, typ := range r.Header["Accept"] { + for _, a := range strings.Split(t.Cfg.Section("taldir").Key("supported_doc_filetypes").String(), " ") { + if typ == a { + fileType = a + } + } + } + + if len(r.Header.Get("Accept-Language")) != 0 { + acceptLangs, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) + for _, lang := range acceptLangs { + extensions, _ := mime.ExtensionsByType(fileType) + for _, ext := range extensions { + log.Printf("Trying %s\n", termsLocation + lang.String() + ext) + fileBytes, err := ioutil.ReadFile(termsLocation + lang.String() + ext) + if nil == err { + w.Header().Set("Content-Type", fileType) + w.Write(fileBytes) + return + } + } + } + w.WriteHeader(404) + return + } + // Default document in expected/default format + defaultLanguage := t.Cfg.Section("taldir").Key("default_doc_lang").MustString("en-US") + extensions, _ := mime.ExtensionsByType(fileType) + for _, ext := range extensions { + fileBytes, err := ioutil.ReadFile(termsLocation + defaultLanguage + ext) + if nil == err { + w.Header().Set("Content-Type", fileType) + w.Write(fileBytes) + return + } + } + w.WriteHeader(404) +} + +func (t *Taldir) privacyResponse(w http.ResponseWriter, r *http.Request) { + fileType := t.Cfg.Section("taldir").Key("default_doc_filetype").MustString("text/markdown") + termsLocation := t.Cfg.Section("taldir").Key("default_pp_path").MustString("privacy/") + for _, typ := range r.Header["Accept"] { + for _, a := range strings.Split(t.Cfg.Section("taldir").Key("supported_doc_filetypes").String(), " ") { + if typ == a { + fileType = a + } + } + } + + if len(r.Header.Get("Accept-Language")) != 0 { + acceptLangs, _, _ := language.ParseAcceptLanguage(r.Header.Get("Accept-Language")) + for _, lang := range acceptLangs { + extensions, _ := mime.ExtensionsByType(fileType) + for _, ext := range extensions { + log.Printf("Trying %s\n", termsLocation + lang.String() + ext) + fileBytes, err := ioutil.ReadFile(termsLocation + lang.String() + ext) + if nil == err { + w.Header().Set("Content-Type", fileType) + w.Write(fileBytes) + return + } + } + } + w.WriteHeader(404) + return + } + // Default document in expected/default format + defaultLanguage := t.Cfg.Section("taldir").Key("default_doc_lang").MustString("en-US") + extensions, _ := mime.ExtensionsByType(fileType) + for _, ext := range extensions { + fileBytes, err := ioutil.ReadFile(termsLocation + defaultLanguage + ext) + if nil == err { + w.Header().Set("Content-Type", fileType) + w.Write(fileBytes) + return + } + } + w.WriteHeader(404) +} + + +func (t *Taldir) setupHandlers() { + t.Router = mux.NewRouter().StrictSlash(true) + + /* ToS API */ + t.Router.HandleFunc("/terms", t.termsResponse).Methods("GET") + t.Router.HandleFunc("/privacy", t.privacyResponse).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) { + _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 + } + + 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) + } + + 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_microseconds").MustInt64(1000) + t.setupHandlers() +} + +func (t *Taldir) Run() { + t.handleRequests() +} diff --git a/util/base32.go b/pkg/util/base32.go diff --git a/util/helper.go b/pkg/util/helper.go