diff --git a/cmd/dkl-local-server/auth.go b/cmd/dkl-local-server/auth.go index 5afd882..0ad4db9 100644 --- a/cmd/dkl-local-server/auth.go +++ b/cmd/dkl-local-server/auth.go @@ -26,7 +26,7 @@ func authorizeToken(r *http.Request, token string) bool { } func forbidden(w http.ResponseWriter, r *http.Request) { - log.Printf("denied access to %s from %s", r.RequestURI, r.RemoteAddr) + log.Printf("denied access to %s from %s", r.URL.Path, r.RemoteAddr) http.Error(w, "Forbidden", http.StatusForbidden) } diff --git a/cmd/dkl-local-server/main.go b/cmd/dkl-local-server/main.go index af329a2..bf4692b 100644 --- a/cmd/dkl-local-server/main.go +++ b/cmd/dkl-local-server/main.go @@ -52,12 +52,12 @@ func main() { } if autoUnlock != "" { log.Printf("auto-unlocking the store") - err := unlockSecretStore([]byte(autoUnlock)) + err := unlockSecretStore("test", []byte(autoUnlock)) if err.Any() { log.Fatal(err) } - log.Print("store auto-unlocked, admin token is ", adminToken) + log.Print("store auto-unlocked, token is ", adminToken) } os.Setenv("DLS_AUTO_UNLOCK", "") diff --git a/cmd/dkl-local-server/secret-store.go b/cmd/dkl-local-server/secret-store.go index 94b7950..ebbf157 100644 --- a/cmd/dkl-local-server/secret-store.go +++ b/cmd/dkl-local-server/secret-store.go @@ -63,7 +63,7 @@ var ( ErrInvalidPassphrase = httperr.NewStd(2, http.StatusBadRequest, "invalid passphrase") ) -func unlockSecretStore(passphrase []byte) (err httperr.Error) { +func unlockSecretStore(name string, passphrase []byte) (err httperr.Error) { unlockMutex.Lock() defer unlockMutex.Unlock() @@ -72,7 +72,7 @@ func unlockSecretStore(passphrase []byte) (err httperr.Error) { } if secStore.IsNew() { - err := secStore.Init(passphrase) + err := secStore.Init(name, passphrase) if err != nil { return httperr.Internal(err) } diff --git a/cmd/dkl-local-server/state.go b/cmd/dkl-local-server/state.go index 4c4bbd9..e71542a 100644 --- a/cmd/dkl-local-server/state.go +++ b/cmd/dkl-local-server/state.go @@ -22,6 +22,7 @@ type State struct { Store struct { DownloadToken string + KeyNames []string } Clusters []ClusterState @@ -59,14 +60,21 @@ func init() { func updateState() { log.Print("updating state") + // store key names + keyNames := make([]string, 0, len(secStore.Keys)) + for _, key := range secStore.Keys { + keyNames = append(keyNames, key.Name) + } + + // config cfg, err := readConfig() if err != nil { - wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil }) + wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil; v.Store.KeyNames = keyNames }) return } if secStore.IsNew() || !secStore.Unlocked() { - wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil }) + wState.Change(func(v *State) { v.HasConfig = false; v.Config = nil; v.Store.KeyNames = keyNames }) return } @@ -122,7 +130,7 @@ func updateState() { // done wState.Change(func(v *State) { v.HasConfig = true - //v.Config = cfg + v.Store.KeyNames = keyNames v.Clusters = clusters v.Hosts = hosts }) diff --git a/cmd/dkl-local-server/ws-public.go b/cmd/dkl-local-server/ws-public.go index 67e0576..e7e52f1 100644 --- a/cmd/dkl-local-server/ws-public.go +++ b/cmd/dkl-local-server/ws-public.go @@ -14,15 +14,32 @@ import ( "m.cluseau.fr/go/httperr" ) +type NamedPassphrase struct { + Name string + Passphrase []byte +} + func wsUnlockStore(req *restful.Request, resp *restful.Response) { - var passphrase string - err := req.ReadEntity(&passphrase) + np := NamedPassphrase{} + err := req.ReadEntity(&np) if err != nil { resp.WriteError(http.StatusBadRequest, err) return } - if err := unlockSecretStore([]byte(passphrase)); err.Any() { + if secStore.IsNew() { + if len(np.Name) == 0 { + wsBadRequest(resp, "no name given") + return + } + } + + if len(np.Passphrase) == 0 { + wsBadRequest(resp, "no passphrase given") + return + } + + if err := unlockSecretStore(np.Name, np.Passphrase); err.Any() { err.WriteJSON(resp.ResponseWriter) return } diff --git a/cmd/dkl-local-server/ws-store.go b/cmd/dkl-local-server/ws-store.go index 12b4923..331b3ca 100644 --- a/cmd/dkl-local-server/ws-store.go +++ b/cmd/dkl-local-server/ws-store.go @@ -1,24 +1,77 @@ package main import ( + "strconv" + "strings" + restful "github.com/emicklei/go-restful" + "novit.tech/direktil/local-server/secretstore" ) func wsStoreAddKey(req *restful.Request, resp *restful.Response) { - var passphrase string + np := NamedPassphrase{} - err := req.ReadEntity(&passphrase) + err := req.ReadEntity(&np) if err != nil { wsBadRequest(resp, err.Error()) return } - if len(passphrase) == 0 { + np.Name = strings.TrimSpace(np.Name) + + if len(np.Name) == 0 { + wsBadRequest(resp, "no name given") + return + } + + if len(np.Passphrase) == 0 { wsBadRequest(resp, "no passphrase given") return } - secStore.AddKey([]byte(passphrase)) + secStore.AddKey(np.Name, np.Passphrase) + defer updateState() + + for _, k := range secStore.Keys { + if k.Name == np.Name { + wsBadRequest(resp, "there's already a passphrase named "+strconv.Quote(np.Name)) + return + } + } + + err = secStore.SaveTo(secKeysStorePath()) + if err != nil { + wsError(resp, err) + return + } +} + +func wsStoreDelKey(req *restful.Request, resp *restful.Response) { + name := "" + + err := req.ReadEntity(&name) + if err != nil { + wsBadRequest(resp, err.Error()) + return + } + + newKeys := make([]secretstore.KeyEntry, 0, len(secStore.Keys)) + for _, k := range secStore.Keys { + if k.Name == name { + continue + } + + newKeys = append(newKeys, k) + } + + if len(newKeys) == 0 { + wsBadRequest(resp, "can't remove the last key from the store") + return + } + + secStore.Keys = newKeys + defer updateState() + err = secStore.SaveTo(secKeysStorePath()) if err != nil { wsError(resp, err) diff --git a/cmd/dkl-local-server/ws.go b/cmd/dkl-local-server/ws.go index fb15aa8..a5cc172 100644 --- a/cmd/dkl-local-server/ws.go +++ b/cmd/dkl-local-server/ws.go @@ -27,7 +27,7 @@ func registerWS(rest *restful.Container) { Produces(mime.JSON). Consumes(mime.JSON). Route(ws.POST("/unlock-store").To(wsUnlockStore). - Reads(""). + Reads(NamedPassphrase{}). Writes(""). Doc("Try to unlock the store")). Route(ws.GET("/store.tar").To(wsStoreDownload). @@ -54,8 +54,11 @@ func registerWS(rest *restful.Container) { // - store management ws.Route(ws.POST("/store/add-key").To(wsStoreAddKey). - Consumes(mime.JSON).Reads(""). + Consumes(mime.JSON).Reads(NamedPassphrase{}). Doc("Add an unlock key to the store")) + ws.Route(ws.POST("/store/delete-key").To(wsStoreDelKey). + Consumes(mime.JSON).Reads(""). + Doc("Remove an unlock key to the store (by its name)")) // - downloads ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload). diff --git a/html/ui/app.css b/html/ui/app.css index 8ed48a0..5695f73 100644 --- a/html/ui/app.css +++ b/html/ui/app.css @@ -21,21 +21,3 @@ .cluster { max-width: 50%; } - -#store-infos { - display: flex; - flex-flow: row wrap; - align-content: center; - justify-content: flex-start; - border-bottom: dashed 1pt; - margin-bottom: 1ex; -} -#store-infos > * { - display: block; - font-size: medium; - padding: 2pt 1ex; - margin: 0 0 0 1ex; -} -#store-infos > *:first-child { - margin-left: 0; -} diff --git a/html/ui/index.html b/html/ui/index.html index 5023130..06e8684 100644 --- a/html/ui/index.html +++ b/html/ui/index.html @@ -41,8 +41,9 @@

Store is new.

Option 1: initialize a new store

- - +
+ +

Option 2: upload a previously downloaded store

@@ -54,7 +55,7 @@ @@ -63,28 +64,12 @@

Invalid token

- +
diff --git a/html/ui/js/app.js b/html/ui/js/app.js index f29c75b..87368f4 100644 --- a/html/ui/js/app.js +++ b/html/ui/js/app.js @@ -9,8 +9,9 @@ createApp({ data() { return { forms: { - store: { }, + store: {}, storeUpload: {}, + delKey: {}, }, session: {}, error: null, @@ -67,13 +68,26 @@ createApp({ this.forms.store = {} }, "application/tar") }, + namedPassphrase(name, passphrase) { + return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)} + }, storeAddKey() { - this.apiPost('/store/add-key', this.forms.store.pass1, (v) => { + this.apiPost('/store/add-key', this.namedPassphrase(), (v) => { this.forms.store = {} }) }, + storeDelKey() { + let name = this.forms.delKey.name + + if (!confirm("Remove key named "+JSON.stringify(name)+"?")) { + return + } + this.apiPost('/store/delete-key', name , (v) => { + this.forms.delKey = {} + }) + }, unlockStore() { - this.apiPost('/public/unlock-store', this.forms.store.pass1, (v) => { + this.apiPost('/public/unlock-store', this.namedPassphrase(), (v) => { this.forms.store = {} if (v) { diff --git a/secretstore/secret-store.go b/secretstore/secret-store.go index df290f1..66e4bd1 100644 --- a/secretstore/secret-store.go +++ b/secretstore/secret-store.go @@ -2,29 +2,34 @@ package secretstore import ( "bufio" + "bytes" "crypto/aes" "crypto/cipher" "crypto/sha512" + "encoding/json" "errors" "fmt" "io" "log" "os" + "strconv" "syscall" "golang.org/x/crypto/argon2" ) type Store struct { + Salt [aes.BlockSize]byte + Keys []KeyEntry + unlocked bool key [32]byte - salt [aes.BlockSize]byte - keys []keyEntry } -type keyEntry struct { - hash [64]byte - encKey [32]byte +type KeyEntry struct { + Name string + Hash [64]byte + EncKey [32]byte } func New() (s *Store) { @@ -77,30 +82,32 @@ func (s *Store) Close() { } func (s *Store) IsNew() bool { - return len(s.keys) == 0 + return len(s.Keys) == 0 } func (s *Store) Unlocked() bool { return s.unlocked } -func (s *Store) Init(passphrase []byte) (err error) { +func (s *Store) Init(name string, passphrase []byte) (err error) { err = randRead(s.key[:]) if err != nil { return } - err = randRead(s.salt[:]) + err = randRead(s.Salt[:]) if err != nil { return } - s.AddKey(passphrase) + s.AddKey(name, passphrase) s.unlocked = true return } +var jsonFormatHdr = []byte("{json}") + func (s *Store) ReadFrom(in io.Reader) (n int64, err error) { memzero(s.key[:]) s.unlocked = false @@ -117,56 +124,52 @@ func (s *Store) ReadFrom(in io.Reader) (n int64, err error) { n += int64(nr) } - // read the salt - readFull(s.salt[:]) + // read the file's start (json header or start of salt) + + readFull(s.Salt[:len(jsonFormatHdr)]) if err != nil { return } - // read the (encrypted) keys - s.keys = make([]keyEntry, 0) - for { - k := keyEntry{} - readFull(k.hash[:]) - if err != nil { - if err == io.EOF { - err = nil - } - return - } - readFull(k.encKey[:]) + if !bytes.Equal(s.Salt[:len(jsonFormatHdr)], jsonFormatHdr) { + // old key file + + // finish reading the salt + readFull(s.Salt[len(jsonFormatHdr):]) if err != nil { return } - s.keys = append(s.keys, k) + // read the (encrypted) keys + s.Keys = make([]KeyEntry, 0) + for { + k := KeyEntry{Name: "key-" + strconv.Itoa(len(s.Keys))} + readFull(k.Hash[:]) + if err != nil { + if err == io.EOF { + err = nil + } + return + } + readFull(k.EncKey[:]) + if err != nil { + return + } + + s.Keys = append(s.Keys, k) + } } + + err = json.NewDecoder(in).Decode(s) + return } func (s *Store) WriteTo(out io.Writer) (n int64, err error) { - write := func(ba []byte) { - var nr int - nr, err = out.Write(ba) - n += int64(nr) - } - - write(s.salt[:]) + _, err = out.Write(jsonFormatHdr) if err != nil { return } - - for _, k := range s.keys { - write(k.hash[:]) - if err != nil { - return - } - - write(k.encKey[:]) - if err != nil { - return - } - } - + err = json.NewEncoder(out).Encode(s) return } @@ -178,8 +181,8 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) { defer memzero(key[:]) var idx = -1 - for i := range s.keys { - if hash == s.keys[i].hash { + for i := range s.Keys { + if hash == s.Keys[i].Hash { idx = i break } @@ -189,28 +192,28 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) { return } - s.decryptTo(s.key[:], s.keys[idx].encKey[:], &key) + s.decryptTo(s.key[:], s.Keys[idx].EncKey[:], &key) s.unlocked = true return true } -func (s *Store) AddKey(passphrase []byte) { +func (s *Store) AddKey(name string, passphrase []byte) { key, hash := s.keyPairFromPassword(passphrase) memzero(passphrase) defer memzero(key[:]) - k := keyEntry{hash: hash} + k := KeyEntry{Name: name, Hash: hash} encKey := s.encrypt(s.key[:], &key) - copy(k.encKey[:], encKey) + copy(k.EncKey[:], encKey) - s.keys = append(s.keys, k) + s.Keys = append(s.Keys, k) } func (s *Store) keyPairFromPassword(password []byte) (key [32]byte, hash [64]byte) { - keySlice := argon2.IDKey(password, s.salt[:], 1, 64*1024, 4, 32) + keySlice := argon2.IDKey(password, s.Salt[:], 1, 64*1024, 4, 32) copy(key[:], keySlice) memzero(keySlice) @@ -236,12 +239,12 @@ func (s *Store) NewDecrypter(iv [aes.BlockSize]byte) cipher.Stream { func (s *Store) encrypt(src []byte, key *[32]byte) (dst []byte) { dst = make([]byte, len(src)) - newEncrypter(s.salt, key).XORKeyStream(dst, src) + newEncrypter(s.Salt, key).XORKeyStream(dst, src) return } func (s *Store) decryptTo(dst []byte, src []byte, key *[32]byte) { - newDecrypter(s.salt, key).XORKeyStream(dst, src) + newDecrypter(s.Salt, key).XORKeyStream(dst, src) } func newEncrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {