taler-mailbox

Service for asynchronous wallet-to-wallet payment messages
Log | Files | Refs | Submodules | README | LICENSE

commit 8ef1cff6b7c441f7d3eae2876299700b62f08bac
parent fbf33892e87b4888cfa5bf1882ada66b19b32cbb
Author: Martin Schanzenbach <schanzen@gnunet.org>
Date:   Tue, 11 Nov 2025 15:32:45 +0100

revamp API (again), add paid mailbox creation

Diffstat:
Mcmd/mailbox-server/main_test.go | 162++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mcmd/mailbox-server/test-mailbox.conf | 4+++-
Mmailbox.conf.example | 3+--
Mpkg/rest/mailbox.go | 470++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
4 files changed, 397 insertions(+), 242 deletions(-)

diff --git a/cmd/mailbox-server/main_test.go b/cmd/mailbox-server/main_test.go @@ -11,6 +11,7 @@ import ( "net/http" "net/http/httptest" "os" + "strconv" "strings" "testing" "time" @@ -62,10 +63,17 @@ func executeRequest(req *http.Request) *httptest.ResponseRecorder { return rr } -func checkResponseCode(t *testing.T, expected, actual int) { +func checkResponseCode(t *testing.T, expected, actual int) bool { if expected != actual { t.Errorf("Expected response code %d, Got %d\n", expected, actual) } + return expected == actual +} + +var merchServerRespondsPaid = false + +func shouldReturnPaid() bool { + return merchServerRespondsPaid } func TestMain(m *testing.M) { @@ -91,11 +99,12 @@ func TestMain(m *testing.M) { if err != nil { fmt.Printf("Error %s\n", err) } - jsonResp := fmt.Sprintf("{\"order_id\":\"%s\"}", orderResp.Order.OrderId) + jsonResp := fmt.Sprintf("{\"order_id\":\"%s\"}", "uniqueOrderId") w.WriteHeader(http.StatusOK) w.Write([]byte(jsonResp)) } else { - if r.Header.Get("PaidIndicator") == "yes" { + fmt.Printf("Responding always paid: %v\n", merchServerRespondsPaid) + if shouldReturnPaid() { jsonResp := "{\"order_status\":\"paid\"}" w.WriteHeader(http.StatusOK) w.Write([]byte(jsonResp)) @@ -159,35 +168,35 @@ func TestGetKeysEmpty(t *testing.T) { checkResponseCode(t, http.StatusNotFound, response.Code) } -func TestPostKeys(t *testing.T) { - var msg mailbox.KeyUpdateRequest +func TestMailboxRegistration(t *testing.T) { + var msg mailbox.MailboxRegistrationRequest // Dummy pubkey encKey := make([]byte, 32) aliceSigningKey := util.Base32CrockfordEncode(testAliceSigningKey) - msg.Keys.EncryptionKey = util.Base32CrockfordEncode(encKey) - msg.Keys.EncryptionKeyType = "X25519" - msg.Keys.Expiration = mailbox.Timestamp{T_s: uint64(time.Now().UnixMicro())} - msg.Keys.SigningKey = aliceSigningKey - msg.Keys.SigningKeyType = "EdDSA" + msg.MailboxMetadata.EncryptionKey = util.Base32CrockfordEncode(encKey) + msg.MailboxMetadata.EncryptionKeyType = "X25519" + msg.MailboxMetadata.Expiration = mailbox.Timestamp{T_s: uint64(time.Now().Add(time.Hour * 24 * 365).UnixMilli() / 1000)} + msg.MailboxMetadata.SigningKey = aliceSigningKey + msg.MailboxMetadata.SigningKeyType = "EdDSA" expNbo := make([]byte, 8) - binary.BigEndian.PutUint64(expNbo, msg.Keys.Expiration.T_s) + binary.BigEndian.PutUint64(expNbo, msg.MailboxMetadata.Expiration.T_s) h := sha512.New() - h.Write([]byte(msg.Keys.EncryptionKeyType)) + h.Write([]byte(msg.MailboxMetadata.EncryptionKeyType)) h.Write(encKey) h.Write(expNbo) - var signed_msg [64 + 4 + 4]byte - size := signed_msg[0:4] + var signedMsg [64 + 4 + 4]byte + size := signedMsg[0:4] binary.BigEndian.PutUint32(size, 64+4+4) - purp := signed_msg[4:8] - binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_KEYS_UPDATE) - copy(signed_msg[8:], h.Sum(nil)) - sig := ed25519.Sign(testAliceSigningKeyPriv, signed_msg[0:]) - if !ed25519.Verify(testAliceSigningKey, signed_msg[0:], sig) { + purp := signedMsg[4:8] + binary.BigEndian.PutUint32(purp, gana.TalerSignaturePurposeMailboxRegister) + copy(signedMsg[8:], h.Sum(nil)) + sig := ed25519.Sign(testAliceSigningKeyPriv, signedMsg[0:]) + if !ed25519.Verify(testAliceSigningKey, signedMsg[0:], sig) { t.Errorf("Signature invalid!") } msg.Signature = util.Base32CrockfordEncode(sig) jsonMsg, _ := json.Marshal(msg) - req, _ := http.NewRequest("POST", "/keys", bytes.NewReader(jsonMsg)) + req, _ := http.NewRequest("POST", "/register", bytes.NewReader(jsonMsg)) response := executeRequest(req) checkResponseCode(t, http.StatusNoContent, response.Code) req, _ = http.NewRequest("GET", "/keys/"+testAliceHashedSigningKeyString, nil) @@ -198,49 +207,95 @@ func TestPostKeys(t *testing.T) { t.Errorf("Expected response, Got %s", body) return } - var respMsg mailbox.MailboxMessageKeys + var respMsg mailbox.MailboxMetadata err := json.NewDecoder(response.Body).Decode(&respMsg) if err != nil { - fmt.Printf("Error %s\n", err) + t.Errorf("Error %s\n", err) + } + if respMsg.SigningKey != msg.MailboxMetadata.SigningKey { + fmt.Printf("Keys mismatch! %v %v\n", respMsg, msg.MailboxMetadata) } - if respMsg != msg.Keys { - fmt.Printf("Keys mismatch! %v %v\n", respMsg, msg.Keys) + if respMsg.EncryptionKey != msg.MailboxMetadata.EncryptionKey { + fmt.Printf("Keys mismatch! %v %v\n", respMsg, msg.MailboxMetadata) } + a.Db.Where("1 = 1").Delete(&mailbox.MailboxMetadata{}) + a.Db.Where("1 = 1").Delete(&mailbox.PendingMailboxRegistration{}) } -func TestPostMessagePaid(t *testing.T) { - testMessage := make([]byte, 256) + +func TestMailboxRegistrationPaid(t *testing.T) { + var msg mailbox.MailboxRegistrationRequest // Make paid - fee, err := talerutil.ParseAmount("KUDOS:1") - if err != nil { - t.Errorf("%v", err) + registrationUpdateFee := talerutil.NewAmount("KUDOS", 1, 0) + monthlyFee := talerutil.NewAmount("KUDOS", 2, 0) + a.RegistrationUpdateFee = &registrationUpdateFee + a.MonthlyFee = &monthlyFee + + // Dummy pubkey + encKey := make([]byte, 32) + aliceSigningKey := util.Base32CrockfordEncode(testAliceSigningKey) + msg.MailboxMetadata.EncryptionKey = util.Base32CrockfordEncode(encKey) + msg.MailboxMetadata.EncryptionKeyType = "X25519" + msg.MailboxMetadata.Expiration = mailbox.Timestamp{T_s: uint64(time.Now().Add(time.Hour * 24 * 365).UnixMicro())} + msg.MailboxMetadata.SigningKey = aliceSigningKey + msg.MailboxMetadata.SigningKeyType = "EdDSA" + expNbo := make([]byte, 8) + binary.BigEndian.PutUint64(expNbo, msg.MailboxMetadata.Expiration.T_s) + h := sha512.New() + h.Write([]byte(msg.MailboxMetadata.EncryptionKeyType)) + h.Write(encKey) + h.Write(expNbo) + var signedMsg [64 + 4 + 4]byte + size := signedMsg[0:4] + binary.BigEndian.PutUint32(size, 64+4+4) + purp := signedMsg[4:8] + binary.BigEndian.PutUint32(purp, gana.TalerSignaturePurposeMailboxRegister) + copy(signedMsg[8:], h.Sum(nil)) + sig := ed25519.Sign(testAliceSigningKeyPriv, signedMsg[0:]) + if !ed25519.Verify(testAliceSigningKey, signedMsg[0:], sig) { + t.Errorf("Signature invalid!") } - a.MessageFee = fee - a.Db.Where("1 = 1").Delete(&mailbox.InboxEntry{}) - req, _ := http.NewRequest("POST", "/"+testAliceHashedSigningKeyString, bytes.NewReader(testMessage)) + msg.Signature = util.Base32CrockfordEncode(sig) + jsonMsg, _ := json.Marshal(msg) + req, _ := http.NewRequest("POST", "/register", bytes.NewReader(jsonMsg)) response := executeRequest(req) - checkResponseCode(t, http.StatusPaymentRequired, response.Code) - // TODO check QR / payto response + req, _ = http.NewRequest("GET", "/keys/"+testAliceHashedSigningKeyString, nil) + response = executeRequest(req) + checkResponseCode(t, http.StatusNotFound, response.Code) - req, _ = http.NewRequest("POST", "/"+testAliceHashedSigningKeyString, bytes.NewReader(testMessage)) - req.Header.Add("PaidIndicator", "yes") + merchServerRespondsPaid = true + req, _ = http.NewRequest("GET", "/keys/"+testAliceHashedSigningKeyString, nil) response = executeRequest(req) + checkResponseCode(t, http.StatusOK, response.Code) - checkResponseCode(t, http.StatusPaymentRequired, response.Code) + req, _ = http.NewRequest("GET", "/keys/"+testAliceHashedSigningKeyString, nil) + response = executeRequest(req) + merchServerRespondsPaid = false + checkResponseCode(t, http.StatusOK, response.Code) body := response.Body.String() - if body != "" { - t.Errorf("Expected empty response, Got %s", body) + if body == "" { + t.Errorf("Expected response, Got %s", body) + return + } + var respMsg mailbox.MailboxMetadata + err := json.NewDecoder(response.Body).Decode(&respMsg) + if err != nil { + fmt.Printf("Error %s\n", err) + } + if respMsg.SigningKey != msg.MailboxMetadata.SigningKey { + fmt.Printf("Keys mismatch! %v %v\n", respMsg, msg.MailboxMetadata) + } + if respMsg.EncryptionKey != msg.MailboxMetadata.EncryptionKey { + fmt.Printf("Keys mismatch! %v %v\n", respMsg, msg.MailboxMetadata) } - a.MessageFee, _ = talerutil.ParseAmount("KUDOS:0") } func TestPostThenDeleteMessage(t *testing.T) { // testMessage := make([]byte, 256) - var deletionReq mailbox.MessageDeletionRequest numMessagesToPost := (a.MessageResponseLimit + 7) testMessages := make([]byte, 256*numMessagesToPost) _, _ = rand.Read(testMessages) @@ -279,25 +334,20 @@ func TestPostThenDeleteMessage(t *testing.T) { for i := 0; i < int(a.MessageResponseLimit); i++ { h.Write(testMessages[i*256 : (i+1)*256]) } - var signed_msg [64 + 4 + 4]byte - size := signed_msg[0:4] - binary.BigEndian.PutUint32(size, 64+4+4) - purp := signed_msg[4:8] - binary.BigEndian.PutUint32(purp, gana.TALER_SIGNATURE_PURPOSE_MAILBOX_MESSAGES_DELETE) - checksum := h.Sum(nil) - copy(signed_msg[8:], checksum) - sig := ed25519.Sign(testAliceSigningKeyPriv, signed_msg[0:]) - if !ed25519.Verify(testAliceSigningKey, signed_msg[0:], sig) { + etagInt, _ := strconv.Atoi(etag) + var signedMsg [4 * 4]byte + binary.BigEndian.PutUint32(signedMsg[0:4], 4*4) + binary.BigEndian.PutUint32(signedMsg[4:8], gana.TalerSignaturePurposeMailboxMessagesDelete) + binary.BigEndian.PutUint32(signedMsg[8:12], uint32(etagInt)) + binary.BigEndian.PutUint32(signedMsg[12:16], uint32(a.MessageResponseLimit)) + sig := ed25519.Sign(testAliceSigningKeyPriv, signedMsg[0:]) + if !ed25519.Verify(testAliceSigningKey, signedMsg[0:], sig) { t.Errorf("Signature invalid!") } - deletionReq.Signature = util.Base32CrockfordEncode(sig) - deletionReq.Count = int(a.MessageResponseLimit) - deletionReq.Checksum = util.Base32CrockfordEncode(checksum) - reqJson, _ := json.Marshal(deletionReq) - hAddress := util.Base32CrockfordEncode(testAliceSigningKey) - req, _ = http.NewRequest("DELETE", "/"+hAddress, bytes.NewBuffer(reqJson)) + req, _ = http.NewRequest("DELETE", "/"+hAddress+"?count="+strconv.Itoa(int(a.MessageResponseLimit)), nil) req.Header.Add("If-Match", etag) + req.Header.Add("Taler-Mailbox-Delete-Signature", util.Base32CrockfordEncode(sig)) response = executeRequest(req) checkResponseCode(t, http.StatusNoContent, response.Code) diff --git a/cmd/mailbox-server/test-mailbox.conf b/cmd/mailbox-server/test-mailbox.conf @@ -2,8 +2,10 @@ bind_to = localhost:11000 production = false message_body_bytes = 256 -message_fee = KUDOS:0 +monthly_fee = KUDOS:0 +registration_update_fee = KUDOS:0 message_response_limit = 25 +pending_registration_expiration = 24h [mailbox-pq] host = localhost diff --git a/mailbox.conf.example b/mailbox.conf.example @@ -4,8 +4,7 @@ base_url = https://example.com production = false message_body_bytes = 256 message_response_limit = 50 -registration_fee = KUDOS:0 -registration_period = 8760h +monthly_fee = KUDOS:0 [mailbox-pq] host = localhost diff --git a/pkg/rest/mailbox.go b/pkg/rest/mailbox.go @@ -19,9 +19,7 @@ package mailbox import ( - "bytes" "crypto/ed25519" - "crypto/sha256" "crypto/sha512" "encoding/binary" "encoding/json" @@ -108,8 +106,11 @@ type Mailbox struct { // Base URL BaseUrl string - // Registration fee for registering mailbox - RegistrationFee *talerutil.Amount + // Registration fee for each validity month mailbox + MonthlyFee *talerutil.Amount + + // Registration fee for registering or modifying mailbox + RegistrationUpdateFee *talerutil.Amount // How many messages will a single response // contain at maximum. @@ -121,8 +122,6 @@ type Mailbox struct { // Currency Spec CurrencySpec talerutil.CurrencySpecification - // Registration period - RegistrationPeriod time.Duration } type RelativeTime struct { @@ -133,6 +132,10 @@ type Timestamp struct { T_s uint64 `json:"t_s"` } +// 1 Month as Go duration +const monthDuration = time.Hour * 24 * 30 + + // VersionResponse is the JSON response of the /config endpoint type VersionResponse struct { // libtool-style representation of the Mailbox protocol version, see @@ -155,16 +158,13 @@ type VersionResponse struct { MessageResponseLimit uint64 `json:"message_response_limit"` // How much is the cost of a single - // registration period of a mailbox + // registration month of a mailbox // May be 0 for a free registration. - RegistrationFee string `json:"registration_fee"` + MonthlyFee string `json:"registration_fee"` - // How long is a registration period - // a mailbox - RegistrationPeriod int64 `json:"registration_period"` } -type MailboxMessageKeys struct { +type MailboxMetadata struct { // ORM gorm.Model `json:"-"` @@ -198,12 +198,25 @@ type MailboxMessageKeys struct { Expiration Timestamp `json:"expiration" gorm:"embedded;embeddedPrefix:expiration_"` } -type KeyUpdateRequest struct { +type PendingMailboxRegistration struct { // ORM gorm.Model `json:"-"` + // Hash of the inbox for this entry + HashedSigningKey string // Requested registration duration + + Duration time.Duration + + // The order ID associated with this registration + OrderID string `json:"-"` + +} + + +type MailboxRegistrationRequest struct { + // Keys to add/update for a mailbox. - Keys MailboxMessageKeys `json:"keys"` + MailboxMetadata MailboxMetadata `json:"mailbox_metadata"` // Signature by the mailbox's signing key affirming // the update of keys, of purpuse @@ -213,23 +226,6 @@ type KeyUpdateRequest struct { Signature string `json:"signature"` } -// MessageDeletionRequest is used to request the deletion of already received -// messages from the mailbox. -type MessageDeletionRequest struct { - - // Number of messages to delete. (Starting from the beginning - // of the latest GET response). - Count int - - // SHA-512 hash over all messages to delete. - Checksum string - - // Signature by the mailbox's private key affirming - // the deletion of the messages, of purpose - // TALER_SIGNATURE_WALLET_MAILBOX_DELETE_MESSAGES. - Signature string `json:"signature"` -} - // MailboxRateLimitedResponse is the JSON response when a rate limit is hit type MailboxRateLimitedResponse struct { @@ -251,7 +247,7 @@ type InboxEntry struct { Body []byte // Hash of the inbox for this entry - HMailbox string + HashedSigningKey string } func (m *Mailbox) configResponse(w http.ResponseWriter, r *http.Request) { @@ -265,9 +261,8 @@ func (m *Mailbox) configResponse(w http.ResponseWriter, r *http.Request) { Name: "taler-mailbox", MessageBodyBytes: m.MessageBodyBytes, MessageResponseLimit: m.MessageResponseLimit, - RegistrationFee: m.RegistrationFee.String(), + MonthlyFee: m.MonthlyFee.String(), DeliveryPeriod: RelativeTime{D_us: uint64(dp.Microseconds())}, - RegistrationPeriod: m.RegistrationPeriod.Microseconds(), } w.Header().Set("Content-Type", "application/json") response, _ := json.Marshal(cfg) @@ -281,10 +276,11 @@ func (m *Mailbox) getMessagesResponse(w http.ResponseWriter, r *http.Request) { // FIXME rate limit // FIXME timeout // FIXME possibly limit results here - err := m.Db.Where("h_mailbox = ?", vars["h_mailbox"]).Limit(int(m.MessageResponseLimit)).Find(&entries).Error + m.checkPendingRegistrationUpdates(vars["h_mailbox"]) + err := m.Db.Where("hashed_signing_key = ?", vars["h_mailbox"]).Limit(int(m.MessageResponseLimit)).Find(&entries).Error if err != nil { log.Printf("%v", err) - w.WriteHeader(http.StatusInternalServerError) + w.WriteHeader(http.StatusNotFound) return } if len(entries) == 0 { @@ -317,12 +313,13 @@ func (m *Mailbox) sendMessageResponse(w http.ResponseWriter, r *http.Request) { http.Error(w, "Cannot read body", http.StatusBadRequest) return } - err = m.Db.First(&entry, "h_mailbox = ? AND body = ?", vars["h_mailbox"], body, true).Error + m.checkPendingRegistrationUpdates(vars["h_mailbox"]) + err = m.Db.First(&entry, "hashed_signing_key = ? AND body = ?", vars["h_mailbox"], body, true).Error if err == nil { w.WriteHeader(http.StatusNotModified) return } - entry.HMailbox = vars["h_mailbox"] + entry.HashedSigningKey = vars["h_mailbox"] entry.Body = body m.Db.Save(&entry) w.WriteHeader(http.StatusNoContent) @@ -330,18 +327,88 @@ func (m *Mailbox) sendMessageResponse(w http.ResponseWriter, r *http.Request) { func (m *Mailbox) getKeysResponse(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) - var keyEntry MailboxMessageKeys + var keyEntry MailboxMetadata + m.checkPendingRegistrationUpdates(vars["h_mailbox"]) err := m.Db.First(&keyEntry, "hashed_signing_key = ?", vars["h_mailbox"]).Error if err != nil { w.WriteHeader(http.StatusNotFound) return } + m.Logf(LogDebug, "entry expires at %d, have %d", keyEntry.Expiration.T_s, time.Now().Unix()) + if keyEntry.Expiration.T_s < uint64(time.Now().Unix()) { + w.WriteHeader(http.StatusNotFound) + return + } response, _ := json.Marshal(keyEntry) w.Write(response) } -func (m *Mailbox) updateKeysResponse(w http.ResponseWriter, r *http.Request) { - var msg KeyUpdateRequest +func (m *Mailbox) validateRegistrationSignature(msg MailboxRegistrationRequest) error { + var expNbo [8]byte + var signedMsg [72]byte + encPk, err := util.Base32CrockfordDecode(msg.MailboxMetadata.EncryptionKey, 32) + if err != nil { + return fmt.Errorf("unable to decode encryption key") + } + pkey, err := util.Base32CrockfordDecode(msg.MailboxMetadata.SigningKey, 32) + if err != nil { + return fmt.Errorf("unable to decode pubkey") + } + pk := ed25519.PublicKey(pkey) + sig, err := util.Base32CrockfordDecode(msg.Signature, 64) + if nil != err { + return fmt.Errorf("unable to decode signature") + } + binary.BigEndian.PutUint64(expNbo[:], msg.MailboxMetadata.Expiration.T_s) + size := signedMsg[0:4] + binary.BigEndian.PutUint32(size, 64+4+4) + purp := signedMsg[4:8] + binary.BigEndian.PutUint32(purp, gana.TalerSignaturePurposeMailboxRegister) + h := sha512.New() + h.Write([]byte(msg.MailboxMetadata.EncryptionKeyType)) // Currently always X25519 + h.Write(encPk) + h.Write(expNbo[:]) + copy(signedMsg[8:], h.Sum(nil)) + if !ed25519.Verify(pk, signedMsg[0:], sig) { + return fmt.Errorf("signature invalid") + } + return nil +} + +// Check if this is a non-zero, positive amount +func calculateCost(sliceCostAmount string, fixedCostAmount string, howLong time.Duration, sliceDuration time.Duration) (*talerutil.Amount, error) { + sliceCount := int(float64(howLong.Microseconds()) / float64(sliceDuration.Microseconds())) + sliceCost, err := talerutil.ParseAmount(sliceCostAmount) + if nil != err { + return nil, err + } + fixedCost, err := talerutil.ParseAmount(fixedCostAmount) + if nil != err { + return nil, err + } + sum := &talerutil.Amount{ + Currency: sliceCost.Currency, + Value: 0, + Fraction: 0, + } + for i := 0; i < sliceCount; i++ { + sum, err = sum.Add(*sliceCost) + if nil != err { + return nil, err + } + } + sum, err = sum.Add(*fixedCost) + if nil != err { + return nil, err + } + return sum, nil +} + + +func (m *Mailbox) registerMailboxResponse(w http.ResponseWriter, r *http.Request) { + var msg MailboxRegistrationRequest + var pendingRegistration PendingMailboxRegistration + var registrationEntry MailboxMetadata if r.Body == nil { m.Logf(LogError, "no request body") http.Error(w, "No request body", http.StatusBadRequest) @@ -352,140 +419,152 @@ func (m *Mailbox) updateKeysResponse(w http.ResponseWriter, r *http.Request) { http.Error(w, "Malformed request body", http.StatusBadRequest) return } - pkey, err := util.Base32CrockfordDecode(msg.Keys.SigningKey, 32) + pkey, err := util.Base32CrockfordDecode(msg.MailboxMetadata.SigningKey, 32) if err != nil { - m.Logf(LogError, "unable to decode pubkey") - w.WriteHeader(http.StatusBadRequest) + http.Error(w, "Public key invalid", http.StatusBadRequest) return } pk := ed25519.PublicKey(pkey) - sig, err := util.Base32CrockfordDecode(msg.Signature, 64) + err = m.validateRegistrationSignature(msg) if nil != err { - m.Logf(LogError, "unable to decode signature") - w.WriteHeader(http.StatusBadRequest) + http.Error(w, "Signature verification failed", http.StatusBadRequest) return } - encPk, err := util.Base32CrockfordDecode(msg.Keys.EncryptionKey, 32) - if err != nil { - m.Logf(LogError, "unable to decode encryption key") - w.WriteHeader(http.StatusBadRequest) - return - } - if !m.RegistrationFee.IsZero() { - - // Check if order exists and was paid already. - h := sha256.New() - h.Sum(pk) - orderID := util.Base32CrockfordEncode(h.Sum(nil)) - httpStatus, paymentStatus, payto, paytoErr := m.Merchant.IsOrderPaid(orderID) - if paytoErr != nil { - fmt.Println(paytoErr) + h := sha512.New() + h.Write(pkey) + hMailbox := util.Base32CrockfordEncode(h.Sum(nil)) + pendingRegistration.HashedSigningKey = hMailbox + // Round to the nearest multiple of a month + reqExpiration := time.Unix(int64(msg.MailboxMetadata.Expiration.T_s), 0) + now := time.Now() + reqDuration := reqExpiration.Sub(now).Round(monthDuration) + err = m.Db.First(&registrationEntry, "hashed_signing_key = ?", hMailbox).Error + if err == nil { + // This probably meansthe registration is modified or extended or both + entryModified := (registrationEntry.EncryptionKey != msg.MailboxMetadata.EncryptionKey) + // At least one MonthlyFee + if reqDuration.Microseconds() == 0 && !entryModified { + // Nothing changed. Return validity + w.WriteHeader(http.StatusNotModified) + return + } + } else { + // Entry does not yet exist, add but immediately expire it + registrationEntry = msg.MailboxMetadata + registrationEntry.Expiration.T_s = uint64(time.Now().Unix() - 1) + hAddr := sha512.New() + hAddr.Write(pk) + registrationEntry.HashedSigningKey = util.Base32CrockfordEncode(hAddr.Sum(nil)) + err = m.Db.Create(&registrationEntry).Error + if nil != err { w.WriteHeader(http.StatusInternalServerError) return } - switch httpStatus { - case http.StatusNotFound: - // Not found. Create new order. - var order merchant.CommonOrder - order.OrderId = orderID - order.Amount = m.RegistrationFee.String() - order.Summary = "Mailbox registration" - order.MerchantBaseUrl = m.BaseUrl - _, newOrderErr := m.Merchant.CreateOrder(order) + } + err = m.Db.First(&pendingRegistration, "hashed_signing_key = ?", hMailbox).Error + pendingRegistrationExists := (nil == err) + if !pendingRegistrationExists { + pendingRegistration.HashedSigningKey = hMailbox + pendingRegistration.Duration = reqDuration + } + // At least the update fee needs to be paid + cost, err := calculateCost(m.MonthlyFee.String(), + m.RegistrationUpdateFee.String(), + reqDuration, + monthDuration) + if err != nil { + fmt.Println(err) + w.WriteHeader(http.StatusInternalServerError) + return + } + if !cost.IsZero() { + if len(pendingRegistration.OrderID) == 0 { + // Add new order + orderID, newOrderErr := m.Merchant.AddNewOrder(*cost, "Mailbox registration", m.BaseUrl) if newOrderErr != nil { - fmt.Println(newOrderErr) + m.Logf(LogError, "%v", newOrderErr) w.WriteHeader(http.StatusInternalServerError) return } - // Check for order again to get payto. - _, _, payto, paytoErr = m.Merchant.IsOrderPaid(orderID) - if paytoErr != nil { - fmt.Println(paytoErr) - w.WriteHeader(http.StatusInternalServerError) - return - } - if payto == "" { - fmt.Println(paytoErr) - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusPaymentRequired) - w.Header().Set("Taler", payto) + m.Logf(LogDebug, "New order ID %s for pending registration for %s", orderID, pendingRegistration.HashedSigningKey) + pendingRegistration.OrderID = orderID + } + // Check if order paid. + // FIXME: Remember that it was activated and paid + // FIXME: We probably need to handle the return code here (see gns registrar for how) + _, _, payto, paytoErr := m.Merchant.IsOrderPaid(pendingRegistration.OrderID) + if paytoErr != nil { + fmt.Println(paytoErr) + w.WriteHeader(http.StatusInternalServerError) + m.Logf(LogError, paytoErr.Error()+"\n") return - case http.StatusOK: - // Check if it was actually paid. - if paymentStatus == "paid" { - break - } - default: + } + if len(payto) != 0 { + m.Db.Save(&pendingRegistration) w.WriteHeader(http.StatusPaymentRequired) - if payto != "" { - w.Header().Set("Taler", payto) - } + w.Header().Set("Taler", payto) // FIXME no idea what to do with this. return } } - var expNbo [8]byte - var signedMsg [72]byte - binary.BigEndian.PutUint64(expNbo[:], msg.Keys.Expiration.T_s) - size := signedMsg[0:4] - binary.BigEndian.PutUint32(size, 64+4+4) - purp := signedMsg[4:8] - binary.BigEndian.PutUint32(purp, gana.TalerSignaturePurposeMailboxRegister) - h := sha512.New() - h.Write([]byte(msg.Keys.EncryptionKeyType)) // Currently always X25519 - h.Write(encPk) - h.Write(expNbo[:]) - copy(signedMsg[8:], h.Sum(nil)) - if !ed25519.Verify(pk, signedMsg[0:], sig) { - w.WriteHeader(http.StatusForbidden) - return - } - var keyEntry MailboxMessageKeys - keyEntry.SigningKeyType = msg.Keys.SigningKeyType - keyEntry.SigningKey = msg.Keys.SigningKey - keyEntry.EncryptionKeyType = msg.Keys.EncryptionKeyType - keyEntry.EncryptionKey = msg.Keys.EncryptionKey - keyEntry.Expiration = msg.Keys.Expiration - hAddr := sha512.New() - hAddr.Write(pk) - keyEntry.HashedSigningKey = util.Base32CrockfordEncode(hAddr.Sum(nil)) - err = m.Db.First(&keyEntry, "signing_key = ?", msg.Keys.SigningKey).Error + // Update expiration time of registration. + registrationEntry.Expiration.T_s += uint64(reqDuration.Seconds()) + m.Db.Delete(pendingRegistration) + err = m.Db.Save(&registrationEntry).Error if nil != err { - m.Db.Create(&keyEntry) - } else { - err = m.Db.Save(&keyEntry).Error - if nil != err { - w.WriteHeader(http.StatusInternalServerError) - return - } + w.WriteHeader(http.StatusInternalServerError) + return } w.WriteHeader(http.StatusNoContent) } -func (m *Mailbox) deleteMessagesResponse(w http.ResponseWriter, r *http.Request) { - vars := mux.Vars(r) - var msg MessageDeletionRequest - var entries []InboxEntry - if r.Body == nil { - http.Error(w, "No request body", 400) +func (m *Mailbox) checkPendingRegistrationUpdates(hMailbox string) { + var pendingEntry PendingMailboxRegistration + var registrationEntry MailboxMetadata + err := m.Db.First(&pendingEntry, "hashed_signing_key = ?", hMailbox).Error + if err != nil { return } - err := json.NewDecoder(r.Body).Decode(&msg) - if err != nil { - w.WriteHeader(http.StatusBadRequest) + m.Logf(LogDebug, "Found pending registration for %s, OrderID: %s", hMailbox, pendingEntry.OrderID) + rc, orderStatus, _, paytoErr := m.Merchant.IsOrderPaid(pendingEntry.OrderID) + if nil != paytoErr { + if rc == http.StatusNotFound { + m.Logf(LogInfo, "Registration order for `%s' not found, removing\n", hMailbox) + } + err := m.Db.Delete(&pendingEntry) + if nil != err { + m.Logf(LogInfo, "%v\n", err) + } + return + } + m.Logf(LogDebug, "Order status for %s is %s", pendingEntry.HashedSigningKey, orderStatus) + if merchant.OrderPaid == orderStatus { + m.Logf(LogDebug, "Order for %v appears to be paid", pendingEntry) + err = m.Db.First(&registrationEntry, "hashed_signing_key = ?", hMailbox).Error + if err == nil { + m.Logf(LogDebug, "Adding %d seconds to entry expiration", uint64(pendingEntry.Duration.Seconds())) + registrationEntry.Expiration.T_s += uint64(pendingEntry.Duration.Seconds()) + m.Db.Save(&registrationEntry) + err := m.Db.Delete(&pendingEntry) + if nil != err { + m.Logf(LogInfo, "%v\n", err) + } + } return } - etag_hdr := r.Header.Get("If-Match") - if etag_hdr == "" { +} + +func (m *Mailbox) deleteMessagesResponse(w http.ResponseWriter, r *http.Request) { + vars := mux.Vars(r) + etagHeader := r.Header.Get("If-Match") + if etagHeader == "" { http.Error(w, "If-Match header missing", 400) return } - if strings.Contains(etag_hdr, ",") { + if strings.Contains(etagHeader, ",") { http.Error(w, "If-Match contains multiple values", 400) return } - etag_expected, err := strconv.Atoi(etag_hdr) + expectedETag, err := strconv.Atoi(etagHeader) if err != nil { http.Error(w, "If-Match contains malformed etag number", 400) return @@ -495,52 +574,55 @@ func (m *Mailbox) deleteMessagesResponse(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusBadRequest) return } - checksum, err := util.Base32CrockfordDecode(msg.Checksum, 64) - if err != nil { - w.WriteHeader(http.StatusBadRequest) + count := 1 + countStr := r.URL.Query().Get("count") + if len(countStr) > 0 { + count, err = strconv.Atoi(countStr) + if err != nil { + http.Error(w, "Malformed count parameter", http.StatusBadRequest) + w.WriteHeader(http.StatusBadRequest) + return + } + } + headerSig := r.Header["Taler-Mailbox-Delete-Signature"] + if nil == headerSig { + http.Error(w, "Missing signature", http.StatusBadRequest) return } pk := ed25519.PublicKey(pkey) - sig, err := util.Base32CrockfordDecode(msg.Signature, 64) + sig, err := util.Base32CrockfordDecode(headerSig[0], 64) if nil != err { w.WriteHeader(http.StatusBadRequest) return } - var signed_msg [72]byte - size := signed_msg[0:4] - binary.BigEndian.PutUint32(size, 64+4+4) - purp := signed_msg[4:8] - binary.BigEndian.PutUint32(purp, gana.TalerSignaturePurposeMailboxMessagesDelete) - copy(signed_msg[8:], checksum) - if !ed25519.Verify(pk, signed_msg[0:], sig) { - w.WriteHeader(http.StatusForbidden) - return - } h := sha512.New() h.Write(pkey) - h_mailbox := util.Base32CrockfordEncode(h.Sum(nil)) - err = m.Db.Where("h_mailbox = ? AND id >= ?", h_mailbox, etag_expected).Limit(msg.Count).Find(&entries).Error - if err != nil { - w.WriteHeader(http.StatusInternalServerError) + hHailbox := util.Base32CrockfordEncode(h.Sum(nil)) + m.checkPendingRegistrationUpdates(hHailbox) + var signedMsg [4 * 4]byte + binary.BigEndian.PutUint32(signedMsg[0:4], 4*4) + binary.BigEndian.PutUint32(signedMsg[4:8], gana.TalerSignaturePurposeMailboxMessagesDelete) + binary.BigEndian.PutUint32(signedMsg[8:12], uint32(expectedETag)) + binary.BigEndian.PutUint32(signedMsg[12:16], uint32(count)) + if !ed25519.Verify(pk, signedMsg[0:], sig) { + w.WriteHeader(http.StatusForbidden) return } - if len(entries) != msg.Count { + // Check that expectedETag actually exists + err = m.Db.Where("hashed_signing_key = ? AND id = ?", hHailbox, expectedETag).Find(&InboxEntry{}).Error + if err != nil { + m.Logf(LogDebug, "Message to delete not found with ID %d", expectedETag) w.WriteHeader(http.StatusNotFound) return } - if entries[0].ID != uint(etag_expected) { - w.WriteHeader(http.StatusPreconditionFailed) - return - } - h_all := sha512.New() - for _, entry := range entries { - h_all.Write(entry.Body) - } - if !bytes.Equal(h_all.Sum(nil), checksum) { - w.WriteHeader(http.StatusNotFound) + var entries []InboxEntry + err = m.Db.Where("hashed_signing_key = ? AND id >= ?", hHailbox, expectedETag).Limit(count).Find(&entries).Error + if err != nil { + w.WriteHeader(http.StatusInternalServerError) return } - m.Db.Delete(&entries) + m.Logf(LogDebug, "Found matching ID, deleting %d messages", len(entries)) + m.Db.Delete(entries) w.WriteHeader(http.StatusNoContent) } @@ -588,8 +670,7 @@ func (m *Mailbox) setupHandlers() { m.Router.HandleFunc("/config", m.configResponse).Methods("GET") /* Mailbox API */ - m.Router.HandleFunc("/keys", m.updateKeysResponse).Methods("POST") - m.Router.HandleFunc("/private/{mailbox}", m.deleteMessagesResponse).Methods("POST") + m.Router.HandleFunc("/register", m.registerMailboxResponse).Methods("POST") m.Router.HandleFunc("/{h_mailbox}", m.sendMessageResponse).Methods("POST") m.Router.HandleFunc("/{h_mailbox}", m.getMessagesResponse).Methods("GET") m.Router.HandleFunc("/{mailbox}", m.deleteMessagesResponse).Methods("DELETE") @@ -614,19 +695,18 @@ func (m *Mailbox) Initialize(cfg MailboxConfig) { m.BaseUrl = cfg.Ini.Section("mailbox").Key("base_url").MustString("https://example.com") m.MessageBodyBytes = cfg.Ini.Section("mailbox").Key("message_body_bytes").MustInt64(256) m.MessageResponseLimit = cfg.Ini.Section("mailbox").Key("message_response_limit").MustUint64(50) - registrationPeriodStr := cfg.Ini.Section("mailbox").Key("registration_period").MustString("8760h") - registrationPeriod, err := time.ParseDuration(registrationPeriodStr) + monthlyFee, err := talerutil.ParseAmount(cfg.Ini.Section("mailbox").Key("monthly_fee").MustString("KUDOS:0")) if err != nil { - fmt.Printf("Failed to parse duration: %v", err) + fmt.Printf("Failed to parse cost: %v", err) os.Exit(1) } - m.RegistrationPeriod = registrationPeriod - fee, err := talerutil.ParseAmount(cfg.Ini.Section("mailbox").Key("registration_fee").MustString("KUDOS:0")) + m.MonthlyFee = monthlyFee + updateFee, err := talerutil.ParseAmount(cfg.Ini.Section("mailbox").Key("registration_update_fee").MustString("KUDOS:0")) if err != nil { fmt.Printf("Failed to parse cost: %v", err) os.Exit(1) } - m.RegistrationFee = fee + m.RegistrationUpdateFee = updateFee _db, err := gorm.Open(cfg.Db, &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), }) @@ -637,20 +717,44 @@ func (m *Mailbox) Initialize(cfg MailboxConfig) { if err := m.Db.AutoMigrate(&InboxEntry{}); err != nil { panic(err) } - if err := m.Db.AutoMigrate(&MailboxMessageKeys{}); err != nil { + if err := m.Db.AutoMigrate(&MailboxMetadata{}); err != nil { + panic(err) + } + go func() { + for { + tx := m.Db.Where("expiration < ?", time.Now()).Delete(&MailboxMetadata{}) + m.Logf(LogInfo, "Cleaned up %d stale registrations.\n", tx.RowsAffected) + time.Sleep(time.Hour * 24) + } + }() + if err := m.Db.AutoMigrate(&PendingMailboxRegistration{}); err != nil { panic(err) } + // Clean up pending + pendingExpStr := cfg.Ini.Section("mailbox").Key("pending_registration_expiration").MustString("24h") + pendingExp, err := time.ParseDuration(pendingExpStr) + if err != nil { + fmt.Printf("Failed to parse pending registraion expiration: %v", err) + os.Exit(1) + } + go func() { + for { + tx := m.Db.Where("created_at < ?", time.Now().Add(-pendingExp)).Delete(&PendingMailboxRegistration{}) + m.Logf(LogInfo, "Cleaned up %d stale pending registrations.\n", tx.RowsAffected) + time.Sleep(pendingExp) + } + }() m.Merchant = cfg.Merchant - if !fee.IsZero() { + if !monthlyFee.IsZero() { merchConfig, err := m.Merchant.GetConfig() if err != nil { fmt.Printf("Failed to get merchant config: %v", err) os.Exit(1) } - currencySpec, currencySupported := merchConfig.Currencies[fee.Currency] + currencySpec, currencySupported := merchConfig.Currencies[monthlyFee.Currency] for !currencySupported { - fmt.Printf("Currency `%s' not supported by merchant!\n", fee.Currency) + fmt.Printf("Currency `%s' not supported by merchant!\n", monthlyFee.Currency) os.Exit(1) } m.CurrencySpec = currencySpec