named passphrases (+deletion by name)
This commit is contained in:
parent
34afe03818
commit
ee5629643c
@ -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)
|
||||
}
|
||||
|
||||
|
@ -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", "")
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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
|
||||
})
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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).
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 {
|
||||
|
Loading…
Reference in New Issue
Block a user