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 - Learn how to use https://github.com/golang/oauth2 instead
// TODO - Look into jwt, etc. // 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 AuthTokenString string
type PublicKey string
type DownloadKey string type DownloadKey string
type Signature string
type AuthScope string type AuthScope string
@ -24,70 +27,37 @@ const ScopeGetWalletState = AuthScope("get-wallet-state")
// For test stubs // For test stubs
type AuthInterface interface { type AuthInterface interface {
NewToken(pubKey PublicKey, DeviceID string, Scope AuthScope) (*AuthToken, error) // TODO maybe have a "refresh token" thing if the client won't have email available all the time?
IsValidSignature(pubKey PublicKey, payload string, signature Signature) bool NewToken(UserId, DeviceId, AuthScope) (*AuthToken, error)
ValidateTokenRequest(tokenRequest *TokenRequest) bool
} }
type Auth struct{} 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 // Note that everything here is given to anybody who presents a valid
// downloadKey and associated email. Currently these fields are safe to give // 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. // at that low security level, but keep this in mind as we change this struct.
type AuthToken struct { type AuthToken struct {
Token AuthTokenString `json:"token"` Token AuthTokenString `json:"token"`
DeviceID string `json:"deviceId"` DeviceId DeviceId `json:"deviceId"`
Scope AuthScope `json:"scope"` Scope AuthScope `json:"scope"`
PubKey PublicKey `json:"publicKey"` UserId UserId `json:"userId"`
Expiration *time.Time `json:"expiration"` 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 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) b := make([]byte, AuthTokenLength)
// TODO - Is this is a secure random function? (Maybe audit)
if _, err := rand.Read(b); err != nil { if _, err := rand.Read(b); err != nil {
return nil, fmt.Errorf("Error generating token: %+v", err) return nil, fmt.Errorf("Error generating token: %+v", err)
} }
return &AuthToken{ return &AuthToken{
Token: AuthTokenString(hex.EncodeToString(b)), Token: AuthTokenString(hex.EncodeToString(b)),
DeviceID: DeviceID, DeviceId: deviceId,
Scope: Scope, Scope: scope,
PubKey: pubKey, UserId: userId,
// TODO add Expiration here instead of putting it in store.go. and thus redo store.go. d'oh. // TODO add Expiration here instead of putting it in store.go. and thus redo store.go. d'oh.
}, nil }, nil
} }
@ -109,3 +79,9 @@ func (d DownloadKey) Obfuscate() string {
hash := sha256.Sum256([]byte(d)) hash := sha256.Sum256([]byte(d))
return hex.EncodeToString(hash[:]) 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 // 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) { func TestAuthSignaturePass(t *testing.T) {
t.Fatalf("Test me: Valid siganture passes") t.Fatalf("Test me: Valid siganture passes")
} }

View file

@ -3,165 +3,69 @@ package server
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log"
"net/http" "net/http"
"orblivion/lbry-id/auth" "orblivion/lbry-id/auth"
"orblivion/lbry-id/store" "orblivion/lbry-id/store"
) )
/* // DeviceId is decided by the device. UserId is decided by the server, and is
TODO - Consider reworking the naming convention in (currently named) // gatekept by Email/Password
`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.
*/
type AuthFullRequest struct { type AuthFullRequest struct {
// TokenRequestJSON: json string within json, so that the string representation is DeviceId auth.DeviceId `json:"deviceId"`
// unambiguous for the purposes of signing. This means we need to deserialize the Email auth.Email `json:"email"`
// request body twice. Password auth.Password `json:"password"`
TokenRequestJSON string `json:"tokenRequestJSON"`
PubKey auth.PublicKey `json:"publicKey"`
Signature auth.Signature `json:"signature"`
} }
func (r *AuthFullRequest) validate() bool { func (r *AuthFullRequest) validate() bool {
return (r.TokenRequestJSON != "" && return (r.DeviceId != "" &&
r.PubKey != auth.PublicKey("") && r.Email != auth.Email("") && // TODO email validation. Here or store. Stdlib does it: https://stackoverflow.com/a/66624104
r.Signature != auth.Signature("")) r.Password != auth.Password(""))
} }
type AuthForGetWalletStateRequest struct { type AuthForGetWalletStateRequest struct {
Email string `json:"email"` Email auth.Email `json:"email"`
DownloadKey auth.DownloadKey `json:"downloadKey"` DownloadKey auth.DownloadKey `json:"downloadKey"`
DeviceID string `json:"deviceId"` DeviceId auth.DeviceId `json:"deviceId"`
} }
func (r *AuthForGetWalletStateRequest) validate() bool { func (r *AuthForGetWalletStateRequest) validate() bool {
return (r.Email != "" && return (r.Email != "" &&
r.DownloadKey != auth.DownloadKey("") && r.DownloadKey != auth.DownloadKey("") &&
r.DeviceID != "") 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))
} }
func (s *Server) getAuthTokenFull(w http.ResponseWriter, req *http.Request) { 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 var authRequest AuthFullRequest
if !getPostData(w, req, &authRequest) { if !getPostData(w, req, &authRequest) {
return return
} }
if !s.auth.IsValidSignature(authRequest.PubKey, authRequest.TokenRequestJSON, authRequest.Signature) { userId, err := s.store.GetUserId(authRequest.Email, authRequest.Password)
errorJSON(w, http.StatusForbidden, "Bad signature") 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 return
} }
var tokenRequest auth.TokenRequest authToken, err := s.auth.NewToken(userId, authRequest.DeviceId, auth.ScopeFull)
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)
if err != nil { if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token") internalServiceErrorJson(w, err, "Error generating auth token")
return return
} }
response, err := json.Marshal(&authToken) response, err := json.Marshal(&authToken)
if err != nil { if err != nil {
internalServiceErrorJSON(w, err, "Error generating auth token") internalServiceErrorJson(w, err, "Error generating auth token")
return return
} }
if err := s.store.SaveToken(authToken); err != nil { if err := s.store.SaveToken(authToken); err != nil {
internalServiceErrorJSON(w, err, "Error saving auth token") internalServiceErrorJson(w, err, "Error saving auth token")
return return
} }

View file

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

View file

@ -11,7 +11,6 @@ import (
"orblivion/lbry-id/store" "orblivion/lbry-id/store"
"orblivion/lbry-id/wallet" "orblivion/lbry-id/wallet"
"testing" "testing"
"time"
) )
// Whereas sever_test.go stubs out auth store and wallet, these will use the real thing, but test fewer paths. // 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{}, &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 // Get auth token - device 1
//////////////////// ////////////////////
var authToken1 auth.AuthToken var authToken1 auth.AuthToken
responseBody, statusCode := request( responseBody, statusCode = request(
t, t,
http.MethodPost, http.MethodPost,
s.getAuthTokenFull, s.getAuthTokenFull,
PathAuthTokenFull, PathAuthTokenFull,
&authToken1, &authToken1,
fmt.Sprintf(`{ `{"deviceId": "dev-1", "email": "abc@example.com", "password": "123"}`,
"tokenRequestJSON": "{\"deviceID\": \"dev-1\", \"requestTime\": %d}",
"publickey": "testPubKey",
"signature": "Good Signature"
}`,
time.Now().Unix(),
),
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
@ -95,8 +102,8 @@ func TestIntegrationWalletUpdates(t *testing.T) {
if len(authToken1.Token) != expectedTokenLength { if len(authToken1.Token) != expectedTokenLength {
t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody)) t.Fatalf("Expected auth response to contain token length 32: result: %+v", string(responseBody))
} }
if authToken1.DeviceID != "dev-1" { if authToken1.DeviceId != "dev-1" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-1", authToken1.DeviceID) t.Fatalf("Unexpected response DeviceId. want: %+v got: %+v", "dev-1", authToken1.DeviceId)
} }
if authToken1.Scope != auth.ScopeFull { if authToken1.Scope != auth.ScopeFull {
t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope) t.Fatalf("Unexpected response Scope. want: %+v got: %+v", auth.ScopeFull, authToken1.Scope)
@ -113,19 +120,13 @@ func TestIntegrationWalletUpdates(t *testing.T) {
s.getAuthTokenFull, s.getAuthTokenFull,
PathAuthTokenFull, PathAuthTokenFull,
&authToken2, &authToken2,
fmt.Sprintf(`{ `{"deviceId": "dev-2", "email": "abc@example.com", "password": "123"}`,
"tokenRequestJSON": "{\"deviceID\": \"dev-2\", \"requestTime\": %d}",
"publickey": "testPubKey",
"signature": "Good Signature"
}`,
time.Now().Unix(),
),
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
if authToken2.DeviceID != "dev-2" { if authToken2.DeviceId != "dev-2" {
t.Fatalf("Unexpected response DeviceID. want: %+v got: %+v", "dev-2", authToken2.DeviceID) t.Fatalf("Unexpected response DeviceId. want: %+v got: %+v", "dev-2", authToken2.DeviceId)
} }
//////////////////// ////////////////////
@ -141,17 +142,15 @@ func TestIntegrationWalletUpdates(t *testing.T) {
&walletStateResponse, &walletStateResponse,
fmt.Sprintf(`{ fmt.Sprintf(`{
"token": "%s", "token": "%s",
"bodyJSON": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }", "walletStateJson": "{\"encryptedWallet\": \"blah\", \"lastSynced\":{\"dev-1\": 1}, \"deviceId\": \"dev-1\" }",
"publickey": "testPubKey", "hmac": "my-hmac-1"
"downloadKey": "myDownloadKey",
"signature": "Good Signature"
}`, authToken1.Token), }`, authToken1.Token),
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
var walletState wallet.WalletState var walletState wallet.WalletState
err := json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState) err := json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %+v", err) t.Fatalf("Unexpected error: %+v", err)
@ -170,16 +169,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
t, t,
http.MethodGet, http.MethodGet,
s.getWalletState, s.getWalletState,
fmt.Sprintf( fmt.Sprintf("%s?token=%s", PathWalletState, authToken2.Token),
"%s?token=%s&publicKey=%s&deviceId=%s",
PathWalletState, authToken2.Token, authToken2.PubKey, "dev-2"),
&walletStateResponse, &walletStateResponse,
"", "",
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState) err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %+v", err) t.Fatalf("Unexpected error: %+v", err)
@ -202,16 +199,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
&walletStateResponse, &walletStateResponse,
fmt.Sprintf(`{ fmt.Sprintf(`{
"token": "%s", "token": "%s",
"bodyJSON": "{\"encryptedWallet\": \"blah2\", \"lastSynced\":{\"dev-1\": 1, \"dev-2\": 2}, \"deviceId\": \"dev-2\" }", "walletStateJson": "{\"encryptedWallet\": \"blah2\", \"lastSynced\":{\"dev-1\": 1, \"dev-2\": 2}, \"deviceId\": \"dev-2\" }",
"publickey": "testPubKey", "hmac": "my-hmac-2"
"downloadKey": "myDownloadKey",
"signature": "Good Signature"
}`, authToken2.Token), }`, authToken2.Token),
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState) err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %+v", err) t.Fatalf("Unexpected error: %+v", err)
@ -230,16 +225,14 @@ func TestIntegrationWalletUpdates(t *testing.T) {
t, t,
http.MethodGet, http.MethodGet,
s.getWalletState, s.getWalletState,
fmt.Sprintf( fmt.Sprintf("%s?token=%s", PathWalletState, authToken1.Token),
"%s?token=%s&publicKey=%s&deviceId=%s",
PathWalletState, authToken1.Token, authToken1.PubKey, "dev-1"),
&walletStateResponse, &walletStateResponse,
"", "",
) )
checkStatusCode(t, statusCode, responseBody) checkStatusCode(t, statusCode, responseBody)
err = json.Unmarshal([]byte(walletStateResponse.BodyJSON), &walletState) err = json.Unmarshal([]byte(walletStateResponse.WalletStateJson), &walletState)
if err != nil { if err != nil {
t.Fatalf("Unexpected error: %+v", err) 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) 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" "orblivion/lbry-id/store"
) )
// TODO email verification cycle
type RegisterRequest struct { type RegisterRequest struct {
Token auth.AuthTokenString `json:"token"` Email auth.Email `json:"email"`
PubKey auth.PublicKey `json:"publicKey"` Password auth.Password `json:"password"`
DeviceID string `json:"deviceId"`
Email string `json:"email"`
} }
func (r *RegisterRequest) validate() bool { func (r *RegisterRequest) validate() bool {
return (r.Token != auth.AuthTokenString("") && return r.Email != "" && r.Password != ""
r.PubKey != auth.PublicKey("") &&
r.DeviceID != "" &&
r.Email != "")
} }
func (s *Server) register(w http.ResponseWriter, req *http.Request) { 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 return
} }
if !s.checkAuth( err := s.store.CreateAccount(registerRequest.Email, registerRequest.Password)
w,
registerRequest.PubKey,
registerRequest.DeviceID,
registerRequest.Token,
auth.ScopeFull,
) {
return
}
err := s.store.InsertEmail(registerRequest.PubKey, registerRequest.Email)
if err != nil { if err != nil {
if err == store.ErrDuplicateEmail || err == store.ErrDuplicateAccount { if err == store.ErrDuplicateEmail || err == store.ErrDuplicateAccount {
errorJSON(w, http.StatusConflict, "Error registering") errorJson(w, http.StatusConflict, "Error registering")
} else { } else {
internalServiceErrorJSON(w, err, "Error registering") internalServiceErrorJson(w, err, "Error registering")
} }
log.Print(err) log.Print(err)
return return
@ -56,7 +43,7 @@ func (s *Server) register(w http.ResponseWriter, req *http.Request) {
response, err = json.Marshal(registerResponse) response, err = json.Marshal(registerResponse)
if err != nil { if err != nil {
internalServiceErrorJSON(w, err, "Error generating register response") internalServiceErrorJson(w, err, "Error generating register response")
return return
} }

View file

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

View file

@ -3,6 +3,8 @@ package server
import ( import (
"fmt" "fmt"
"orblivion/lbry-id/auth" "orblivion/lbry-id/auth"
"orblivion/lbry-id/store"
"orblivion/lbry-id/wallet"
"testing" "testing"
) )
@ -10,28 +12,19 @@ import (
type TestAuth struct { type TestAuth struct {
TestToken auth.AuthTokenString TestToken auth.AuthTokenString
FailSigCheck bool
FailGenToken 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 { if a.FailGenToken {
return nil, fmt.Errorf("Test error: fail to generate token") return nil, fmt.Errorf("Test error: fail to generate token")
} }
return &auth.AuthToken{Token: a.TestToken, Scope: Scope}, nil return &auth.AuthToken{Token: a.TestToken, UserId: userId, DeviceId: deviceId, 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
} }
type TestStore struct { type TestStore struct {
FailSave bool FailSave bool
FailLogin bool
SaveTokenCalled bool SaveTokenCalled bool
} }
@ -44,29 +37,31 @@ func (s *TestStore) SaveToken(token *auth.AuthToken) error {
return nil return nil
} }
func (s *TestStore) GetToken(auth.PublicKey, string) (*auth.AuthToken, error) { func (s *TestStore) GetToken(auth.AuthTokenString) (*auth.AuthToken, error) {
return nil, nil return nil, nil
} }
func (s *TestStore) GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error) { func (s *TestStore) GetUserId(auth.Email, auth.Password) (auth.UserId, error) {
return "", nil 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 return nil
} }
func (s *TestStore) SetWalletState( func (s *TestStore) SetWalletState(
pubKey auth.PublicKey, UserId auth.UserId,
walletStateJson string, walletStateJson string,
sequence int, sequence int,
signature auth.Signature, hmac wallet.WalletStateHmac,
downloadKey auth.DownloadKey, ) (latestWalletStateJson string, latestHmac wallet.WalletStateHmac, updated bool, err error) {
) (latestWalletStateJson string, latestSignature auth.Signature, updated bool, err error) {
return 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 return
} }

View file

@ -11,25 +11,19 @@ import (
type WalletStateRequest struct { type WalletStateRequest struct {
Token auth.AuthTokenString `json:"token"` Token auth.AuthTokenString `json:"token"`
BodyJSON string `json:"bodyJSON"` WalletStateJson string `json:"walletStateJson"`
PubKey auth.PublicKey `json:"publicKey"` Hmac wallet.WalletStateHmac `json:"hmac"`
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"`
} }
func (r *WalletStateRequest) validate() bool { func (r *WalletStateRequest) validate() bool {
return (r.Token != auth.AuthTokenString("") && return (r.Token != auth.AuthTokenString("") &&
r.BodyJSON != "" && r.WalletStateJson != "" &&
r.PubKey != auth.PublicKey("") && r.Hmac != wallet.WalletStateHmac(""))
r.Signature != auth.Signature(""))
} }
type WalletStateResponse struct { type WalletStateResponse struct {
BodyJSON string `json:"bodyJSON"` WalletStateJson string `json:"walletStateJson"`
Signature auth.Signature `json:"signature"` 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 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 { } else if req.Method == http.MethodPost {
s.postWalletState(w, req) s.postWalletState(w, req)
} else { } else {
errorJSON(w, http.StatusMethodNotAllowed, "") errorJson(w, http.StatusMethodNotAllowed, "")
} }
} }
// TODO - There's probably a struct-based solution here like with POST/PUT. // TODO - There's probably a struct-based solution here like with POST/PUT.
// We could put that struct up top as well. // 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"] 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 { if !hasTokenSlice {
err = fmt.Errorf("Missing token parameter") err = fmt.Errorf("Missing token parameter")
} }
if !hasPubKey {
err = fmt.Errorf("Missing publicKey parameter")
}
if err == nil { if err == nil {
deviceId = deviceIDSlice[0]
token = auth.AuthTokenString(tokenSlice[0]) token = auth.AuthTokenString(tokenSlice[0])
pubKey = auth.PublicKey(pubKeySlice[0])
} }
return return
@ -74,39 +58,41 @@ func (s *Server) getWalletState(w http.ResponseWriter, req *http.Request) {
return return
} }
pubKey, deviceId, token, err := getWalletStateParams(req) token, paramsErr := getWalletStateParams(req)
if err != nil { if paramsErr != nil {
// In this specific case, err is limited to values that are safe to give to // In this specific case, the error is limited to values that are safe to
// the user // give to the user.
errorJSON(w, http.StatusBadRequest, err.Error()) errorJson(w, http.StatusBadRequest, paramsErr.Error())
return return
} }
if !s.checkAuth(w, pubKey, deviceId, token, auth.ScopeGetWalletState) { authToken := s.checkAuth(w, token, auth.ScopeGetWalletState)
if authToken == nil {
return return
} }
latestWalletStateJSON, latestSignature, err := s.store.GetWalletState(pubKey) latestWalletStateJson, latestHmac, err := s.store.GetWalletState(authToken.UserId)
var response []byte var response []byte
if err == store.ErrNoWalletState { if err == store.ErrNoWalletState {
errorJSON(w, http.StatusNotFound, "No wallet state") errorJson(w, http.StatusNotFound, "No wallet state")
return return
} else if err != nil { } else if err != nil {
internalServiceErrorJSON(w, err, "Error retrieving walletState") internalServiceErrorJson(w, err, "Error retrieving walletState")
return return
} }
walletStateResponse := WalletStateResponse{ walletStateResponse := WalletStateResponse{
BodyJSON: latestWalletStateJSON, WalletStateJson: latestWalletStateJson,
Signature: latestSignature, Hmac: latestHmac,
} }
response, err = json.Marshal(walletStateResponse) response, err = json.Marshal(walletStateResponse)
if err != nil { if err != nil {
internalServiceErrorJSON(w, err, "Error generating latestWalletState response") internalServiceErrorJson(w, err, "Error generating latestWalletState response")
return return
} }
@ -119,44 +105,33 @@ func (s *Server) postWalletState(w http.ResponseWriter, req *http.Request) {
return return
} }
if !s.auth.IsValidSignature(walletStateRequest.PubKey, walletStateRequest.BodyJSON, walletStateRequest.Signature) { var walletStateMetadata wallet.WalletStateMetadata
errorJSON(w, http.StatusBadRequest, "Bad signature") if err := json.Unmarshal([]byte(walletStateRequest.WalletStateJson), &walletStateMetadata); err != nil {
errorJson(w, http.StatusBadRequest, "Malformed walletStateJson")
return return
} }
var walletState wallet.WalletState if s.walletUtil.ValidateWalletStateMetadata(&walletStateMetadata) {
if err := json.Unmarshal([]byte(walletStateRequest.BodyJSON), &walletState); err != nil {
errorJSON(w, http.StatusBadRequest, "Malformed walletState JSON")
return
}
if s.walletUtil.ValidateWalletState(&walletState) {
// TODO // TODO
} }
if !s.checkAuth( authToken := s.checkAuth(w, walletStateRequest.Token, auth.ScopeFull)
w, if authToken == nil {
walletStateRequest.PubKey,
walletState.DeviceID,
walletStateRequest.Token,
auth.ScopeFull,
) {
return return
} }
// TODO - We could do an extra check - pull from db, make sure the new // 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 // 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. // 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 // We do already do some validation checks here, but those doesn't require
// new database calls. // new database calls.
latestWalletStateJSON, latestSignature, updated, err := s.store.SetWalletState( latestWalletStateJson, latestHmac, updated, err := s.store.SetWalletState(
walletStateRequest.PubKey, authToken.UserId,
walletStateRequest.BodyJSON, walletStateRequest.WalletStateJson,
walletState.Sequence(), walletStateMetadata.Sequence(),
walletStateRequest.Signature, walletStateRequest.Hmac,
walletStateRequest.DownloadKey,
) )
var response []byte 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 // there was nothing there. This should only happen if the client sets
// sequence != 1 for the first walletState, which would be a bug. // sequence != 1 for the first walletState, which would be a bug.
// TODO - figure out better error messages and/or document this // 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 return
} else if err != nil { } else if err != nil {
// Something other than sequence error // Something other than sequence error
internalServiceErrorJSON(w, err, "Error saving walletState") internalServiceErrorJson(w, err, "Error saving walletState")
return return
} }
walletStateResponse := WalletStateResponse{ walletStateResponse := WalletStateResponse{
BodyJSON: latestWalletStateJSON, WalletStateJson: latestWalletStateJson,
Signature: latestSignature, Hmac: latestHmac,
} }
if !updated { if !updated {
// TODO - should we even call this an error? // 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) response, err = json.Marshal(walletStateResponse)
if err != nil { if err != nil {
internalServiceErrorJSON(w, err, "Error generating walletState response") internalServiceErrorJson(w, err, "Error generating walletStateResponse")
return return
} }

View file

@ -25,7 +25,7 @@ func TestServerPostWalletTooLate(t *testing.T) {
} }
func TestServerPostWalletErrors(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 != 1 for first entry
// Client sends sequence == x + 10 for xth entry or whatever // Client sends sequence == x + 10 for xth entry or whatever
t.Fatalf("Test me: PostWallet fails for various reasons") t.Fatalf("Test me: PostWallet fails for various reasons")
@ -33,7 +33,6 @@ func TestServerPostWalletErrors(t *testing.T) {
func TestServerValidateWalletStateRequest(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 // 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()") t.Fatalf("Test me: Implement and test WalletStateRequest.validate()")
} }

View file

@ -1,6 +1,6 @@
package store 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 ( import (
"database/sql" "database/sql"
@ -10,6 +10,7 @@ import (
"github.com/mattn/go-sqlite3" "github.com/mattn/go-sqlite3"
"log" "log"
"orblivion/lbry-id/auth" "orblivion/lbry-id/auth"
"orblivion/lbry-id/wallet"
"time" "time"
) )
@ -23,17 +24,17 @@ var (
ErrDuplicateEmail = fmt.Errorf("Email already exists for this user") ErrDuplicateEmail = fmt.Errorf("Email already exists for this user")
ErrDuplicateAccount = fmt.Errorf("User already has an account") 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 // For test stubs
type StoreInterface interface { type StoreInterface interface {
SaveToken(*auth.AuthToken) error SaveToken(*auth.AuthToken) error
GetToken(auth.PublicKey, string) (*auth.AuthToken, error) GetToken(auth.AuthTokenString) (*auth.AuthToken, error)
SetWalletState(auth.PublicKey, string, int, auth.Signature, auth.DownloadKey) (string, auth.Signature, bool, error) SetWalletState(auth.UserId, string, int, wallet.WalletStateHmac) (string, wallet.WalletStateHmac, bool, error)
GetWalletState(auth.PublicKey) (string, auth.Signature, error) GetWalletState(auth.UserId) (string, wallet.WalletStateHmac, error)
GetPublicKey(string, auth.DownloadKey) (auth.PublicKey, error) GetUserId(auth.Email, auth.Password) (auth.UserId, error)
InsertEmail(auth.PublicKey, string) (err error) CreateAccount(auth.Email, auth.Password) (err error)
} }
type Store struct { type Store struct {
@ -56,29 +57,41 @@ func (s *Store) Migrate() error {
// specify "WHERE sequence=5". Only one of these commands will succeed, and // specify "WHERE sequence=5". Only one of these commands will succeed, and
// the other will get back an error. // 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? // TODO does it actually fail with empty "NOT NULL" fields?
query := ` query := `
CREATE TABLE IF NOT EXISTS auth_tokens( CREATE TABLE IF NOT EXISTS auth_tokens(
token TEXT NOT NULL, token TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL, user_id INTEGER NOT NULL,
device_id TEXT NOT NULL, device_id TEXT NOT NULL,
scope TEXT NOT NULL, scope TEXT NOT NULL,
expiration DATETIME NOT NULL, expiration DATETIME NOT NULL,
PRIMARY KEY (device_id) PRIMARY KEY (user_id, device_id)
); );
CREATE TABLE IF NOT EXISTS wallet_states( CREATE TABLE IF NOT EXISTS wallet_states(
public_key TEXT NOT NULL, user_id INTEGER NOT NULL,
wallet_state_blob TEXT NOT NULL, wallet_state_blob TEXT NOT NULL,
sequence INTEGER NOT NULL, sequence INTEGER NOT NULL,
signature TEXT NOT NULL, hmac TEXT NOT NULL,
download_key TEXT NOT NULL, PRIMARY KEY (user_id)
PRIMARY KEY (public_key) FOREIGN KEY (user_id) REFERENCES accounts(user_id)
); );
CREATE TABLE IF NOT EXISTS accounts( CREATE TABLE IF NOT EXISTS accounts(
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL, user_id INTEGER PRIMARY KEY AUTOINCREMENT,
PRIMARY KEY (public_key), password TEXT NOT NULL
FOREIGN KEY (public_key) REFERENCES wallet_states(public_key)
); );
` `
@ -90,12 +103,16 @@ func (s *Store) Migrate() error {
// Auth Token // // 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() expirationCutoff := time.Now().UTC()
rows, err := s.db.Query( rows, err := s.db.Query(
"SELECT * FROM auth_tokens WHERE public_key=? AND device_id=? AND expiration>?", "SELECT * FROM auth_tokens WHERE token=? AND expiration>?", token, expirationCutoff,
pubKey, deviceID, expirationCutoff,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@ -107,8 +124,8 @@ func (s *Store) GetToken(pubKey auth.PublicKey, deviceID string) (*auth.AuthToke
err := rows.Scan( err := rows.Scan(
&authToken.Token, &authToken.Token,
&authToken.PubKey, &authToken.UserId,
&authToken.DeviceID, &authToken.DeviceId,
&authToken.Scope, &authToken.Scope,
&authToken.Expiration, &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) { func (s *Store) insertToken(authToken *auth.AuthToken, expiration time.Time) (err error) {
_, err = s.db.Exec( _, err = s.db.Exec(
"INSERT INTO auth_tokens (token, public_key, device_id, scope, expiration) values(?,?,?,?,?)", "INSERT INTO auth_tokens (token, user_id, device_id, scope, expiration) values(?,?,?,?,?)",
authToken.Token, authToken.PubKey, authToken.DeviceID, authToken.Scope, expiration, authToken.Token, authToken.UserId, authToken.DeviceId, authToken.Scope, expiration,
) )
var sqliteErr sqlite3.Error 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) { func (s *Store) updateToken(authToken *auth.AuthToken, experation time.Time) (err error) {
res, err := s.db.Exec( res, err := s.db.Exec(
"UPDATE auth_tokens SET token=?, expiration=?, scope=? WHERE public_key=? AND device_id=?", "UPDATE auth_tokens SET token=?, expiration=?, scope=? WHERE user_id=? AND device_id=?",
authToken.Token, experation, authToken.Scope, authToken.PubKey, authToken.DeviceID, authToken.Token, experation, authToken.Scope, authToken.UserId, authToken.DeviceId,
) )
if err != nil { if err != nil {
return return
@ -191,10 +208,10 @@ func (s *Store) SaveToken(token *auth.AuthToken) (err error) {
// Wallet State / Download Key // // 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( rows, err := s.db.Query(
"SELECT wallet_state_blob, signature FROM wallet_states WHERE public_key=?", "SELECT wallet_state_blob, hmac FROM wallet_states WHERE user_id=?",
pubKey, userId,
) )
if err != nil { if err != nil {
return return
@ -203,8 +220,8 @@ func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, s
for rows.Next() { for rows.Next() {
err = rows.Scan( err = rows.Scan(
&walletStateJSON, &walletStateJson,
&signature, &hmac,
) )
return return
} }
@ -213,17 +230,16 @@ func (s *Store) GetWalletState(pubKey auth.PublicKey) (walletStateJSON string, s
} }
func (s *Store) insertFirstWalletState( func (s *Store) insertFirstWalletState(
pubKey auth.PublicKey, userId auth.UserId,
walletStateJSON string, walletStateJson string,
signature auth.Signature, hmac wallet.WalletStateHmac,
downloadKey auth.DownloadKey,
) (err error) { ) (err error) {
// This will only be used to attempt to insert the first wallet state // 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 // (sequence=1). The database will enforce that this will not be set
// if this user already has a walletState. // if this user already has a walletState.
_, err = s.db.Exec( _, err = s.db.Exec(
"INSERT INTO wallet_states (public_key, wallet_state_blob, sequence, signature, download_key) values(?,?,?,?,?)", "INSERT INTO wallet_states (user_id, wallet_state_blob, sequence, hmac) values(?,?,?,?)",
pubKey, walletStateJSON, 1, signature, downloadKey.Obfuscate(), userId, walletStateJson, 1, hmac,
) )
var sqliteErr sqlite3.Error var sqliteErr sqlite3.Error
@ -239,19 +255,18 @@ func (s *Store) insertFirstWalletState(
} }
func (s *Store) updateWalletStateToSequence( func (s *Store) updateWalletStateToSequence(
pubKey auth.PublicKey, userId auth.UserId,
walletStateJSON string, walletStateJson string,
sequence int, sequence int,
signature auth.Signature, hmac wallet.WalletStateHmac,
downloadKey auth.DownloadKey,
) (err error) { ) (err error) {
// This will be used for wallet states with sequence > 1. // 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. // 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 // This way, if two clients attempt to update at the same time, it will return
// ErrNoWalletState for the second one. // ErrNoWalletState for the second one.
res, err := s.db.Exec( res, err := s.db.Exec(
"UPDATE wallet_states SET wallet_state_blob=?, sequence=?, signature=?, download_key=? WHERE public_key=? AND sequence=?", "UPDATE wallet_states SET wallet_state_blob=?, sequence=?, hmac=? WHERE user_id=? AND sequence=?",
walletStateJSON, sequence, signature, downloadKey.Obfuscate(), pubKey, sequence-1, walletStateJson, sequence, hmac, userId, sequence-1,
) )
if err != nil { if err != nil {
return return
@ -269,25 +284,24 @@ func (s *Store) updateWalletStateToSequence(
// Assumption: walletState has been validated (sequence >=1, etc) // Assumption: walletState has been validated (sequence >=1, etc)
// Assumption: Sequence matches walletState.Sequence() // Assumption: Sequence matches walletState.Sequence()
// Sequence is only passed in here to avoid deserializing walletStateJSON again // Sequence is only passed in here to avoid deserializing walletStateJson again
// WalletState *struct* is not passed in because we need the exact signed string // WalletState *struct* is not passed in because the clients need the exact string to match the hmac
func (s *Store) SetWalletState( func (s *Store) SetWalletState(
pubKey auth.PublicKey, userId auth.UserId,
walletStateJSON string, walletStateJson string,
sequence int, sequence int,
signature auth.Signature, hmac wallet.WalletStateHmac,
downloadKey auth.DownloadKey, ) (latestWalletStateJson string, latestHmac wallet.WalletStateHmac, updated bool, err error) {
) (latestWalletStateJSON string, latestSignature auth.Signature, updated bool, err error) {
if sequence == 1 { if sequence == 1 {
// If sequence == 1, the client assumed that this is our first // If sequence == 1, the client assumed that this is our first
// walletState. Try to insert. If we get a conflict, the client // walletState. Try to insert. If we get a conflict, the client
// assumed incorrectly and we proceed below to return the latest // assumed incorrectly and we proceed below to return the latest
// walletState from the db. // walletState from the db.
err = s.insertFirstWalletState(pubKey, walletStateJSON, signature, downloadKey) err = s.insertFirstWalletState(userId, walletStateJson, hmac)
if err == nil { if err == nil {
// Successful update // Successful update
latestWalletStateJSON = walletStateJSON latestWalletStateJson = walletStateJson
latestSignature = signature latestHmac = hmac
updated = true updated = true
return return
} else if err != ErrDuplicateWalletState { } else if err != ErrDuplicateWalletState {
@ -299,10 +313,10 @@ func (s *Store) SetWalletState(
// with sequence - 1. Explicitly try to update the walletState with // with sequence - 1. Explicitly try to update the walletState with
// sequence - 1. If we updated no rows, the client assumed incorrectly // sequence - 1. If we updated no rows, the client assumed incorrectly
// and we proceed below to return the latest walletState from the db. // 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 { if err == nil {
latestWalletStateJSON = walletStateJSON latestWalletStateJson = walletStateJson
latestSignature = signature latestHmac = hmac
updated = true updated = true
return return
} else if err != ErrNoWalletState { } 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 // 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 // already accounted for it with `updated=false`. Instead, we'll pass on any
// errors from calling `GetWalletState`. // errors from calling `GetWalletState`.
latestWalletStateJSON, latestSignature, err = s.GetWalletState(pubKey) latestWalletStateJson, latestHmac, err = s.GetWalletState(userId)
return 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( rows, err := s.db.Query(
`SELECT ws.public_key from wallet_states ws INNER JOIN accounts a `SELECT user_id from accounts WHERE email=? AND password=?`,
ON a.public_key=ws.public_key email, password.Obfuscate(),
WHERE email=? AND download_key=?`,
email, downloadKey.Obfuscate(),
) )
if err != nil { if err != nil {
return return
@ -334,27 +346,30 @@ func (s *Store) GetPublicKey(email string, downloadKey auth.DownloadKey) (pubKey
defer rows.Close() defer rows.Close()
for rows.Next() { for rows.Next() {
err = rows.Scan(&pubKey) err = rows.Scan(&userId)
return return
} }
err = ErrNoPubKey err = ErrNoUId
return 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( _, err = s.db.Exec(
"INSERT INTO accounts (public_key, email) values(?,?)", "INSERT INTO accounts (email, password) values(?,?)",
pubKey, email, email, password.Obfuscate(),
) )
var sqliteErr sqlite3.Error var sqliteErr sqlite3.Error
if errors.As(err, &sqliteErr) { if errors.As(err, &sqliteErr) {
// I initially expected to need to check for ErrConstraintUnique. // I initially expected to need to check for ErrConstraintUnique.
// Maybe for psql it will be? // 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) { if errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey) {
err = ErrDuplicateEmail err = ErrDuplicateEmail
} }

View file

@ -19,52 +19,54 @@ func TestStoreInsertToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t) s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile) defer StoreTestCleanup(sqliteTmpFile)
// created for addition to the DB (no expiration attached)
authToken1 := auth.AuthToken{ authToken1 := auth.AuthToken{
Token: "seekrit-1", Token: "seekrit-1",
DeviceID: "dID", DeviceId: "dId",
Scope: "*", Scope: "*",
PubKey: "pubKey", UserId: 123,
} }
expiration := time.Now().Add(time.Hour * 24 * 14).UTC()
// 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"
// Get a token, come back empty // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
} }
// Put in a token // 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) 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 // 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1DB) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1Expected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken1DB, *gotToken) t.Fatalf("token: \n expected %+v\n got: %+v", authToken1Expected, *gotToken)
} }
// Try to put a different token, fail becaues we already have one // Try to put a different token, fail because we already have one
if err := s.insertToken(&authToken2, *authToken1DB.Expiration); err != ErrDuplicateToken { authToken2 := authToken1
authToken2.Token = "seekrit-2"
if err := s.insertToken(&authToken2, expiration); err != ErrDuplicateToken {
t.Fatalf(`insertToken err: wanted "%+v", got "%+v"`, ErrDuplicateToken, err) t.Fatalf(`insertToken err: wanted "%+v", got "%+v"`, ErrDuplicateToken, err)
} }
// Get the same *first* token we successfully put in // 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1DB) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken1Expected) {
t.Fatalf("token: expected %+v, got: %+v", authToken1DB, gotToken) t.Fatalf("token: expected %+v, got: %+v", authToken1Expected, gotToken)
} }
} }
@ -76,57 +78,67 @@ func TestStoreUpdateToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t) s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile) defer StoreTestCleanup(sqliteTmpFile)
authToken1 := auth.AuthToken{ // created for addition to the DB (no expiration attached)
Token: "seekrit-1", authTokenUpdate := auth.AuthToken{
DeviceID: "dID", Token: "seekrit-update",
DeviceId: "dId",
Scope: "*", Scope: "*",
PubKey: "pubKey", UserId: 123,
} }
authToken2 := authToken1 expiration := time.Now().Add(time.Hour * 24 * 14).UTC()
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())
// Try to get a token, come back empty because we're just starting out // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) 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 // 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) 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 // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
} }
// Put in a token - just so we have something to test updateToken with // Put in a different token, just so we have something to test that
if err := s.insertToken(&authToken1, *authToken2DB.Expiration); err != nil { // 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) t.Fatalf("Unexpected error in insertToken: %+v", err)
} }
// Now successfully update token // 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) 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 // 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken2DB) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenUpdateExpected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken2DB, *gotToken) 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) // Test first and second Save (one for insert, one for update)
// Get fails initially // Get fails initially
// Put token1-d1 token1-d2 // Put token1-d1 token1-d2
@ -138,30 +150,24 @@ func TestStoreSaveToken(t *testing.T) {
defer StoreTestCleanup(sqliteTmpFile) defer StoreTestCleanup(sqliteTmpFile)
// Version 1 of the token for both devices // Version 1 of the token for both devices
// created for addition to the DB (no expiration attached)
authToken_d1_1 := auth.AuthToken{ authToken_d1_1 := auth.AuthToken{
Token: "seekrit-d1-1", Token: "seekrit-d1-1",
DeviceID: "dID-1", DeviceId: "dId-1",
Scope: "*", Scope: "*",
PubKey: "pubKey", UserId: 123,
} }
authToken_d2_1 := authToken_d1_1 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" 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 // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) 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 // 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_1) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_1) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken_d1_1, gotToken) 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) 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) 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 // Save Version 2 tokens for both devices
if err = s.SaveToken(&authToken_d1_2); err != nil { if err = s.SaveToken(&authToken_d1_2); err != nil {
t.Fatalf("Unexpected error in SaveToken: %+v", err) 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 // 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_2) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authToken_d1_2) {
t.Fatalf("token: \n expected %+v\n got: %+v", authToken_d1_2, gotToken) 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 { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) 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) // test GetToken using insertToken and updateToken as helpers (so we can set expiration timestamps)
// normal // normal
// not found for pubkey // token not found
// not found for device (one for another device does exist) // expired not returned
// expired token not returned
func TestStoreGetToken(t *testing.T) { func TestStoreGetToken(t *testing.T) {
s, sqliteTmpFile := StoreTestInit(t) s, sqliteTmpFile := StoreTestInit(t)
defer StoreTestCleanup(sqliteTmpFile) defer StoreTestCleanup(sqliteTmpFile)
@ -245,39 +257,34 @@ func TestStoreGetToken(t *testing.T) {
// created for addition to the DB (no expiration attached) // created for addition to the DB (no expiration attached)
authToken := auth.AuthToken{ authToken := auth.AuthToken{
Token: "seekrit-d1", Token: "seekrit-d1",
DeviceID: "dID", DeviceId: "dId",
Scope: "*", Scope: "*",
PubKey: "pubKey", UserId: 123,
} }
expiration := time.Time(time.Now().UTC().Add(time.Hour * 24 * 14))
// 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)))
// Not found (nothing saved for this pubkey) // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err) t.Fatalf("Expected ErrNoToken. token: %+v err: %+v", gotToken, err)
} }
// Put in a token // 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) 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 // Confirm it saved
gotToken, err = s.GetToken(authToken.PubKey, authToken.DeviceID) gotToken, err = s.GetToken(authToken.Token)
if err != nil { if err != nil {
t.Fatalf("Unexpected error in GetToken: %+v", err) t.Fatalf("Unexpected error in GetToken: %+v", err)
} }
if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenDB) { if gotToken == nil || !reflect.DeepEqual(*gotToken, authTokenExpected) {
t.Fatalf("token: \n expected %+v\n got: %+v", authTokenDB, gotToken) t.Fatalf("token: \n expected %+v\n got: %+v", authTokenExpected, 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)
} }
// Update the token to be expired // Update the token to be expired
@ -287,7 +294,7 @@ func TestStoreGetToken(t *testing.T) {
} }
// Fail to get the expired token // 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 { if gotToken != nil || err != ErrNoToken {
t.Fatalf("Expected ErrNoToken, for expired token. token: %+v err: %+v", gotToken, err) 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 ## Initial setup and account recovery
``` Set up two clients with the same account (which won't exist on the server yet).
>>> 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)
``` ```
>>> c1.new_wallet('email@example.com', '123') >>> from test_client import Client
>>> c1.get_full_auth_token() >>> c1 = Client()
Got auth token: 787cefea147f3a7b38e1b9fda49490371b52a3b7077507364854b72c3538f94e >>> 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() >>> c1.device_id
Successfully updated wallet state '974690df-85a6-481d-9015-6293226db8c9'
Got new walletState: {'deviceId': 'e0349bc4-7e7a-48a2-a562-6c530b28a350', 'lastSynced': {'e0349bc4-7e7a-48a2-a562-6c530b28a350': 1}, 'encryptedWallet': ''} >>> 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. Register the account on the server with one of the clients.
Send the email address
``` ```
>>> c1.register() >>> c1.register()
Registered Registered
``` ```
Now let's set up a second device Now that the account exists, grab an auth token with both clients.
```
>>> 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)
``` ```
>>> c1.get_full_auth_token()
Got auth token: 941e5159a2caff15f0bdc1c0e6da92691d3073543dbfae810cfe57d51c35f0e0
>>> c2.get_full_auth_token() >>> 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() >>> 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() >>> c2.post_wallet_state()
Error 403 Successfully updated wallet state on server
b'{"error":"Forbidden: Scope"}\n' 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. ## 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.)
>>> 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
``` ```
>>> c1.cur_encrypted_wallet()
''
>>> c1.change_encrypted_wallet() >>> c1.change_encrypted_wallet()
>>> c1.cur_encrypted_wallet() >>> c1.cur_encrypted_wallet()
':f801' ':2fbE'
```
The wallet is synced between the clients.
```
>>> c1.post_wallet_state() >>> c1.post_wallet_state()
Successfully updated wallet state Successfully updated wallet state on server
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 2}, 'encryptedWallet': ':f801'} Got new walletState:
>>> c1.cur_encrypted_wallet() {'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
':f801' 'encryptedWallet': ':2fbE',
``` 'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 3}}
The other client gets the update and sees the same thing locally:
```
>>> c2.get_wallet_state() >>> 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() >>> 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() >>> c1.change_encrypted_wallet()
>>> c2.change_encrypted_wallet() >>> c2.change_encrypted_wallet()
>>> c1.cur_encrypted_wallet() >>> c1.cur_encrypted_wallet()
':f801:576b' ':2fbE:BD62'
>>> c2.cur_encrypted_wallet() >>> 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() >>> c1.post_wallet_state()
Successfully updated wallet state Successfully updated wallet state on server
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'} Got new walletState:
{'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
>>> c2.post_wallet_state() 'encryptedWallet': ':2fbE:BD62',
Wallet state out of date. Getting updated wallet state. Try again. 'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
Got new walletState: {'deviceId': 'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938', 'lastSynced': {'f9acb3bb-ec3b-43f9-9c93-b279b9fdc938': 3}, 'encryptedWallet': ':f801:576b'} '974690df-85a6-481d-9015-6293226db8c9': 4}}
``` >>> c2.get_wallet_state()
Got latest walletState:
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`). {'deviceId': '974690df-85a6-481d-9015-6293226db8c9',
'encryptedWallet': ':2fbE:BD62',
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.) 'lastSynced': {'545643c9-ee47-443d-b260-cb9178b8646c': 2,
'974690df-85a6-481d-9015-6293226db8c9': 4}}
```
>>> c2.cur_encrypted_wallet() >>> 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() >>> c2.post_wallet_state()
Successfully updated wallet state Successfully updated wallet state on server
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'} 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() >>> 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() >>> 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 #!/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' BASE_URL = 'http://localhost:8090'
AUTH_FULL_URL = BASE_URL + '/auth/full' AUTH_FULL_URL = BASE_URL + '/auth/full'
@ -12,8 +13,21 @@ def wallet_state_sequence(wallet_state):
return 0 return 0
return wallet_state['lastSynced'][wallet_state['deviceId']] return wallet_state['lastSynced'][wallet_state['deviceId']]
def download_key(password): # TODO - do this correctly
return hashlib.sha256(password.encode('utf-8')).hexdigest() 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(): class Client():
def _validate_new_wallet_state(self, new_wallet_state): def _validate_new_wallet_state(self, new_wallet_state):
@ -48,25 +62,34 @@ class Client():
self.wallet_state = None self.wallet_state = None
def new_wallet(self, email, password): # TODO - save change to disk in between, associated with account and/or
# Obviously not real behavior # wallet
self.public_key = ''.join(random.choice(string.hexdigits) for x in range(32)) 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 # camel-cased to ease json interop
self.wallet_state = {'lastSynced': {}, 'encryptedWallet': ''} self.wallet_state = {'lastSynced': {}, 'encryptedWallet': ''}
# TODO - actual encryption with password # TODO - actual encryption with encryption_key
self._encrypted_wallet_local_changes = '' self._encrypted_wallet_local_changes = ''
def set_account(self, email, root_password):
self.email = email self.email = email
self.password = password self.root_password = root_password
def register(self): def register(self):
body = json.dumps({ body = json.dumps({
'token': self.auth_token,
'publicKey': self.public_key,
'deviceId': self.device_id,
'email': self.email, 'email': self.email,
'password': create_login_password(self.root_password),
}) })
response = requests.post(REGISTER_URL, body) response = requests.post(REGISTER_URL, body)
if response.status_code != 201: if response.status_code != 201:
@ -78,7 +101,7 @@ class Client():
def get_download_auth_token(self, email, password): def get_download_auth_token(self, email, password):
body = json.dumps({ body = json.dumps({
'email': email, 'email': email,
'downloadKey': download_key(password), 'password': create_login_password(password),
'deviceId': self.device_id, 'deviceId': self.device_id,
}) })
response = requests.post(AUTH_GET_WALLET_STATE_URL, body) response = requests.post(AUTH_GET_WALLET_STATE_URL, body)
@ -87,22 +110,18 @@ class Client():
print (response.content) print (response.content)
return return
self.auth_token = json.loads(response.content)['token'] 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 auth token: ", self.auth_token)
print ("Got public key: ", self.public_key)
self.email = email 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): 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({ body = json.dumps({
'tokenRequestJSON': json.dumps({'deviceId': self.device_id, 'requestTime': int(time.time())}), 'email': self.email,
'publicKey': self.public_key, 'password': create_login_password(self.root_password),
'signature': 'Good Signature', 'deviceId': self.device_id,
}) })
response = requests.post(AUTH_FULL_URL, body) response = requests.post(AUTH_FULL_URL, body)
if response.status_code != 200: if response.status_code != 200:
@ -112,11 +131,14 @@ class Client():
self.auth_token = json.loads(response.content)['token'] self.auth_token = json.loads(response.content)['token']
print ("Got auth token: ", self.auth_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): def get_wallet_state(self):
params = { params = {
'token': self.auth_token, 'token': self.auth_token,
'publicKey': self.public_key,
'deviceId': self.device_id,
} }
response = requests.get(WALLET_STATE_URL, params=params) response = requests.get(WALLET_STATE_URL, params=params)
if response.status_code != 200: if response.status_code != 200:
@ -124,17 +146,16 @@ class Client():
print (response.content) print (response.content)
return return
if json.loads(response.content)['signature'] != "Good Signature": new_wallet_state_str = json.loads(response.content)['walletStateJson']
print ('Error - bad signature on new wallet') new_wallet_state = json.loads(new_wallet_state_str)
print (response.content) encryption_key = create_encryption_key(self.root_password)
return hmac = json.loads(response.content)['hmac']
if response.status_code != 200: if not check_hmac(new_wallet_state_str, encryption_key, hmac):
print ('Error', response.status_code) print ('Error - bad hmac on new wallet')
print (response.content) print (response.content)
return return
# In reality, we'd examine, merge, verify, validate etc this new wallet state. # 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): 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 ('Error - new wallet does not validate')
print (response.content) print (response.content)
@ -144,58 +165,63 @@ class Client():
# This is if we're getting a wallet_state for the first time. Initialize # This is if we're getting a wallet_state for the first time. Initialize
# the local changes. # the local changes.
self._encrypted_wallet_local_changes = '' self._encrypted_wallet_local_changes = ''
self.wallet_state = new_wallet_state 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): def post_wallet_state(self):
# Create a *new* wallet state, indicating that it was last updated by this # 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. # 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 # Don't set self.wallet_state to this until we know that it's accepted by
# the server. # the server.
if self.wallet_state: if not self.wallet_state:
print ("No wallet state to post.")
return
submitted_wallet_state = { submitted_wallet_state = {
"deviceId": self.device_id, "deviceId": self.device_id,
"lastSynced": dict(self.wallet_state['lastSynced']), "lastSynced": dict(self.wallet_state['lastSynced']),
"encryptedWallet": self.cur_encrypted_wallet(), "encryptedWallet": self.cur_encrypted_wallet(),
} }
submitted_wallet_state['lastSynced'][self.device_id] = wallet_state_sequence(self.wallet_state) + 1 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({ body = json.dumps({
'token': self.auth_token, 'token': self.auth_token,
'bodyJSON': json.dumps(submitted_wallet_state), 'walletStateJson': submitted_wallet_state_str,
'publicKey': self.public_key, 'hmac': submitted_wallet_state_hmac
'downloadKey': download_key(self.password),
'signature': 'Good Signature',
}) })
response = requests.post(WALLET_STATE_URL, body) response = requests.post(WALLET_STATE_URL, body)
if response.status_code == 200: if response.status_code == 200:
# Our local changes are no longer local, so we reset them # Our local changes are no longer local, so we reset them
self._encrypted_wallet_local_changes = '' self._encrypted_wallet_local_changes = ''
print ('Successfully updated wallet state') print ('Successfully updated wallet state on server')
elif response.status_code == 409: elif response.status_code == 409:
print ('Wallet state out of date. Getting updated wallet state. Try again.') 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: else:
print ('Error', response.status_code) print ('Error', response.status_code)
print (response.content) print (response.content)
return return
if json.loads(response.content)['signature'] != "Good Signature": # Now we get a new wallet state back as a response
print ('Error - bad signature on new wallet') # 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) print (response.content)
return return
# In reality, we'd examine, merge, verify, validate etc this new wallet state. # 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): 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 ('Error - new wallet does not validate')
print (response.content) print (response.content)
@ -203,7 +229,8 @@ class Client():
self.wallet_state = new_wallet_state 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): def change_encrypted_wallet(self):
if not self.wallet_state: if not self.wallet_state:

View file

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

View file

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