named passphrases (+deletion by name)

This commit is contained in:
Mikaël Cluseau 2023-09-10 16:47:54 +02:00
parent 34afe03818
commit ee5629643c
11 changed files with 200 additions and 113 deletions

View File

@ -26,7 +26,7 @@ func authorizeToken(r *http.Request, token string) bool {
} }
func forbidden(w http.ResponseWriter, r *http.Request) { 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) http.Error(w, "Forbidden", http.StatusForbidden)
} }

View File

@ -52,12 +52,12 @@ func main() {
} }
if autoUnlock != "" { if autoUnlock != "" {
log.Printf("auto-unlocking the store") log.Printf("auto-unlocking the store")
err := unlockSecretStore([]byte(autoUnlock)) err := unlockSecretStore("test", []byte(autoUnlock))
if err.Any() { if err.Any() {
log.Fatal(err) 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", "") os.Setenv("DLS_AUTO_UNLOCK", "")

View File

@ -63,7 +63,7 @@ var (
ErrInvalidPassphrase = httperr.NewStd(2, http.StatusBadRequest, "invalid passphrase") 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() unlockMutex.Lock()
defer unlockMutex.Unlock() defer unlockMutex.Unlock()
@ -72,7 +72,7 @@ func unlockSecretStore(passphrase []byte) (err httperr.Error) {
} }
if secStore.IsNew() { if secStore.IsNew() {
err := secStore.Init(passphrase) err := secStore.Init(name, passphrase)
if err != nil { if err != nil {
return httperr.Internal(err) return httperr.Internal(err)
} }

View File

@ -22,6 +22,7 @@ type State struct {
Store struct { Store struct {
DownloadToken string DownloadToken string
KeyNames []string
} }
Clusters []ClusterState Clusters []ClusterState
@ -59,14 +60,21 @@ func init() {
func updateState() { func updateState() {
log.Print("updating state") 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() cfg, err := readConfig()
if err != nil { 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 return
} }
if secStore.IsNew() || !secStore.Unlocked() { 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 return
} }
@ -122,7 +130,7 @@ func updateState() {
// done // done
wState.Change(func(v *State) { wState.Change(func(v *State) {
v.HasConfig = true v.HasConfig = true
//v.Config = cfg v.Store.KeyNames = keyNames
v.Clusters = clusters v.Clusters = clusters
v.Hosts = hosts v.Hosts = hosts
}) })

View File

@ -14,15 +14,32 @@ import (
"m.cluseau.fr/go/httperr" "m.cluseau.fr/go/httperr"
) )
type NamedPassphrase struct {
Name string
Passphrase []byte
}
func wsUnlockStore(req *restful.Request, resp *restful.Response) { func wsUnlockStore(req *restful.Request, resp *restful.Response) {
var passphrase string np := NamedPassphrase{}
err := req.ReadEntity(&passphrase) err := req.ReadEntity(&np)
if err != nil { if err != nil {
resp.WriteError(http.StatusBadRequest, err) resp.WriteError(http.StatusBadRequest, err)
return 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) err.WriteJSON(resp.ResponseWriter)
return return
} }

View File

@ -1,24 +1,77 @@
package main package main
import ( import (
"strconv"
"strings"
restful "github.com/emicklei/go-restful" restful "github.com/emicklei/go-restful"
"novit.tech/direktil/local-server/secretstore"
) )
func wsStoreAddKey(req *restful.Request, resp *restful.Response) { func wsStoreAddKey(req *restful.Request, resp *restful.Response) {
var passphrase string np := NamedPassphrase{}
err := req.ReadEntity(&passphrase) err := req.ReadEntity(&np)
if err != nil { if err != nil {
wsBadRequest(resp, err.Error()) wsBadRequest(resp, err.Error())
return 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") wsBadRequest(resp, "no passphrase given")
return 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()) err = secStore.SaveTo(secKeysStorePath())
if err != nil { if err != nil {
wsError(resp, err) wsError(resp, err)

View File

@ -27,7 +27,7 @@ func registerWS(rest *restful.Container) {
Produces(mime.JSON). Produces(mime.JSON).
Consumes(mime.JSON). Consumes(mime.JSON).
Route(ws.POST("/unlock-store").To(wsUnlockStore). Route(ws.POST("/unlock-store").To(wsUnlockStore).
Reads(""). Reads(NamedPassphrase{}).
Writes(""). Writes("").
Doc("Try to unlock the store")). Doc("Try to unlock the store")).
Route(ws.GET("/store.tar").To(wsStoreDownload). Route(ws.GET("/store.tar").To(wsStoreDownload).
@ -54,8 +54,11 @@ func registerWS(rest *restful.Container) {
// - store management // - store management
ws.Route(ws.POST("/store/add-key").To(wsStoreAddKey). 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")) 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 // - downloads
ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload). ws.Route(ws.POST("/authorize-download").To(wsAuthorizeDownload).

View File

@ -21,21 +21,3 @@
.cluster { .cluster {
max-width: 50%; 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;
}

View File

@ -41,8 +41,9 @@
<p>Store is new.</p> <p>Store is new.</p>
<p>Option 1: initialize a new store</p> <p>Option 1: initialize a new store</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
<input type="password" v-model="forms.store.pass1" name="passphrase" required /> <input type="text" v-model="forms.store.name" name="name" placeholder="Name" /><br/>
<input type="password" v-model="forms.store.pass2" required /> <input type="password" v-model="forms.store.pass1" name="passphrase" required placeholder="Passphrase" />
<input type="password" v-model="forms.store.pass2" required placeholder="Passphrase confirmation" />
<input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" /> <input type="submit" value="initialize" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form> </form>
<p>Option 2: upload a previously downloaded store</p> <p>Option 2: upload a previously downloaded store</p>
@ -54,7 +55,7 @@
<template v-else-if="!publicState.Store.Open"> <template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p> <p>Store is not open.</p>
<form @submit="unlockStore"> <form @submit="unlockStore">
<input type="password" name="passphrase" v-model="forms.store.pass1" required /> <input type="password" name="passphrase" v-model="forms.store.pass1" required placeholder="Passphrase" />
<input type="submit" value="unlock" :disabled="!forms.store.pass1" /> <input type="submit" value="unlock" :disabled="!forms.store.pass1" />
</form> </form>
</template> </template>
@ -63,28 +64,12 @@
<p v-else>Invalid token</p> <p v-else>Invalid token</p>
<form @submit="setToken"> <form @submit="setToken">
<input type="password" v-model="forms.setToken" required /> <input type="password" v-model="forms.setToken" required placeholder="Token" />
<input type="submit" value="set token"/> <input type="submit" value="set token"/>
</form> </form>
</template> </template>
<template v-else> <template v-else>
<div id="store-infos">
<h2>Config</h2>
<form @submit="uploadConfig">
<input type="file" ref="configUpload" required />
<input type="submit" value="upload config" />
</form>
<h2>Store</h2>
<a :href="'/public/store.tar?token='+state.Store.DownloadToken" target="_blank">download</a>
<form @submit="storeAddKey" action="/store/add-key">
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required />
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form>
</div>
<div v-if="state.Clusters" id="clusters"> <div v-if="state.Clusters" id="clusters">
<h2>Clusters</h2> <h2>Clusters</h2>
<div class="sheets"> <div class="sheets">
@ -100,7 +85,29 @@
</div> </div>
</div> </div>
<pre v-if="false">{{ state }}</pre> <h2>Admin actions</h2>
<h3>Config</h3>
<form @submit="uploadConfig">
<input type="file" ref="configUpload" required />
<input type="submit" value="upload config" />
</form>
<h3>Store</h3>
<p><a :href="'/public/store.tar?token='+state.Store.DownloadToken" target="_blank">Download</a></p>
<form @submit="storeAddKey" action="/store/add-key">
<p>Add an unlock phrase:</p>
<input type="text" v-model="forms.store.name" name="name" required placeholder="Name" /><br/>
<input type="password" v-model="forms.store.pass1" name="passphrase" autocomplete="new-password" required placeholder="Phrase" />
<input type="password" v-model="forms.store.pass2" autocomplete="new-password" required placeholder="Phrase confirmation" />
<input type="submit" value="add unlock phrase" :disabled="!forms.store.pass1 || forms.store.pass1 != forms.store.pass2" />
</form>
<form @submit="storeDelKey" action="/store/delete-key">
<p>Remove an unlock phrase:</p>
<input type="text" v-model="forms.delKey.name" name="name" required placeholder="Name" />
<input type="submit" value="remove unlock phrase" />
<p v-if="state.Store.KeyNames">Available names:
<template v-for="k,i in state.Store.KeyNames">{{i?", ":""}}<code @click="forms.delKey.name=k">{{k}}</code></template>.</p>
</form>
</template> </template>
</div> </div>

View File

@ -9,8 +9,9 @@ createApp({
data() { data() {
return { return {
forms: { forms: {
store: { }, store: {},
storeUpload: {}, storeUpload: {},
delKey: {},
}, },
session: {}, session: {},
error: null, error: null,
@ -67,13 +68,26 @@ createApp({
this.forms.store = {} this.forms.store = {}
}, "application/tar") }, "application/tar")
}, },
namedPassphrase(name, passphrase) {
return {Name: this.forms.store.name, Passphrase: btoa(this.forms.store.pass1)}
},
storeAddKey() { storeAddKey() {
this.apiPost('/store/add-key', this.forms.store.pass1, (v) => { this.apiPost('/store/add-key', this.namedPassphrase(), (v) => {
this.forms.store = {} 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() { unlockStore() {
this.apiPost('/public/unlock-store', this.forms.store.pass1, (v) => { this.apiPost('/public/unlock-store', this.namedPassphrase(), (v) => {
this.forms.store = {} this.forms.store = {}
if (v) { if (v) {

View File

@ -2,29 +2,34 @@ package secretstore
import ( import (
"bufio" "bufio"
"bytes"
"crypto/aes" "crypto/aes"
"crypto/cipher" "crypto/cipher"
"crypto/sha512" "crypto/sha512"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log"
"os" "os"
"strconv"
"syscall" "syscall"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
type Store struct { type Store struct {
Salt [aes.BlockSize]byte
Keys []KeyEntry
unlocked bool unlocked bool
key [32]byte key [32]byte
salt [aes.BlockSize]byte
keys []keyEntry
} }
type keyEntry struct { type KeyEntry struct {
hash [64]byte Name string
encKey [32]byte Hash [64]byte
EncKey [32]byte
} }
func New() (s *Store) { func New() (s *Store) {
@ -77,30 +82,32 @@ func (s *Store) Close() {
} }
func (s *Store) IsNew() bool { func (s *Store) IsNew() bool {
return len(s.keys) == 0 return len(s.Keys) == 0
} }
func (s *Store) Unlocked() bool { func (s *Store) Unlocked() bool {
return s.unlocked 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[:]) err = randRead(s.key[:])
if err != nil { if err != nil {
return return
} }
err = randRead(s.salt[:]) err = randRead(s.Salt[:])
if err != nil { if err != nil {
return return
} }
s.AddKey(passphrase) s.AddKey(name, passphrase)
s.unlocked = true s.unlocked = true
return return
} }
var jsonFormatHdr = []byte("{json}")
func (s *Store) ReadFrom(in io.Reader) (n int64, err error) { func (s *Store) ReadFrom(in io.Reader) (n int64, err error) {
memzero(s.key[:]) memzero(s.key[:])
s.unlocked = false s.unlocked = false
@ -117,56 +124,52 @@ func (s *Store) ReadFrom(in io.Reader) (n int64, err error) {
n += int64(nr) n += int64(nr)
} }
// read the salt // read the file's start (json header or start of salt)
readFull(s.salt[:])
readFull(s.Salt[:len(jsonFormatHdr)])
if err != nil {
return
}
if !bytes.Equal(s.Salt[:len(jsonFormatHdr)], jsonFormatHdr) {
// old key file
// finish reading the salt
readFull(s.Salt[len(jsonFormatHdr):])
if err != nil { if err != nil {
return return
} }
// read the (encrypted) keys // read the (encrypted) keys
s.keys = make([]keyEntry, 0) s.Keys = make([]KeyEntry, 0)
for { for {
k := keyEntry{} k := KeyEntry{Name: "key-" + strconv.Itoa(len(s.Keys))}
readFull(k.hash[:]) readFull(k.Hash[:])
if err != nil { if err != nil {
if err == io.EOF { if err == io.EOF {
err = nil err = nil
} }
return return
} }
readFull(k.encKey[:]) readFull(k.EncKey[:])
if err != nil { if err != nil {
return return
} }
s.keys = append(s.keys, k) s.Keys = append(s.Keys, k)
} }
}
err = json.NewDecoder(in).Decode(s)
return
} }
func (s *Store) WriteTo(out io.Writer) (n int64, err error) { func (s *Store) WriteTo(out io.Writer) (n int64, err error) {
write := func(ba []byte) { _, err = out.Write(jsonFormatHdr)
var nr int
nr, err = out.Write(ba)
n += int64(nr)
}
write(s.salt[:])
if err != nil { if err != nil {
return return
} }
err = json.NewEncoder(out).Encode(s)
for _, k := range s.keys {
write(k.hash[:])
if err != nil {
return
}
write(k.encKey[:])
if err != nil {
return
}
}
return return
} }
@ -178,8 +181,8 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) {
defer memzero(key[:]) defer memzero(key[:])
var idx = -1 var idx = -1
for i := range s.keys { for i := range s.Keys {
if hash == s.keys[i].hash { if hash == s.Keys[i].Hash {
idx = i idx = i
break break
} }
@ -189,28 +192,28 @@ func (s *Store) Unlock(passphrase []byte) (ok bool) {
return return
} }
s.decryptTo(s.key[:], s.keys[idx].encKey[:], &key) s.decryptTo(s.key[:], s.Keys[idx].EncKey[:], &key)
s.unlocked = true s.unlocked = true
return true return true
} }
func (s *Store) AddKey(passphrase []byte) { func (s *Store) AddKey(name string, passphrase []byte) {
key, hash := s.keyPairFromPassword(passphrase) key, hash := s.keyPairFromPassword(passphrase)
memzero(passphrase) memzero(passphrase)
defer memzero(key[:]) defer memzero(key[:])
k := keyEntry{hash: hash} k := KeyEntry{Name: name, Hash: hash}
encKey := s.encrypt(s.key[:], &key) 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) { 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) copy(key[:], keySlice)
memzero(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) { func (s *Store) encrypt(src []byte, key *[32]byte) (dst []byte) {
dst = make([]byte, len(src)) dst = make([]byte, len(src))
newEncrypter(s.salt, key).XORKeyStream(dst, src) newEncrypter(s.Salt, key).XORKeyStream(dst, src)
return return
} }
func (s *Store) decryptTo(dst []byte, src []byte, key *[32]byte) { 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 { func newEncrypter(iv [aes.BlockSize]byte, key *[32]byte) cipher.Stream {