Change to normal password auth, and various things

This commit is contained in:
Daniel Krol 2022-06-07 13:25:14 -04:00
parent 15c68d7153
commit 0bf11b059c
17 changed files with 776 additions and 821 deletions

View file

@ -10,12 +10,15 @@ import (
// TODO - Learn how to use https://github.com/golang/oauth2 instead
// TODO - Look into jwt, etc.
// For now I just want a process that's shaped like what I'm looking for (pubkey signatures, downloadKey, etc)
// For now I just want a process that's shaped like what I'm looking for.
// (email/password, encrypted wallets, hmac, lastSynced, etc)
type UserId int32
type Email string
type DeviceId string
type Password string
type AuthTokenString string
type PublicKey string
type DownloadKey string
type Signature string
type AuthScope string
@ -24,70 +27,37 @@ const ScopeGetWalletState = AuthScope("get-wallet-state")
// For test stubs
type AuthInterface interface {
NewToken(pubKey PublicKey, DeviceID string, Scope AuthScope) (*AuthToken, error)
IsValidSignature(pubKey PublicKey, payload string, signature Signature) bool
ValidateTokenRequest(tokenRequest *TokenRequest) bool
// TODO maybe have a "refresh token" thing if the client won't have email available all the time?
NewToken(UserId, DeviceId, AuthScope) (*AuthToken, error)
}
type Auth struct{}
func (a *Auth) IsValidSignature(pubKey PublicKey, payload string, signature Signature) bool {
// TODO - a real check
return signature == "Good Signature"
}
// Note that everything here is given to anybody who presents a valid
// downloadKey and associated email. Currently these fields are safe to give
// at that low security level, but keep this in mind as we change this struct.
type AuthToken struct {
Token AuthTokenString `json:"token"`
DeviceID string `json:"deviceId"`
DeviceId DeviceId `json:"deviceId"`
Scope AuthScope `json:"scope"`
PubKey PublicKey `json:"publicKey"`
UserId UserId `json:"userId"`
Expiration *time.Time `json:"expiration"`
}
type TokenRequest struct {
DeviceID string `json:"deviceId"`
RequestTime int64 `json:"requestTime"`
// TODO - add target domain as well. anything to limit the scope of the
// request to mitigate replays.
}
func (a *Auth) ValidateTokenRequest(tokenRequest *TokenRequest) bool {
if tokenRequest.DeviceID == "" {
return false
}
// Since we're going by signatures with a key that we don't want to change,
// let's avoid replays.
timeDiff := time.Now().Unix() - tokenRequest.RequestTime
if timeDiff < -2 {
// Maybe time drift will cause the request time to be in the future. This
// would also include request time. Only allow a few seconds of this.
return false
}
if timeDiff > 60 {
// Maybe the request is slow. Allow for a minute of lag time.
return false
}
return true
}
const AuthTokenLength = 32
func (a *Auth) NewToken(pubKey PublicKey, DeviceID string, Scope AuthScope) (*AuthToken, error) {
func (a *Auth) NewToken(userId UserId, deviceId DeviceId, scope AuthScope) (*AuthToken, error) {
b := make([]byte, AuthTokenLength)
// TODO - Is this is a secure random function? (Maybe audit)
if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("Error generating token: %+v", err)
}
return &AuthToken{
Token: AuthTokenString(hex.EncodeToString(b)),
DeviceID: DeviceID,
Scope: Scope,
PubKey: pubKey,
DeviceId: deviceId,
Scope: scope,
UserId: userId,
// TODO add Expiration here instead of putting it in store.go. and thus redo store.go. d'oh.
}, nil
}
@ -109,3 +79,9 @@ func (d DownloadKey) Obfuscate() string {
hash := sha256.Sum256([]byte(d))
return hex.EncodeToString(hash[:])
}
func (p Password) Obfuscate() string {
// TODO KDF instead
hash := sha256.Sum256([]byte(p))
return hex.EncodeToString(hash[:])
}

View file

@ -6,11 +6,6 @@ import (
// Test stubs for now
func TestAuthValidateTokenRequest(t *testing.T) {
// also add a basic test case for this in TestServerAuthHandlerErrors to make sure it's called at all
t.Fatalf("Test me: Implement and test ValidateTokenRequest")
}
func TestAuthSignaturePass(t *testing.T) {
t.Fatalf("Test me: Valid siganture passes")
}

View file

@ -3,165 +3,69 @@ package server
import (
"encoding/json"
"fmt"
"log"
"net/http"
"orblivion/lbry-id/auth"
"orblivion/lbry-id/store"
)
/*
TODO - Consider reworking the naming convention in (currently named)
`AuthFullRequest` so we can reuse code with `WalletStateRequest`. Both structs
have a pubkey, a signature, and a signed payload (which is in turn an encoded
json string). We verify the signature for both in a similar pattern.
*/
// DeviceId is decided by the device. UserId is decided by the server, and is
// gatekept by Email/Password
type AuthFullRequest struct {
// TokenRequestJSON: json string within json, so that the string representation is
// unambiguous for the purposes of signing. This means we need to deserialize the
// request body twice.
TokenRequestJSON string `json:"tokenRequestJSON"`
PubKey auth.PublicKey `json:"publicKey"`
Signature auth.Signature `json:"signature"`
DeviceId auth.DeviceId `json:"deviceId"`
Email auth.Email `json:"email"`
Password auth.Password `json:"password"`
}
func (r *AuthFullRequest) validate() bool {
return (r.TokenRequestJSON != "" &&
r.PubKey != auth.PublicKey("") &&
r.Signature != auth.Signature(""))
return (r.DeviceId != "" &&
r.Email != auth.Email("") && // TODO email validation. Here or store. Stdlib does it: https://stackoverflow.com/a/66624104
r.Password != auth.Password(""))
}
type AuthForGetWalletStateRequest struct {
Email string `json:"email"`
Email auth.Email `json:"email"`
DownloadKey auth.DownloadKey `json:"downloadKey"`
DeviceID string `json:"deviceId"`
DeviceId auth.DeviceId `json:"deviceId"`
}
func (r *AuthForGetWalletStateRequest) validate() bool {
return (r.Email != "" &&
r.DownloadKey != auth.DownloadKey("") &&
r.DeviceID != "")
}
// NOTE - (Perhaps for docs)
//
// This is not very well authenticated. Requiring the downloadKey and email
// isn't very high security. It adds an entry into the same auth_tokens db
// table as full auth tokens. There won't be a danger of a malicious actor
// overriding existing auth tokens so long as the legitimate devices choose
// unique DeviceIDs. (DeviceID being part of the primary key in the auth token
// table.)
//
// A malicious actor could try to flood the auth token table to take down the
// server, but then again they could do this with a legitimate account as well.
// We could perhaps require registration (valid email) for full auth tokens and
// limit to 10 get-wallet-state auth tokens per account.
func (s *Server) getAuthTokenForGetWalletState(w http.ResponseWriter, req *http.Request) {
var authRequest AuthForGetWalletStateRequest
if !getPostData(w, req, &authRequest) {
return
}
pubKey, err := s.store.GetPublicKey(authRequest.Email, authRequest.DownloadKey)
if err == store.ErrNoPubKey {
errorJSON(w, http.StatusUnauthorized, "No match for email and password")
return
}
if err != nil {
internalServiceErrorJSON(w, err, "Error getting public key")
return
}
authToken, err := s.auth.NewToken(pubKey, authRequest.DeviceID, auth.ScopeGetWalletState)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token")
log.Print(err)
return
}
// NOTE - see comment on auth.AuthToken definition regarding what we may
// want to present to the client that has only presented a valid
// downloadKey and email
response, err := json.Marshal(&authToken)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token")
return
}
if err := s.store.SaveToken(authToken); err != nil {
internalServiceErrorJSON(w, err, "Error saving auth token")
log.Print(err)
return
}
fmt.Fprintf(w, string(response))
r.DeviceId != "")
}
func (s *Server) getAuthTokenFull(w http.ResponseWriter, req *http.Request) {
/*
(This comment may only be needed for WIP)
Server should be in charge of such things as:
* Request body size check (in particular to not tie up signature check)
* JSON validation/deserialization
auth.Auth should be in charge of such things as:
* Checking signatures
* Generating tokens
The order of events:
* Server checks the request body size
* Server deserializes and then validates the AuthFullRequest
* auth.Auth checks the signature of authRequest.TokenRequestJSON
* This the awkward bit, since auth.Auth is being passed a (serialized) JSON string.
However, it's not deserializing it. It's ONLY checking the signature of it
as a string per se. (The same function will be used for signed walletState)
* Server deserializes and then validates the auth.TokenRequest
* auth.Auth takes auth.TokenRequest and PubKey and generates a token
* DataStore stores the token. The pair (PubKey, TokenRequest.DeviceID) is the primary key.
We should have one token for each device.
*/
var authRequest AuthFullRequest
if !getPostData(w, req, &authRequest) {
return
}
if !s.auth.IsValidSignature(authRequest.PubKey, authRequest.TokenRequestJSON, authRequest.Signature) {
errorJSON(w, http.StatusForbidden, "Bad signature")
userId, err := s.store.GetUserId(authRequest.Email, authRequest.Password)
if err == store.ErrNoUId {
errorJson(w, http.StatusUnauthorized, "No match for email and password")
return
}
if err != nil {
internalServiceErrorJson(w, err, "Error getting User Id")
return
}
var tokenRequest auth.TokenRequest
if err := json.Unmarshal([]byte(authRequest.TokenRequestJSON), &tokenRequest); err != nil {
errorJSON(w, http.StatusBadRequest, "Malformed tokenRequest JSON")
return
}
if !s.auth.ValidateTokenRequest(&tokenRequest) {
errorJSON(w, http.StatusBadRequest, "tokenRequest failed validation")
return
}
authToken, err := s.auth.NewToken(authRequest.PubKey, tokenRequest.DeviceID, auth.ScopeFull)
authToken, err := s.auth.NewToken(userId, authRequest.DeviceId, auth.ScopeFull)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token")
internalServiceErrorJson(w, err, "Error generating auth token")
return
}
response, err := json.Marshal(&authToken)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token")
internalServiceErrorJson(w, err, "Error generating auth token")
return
}
if err := s.store.SaveToken(authToken); err != nil {
internalServiceErrorJSON(w, err, "Error saving auth token")
internalServiceErrorJson(w, err, "Error saving auth token")
return
}

View file

@ -18,7 +18,7 @@ func TestServerAuthHandlerSuccess(t *testing.T) {
testStore := TestStore{}
s := Server{&testAuth, &testStore, &wallet.WalletUtil{}}
requestBody := []byte(`{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`)
requestBody := []byte(`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`)
req := httptest.NewRequest(http.MethodPost, PathAuthTokenFull, bytes.NewBuffer(requestBody))
w := httptest.NewRecorder()
@ -51,7 +51,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
expectedStatusCode int
expectedErrorString string
authFailSigCheck bool
authFailLogin bool
authFailGenToken bool
storeFailSave bool
}{
@ -65,7 +65,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
{
name: "request body too large",
method: http.MethodPost,
requestBody: fmt.Sprintf(`{"tokenRequestJSON": "%s"}`, strings.Repeat("a", 10000)),
requestBody: fmt.Sprintf(`{"password": "%s"}`, strings.Repeat("a", 10000)),
expectedStatusCode: http.StatusRequestEntityTooLarge,
expectedErrorString: http.StatusText(http.StatusRequestEntityTooLarge),
},
@ -84,26 +84,19 @@ func TestServerAuthHandlerErrors(t *testing.T) {
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Request failed validation",
},
{
name: "signature check fail",
name: "login fail",
method: http.MethodPost,
// so long as the JSON is well-formed, the content doesn't matter here since the signature check will be stubbed out
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
expectedStatusCode: http.StatusForbidden,
expectedErrorString: http.StatusText(http.StatusForbidden) + ": Bad signature",
// so long as the JSON is well-formed, the content doesn't matter here since the password check will be stubbed out
requestBody: `{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
expectedStatusCode: http.StatusUnauthorized,
expectedErrorString: http.StatusText(http.StatusUnauthorized) + ": No match for email and password",
authFailSigCheck: true,
},
{
name: "malformed tokenRequest JSON",
method: http.MethodPost,
requestBody: `{"tokenRequestJSON": "{", "publicKey": "abc", "signature": "123"}`,
expectedStatusCode: http.StatusBadRequest,
expectedErrorString: http.StatusText(http.StatusBadRequest) + ": Malformed tokenRequest JSON",
authFailLogin: true,
},
{
name: "generate token fail",
method: http.MethodPost,
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
requestBody: `{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
expectedStatusCode: http.StatusInternalServerError,
expectedErrorString: http.StatusText(http.StatusInternalServerError),
@ -112,7 +105,7 @@ func TestServerAuthHandlerErrors(t *testing.T) {
{
name: "save token fail",
method: http.MethodPost,
requestBody: `{"tokenRequestJSON": "{}", "publicKey": "abc", "signature": "123"}`,
requestBody: `{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
expectedStatusCode: http.StatusInternalServerError,
expectedErrorString: http.StatusText(http.StatusInternalServerError),
@ -125,8 +118,8 @@ func TestServerAuthHandlerErrors(t *testing.T) {
// Set this up to fail according to specification
testAuth := TestAuth{TestToken: auth.AuthTokenString("seekrit")}
testStore := TestStore{}
if tc.authFailSigCheck {
testAuth.FailSigCheck = true
if tc.authFailLogin {
testStore.FailLogin = true
} else if tc.authFailGenToken {
testAuth.FailGenToken = true
} else if tc.storeFailSave {

View file

@ -11,7 +11,6 @@ import (
"orblivion/lbry-id/store"
"orblivion/lbry-id/wallet"
"testing"
"time"
)
// Whereas sever_test.go stubs out auth store and wallet, these will use the real thing, but test fewer paths.
@ -68,24 +67,32 @@ func TestIntegrationWalletUpdates(t *testing.T) {
&wallet.WalletUtil{},
)
////////////////////
// Register email address - any device
////////////////////
var registerResponse struct{}
responseBody, statusCode := request(
t,
http.MethodPost,
s.register,
PathRegister,
&registerResponse,
`{"email": "abc@example.com", "password": "123"}`,
)
////////////////////
// Get auth token - device 1
////////////////////
var authToken1 auth.AuthToken
responseBody, statusCode := request(
responseBody, statusCode = request(
t,
http.MethodPost,
s.getAuthTokenFull,
PathAuthTokenFull,
&authToken1,
fmt.Sprintf(`{
"tokenRequestJSON": "{\"deviceID\": \"dev-1\", \"requestTime\": %d}",
"publickey": "testPubKey",
"signature": "Good Signature"
}`,
time.Now().Unix(),
),
`{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
)
checkStatusCode(t, statusCode, responseBody)
@ -95,8 +102,8 @@ func TestIntegrationWalletUpdates(t *testing.T) {
if len(authToken1.Token) != expectedTokenLength {
t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
}
if authToken1.DeviceID != "dev-1" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-1", authToken1.DeviceID)
if authToken1.DeviceId != "dev-1" {
t.Fatalf("Unexpected response DeviceId. want: %+v got: %+v", "dev-1", authToken1.DeviceId)
}
if authToken1.Scope != auth.ScopeFull {
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope)
@ -113,19 +120,13 @@ func TestIntegrationWalletUpdates(t *testing.T) {
s.getAuthTokenFull,
PathAuthTokenFull,
&authToken2,
fmt.Sprintf(`{
"tokenRequestJSON": "{\"deviceID\": \"dev-2\", \"requestTime\": %d}",
"publickey": "testPubKey",
"signature": "Good Signature"
}`,
time.Now().Unix(),
),
`{"deviceId": "dev-2", "email": "abc@example.com", "password": "123"}`,
)
checkStatusCode(t, statusCode, responseBody)
if authToken2.DeviceID != "dev-2" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-2", authToken2.DeviceID)
if authToken2.DeviceId != "dev-2" {
t.Fatalf("Unexpected response DeviceId. want: %+v got: %+v", "dev-2", authToken2.DeviceId)
}
////////////////////
@ -141,17 +142,15 @@ func TestIntegrationWalletUpdates(t *testing.T) {
&walletStateResponse,
fmt.Sprintf(`{
"token": "%s",
"bodyJSON": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
"publickey": "testPubKey",
"downloadKey": "myDownloadKey",
"signature": "Good Signature"
"walletStateJson": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
"hmac": "my-hmac-1"
}`, authToken1.Token),
)
checkStatusCode(t, statusCode, responseBody)
var walletState wallet.WalletState
err := json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
err := json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
@ -170,16 +169,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
t,
http.MethodGet,
s.getWalletState,
fmt.Sprintf(
"%s?token=%s&publicKey=%s&deviceId=%s",
PathWalletState, authToken2.Token, authToken2.PubKey, "dev-2"),
fmt.Sprintf("%s?token=%s", PathWalletState, authToken2.Token),
&walletStateResponse,
"",
)
checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
@ -202,16 +199,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
&walletStateResponse,
fmt.Sprintf(`{
"token": "%s",
"bodyJSON": "{\"encryptedWallet\": \"blah2\", \"lastSynced\":{\"dev-1\": 1, \"dev-2\": 2}, \"deviceId\": \"dev-2\" }",
"publickey": "testPubKey",
"downloadKey": "myDownloadKey",
"signature": "Good Signature"
"walletStateJson": "{\"encryptedWallet\": \"blah2\", \"lastSynced\":{\"dev-1\": 1, \"dev-2\": 2}, \"deviceId\": \"dev-2\" }",
"hmac": "my-hmac-2"
}`, authToken2.Token),
)
checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
@ -230,16 +225,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
t,
http.MethodGet,
s.getWalletState,
fmt.Sprintf(
"%s?token=%s&publicKey=%s&deviceId=%s",
PathWalletState, authToken1.Token, authToken1.PubKey, "dev-1"),
fmt.Sprintf("%s?token=%s", PathWalletState, authToken1.Token),
&walletStateResponse,
"",
)
checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
@ -250,159 +243,3 @@ func TestIntegrationWalletUpdates(t *testing.T) {
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 2, sequence)
}
}
// Test a flow with a new device that needs to use the download key
func TestIntegrationNewDevice(t *testing.T) {
st, tmpFile := store.StoreTestInit(t)
defer store.StoreTestCleanup(tmpFile)
s := Server{
&auth.Auth{},
&st,
&wallet.WalletUtil{},
}
////////////////////
// Get full auth token - device 1
////////////////////
var authToken1 auth.AuthToken
responseBody, statusCode := request(
t,
http.MethodPost,
s.getAuthTokenFull,
PathAuthTokenFull,
&authToken1,
fmt.Sprintf(`{
"tokenRequestJSON": "{\"deviceID\": \"dev-1\", \"requestTime\": %d}",
"publickey": "testPubKey",
"signature": "Good Signature"
}`,
time.Now().Unix(),
),
)
checkStatusCode(t, statusCode, responseBody)
if authToken1.DeviceID != "dev-1" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-1", authToken1.DeviceID)
}
if authToken1.Scope != auth.ScopeFull {
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope)
}
////////////////////
// Register email address - device 1
////////////////////
var registerResponse struct{}
responseBody, statusCode = request(
t,
http.MethodPost,
s.register,
PathRegister,
&registerResponse,
fmt.Sprintf(`{
"token": "%s",
"publicKey": "testPubKey",
"deviceId": "dev-1",
"email": "address@example.com"
}`, authToken1.Token),
)
checkStatusCode(t, statusCode, responseBody, http.StatusCreated)
////////////////////
// Put wallet state - device 1
////////////////////
var walletStateResponse WalletStateResponse
responseBody, statusCode = request(
t,
http.MethodPost,
s.postWalletState,
PathWalletState,
&walletStateResponse,
fmt.Sprintf(`{
"token": "%s",
"bodyJSON": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
"publickey": "testPubKey",
"downloadKey": "myDownloadKey",
"signature": "Good Signature"
}`, authToken1.Token),
)
checkStatusCode(t, statusCode, responseBody)
var walletState wallet.WalletState
err := json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
}
sequence := walletState.Sequence()
if sequence != 1 {
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
}
////////////////////
// Get get-wallet-state auth token - device 2
////////////////////
var authToken2 auth.AuthToken
responseBody, statusCode = request(
t,
http.MethodPost,
s.getAuthTokenForGetWalletState,
PathAuthTokenGetWalletState,
&authToken2,
`{
"email": "address@example.com",
"downloadKey": "myDownloadKey",
"deviceID": "dev-2"
}`,
)
checkStatusCode(t, statusCode, responseBody)
// result.Token is in hex, auth.AuthTokenLength is bytes in the original
expectedTokenLength := auth.AuthTokenLength * 2
if len(authToken2.Token) != expectedTokenLength {
t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
}
if authToken2.DeviceID != "dev-2" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-2", authToken2.DeviceID)
}
if authToken2.Scope != auth.ScopeGetWalletState {
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeGetWalletState, authToken1.Scope)
}
////////////////////
// Get wallet state - device 2
////////////////////
responseBody, statusCode = request(
t,
http.MethodGet,
s.getWalletState,
fmt.Sprintf(
"%s?token=%s&publicKey=%s&deviceId=%s",
PathWalletState, authToken2.Token, authToken2.PubKey, "dev-2"),
&walletStateResponse,
"",
)
checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState)
if err != nil {
t.Fatalf("Unexpected error: %+v", err)
}
sequence = walletState.Sequence()
if sequence != 1 {
t.Fatalf("Unexpected response Sequence(). want: %+v got: %+v", 1, sequence)
}
}

View file

@ -9,18 +9,15 @@ import (
"orblivion/lbry-id/store"
)
// TODO email verification cycle
type RegisterRequest struct {
Token auth.AuthTokenString `json:"token"`
PubKey auth.PublicKey `json:"publicKey"`
DeviceID string `json:"deviceId"`
Email string `json:"email"`
Email auth.Email `json:"email"`
Password auth.Password `json:"password"`
}
func (r *RegisterRequest) validate() bool {
return (r.Token != auth.AuthTokenString("") &&
r.PubKey != auth.PublicKey("") &&
r.DeviceID != "" &&
r.Email != "")
return r.Email != "" && r.Password != ""
}
func (s *Server) register(w http.ResponseWriter, req *http.Request) {
@ -29,23 +26,13 @@ func (s *Server) register(w http.ResponseWriter, req *http.Request) {
return
}
if !s.checkAuth(
w,
registerRequest.PubKey,
registerRequest.DeviceID,
registerRequest.Token,
auth.ScopeFull,
) {
return
}
err := s.store.InsertEmail(registerRequest.PubKey, registerRequest.Email)
err := s.store.CreateAccount(registerRequest.Email, registerRequest.Password)
if err != nil {
if err == store.ErrDuplicateEmail || err == store.ErrDuplicateAccount {
errorJSON(w, http.StatusConflict, "Error registering")
errorJson(w, http.StatusConflict, "Error registering")
} else {
internalServiceErrorJSON(w, err, "Error registering")
internalServiceErrorJson(w, err, "Error registering")
}
log.Print(err)
return
@ -56,7 +43,7 @@ func (s *Server) register(w http.ResponseWriter, req *http.Request) {
response, err = json.Marshal(registerResponse)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating register response")
internalServiceErrorJson(w, err, "Error generating register response")
return
}

View file

@ -13,7 +13,6 @@ import (
// TODO proper doc comments!
const PathAuthTokenFull = "/auth/full"
const PathAuthTokenGetWalletState = "/auth/get-wallet-state"
const PathRegister = "/signup"
const PathWalletState = "/wallet-state"
@ -39,30 +38,32 @@ type ErrorResponse struct {
Error string `json:"error"`
}
func errorJSON(w http.ResponseWriter, code int, extra string) {
func errorJson(w http.ResponseWriter, code int, extra string) {
errorStr := http.StatusText(code)
if extra != "" {
errorStr = errorStr + ": " + extra
}
authErrorJSON, err := json.Marshal(ErrorResponse{Error: errorStr})
authErrorJson, err := json.Marshal(ErrorResponse{Error: errorStr})
if err != nil {
// In case something really stupid happens
http.Error(w, `{"error": "error when JSON-encoding error message"}`, code)
}
http.Error(w, string(authErrorJSON), code)
http.Error(w, string(authErrorJson), code)
return
}
// Don't report any details to the user. Log it instead.
func internalServiceErrorJSON(w http.ResponseWriter, err error, errContext string) {
func internalServiceErrorJson(w http.ResponseWriter, serverErr error, errContext string) {
errorStr := http.StatusText(http.StatusInternalServerError)
authErrorJSON, err := json.Marshal(ErrorResponse{Error: errorStr})
authErrorJson, err := json.Marshal(ErrorResponse{Error: errorStr})
if err != nil {
// In case something really stupid happens
http.Error(w, `{"error": "error when JSON-encoding error message"}`, http.StatusInternalServerError)
log.Printf("error when JSON-encoding error message")
return
}
http.Error(w, string(authErrorJSON), http.StatusInternalServerError)
log.Printf("%s: %+v\n", errContext, err)
http.Error(w, string(authErrorJson), http.StatusInternalServerError)
log.Printf("%s: %+v\n", errContext, serverErr)
return
}
@ -77,7 +78,7 @@ func internalServiceErrorJSON(w http.ResponseWriter, err error, errContext strin
func requestOverhead(w http.ResponseWriter, req *http.Request, method string) bool {
if req.Method != method {
errorJSON(w, http.StatusMethodNotAllowed, "")
errorJson(w, http.StatusMethodNotAllowed, "")
return false
}
@ -106,13 +107,13 @@ func getPostData(w http.ResponseWriter, req *http.Request, reqStruct PostRequest
}
if err := json.NewDecoder(req.Body).Decode(&reqStruct); err != nil {
errorJSON(w, http.StatusBadRequest, "Malformed request body JSON")
errorJson(w, http.StatusBadRequest, "Malformed request body JSON")
return false
}
if !reqStruct.validate() {
// TODO validate() should return useful error messages instead of a bool.
errorJSON(w, http.StatusBadRequest, "Request failed validation")
errorJson(w, http.StatusBadRequest, "Request failed validation")
return false
}
@ -124,41 +125,35 @@ func getGetData(w http.ResponseWriter, req *http.Request) bool {
return requestOverhead(w, req, http.MethodGet)
}
// TODO - probably don't return all of authToken since we only need userId and
// deviceId. Also this is apparently not idiomatic go error handling.
func (s *Server) checkAuth(
w http.ResponseWriter,
pubKey auth.PublicKey,
deviceId string,
token auth.AuthTokenString,
scope auth.AuthScope,
) bool {
authToken, err := s.store.GetToken(pubKey, deviceId)
) *auth.AuthToken {
authToken, err := s.store.GetToken(token)
if err == store.ErrNoToken {
errorJSON(w, http.StatusUnauthorized, "Token Not Found")
return false
errorJson(w, http.StatusUnauthorized, "Token Not Found")
return nil
}
if err != nil {
internalServiceErrorJSON(w, err, "Error getting Token")
return false
}
if authToken.Token != token {
errorJSON(w, http.StatusUnauthorized, "Token Invalid")
return false
internalServiceErrorJson(w, err, "Error getting Token")
return nil
}
if !authToken.ScopeValid(scope) {
errorJSON(w, http.StatusForbidden, "Scope")
return false
errorJson(w, http.StatusForbidden, "Scope")
return nil
}
return true
return authToken
}
// TODO - both wallet and token requests should be PUT, not POST.
// PUT = "...creates a new resource or replaces a representation of the target resource with the request payload."
func (s *Server) Serve() {
http.HandleFunc(PathAuthTokenGetWalletState, s.getAuthTokenForGetWalletState)
http.HandleFunc(PathAuthTokenFull, s.getAuthTokenFull)
http.HandleFunc(PathWalletState, s.handleWalletState)
http.HandleFunc(PathRegister, s.register)

View file

@ -3,6 +3,8 @@ package server
import (
"fmt"
"orblivion/lbry-id/auth"
"orblivion/lbry-id/store"
"orblivion/lbry-id/wallet"
"testing"
)
@ -10,28 +12,19 @@ import (
type TestAuth struct {
TestToken auth.AuthTokenString
FailSigCheck bool
FailGenToken bool
}
func (a *TestAuth) NewToken(pubKey auth.PublicKey, DeviceID string, Scope auth.AuthScope) (*auth.AuthToken, error) {
func (a *TestAuth) NewToken(userId auth.UserId, deviceId auth.DeviceId, scope auth.AuthScope) (*auth.AuthToken, error) {
if a.FailGenToken {
return nil, fmt.Errorf("Test error: fail to generate token")
}
return &auth.AuthToken{Token: a.TestToken, Scope: Scope}, nil
}
func (a *TestAuth) IsValidSignature(pubKey auth.PublicKey, payload string, signature auth.Signature) bool {
return !a.FailSigCheck
}
func (a *TestAuth) ValidateTokenRequest(tokenRequest *auth.TokenRequest) bool {
// TODO
return true
return &auth.AuthToken{Token: a.TestToken, UserId: userId, DeviceId: deviceId, Scope: scope}, nil
}
type TestStore struct {
FailSave bool
FailLogin bool
SaveTokenCalled bool
}
@ -44,29 +37,31 @@ func (s *TestStore) SaveToken(token *auth.AuthToken) error {
return nil
}
func (s *TestStore) GetToken(auth.PublicKey, string) (*auth.AuthToken, error) {
func (s *TestStore) GetToken(auth.AuthTokenString) (*auth.AuthToken, error) {
return nil, nil
}
func (s *TestStore) GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error) {
return "", nil
func (s *TestStore) GetUserId(auth.Email, auth.Password) (auth.UserId, error) {
if s.FailLogin {
return 0, store.ErrNoUId
}
return 0, nil
}
func (s *TestStore) InsertEmail(auth.PublicKey, string) error {
func (s *TestStore) CreateAccount(auth.Email, auth.Password) error {
return nil
}
func (s *TestStore) SetWalletState(
pubKey auth.PublicKey,
UserId auth.UserId,
walletStateJson string,
sequence int,
signature auth.Signature,
downloadKey auth.DownloadKey,
) (latestWalletStateJson string, latestSignature auth.Signature, updated bool, err error) {
hmac wallet.WalletStateHmac,
) (latestWalletStateJson string, latestHmac wallet.WalletStateHmac, updated bool, err error) {
return
}
func (s *TestStore) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, signature auth.Signature, err error) {
func (s *TestStore) GetWalletState(UserId auth.UserId) (walletStateJson string, hmac wallet.WalletStateHmac, err error) {
return
}

View file

@ -11,25 +11,19 @@ import (
type WalletStateRequest struct {
Token auth.AuthTokenString `json:"token"`
BodyJSON string `json:"bodyJSON"`
PubKey auth.PublicKey `json:"publicKey"`
Signature auth.Signature `json:"signature"`
// downloadKey is derived from the same password used to encrypt the wallet.
// We want to keep it all in sync so we update it at the same time.
DownloadKey auth.DownloadKey `json:"downloadKey"`
WalletStateJson string `json:"walletStateJson"`
Hmac wallet.WalletStateHmac `json:"hmac"`
}
func (r *WalletStateRequest) validate() bool {
return (r.Token != auth.AuthTokenString("") &&
r.BodyJSON != "" &&
r.PubKey != auth.PublicKey("") &&
r.Signature != auth.Signature(""))
r.WalletStateJson != "" &&
r.Hmac != wallet.WalletStateHmac(""))
}
type WalletStateResponse struct {
BodyJSON string `json:"bodyJSON"`
Signature auth.Signature `json:"signature"`
WalletStateJson string `json:"walletStateJson"`
Hmac wallet.WalletStateHmac `json:"hmac"`
Error string `json:"error"` // in case of 409 Conflict responses. TODO - make field not show up if it's empty, to avoid confusion
}
@ -39,31 +33,21 @@ func (s *Server) handleWalletState(w http.ResponseWriter, req *http.Request) {
} else if req.Method == http.MethodPost {
s.postWalletState(w, req)
} else {
errorJSON(w, http.StatusMethodNotAllowed, "")
errorJson(w, http.StatusMethodNotAllowed, "")
}
}
// TODO - There's probably a struct-based solution here like with POST/PUT.
// We could put that struct up top as well.
func getWalletStateParams(req *http.Request) (pubKey auth.PublicKey, deviceId string, token auth.AuthTokenString, err error) {
func getWalletStateParams(req *http.Request) (token auth.AuthTokenString, err error) {
tokenSlice, hasTokenSlice := req.URL.Query()["token"]
deviceIDSlice, hasDeviceId := req.URL.Query()["deviceId"]
pubKeySlice, hasPubKey := req.URL.Query()["publicKey"]
if !hasDeviceId {
err = fmt.Errorf("Missing deviceId parameter")
}
if !hasTokenSlice {
err = fmt.Errorf("Missing token parameter")
}
if !hasPubKey {
err = fmt.Errorf("Missing publicKey parameter")
}
if err == nil {
deviceId = deviceIDSlice[0]
token = auth.AuthTokenString(tokenSlice[0])
pubKey = auth.PublicKey(pubKeySlice[0])
}
return
@ -74,39 +58,41 @@ func (s *Server) getWalletState(w http.ResponseWriter, req *http.Request) {
return
}
pubKey, deviceId, token, err := getWalletStateParams(req)
token, paramsErr := getWalletStateParams(req)
if err != nil {
// In this specific case, err is limited to values that are safe to give to
// the user
errorJSON(w, http.StatusBadRequest, err.Error())
if paramsErr != nil {
// In this specific case, the error is limited to values that are safe to
// give to the user.
errorJson(w, http.StatusBadRequest, paramsErr.Error())
return
}
if !s.checkAuth(w, pubKey, deviceId, token, auth.ScopeGetWalletState) {
authToken := s.checkAuth(w, token, auth.ScopeGetWalletState)
if authToken == nil {
return
}
latestWalletStateJSON, latestSignature, err := s.store.GetWalletState(pubKey)
latestWalletStateJson, latestHmac, err := s.store.GetWalletState(authToken.UserId)
var response []byte
if err == store.ErrNoWalletState {
errorJSON(w, http.StatusNotFound, "No wallet state")
errorJson(w, http.StatusNotFound, "No wallet state")
return
} else if err != nil {
internalServiceErrorJSON(w, err, "Error retrieving walletState")
internalServiceErrorJson(w, err, "Error retrieving walletState")
return
}
walletStateResponse := WalletStateResponse{
BodyJSON: latestWalletStateJSON,
Signature: latestSignature,
WalletStateJson: latestWalletStateJson,
Hmac: latestHmac,
}
response, err = json.Marshal(walletStateResponse)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating latestWalletState response")
internalServiceErrorJson(w, err, "Error generating latestWalletState response")
return
}
@ -119,44 +105,33 @@ func (s *Server) postWalletState(w http.ResponseWriter, req *http.Request) {
return
}
if !s.auth.IsValidSignature(walletStateRequest.PubKey, walletStateRequest.BodyJSON, walletStateRequest.Signature) {
errorJSON(w, http.StatusBadRequest, "Bad signature")
var walletStateMetadata wallet.WalletStateMetadata
if err := json.Unmarshal([]byte(walletStateRequest.WalletStateJson), &walletStateMetadata); err != nil {
errorJson(w, http.StatusBadRequest, "Malformed walletStateJson")
return
}
var walletState wallet.WalletState
if err := json.Unmarshal([]byte(walletStateRequest.BodyJSON), &walletState); err != nil {
errorJSON(w, http.StatusBadRequest, "Malformed walletState JSON")
return
}
if s.walletUtil.ValidateWalletState(&walletState) {
if s.walletUtil.ValidateWalletStateMetadata(&walletStateMetadata) {
// TODO
}
if !s.checkAuth(
w,
walletStateRequest.PubKey,
walletState.DeviceID,
walletStateRequest.Token,
auth.ScopeFull,
) {
authToken := s.checkAuth(w, walletStateRequest.Token, auth.ScopeFull)
if authToken == nil {
return
}
// TODO - We could do an extra check - pull from db, make sure the new
// walletState doesn't regress lastSynced for any given device.
// walletStateMetadata doesn't regress lastSynced for any given device.
// This is primarily the responsibility of the clients, but we may want to
// trade a db call here for a double-check against bugs in the client.
// We do already do some validation checks here, but those doesn't require
// new database calls.
latestWalletStateJSON, latestSignature, updated, err := s.store.SetWalletState(
walletStateRequest.PubKey,
walletStateRequest.BodyJSON,
walletState.Sequence(),
walletStateRequest.Signature,
walletStateRequest.DownloadKey,
latestWalletStateJson, latestHmac, updated, err := s.store.SetWalletState(
authToken.UserId,
walletStateRequest.WalletStateJson,
walletStateMetadata.Sequence(),
walletStateRequest.Hmac,
)
var response []byte
@ -166,17 +141,17 @@ func (s *Server) postWalletState(w http.ResponseWriter, req *http.Request) {
// there was nothing there. This should only happen if the client sets
// sequence != 1 for the first walletState, which would be a bug.
// TODO - figure out better error messages and/or document this
errorJSON(w, http.StatusConflict, "Bad sequence number (No existing wallet state)")
errorJson(w, http.StatusConflict, "Bad sequence number (No existing wallet state)")
return
} else if err != nil {
// Something other than sequence error
internalServiceErrorJSON(w, err, "Error saving walletState")
internalServiceErrorJson(w, err, "Error saving walletState")
return
}
walletStateResponse := WalletStateResponse{
BodyJSON: latestWalletStateJSON,
Signature: latestSignature,
WalletStateJson: latestWalletStateJson,
Hmac: latestHmac,
}
if !updated {
// TODO - should we even call this an error?
@ -185,7 +160,7 @@ func (s *Server) postWalletState(w http.ResponseWriter, req *http.Request) {
response, err = json.Marshal(walletStateResponse)
if err != nil {
internalServiceErrorJSON(w, err, "Error generating walletState response")
internalServiceErrorJson(w, err, "Error generating walletStateResponse")
return
}

View file

@ -25,7 +25,7 @@ func TestServerPostWalletTooLate(t *testing.T) {
}
func TestServerPostWalletErrors(t *testing.T) {
// (malformed json, db fail, auth token not found, walletstate signature fail, walletstate invalid (via stub, make sure the validation function is even called), sequence too high, device id doesn't match token device id)
// (malformed json, db fail, auth token not found, walletstate invalid (via stub, make sure the validation function is even called), sequence too high, device id doesn't match token device id)
// Client sends sequence != 1 for first entry
// Client sends sequence == x + 10 for xth entry or whatever
t.Fatalf("Test me: PostWallet fails for various reasons")
@ -33,7 +33,6 @@ func TestServerPostWalletErrors(t *testing.T) {
func TestServerValidateWalletStateRequest(t *testing.T) {
// also add a basic test case for this in TestServerAuthHandlerSuccess to make sure it's called at all
// Maybe 401 specifically for missing signature?
t.Fatalf("Test me: Implement and test WalletStateRequest.validate()")
}

View file

@ -1,6 +1,6 @@
package store
// TODO - DeviceID - What about clients that lie about deviceID? Maybe require a certain format to make sure it gives a real value? Something it wouldn't come up with by accident.
// TODO - DeviceId - What about clients that lie about deviceId? Maybe require a certain format to make sure it gives a real value? Something it wouldn't come up with by accident.
import (
"database/sql"
@ -10,6 +10,7 @@ import (
"github.com/mattn/go-sqlite3"
"log"
"orblivion/lbry-id/auth"
"orblivion/lbry-id/wallet"
"time"
)
@ -23,17 +24,17 @@ var (
ErrDuplicateEmail = fmt.Errorf("Email already exists for this user")
ErrDuplicateAccount = fmt.Errorf("User already has an account")
ErrNoPubKey = fmt.Errorf("Public Key not found with these credentials")
ErrNoUId = fmt.Errorf("User Id not found with these credentials")
)
// For test stubs
type StoreInterface interface {
SaveToken(*auth.AuthToken) error
GetToken(auth.PublicKey, string) (*auth.AuthToken, error)
SetWalletState(auth.PublicKey, string, int, auth.Signature, auth.DownloadKey) (string, auth.Signature, bool, error)
GetWalletState(auth.PublicKey) (string, auth.Signature, error)
GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error)
InsertEmail(auth.PublicKey, string) (err error)
GetToken(auth.AuthTokenString) (*auth.AuthToken, error)
SetWalletState(auth.UserId, string, int, wallet.WalletStateHmac) (string, wallet.WalletStateHmac, bool, error)
GetWalletState(auth.UserId) (string, wallet.WalletStateHmac, error)
GetUserId(auth.Email, auth.Password) (auth.UserId, error)
CreateAccount(auth.Email, auth.Password) (err error)
}
type Store struct {
@ -56,29 +57,41 @@ func (s *Store) Migrate() error {
// specify "WHERE sequence=5". Only one of these commands will succeed, and
// the other will get back an error.
// We use AUTOINCREMENT against the protestations of people on the Internet
// who claim that INTEGER PRIMARY KEY automatically has autoincrment, and
// that using it when it's not "strictly needed" uses extra resources. But
// without AUTOINCREMENT, it might reuse primary keys if a row is deleted and
// re-added. Who wants that risk? Besides, we'll switch to Postgres when it's
// time to scale anyway.
// We use UNIQUE on auth_tokens.token so that we can retrieve it easily and
// identify the user (and I suppose the uniqueness provides a little extra
// security in case we screw up the random generator). However the primary
// key should still be (user_id, device_id) so that a device's row can be
// updated with a new token.
// TODO does it actually fail with empty "NOT NULL" fields?
query := `
CREATE TABLE IF NOT EXISTS auth_tokens(
token TEXT NOT NULL,
public_key TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
device_id TEXT NOT NULL,
scope TEXT NOT NULL,
expiration DATETIME NOT NULL,
PRIMARY KEY (device_id)
PRIMARY KEY (user_id, device_id)
);
CREATE TABLE IF NOT EXISTS wallet_states(
public_key TEXT NOT NULL,
user_id INTEGER NOT NULL,
wallet_state_blob TEXT NOT NULL,
sequence INTEGER NOT NULL,
signature TEXT NOT NULL,
download_key TEXT NOT NULL,
PRIMARY KEY (public_key)
hmac TEXT NOT NULL,
PRIMARY KEY (user_id)
FOREIGN KEY (user_id) REFERENCES accounts(user_id)
);
CREATE TABLE IF NOT EXISTS accounts(
email TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
PRIMARY KEY (public_key),
FOREIGN KEY (public_key) REFERENCES wallet_states(public_key)
user_id INTEGER PRIMARY KEY AUTOINCREMENT,
password TEXT NOT NULL
);
`
@ -90,12 +103,16 @@ func (s *Store) Migrate() error {
// Auth Token //
////////////////
func (s *Store) GetToken(pubKey auth.PublicKey, deviceID string) (*auth.AuthToken, error) {
// TODO - Is it safe to assume that the owner of the token is legit, and is
// coming from the legit device id? No need to query by userId and deviceId
// (which I did previously)?
//
// TODO Put the timestamp in the token to avoid duplicates over time. And/or just use a library! Someone solved this already.
func (s *Store) GetToken(token auth.AuthTokenString) (*auth.AuthToken, error) {
expirationCutoff := time.Now().UTC()
rows, err := s.db.Query(
"SELECT * FROM auth_tokens WHERE public_key=? AND device_id=? AND expiration>?",
pubKey, deviceID, expirationCutoff,
"SELECT * FROM auth_tokens WHERE token=? AND expiration>?", token, expirationCutoff,
)
if err != nil {
return nil, err
@ -107,8 +124,8 @@ func (s *Store) GetToken(pubKey auth.PublicKey, deviceID string) (*auth.AuthToke
err := rows.Scan(
&authToken.Token,
&authToken.PubKey,
&authToken.DeviceID,
&authToken.UserId,
&authToken.DeviceId,
&authToken.Scope,
&authToken.Expiration,
)
@ -123,8 +140,8 @@ func (s *Store) GetToken(pubKey auth.PublicKey, deviceID string) (*auth.AuthToke
func (s *Store) insertToken(authToken *auth.AuthToken, expiration time.Time) (err error) {
_, err = s.db.Exec(
"INSERT INTO auth_tokens (token, public_key, device_id, scope, expiration) values(?,?,?,?,?)",
authToken.Token, authToken.PubKey, authToken.DeviceID, authToken.Scope, expiration,
"INSERT INTO auth_tokens (token, user_id, device_id, scope, expiration) values(?,?,?,?,?)",
authToken.Token, authToken.UserId, authToken.DeviceId, authToken.Scope, expiration,
)
var sqliteErr sqlite3.Error
@ -141,8 +158,8 @@ func (s *Store) insertToken(authToken *auth.AuthToken, expiration time.Time) (er
func (s *Store) updateToken(authToken *auth.AuthToken, experation time.Time) (err error) {
res, err := s.db.Exec(
"UPDATE auth_tokens SET token=?, expiration=?, scope=? WHERE public_key=? AND device_id=?",
authToken.Token, experation, authToken.Scope, authToken.PubKey, authToken.DeviceID,
"UPDATE auth_tokens SET token=?, expiration=?, scope=? WHERE user_id=? AND device_id=?",
authToken.Token, experation, authToken.Scope, authToken.UserId, authToken.DeviceId,
)
if err != nil {
return
@ -191,10 +208,10 @@ func (s *Store) SaveToken(token *auth.AuthToken) (err error) {
// Wallet State / Download Key //
/////////////////////////////////
func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, signature auth.Signature, err error) {
func (s *Store) GetWalletState(userId auth.UserId) (walletStateJson string, hmac wallet.WalletStateHmac, err error) {
rows, err := s.db.Query(
"SELECT wallet_state_blob, signature FROM wallet_states WHERE public_key=?",
pubKey,
"SELECT wallet_state_blob, hmac FROM wallet_states WHERE user_id=?",
userId,
)
if err != nil {
return
@ -203,8 +220,8 @@ func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, s
for rows.Next() {
err = rows.Scan(
&walletStateJSON,
&signature,
&walletStateJson,
&hmac,
)
return
}
@ -213,17 +230,16 @@ func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, s
}
func (s *Store) insertFirstWalletState(
pubKey auth.PublicKey,
walletStateJSON string,
signature auth.Signature,
downloadKey auth.DownloadKey,
userId auth.UserId,
walletStateJson string,
hmac wallet.WalletStateHmac,
) (err error) {
// This will only be used to attempt to insert the first wallet state
// (sequence=1). The database will enforce that this will not be set
// if this user already has a walletState.
_, err = s.db.Exec(
"INSERT INTO wallet_states (public_key, wallet_state_blob, sequence, signature, download_key) values(?,?,?,?,?)",
pubKey, walletStateJSON, 1, signature, downloadKey.Obfuscate(),
"INSERT INTO wallet_states (user_id, wallet_state_blob, sequence, hmac) values(?,?,?,?)",
userId, walletStateJson, 1, hmac,
)
var sqliteErr sqlite3.Error
@ -239,19 +255,18 @@ func (s *Store) insertFirstWalletState(
}
func (s *Store) updateWalletStateToSequence(
pubKey auth.PublicKey,
walletStateJSON string,
userId auth.UserId,
walletStateJson string,
sequence int,
signature auth.Signature,
downloadKey auth.DownloadKey,
hmac wallet.WalletStateHmac,
) (err error) {
// This will be used for wallet states with sequence > 1.
// Use the database to enforce that we only update if we are incrementing the sequence.
// This way, if two clients attempt to update at the same time, it will return
// ErrNoWalletState for the second one.
res, err := s.db.Exec(
"UPDATE wallet_states SET wallet_state_blob=?, sequence=?, signature=?, download_key=? WHERE public_key=? AND sequence=?",
walletStateJSON, sequence, signature, downloadKey.Obfuscate(), pubKey, sequence-1,
"UPDATE wallet_states SET wallet_state_blob=?, sequence=?, hmac=? WHERE user_id=? AND sequence=?",
walletStateJson, sequence, hmac, userId, sequence-1,
)
if err != nil {
return
@ -269,25 +284,24 @@ func (s *Store) updateWalletStateToSequence(
// Assumption: walletState has been validated (sequence >=1, etc)
// Assumption: Sequence matches walletState.Sequence()
// Sequence is only passed in here to avoid deserializing walletStateJSON again
// WalletState *struct* is not passed in because we need the exact signed string
// Sequence is only passed in here to avoid deserializing walletStateJson again
// WalletState *struct* is not passed in because the clients need the exact string to match the hmac
func (s *Store) SetWalletState(
pubKey auth.PublicKey,
walletStateJSON string,
userId auth.UserId,
walletStateJson string,
sequence int,
signature auth.Signature,
downloadKey auth.DownloadKey,
) (latestWalletStateJSON string, latestSignature auth.Signature, updated bool, err error) {
hmac wallet.WalletStateHmac,
) (latestWalletStateJson string, latestHmac wallet.WalletStateHmac, updated bool, err error) {
if sequence == 1 {
// If sequence == 1, the client assumed that this is our first
// walletState. Try to insert. If we get a conflict, the client
// assumed incorrectly and we proceed below to return the latest
// walletState from the db.
err = s.insertFirstWalletState(pubKey, walletStateJSON, signature, downloadKey)
err = s.insertFirstWalletState(userId, walletStateJson, hmac)
if err == nil {
// Successful update
latestWalletStateJSON = walletStateJSON
latestSignature = signature
latestWalletStateJson = walletStateJson
latestHmac = hmac
updated = true
return
} else if err != ErrDuplicateWalletState {
@ -299,10 +313,10 @@ func (s *Store) SetWalletState(
// with sequence - 1. Explicitly try to update the walletState with
// sequence - 1. If we updated no rows, the client assumed incorrectly
// and we proceed below to return the latest walletState from the db.
err = s.updateWalletStateToSequence(pubKey, walletStateJSON, sequence, signature, downloadKey)
err = s.updateWalletStateToSequence(userId, walletStateJson, sequence, hmac)
if err == nil {
latestWalletStateJSON = walletStateJSON
latestSignature = signature
latestWalletStateJson = walletStateJson
latestHmac = hmac
updated = true
return
} else if err != ErrNoWalletState {
@ -317,16 +331,14 @@ func (s *Store) SetWalletState(
// Note that this means that `err` will not be `nil` at this point, but we
// already accounted for it with `updated=false`. Instead, we'll pass on any
// errors from calling `GetWalletState`.
latestWalletStateJSON, latestSignature, err = s.GetWalletState(pubKey)
latestWalletStateJson, latestHmac, err = s.GetWalletState(userId)
return
}
func (s *Store) GetPublicKey(email string, downloadKey auth.DownloadKey) (pubKey auth.PublicKey, err error) {
func (s *Store) GetUserId(email auth.Email, password auth.Password) (userId auth.UserId, err error) {
rows, err := s.db.Query(
`SELECT ws.public_key from wallet_states ws INNER JOIN accounts a
ON a.public_key=ws.public_key
WHERE email=? AND download_key=?`,
email, downloadKey.Obfuscate(),
`SELECT user_id from accounts WHERE email=? AND password=?`,
email, password.Obfuscate(),
)
if err != nil {
return
@ -334,27 +346,30 @@ func (s *Store) GetPublicKey(email string, downloadKey auth.DownloadKey) (pubKey
defer rows.Close()
for rows.Next() {
err = rows.Scan(&pubKey)
err = rows.Scan(&userId)
return
}
err = ErrNoPubKey
err = ErrNoUId
return
}
///////////
// Email //
///////////
/////////////
// Account //
/////////////
func (s *Store) InsertEmail(pubKey auth.PublicKey, email string) (err error) {
func (s *Store) CreateAccount(email auth.Email, password auth.Password) (err error) {
// userId auto-increments
_, err = s.db.Exec(
"INSERT INTO accounts (public_key, email) values(?,?)",
pubKey, email,
"INSERT INTO accounts (email, password) values(?,?)",
email, password.Obfuscate(),
)
var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) {
// I initially expected to need to check for ErrConstraintUnique.
// Maybe for psql it will be?
// TODO - is this right? Does the above comment explain that it's backwards
// from what I would have expected? Or did I do this backwards?
if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
err = ErrDuplicateEmail
}

View file

@ -19,52 +19,54 @@ func TestStoreInsertToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile)
// created for addition to the DB (no expiration attached)
authToken1 := auth.AuthToken{
Token: "seekrit-1",
DeviceID: "dID",
DeviceId: "dId",
Scope: "*",
PubKey: "pubKey",
UserId: 123,
}
// The value expected when we pull it from the database.
authToken1DB := authToken1
authToken1DB.Expiration = timePtr(time.Now().Add(time.Hour * 24 * 14).UTC())
authToken2 := authToken1
authToken2.Token = "seekrit-2"
expiration := time.Now().Add(time.Hour * 24 * 14).UTC()
// Get a token, come back empty
gotToken, err := s.GetToken(authToken1.PubKey, authToken1.DeviceID)
gotToken, err := s.GetToken(authToken1.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
// Put in a token
if err := s.insertToken(&authToken1, *authToken1DB.Expiration); err != nil {
if err := s.insertToken(&authToken1, expiration); err != nil {
t.Fatalf("Unexpected error in insertToken: %+v", err)
}
// The value expected when we pull it from the database.
authToken1Expected := authToken1
authToken1Expected.Expiration = timePtr(expiration)
// Get and confirm the token we just put in
gotToken, err = s.GetToken(authToken1.PubKey, authToken1.DeviceID)
gotToken, err = s.GetToken(authToken1.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1DB) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken1DB, *gotToken)
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1Expected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken1Expected, *gotToken)
}
// Try to put a different token, fail becaues we already have one
if err := s.insertToken(&authToken2, *authToken1DB.Expiration); err != ErrDuplicateToken {
// Try to put a different token, fail because we already have one
authToken2 := authToken1
authToken2.Token = "seekrit-2"
if err := s.insertToken(&authToken2, expiration); err != ErrDuplicateToken {
t.Fatalf(`insertToken err: wanted "%+v", got "%+v"`, ErrDuplicateToken, err)
}
// Get the same *first* token we successfully put in
gotToken, err = s.GetToken(authToken1.PubKey, authToken1.DeviceID)
gotToken, err = s.GetToken(authToken1.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1DB) {
t.Fatalf("token: expected %+v, got: %+v", authToken1DB, gotToken)
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1Expected) {
t.Fatalf("token: expected %+v, got: %+v", authToken1Expected, gotToken)
}
}
@ -76,57 +78,67 @@ func TestStoreUpdateToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile)
authToken1 := auth.AuthToken{
Token: "seekrit-1",
DeviceID: "dID",
// created for addition to the DB (no expiration attached)
authTokenUpdate := auth.AuthToken{
Token: "seekrit-update",
DeviceId: "dId",
Scope: "*",
PubKey: "pubKey",
UserId: 123,
}
authToken2 := authToken1
authToken2.Token = "seekrit-2"
// The value expected when we pull it from the database.
authToken2DB := authToken2
authToken2DB.Expiration = timePtr(time.Now().Add(time.Hour * 24 * 14).UTC())
expiration := time.Now().Add(time.Hour * 24 * 14).UTC()
// Try to get a token, come back empty because we're just starting out
gotToken, err := s.GetToken(authToken1.PubKey, authToken1.DeviceID)
gotToken, err := s.GetToken(authTokenUpdate.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
// Try to update the token - fail because we don't have an entry there in the first place
if err := s.updateToken(&authToken1, *authToken2DB.Expiration); err != ErrNoToken {
if err := s.updateToken(&authTokenUpdate, expiration); err != ErrNoToken {
t.Fatalf(`updateToken err: wanted "%+v", got "%+v"`, ErrNoToken, err)
}
// Try to get a token, come back empty because the update attempt failed to do anything
gotToken, err = s.GetToken(authToken1.PubKey, authToken1.DeviceID)
gotToken, err = s.GetToken(authTokenUpdate.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
// Put in a token - just so we have something to test updateToken with
if err := s.insertToken(&authToken1, *authToken2DB.Expiration); err != nil {
// Put in a different token, just so we have something to test that
// updateToken overwrites it
authTokenInsert := authTokenUpdate
authTokenInsert.Token = "seekrit-insert"
if err := s.insertToken(&authTokenInsert, expiration); err != nil {
t.Fatalf("Unexpected error in insertToken: %+v", err)
}
// Now successfully update token
if err := s.updateToken(&authToken2, *authToken2DB.Expiration); err != nil {
if err := s.updateToken(&authTokenUpdate, expiration); err != nil {
t.Fatalf("Unexpected error in updateToken: %+v", err)
}
// The value expected when we pull it from the database.
authTokenUpdateExpected := authTokenUpdate
authTokenUpdateExpected.Expiration = timePtr(expiration)
// Get and confirm the token we just put in
gotToken, err = s.GetToken(authToken2.PubKey, authToken2.DeviceID)
gotToken, err = s.GetToken(authTokenUpdate.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken2DB) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken2DB, *gotToken)
if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenUpdateExpected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authTokenUpdateExpected, *gotToken)
}
// Fail to get the token we previously inserted, because it's now been overwritten
gotToken, err = s.GetToken(authTokenInsert.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
}
// Two different devices.
// Test that a user can have two different devices.
// Test first and second Save (one for insert, one for update)
// Get fails initially
// Put token1-d1 token1-d2
@ -138,30 +150,24 @@ func TestStoreSaveToken(t *testing.T) {
defer StoreTestCleanup(sqliteTmpFile)
// Version 1 of the token for both devices
// created for addition to the DB (no expiration attached)
authToken_d1_1 := auth.AuthToken{
Token: "seekrit-d1-1",
DeviceID: "dID-1",
DeviceId: "dId-1",
Scope: "*",
PubKey: "pubKey",
UserId: 123,
}
authToken_d2_1 := authToken_d1_1
authToken_d2_1.DeviceID = "dID-2"
authToken_d2_1.DeviceId = "dId-2"
authToken_d2_1.Token = "seekrit-d2-1"
// Version 2 of the token for both devices
authToken_d1_2 := authToken_d1_1
authToken_d1_2.Token = "seekrit-d1-2"
authToken_d2_2 := authToken_d2_1
authToken_d2_2.Token = "seekrit-d2-2"
// Try to get the tokens, come back empty because we're just starting out
gotToken, err := s.GetToken(authToken_d1_1.PubKey, authToken_d1_1.DeviceID)
gotToken, err := s.GetToken(authToken_d1_1.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
gotToken, err = s.GetToken(authToken_d2_1.PubKey, authToken_d2_1.DeviceID)
gotToken, err = s.GetToken(authToken_d2_1.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
@ -184,14 +190,14 @@ func TestStoreSaveToken(t *testing.T) {
}
// Get and confirm the tokens we just put in
gotToken, err = s.GetToken(authToken_d1_1.PubKey, authToken_d1_1.DeviceID)
gotToken, err = s.GetToken(authToken_d1_1.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_1) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken_d1_1, gotToken)
}
gotToken, err = s.GetToken(authToken_d2_1.PubKey, authToken_d2_1.DeviceID)
gotToken, err = s.GetToken(authToken_d2_1.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
@ -199,6 +205,13 @@ func TestStoreSaveToken(t *testing.T) {
t.Fatalf("token: expected %+v, got: %+v", authToken_d2_1, gotToken)
}
// Version 2 of the token for both devices
authToken_d1_2 := authToken_d1_1
authToken_d1_2.Token = "seekrit-d1-2"
authToken_d2_2 := authToken_d2_1
authToken_d2_2.Token = "seekrit-d2-2"
// Save Version 2 tokens for both devices
if err = s.SaveToken(&authToken_d1_2); err != nil {
t.Fatalf("Unexpected error in SaveToken: %+v", err)
@ -217,14 +230,14 @@ func TestStoreSaveToken(t *testing.T) {
}
// Get and confirm the tokens we just put in
gotToken, err = s.GetToken(authToken_d1_2.PubKey, authToken_d1_2.DeviceID)
gotToken, err = s.GetToken(authToken_d1_2.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_2) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken_d1_2, gotToken)
}
gotToken, err = s.GetToken(authToken_d2_2.PubKey, authToken_d2_2.DeviceID)
gotToken, err = s.GetToken(authToken_d2_2.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
@ -235,9 +248,8 @@ func TestStoreSaveToken(t *testing.T) {
// test GetToken using insertToken and updateToken as helpers (so we can set expiration timestamps)
// normal
// not found for pubkey
// not found for device (one for another device does exist)
// expired token not returned
// token not found
// expired not returned
func TestStoreGetToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile)
@ -245,39 +257,34 @@ func TestStoreGetToken(t *testing.T) {
// created for addition to the DB (no expiration attached)
authToken := auth.AuthToken{
Token: "seekrit-d1",
DeviceID: "dID",
DeviceId: "dId",
Scope: "*",
PubKey: "pubKey",
UserId: 123,
}
// The value expected when we pull it from the database.
authTokenDB := authToken
authTokenDB.Expiration = timePtr(time.Time(time.Now().UTC().Add(time.Hour * 24 * 14)))
expiration := time.Time(time.Now().UTC().Add(time.Hour * 24 * 14))
// Not found (nothing saved for this pubkey)
gotToken, err := s.GetToken(authToken.PubKey, authToken.DeviceID)
gotToken, err := s.GetToken(authToken.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
}
// Put in a token
if err := s.insertToken(&authToken, *authTokenDB.Expiration); err != nil {
if err := s.insertToken(&authToken, expiration); err != nil {
t.Fatalf("Unexpected error in insertToken: %+v", err)
}
// The value expected when we pull it from the database.
authTokenExpected := authToken
authTokenExpected.Expiration = timePtr(expiration)
// Confirm it saved
gotToken, err = s.GetToken(authToken.PubKey, authToken.DeviceID)
gotToken, err = s.GetToken(authToken.Token)
if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err)
}
if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenDB) {
t.Fatalf("token: \n expected %+v\n got: %+v", authTokenDB, gotToken)
}
// Fail to get for another device
gotToken, err = s.GetToken(authToken.PubKey, "other-device")
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken for nonexistent device. token: %+v err: %+v", gotToken, err)
if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenExpected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authTokenExpected, gotToken)
}
// Update the token to be expired
@ -287,7 +294,7 @@ func TestStoreGetToken(t *testing.T) {
}
// Fail to get the expired token
gotToken, err = s.GetToken(authToken.PubKey, authToken.DeviceID)
gotToken, err = s.GetToken(authToken.Token)
if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken, for expired token. token: %+v err: %+v", gotToken, err)
}

View file

@ -4,144 +4,211 @@ A couple example flows so it's clear how it works.
## Initial setup and account recovery
```
>>> import test_client
>>> c1 = test_client.Client()
```
Create a new wallet locally and authenticate based on the newly created public key (the email and password are not used just yet)
Set up two clients with the same account (which won't exist on the server yet).
```
>>> c1.new_wallet('email@example.com', '123')
>>> c1.get_full_auth_token()
Got auth token: 787cefea147f3a7b38e1b9fda49490371b52a3b7077507364854b72c3538f94e
>>> from test_client import Client
>>> c1 = Client()
>>> c2 = Client()
>>> c1.set_account("joe2@example.com", "123abc2")
>>> c2.set_account("joe2@example.com", "123abc2")
```
Post the wallet along with the downloadKey. The downloadKey is based on the password. It's the same password that will be used (in the full implementation) to encrypt the wallet. This is why we are sending it with the wallet state. We want to keep everything related to the user's password consistent.
Each device will have a device_id which will be used in the wallet state metadata to mark which device created a given version. This is used in the `lastSynced` field (see below).
```
>>> c1.post_wallet_state()
Successfully updated wallet state
Got new walletState: {'deviceId': 'e0349bc4-7e7a-48a2-a562-6c530b28a350', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1}, 'encryptedWallet': ''}
>>> c1.device_id
'974690df-85a6-481d-9015-6293226db8c9'
>>> c2.device_id
'545643c9-ee47-443d-b260-cb9178b8646c'
```
Note that every time a client posts, the server sends back the latest wallet state, whether or not the posted wallet state was rejected for being out of sequence. More on this below.
Send the email address
Register the account on the server with one of the clients.
```
>>> c1.register()
Registered
```
Now let's set up a second device
```
>>> c2 = test_client.Client()
```
Gets limited-scope auth token (which includes pubkey) based on email address and downloadKey (which comes from password). This token only allows downloading a wallet state (thus the "downloadKey").
```
>>> c2.get_download_auth_token('email@example.com', '123')
Got auth token: fd3f4074e6f1b2401b33e21ce5f69d93255680b37c334b6a4e8ea6385b454b0b
Got public key: eeA0FfE5E57E3647524759CA9D7c7Cb1
>>>
```
Full auth token requires signature, which requires the wallet, which we don't have yet. (For demo we have a fake signature check, so this restriction is faked by the client)
Now that the account exists, grab an auth token with both clients.
```
>>> c1.get_full_auth_token()
Got auth token: 941e5159a2caff15f0bdc1c0e6da92691d3073543dbfae810cfe57d51c35f0e0
>>> c2.get_full_auth_token()
No wallet state, thus no access to private key (or so we pretend for this demo), thus we cannot create a signature
Got auth token: b323a18e51263ac052777ca68de716c1f3b4983bf4c918477e355f637c8ea2d4
```
Get the wallet state.
## Syncing
Create a new wallet state (wallet + metadata) and post it to the server. Note that after posting, it says it "got" a new wallet state. This is because the post endpoint also returns the latest version. The purpose of this will be explained in "Conflicts" below.
The fields in the walletstate are:
* `encryptedWallet` - the actual encrypted wallet data
* `lastSynced` - a mapping between deviceId and the latest sequence number that it _created_. This is bookkeeping to prevent certain syncing errors.
* `deviceId` - the device that made _this_ wallet state version (NOTE this admittedly seems redundant with `lastSynced` and may be removed)
```
>>> c1.new_wallet_state()
>>> c1.post_wallet_state()
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': '',
'lastSynced': {'974690df-85a6-481d-9015-6293226db8c9': 1}}
```
With the other client, get it from the server. Note that both clients have the same data now.
```
>>> c2.get_wallet_state()
Got latest walletState: {'deviceId': 'e0349bc4-7e7a-48a2-a562-6c530b28a350', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1}, 'encryptedWallet': ''}
Got latest walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': '',
'lastSynced': {'974690df-85a6-481d-9015-6293226db8c9': 1}}
```
The download-only auth token doesn't allow posting a wallet.
## Updating
Push a new version, get it with the other client. Even though we haven't edited the encrypted wallet yet, each version of a wallet _state_ has an incremented sequence number, and the deviceId that created it.
```
>>> c2.post_wallet_state()
Error 403
b'{"error":"Forbidden: Scope"}\n'
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': '',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 1}}
>>> c1.get_wallet_state()
Got latest walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': '',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 1}}
```
But, we can get the full auth token now that we downloaded the wallet. In the full implementation, the wallet would be encrypted with the password. This means that somebody who merely intercepts the public key and download key wouldn't be able to do this step.
```
>>> c2.get_full_auth_token()
Got auth token: 4b19739a66f55aff5b7e0f1375c42f41d944b5175f5c5d32b35698a360bb0e5b
>>> c2.post_wallet_state()
Successfully updated wallet state
Got new walletState: {'deviceId': '2ede3f32-4e65-4312-8b89-3b6bde0c5d8e', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1, '2ede3f32-4e65-4312-8b89-3b6bde0c5d8e': 2}, 'encryptedWallet': ''}
```
# Handling conflicts
Changes here are represented by 4 random characters separated by colons. The sequence of the changes is relevant to the final state of the wallet. Our goal is to make sure that all clients have all of the changes in the same order. This will thus demonstrate how clients can implement a "rebase" behavior when there is a conflict. In a full implementation, there would also be a system to resolve merge conflicts, but that is out of scope here.
First, create a local change and post it
## Wallet Changes
For demo purposes, this test client represents each change to the wallet by appending segments separated by `:` so that we can more easily follow the history. (The real app will not actually edit the wallet in the form of an append log.)
```
>>> c1.cur_encrypted_wallet()
''
>>> c1.change_encrypted_wallet()
>>> c1.cur_encrypted_wallet()
':f801'
':2fbE'
```
The wallet is synced between the clients.
```
>>> c1.post_wallet_state()
Successfully updated wallet state
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 2}, 'encryptedWallet': ':f801'}
>>> c1.cur_encrypted_wallet()
':f801'
```
The other client gets the update and sees the same thing locally:
```
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 3}}
>>> c2.get_wallet_state()
Got latest walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 2}, 'encryptedWallet': ':f801'}
Got latest walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 3}}
>>> c2.cur_encrypted_wallet()
':f801'
':2fbE'
```
Now, both clients make different local changes and both try to post them
## Merging Changes
Both clients create changes. They now have diverging wallets.
```
>>> c1.change_encrypted_wallet()
>>> c2.change_encrypted_wallet()
>>> c1.cur_encrypted_wallet()
':f801:576b'
':2fbE:BD62'
>>> c2.cur_encrypted_wallet()
':f801:dDE7'
':2fbE:e7ac'
```
One client posts its change first. The other client pulls that change, and _merges_ those changes on top of the changes it had saved locally.
The _merge base_ that a given client uses is the last version that it successfully got from or posted to the server. You can see the merge base here: the first part of the wallet which does not change from this merge.
```
>>> c1.post_wallet_state()
Successfully updated wallet state
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'}
>>> c2.post_wallet_state()
Wallet state out of date. Getting updated wallet state. Try again.
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'}
```
Client 2 gets a conflict, and the server sends it the updated wallet state that was just created by Client 1 (to save an extra request to `getWalletState`).
Its local change still exists, but now it's on top of client 1's latest change. (In a full implementation, this is where conflict resolution might take place.)
```
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE:BD62',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
>>> c2.get_wallet_state()
Got latest walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE:BD62',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
>>> c2.cur_encrypted_wallet()
':f801:576b:dDE7'
':2fbE:BD62:e7ac'
```
Client 2 tries again to post, and it succeeds. Client 1 receives it.
Finally, the client with the merged wallet pushes it to the server, and the other client gets the update.
```
>>> c2.post_wallet_state()
Successfully updated wallet state
Got new walletState: {'deviceId': '127e0045-425c-4dd8-a742-90cd52b9377b', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3, '127e0045-425c-4dd8-a742-90cd52b9377b': 4}, 'encryptedWallet': ':f801:576b:dDE7'}
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': ':2fbE:BD62:e7ac',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 5,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
>>> c1.get_wallet_state()
Got latest walletState: {'deviceId': '127e0045-425c-4dd8-a742-90cd52b9377b', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3, '127e0045-425c-4dd8-a742-90cd52b9377b': 4}, 'encryptedWallet': ':f801:576b:dDE7'}
Got latest walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': ':2fbE:BD62:e7ac',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 5,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
>>> c1.cur_encrypted_wallet()
':f801:576b:dDE7'
':2fbE:BD62:e7ac'
```
## Conflicts
A client cannot post if it is not up to date. It needs to merge in any new changes on the server before posting its own changes. For convenience, if a conflicting post request is made, the server responds with the latest version of the wallet state (just like a GET request). This way the client doesn't need to make a second request to perform the merge.
(If a non-conflicting post request is made, it responds with the same wallet state that the client just posted, as it is now the server's current wallet state)
```
>>> c2.change_encrypted_wallet()
>>> c2.post_wallet_state()
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': ':2fbE:BD62:e7ac:4EEf',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 6,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
>>> c1.change_encrypted_wallet()
>>> c1.post_wallet_state()
Wallet state out of date. Getting updated wallet state. Try again.
Got new walletState:
{'deviceId': '545643c9-ee47-443d-b260-cb9178b8646c',
'encryptedWallet': ':2fbE:BD62:e7ac:4EEf',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 6,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
```
Now the merge is complete, and the client can make a second post request containing the merged wallet.
```
>>> c1.post_wallet_state()
Successfully updated wallet state on server
Got new walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE:BD62:e7ac:4EEf:DC86',
'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 6,
'974690df-85a6-481d-9015-6293226db8c9': 7}}
```

177
test_client/gen-readme.py Normal file
View file

@ -0,0 +1,177 @@
# Generate the README since I want real behavior interspersed with comments
# Come to think of it, this is accidentally a pretty okay integration test for client and server
# NOTE - delete the database before running this, or else you'll get an error for registering. also we want the wallet to start empty
def code_block(code):
print ("```")
for line in code.strip().split('\n'):
print(">>> " + line)
if ' = ' in line or "import" in line:
exec('global c1, c2\n' + line)
else:
result = eval(line)
if result is not None:
print(repr(result))
print ("```")
print("""# Test Client
A couple example flows so it's clear how it works.
""")
print("""## Initial setup and account recovery
Set up two clients with the same account (which won't exist on the server yet).
""")
code_block("""
from test_client import Client
c1 = Client()
c2 = Client()
c1.set_account("joe2@example.com", "123abc2")
c2.set_account("joe2@example.com", "123abc2")
""")
print("""
Each device will have a device_id which will be used in the wallet state metadata to mark which device created a given version. This is used in the `lastSynced` field (see below).
""")
code_block("""
c1.device_id
c2.device_id
""")
print("""
Register the account on the server with one of the clients.
""")
code_block("""
c1.register()
""")
print("""
Now that the account exists, grab an auth token with both clients.
""")
code_block("""
c1.get_full_auth_token()
c2.get_full_auth_token()
""")
# TODO - wait isn't it redundant to have the `deviceId` field, for the same reason it's redundant to have the `sequence` field?
print("""
## Syncing
Create a new wallet state (wallet + metadata) and post it to the server. Note that after posting, it says it "got" a new wallet state. This is because the post endpoint also returns the latest version. The purpose of this will be explained in "Conflicts" below.
The fields in the walletstate are:
* `encryptedWallet` - the actual encrypted wallet data
* `lastSynced` - a mapping between deviceId and the latest sequence number that it _created_. This is bookkeeping to prevent certain syncing errors.
* `deviceId` - the device that made _this_ wallet state version (NOTE this admittedly seems redundant with `lastSynced` and may be removed)
""")
code_block("""
c1.new_wallet_state()
c1.post_wallet_state()
""")
print("""
With the other client, get it from the server. Note that both clients have the same data now.
""")
code_block("""
c2.get_wallet_state()
""")
print("""
## Updating
Push a new version, get it with the other client. Even though we haven't edited the encrypted wallet yet, each version of a wallet _state_ has an incremented sequence number, and the deviceId that created it.
""")
code_block("""
c2.post_wallet_state()
c1.get_wallet_state()
""")
print("""
## Wallet Changes
For demo purposes, this test client represents each change to the wallet by appending segments separated by `:` so that we can more easily follow the history. (The real app will not actually edit the wallet in the form of an append log.)
""")
code_block("""
c1.cur_encrypted_wallet()
c1.change_encrypted_wallet()
c1.cur_encrypted_wallet()
""")
print("""
The wallet is synced between the clients.
""")
code_block("""
c1.post_wallet_state()
c2.get_wallet_state()
c2.cur_encrypted_wallet()
""")
print("""
## Merging Changes
Both clients create changes. They now have diverging wallets.
""")
code_block("""
c1.change_encrypted_wallet()
c2.change_encrypted_wallet()
c1.cur_encrypted_wallet()
c2.cur_encrypted_wallet()
""")
print("""
One client posts its change first. The other client pulls that change, and _merges_ those changes on top of the changes it had saved locally.
The _merge base_ that a given client uses is the last version that it successfully got from or posted to the server. You can see the merge base here: the first part of the wallet which does not change from this merge.
""")
code_block("""
c1.post_wallet_state()
c2.get_wallet_state()
c2.cur_encrypted_wallet()
""")
print("""
Finally, the client with the merged wallet pushes it to the server, and the other client gets the update.
""")
code_block("""
c2.post_wallet_state()
c1.get_wallet_state()
c1.cur_encrypted_wallet()
""")
print("""
## Conflicts
A client cannot post if it is not up to date. It needs to merge in any new changes on the server before posting its own changes. For convenience, if a conflicting post request is made, the server responds with the latest version of the wallet state (just like a GET request). This way the client doesn't need to make a second request to perform the merge.
(If a non-conflicting post request is made, it responds with the same wallet state that the client just posted, as it is now the server's current wallet state)
""")
code_block("""
c2.change_encrypted_wallet()
c2.post_wallet_state()
c1.change_encrypted_wallet()
c1.post_wallet_state()
""")
print("""
Now the merge is complete, and the client can make a second post request containing the merged wallet.
""")
code_block("""
c1.post_wallet_state()
""")

View file

@ -1,5 +1,6 @@
#!/bin/python3
import random, string, json, uuid, requests, hashlib, time
import random, string, json, uuid, requests, hashlib
from pprint import pprint
BASE_URL = 'http://localhost:8090'
AUTH_FULL_URL = BASE_URL + '/auth/full'
@ -12,8 +13,21 @@ def wallet_state_sequence(wallet_state):
return 0
return wallet_state['lastSynced'][wallet_state['deviceId']]
def download_key(password):
return hashlib.sha256(password.encode('utf-8')).hexdigest()
# TODO - do this correctly
def create_login_password(root_password):
return hashlib.sha256(root_password.encode('utf-8')).hexdigest()[:32]
# TODO - do this correctly
def create_encryption_key(root_password):
return hashlib.sha256(root_password.encode('utf-8')).hexdigest()[32:]
# TODO - do this correctly
def check_hmac(wallet_state, encryption_key, hmac):
return hmac == 'Good HMAC'
# TODO - do this correctly
def create_hmac(wallet_state, encryption_key):
return 'Good HMAC'
class Client():
def _validate_new_wallet_state(self, new_wallet_state):
@ -48,25 +62,34 @@ class Client():
self.wallet_state = None
def new_wallet(self, email, password):
# Obviously not real behavior
self.public_key = ''.join(random.choice(string.hexdigits) for x in range(32))
# TODO - save change to disk in between, associated with account and/or
# wallet
self._encrypted_wallet_local_changes = ''
# TODO - make this act more sdk-like. in fact maybe even install the sdk?
# TODO - This does not deal with the question of tying accounts to wallets.
# Does a new wallet state mean a we're creating a new account? What happens
# if we create a new wallet state tied to an existing account? Do we merge it
# with what's on the server anyway? Do we refuse to merge, or warn the user?
# Etc. This sort of depends on how the LBRY Desktop/SDK usually behave. For
# now, it'll end up just merging any un-saved local changes with whatever is
# on the server.
def new_wallet_state(self):
# camel-cased to ease json interop
self.wallet_state = {'lastSynced': {}, 'encryptedWallet': ''}
# TODO - actual encryption with password
# TODO - actual encryption with encryption_key
self._encrypted_wallet_local_changes = ''
def set_account(self, email, root_password):
self.email = email
self.password = password
self.root_password = root_password
def register(self):
body = json.dumps({
'token': self.auth_token,
'publicKey': self.public_key,
'deviceId': self.device_id,
'email': self.email,
'password': create_login_password(self.root_password),
})
response = requests.post(REGISTER_URL, body)
if response.status_code != 201:
@ -78,7 +101,7 @@ class Client():
def get_download_auth_token(self, email, password):
body = json.dumps({
'email': email,
'downloadKey': download_key(password),
'password': create_login_password(password),
'deviceId': self.device_id,
})
response = requests.post(AUTH_GET_WALLET_STATE_URL, body)
@ -87,22 +110,18 @@ class Client():
print (response.content)
return
self.auth_token = json.loads(response.content)['token']
self.public_key = json.loads(response.content)['publicKey']
print ("Got auth token: ", self.auth_token)
print ("Got public key: ", self.public_key)
self.email = email
self.password = password
self.root_password = root_password
# TODO - Rename to get_auth_token. same in go. Remember to grep, gotta change
# it in README as well.
def get_full_auth_token(self):
if not self.wallet_state:
print ("No wallet state, thus no access to private key (or so we pretend for this demo), thus we cannot create a signature")
return
body = json.dumps({
'tokenRequestJSON': json.dumps({'deviceId': self.device_id, 'requestTime': int(time.time())}),
'publicKey': self.public_key,
'signature': 'Good Signature',
'email': self.email,
'password': create_login_password(self.root_password),
'deviceId': self.device_id,
})
response = requests.post(AUTH_FULL_URL, body)
if response.status_code != 200:
@ -112,11 +131,14 @@ class Client():
self.auth_token = json.loads(response.content)['token']
print ("Got auth token: ", self.auth_token)
# TODO - What about cases where we are managing multiple different wallets?
# Some will have lower sequences. If you accidentally mix it up client-side,
# you might end up overwriting one with a lower sequence entirely. Maybe we
# want to annotate them with which account we're talking about. Again, we
# should see how LBRY Desktop/SDK deal with it.
def get_wallet_state(self):
params = {
'token': self.auth_token,
'publicKey': self.public_key,
'deviceId': self.device_id,
}
response = requests.get(WALLET_STATE_URL, params=params)
if response.status_code != 200:
@ -124,17 +146,16 @@ class Client():
print (response.content)
return
if json.loads(response.content)['signature'] != "Good Signature":
print ('Error - bad signature on new wallet')
print (response.content)
return
if response.status_code != 200:
print ('Error', response.status_code)
new_wallet_state_str = json.loads(response.content)['walletStateJson']
new_wallet_state = json.loads(new_wallet_state_str)
encryption_key = create_encryption_key(self.root_password)
hmac = json.loads(response.content)['hmac']
if not check_hmac(new_wallet_state_str, encryption_key, hmac):
print ('Error - bad hmac on new wallet')
print (response.content)
return
# In reality, we'd examine, merge, verify, validate etc this new wallet state.
new_wallet_state = json.loads(json.loads(response.content)['bodyJSON'])
if self.wallet_state != new_wallet_state and not self._validate_new_wallet_state(new_wallet_state):
print ('Error - new wallet does not validate')
print (response.content)
@ -144,58 +165,63 @@ class Client():
# This is if we're getting a wallet_state for the first time. Initialize
# the local changes.
self._encrypted_wallet_local_changes = ''
self.wallet_state = new_wallet_state
print ("Got latest walletState: ", self.wallet_state)
print ("Got latest walletState:")
pprint(self.wallet_state)
def post_wallet_state(self):
# Create a *new* wallet state, indicating that it was last updated by this
# device, with the updated sequence, and include our local encrypted wallet changes.
# Don't set self.wallet_state to this until we know that it's accepted by
# the server.
if self.wallet_state:
if not self.wallet_state:
print ("No wallet state to post.")
return
submitted_wallet_state = {
"deviceId": self.device_id,
"lastSynced": dict(self.wallet_state['lastSynced']),
"encryptedWallet": self.cur_encrypted_wallet(),
}
submitted_wallet_state['lastSynced'][self.device_id] = wallet_state_sequence(self.wallet_state) + 1
else:
# If we have no self.wallet_state, we shouldn't be able to have a full
# auth token, so this code path is just to demonstrate an auth failure
submitted_wallet_state = {
"deviceId": self.device_id,
"lastSynced": {self.device_id: 1},
"encryptedWallet": self.cur_encrypted_wallet(),
}
encryption_key = create_encryption_key(self.root_password)
submitted_wallet_state_str = json.dumps(submitted_wallet_state)
submitted_wallet_state_hmac = create_hmac(submitted_wallet_state_str, encryption_key)
body = json.dumps({
'token': self.auth_token,
'bodyJSON': json.dumps(submitted_wallet_state),
'publicKey': self.public_key,
'downloadKey': download_key(self.password),
'signature': 'Good Signature',
'walletStateJson': submitted_wallet_state_str,
'hmac': submitted_wallet_state_hmac
})
response = requests.post(WALLET_STATE_URL, body)
if response.status_code == 200:
# Our local changes are no longer local, so we reset them
self._encrypted_wallet_local_changes = ''
print ('Successfully updated wallet state')
print ('Successfully updated wallet state on server')
elif response.status_code == 409:
print ('Wallet state out of date. Getting updated wallet state. Try again.')
# Don't return yet! We got the updated state here, so we still process it below.
else:
print ('Error', response.status_code)
print (response.content)
return
if json.loads(response.content)['signature'] != "Good Signature":
print ('Error - bad signature on new wallet')
# Now we get a new wallet state back as a response
# TODO - factor this into the same thing as the get_wallet_state function
new_wallet_state_str = json.loads(response.content)['walletStateJson']
new_wallet_state_hmac = json.loads(response.content)['hmac']
new_wallet_state = json.loads(new_wallet_state_str)
if not check_hmac(new_wallet_state_str, encryption_key, new_wallet_state_hmac):
print ('Error - bad hmac on new wallet')
print (response.content)
return
# In reality, we'd examine, merge, verify, validate etc this new wallet state.
new_wallet_state = json.loads(json.loads(response.content)['bodyJSON'])
if submitted_wallet_state != new_wallet_state and not self._validate_new_wallet_state(new_wallet_state):
print ('Error - new wallet does not validate')
print (response.content)
@ -203,7 +229,8 @@ class Client():
self.wallet_state = new_wallet_state
print ("Got new walletState: ", self.wallet_state)
print ("Got new walletState:")
pprint(self.wallet_state)
def change_encrypted_wallet(self):
if not self.wallet_state:

View file

@ -1,26 +1,32 @@
package wallet
import "orblivion/lbry-id/auth"
// Currently a small package but given other packages it makes imports easier.
// Also this might grow substantially over time
// For test stubs
type WalletUtilInterface interface {
ValidateWalletState(walletState *WalletState) bool
ValidateWalletStateMetadata(walletState *WalletStateMetadata) bool
}
type WalletUtil struct{}
type WalletState struct {
DeviceID string `json:"deviceId"`
LastSynced map[string]int `json:"lastSynced"`
// This is a subset of the WalletState structure, only the metadata fields. We
// don't need access to the encrypted wallet.
type WalletStateMetadata struct {
DeviceId auth.DeviceId `json:"deviceId"`
LastSynced map[auth.DeviceId]int `json:"lastSynced"`
}
type WalletStateHmac string
// TODO - These "validate" functions could/should be methods. Though I think
// we'd lose mockability for testing, since the method isn't the
// WalletUtilInterface.
// Mainly the job of the clients but we may as well short-circuit problems
// here before saving them.
func (wu *WalletUtil) ValidateWalletState(walletState *WalletState) bool {
func (wu *WalletUtil) ValidateWalletStateMetadata(walletState *WalletStateMetadata) bool {
// TODO - nonempty fields, up to date, etc
return true
@ -28,6 +34,6 @@ func (wu *WalletUtil) ValidateWalletState(walletState *WalletState) bool {
// Assumptions: `ws` has been validated
// Avoid having to check for error
func (ws *WalletState) Sequence() int {
return ws.LastSynced[ws.DeviceID]
func (ws *WalletStateMetadata) Sequence() int {
return ws.LastSynced[ws.DeviceId]
}

View file

@ -7,11 +7,11 @@ import (
// Test stubs for now
func TestWalletSequence(t *testing.T) {
t.Fatalf("Test me: test that walletState.Sequence() == walletState.lastSynced[wallet.DeviceID]")
t.Fatalf("Test me: test that walletState.Sequence() == walletState.lastSynced[wallet.DeviceId]")
}
func TestWalletValidateWalletState(t *testing.T) {
// walletState.DeviceID in walletState.lastSynced
// walletState.DeviceId in walletState.lastSynced
// Sequence for lastSynced all > 1
t.Fatalf("Test me: Implement and test validateWalletState.")
}