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) {
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)
}

View File

@ -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", "")

View File

@ -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)
}

View File

@ -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
})

View File

@ -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
}

View File

@ -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)

View File

@ -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).

View File

@ -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;
}

View File

@ -41,8 +41,9 @@
<p>Store is new.</p>
<p>Option 1: initialize a new store</p>
<form @submit="unlockStore">
<input type="password" v-model="forms.store.pass1" name="passphrase" required />
<input type="password" v-model="forms.store.pass2" required />
<input type="text" v-model="forms.store.name" name="name" placeholder="Name" /><br/>
<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" />
</form>
<p>Option 2: upload a previously downloaded store</p>
@ -54,7 +55,7 @@
<template v-else-if="!publicState.Store.Open">
<p>Store is not open.</p>
<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" />
</form>
</template>
@ -63,28 +64,12 @@
<p v-else>Invalid token</p>
<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"/>
</form>
</template>
<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">
<h2>Clusters</h2>
<div class="sheets">
@ -100,7 +85,29 @@
</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>
</div>

View File

@ -11,6 +11,7 @@ createApp({
forms: {
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) {

View File

@ -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
}
if !bytes.Equal(s.Salt[:len(jsonFormatHdr)], jsonFormatHdr) {
// old key file
// finish reading the salt
readFull(s.Salt[len(jsonFormatHdr):])
if err != nil {
return
}
// read the (encrypted) keys
s.keys = make([]keyEntry, 0)
s.Keys = make([]KeyEntry, 0)
for {
k := keyEntry{}
readFull(k.hash[:])
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[:])
readFull(k.EncKey[:])
if err != nil {
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) {
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 {