Initial UX, creation of encrypted note files
This commit is contained in:
parent
412bf79941
commit
ac845e8fe7
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -1,3 +1,3 @@
|
||||||
fonts/
|
notes/
|
||||||
screenshot-*.png
|
./dethnote
|
||||||
map-*.json
|
./dethnote.exe
|
||||||
|
|
2
Makefile
2
Makefile
|
@ -21,7 +21,7 @@ build:
|
||||||
# `make run` to run it in debug mode.
|
# `make run` to run it in debug mode.
|
||||||
.PHONY: run
|
.PHONY: run
|
||||||
run:
|
run:
|
||||||
go run main.go -debug
|
./go-reload main.go -debug
|
||||||
|
|
||||||
# `make test` to run unit tests.
|
# `make test` to run unit tests.
|
||||||
.PHONY: test
|
.PHONY: test
|
||||||
|
|
95
go-reload
Executable file
95
go-reload
Executable file
|
@ -0,0 +1,95 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Credit from: https://github.com/alexedwards/go-reload/tree/aabe19d0a9935d1238763a4a35e71787854cd5f5
|
||||||
|
|
||||||
|
####################################################
|
||||||
|
# @kirsle's custom changes from upstream are below #
|
||||||
|
####################################################
|
||||||
|
|
||||||
|
function die() {
|
||||||
|
echo >&2 $1
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Before we crash and burn, make sure necessary programs are installed!
|
||||||
|
command -v inotifywait || die "I need the inotifywait command (apt install inotify-tools)"
|
||||||
|
|
||||||
|
################################
|
||||||
|
# end @kirsle's custom changes #
|
||||||
|
################################
|
||||||
|
|
||||||
|
function monitor() {
|
||||||
|
if [ "$2" = "true" ]; then
|
||||||
|
# Watch all files in the specified directory
|
||||||
|
# Call the restart function when they are saved
|
||||||
|
inotifywait -q -m -r -e close_write -e moved_to $1 |
|
||||||
|
while read line; do
|
||||||
|
restart
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# Watch all *.go files in the specified directory
|
||||||
|
# Call the restart function when they are saved
|
||||||
|
inotifywait -q -m -r -e close_write -e moved_to --exclude '[^g][^o]$' $1 |
|
||||||
|
while read line; do
|
||||||
|
restart
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Terminate and rerun the main Go program
|
||||||
|
function restart {
|
||||||
|
if [ "$(pidof $PROCESS_NAME)" ]; then
|
||||||
|
killall -q -w -9 $PROCESS_NAME
|
||||||
|
fi
|
||||||
|
echo ">> Reloading..."
|
||||||
|
eval "go run $ARGS &"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Make sure all background processes get terminated
|
||||||
|
function close {
|
||||||
|
killall -q -w -9 inotifywait
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
trap close INT
|
||||||
|
echo "== Go-reload"
|
||||||
|
|
||||||
|
WATCH_ALL=false
|
||||||
|
while getopts ":a" opt; do
|
||||||
|
case $opt in
|
||||||
|
a)
|
||||||
|
WATCH_ALL=true
|
||||||
|
;;
|
||||||
|
\?)
|
||||||
|
echo "Invalid option: -$OPTARG" >&2
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
shift "$((OPTIND - 1))"
|
||||||
|
|
||||||
|
FILE_NAME=$(basename $1)
|
||||||
|
PROCESS_NAME=${FILE_NAME%%.*}
|
||||||
|
|
||||||
|
ARGS=$@
|
||||||
|
|
||||||
|
# Start the main Go program
|
||||||
|
echo ">> Watching directories, CTRL+C to stop"
|
||||||
|
eval "go run $ARGS &"
|
||||||
|
|
||||||
|
# Monitor all /src directories on the GOPATH
|
||||||
|
OIFS="$IFS"
|
||||||
|
IFS=':'
|
||||||
|
for path in $GOPATH
|
||||||
|
do
|
||||||
|
monitor $path/src $WATCH_ALL &
|
||||||
|
done
|
||||||
|
IFS="$OIFS"
|
||||||
|
|
||||||
|
# If the current working directory isn't on the GOPATH, monitor it too
|
||||||
|
if [[ $PWD != "$GOPATH/"* ]]
|
||||||
|
then
|
||||||
|
monitor $PWD $WATCH_ALL
|
||||||
|
fi
|
||||||
|
|
||||||
|
wait
|
13
main.go
13
main.go
|
@ -30,6 +30,11 @@ var (
|
||||||
root string
|
root string
|
||||||
rootDefault = "./notes"
|
rootDefault = "./notes"
|
||||||
|
|
||||||
|
// -mailgun-url <url>: mailgun API URL for sending out emails.
|
||||||
|
// -mg <url> Takes the format "<api_key>@<api_base_url>"
|
||||||
|
// Example: "key-a1b23c@https://api.mailgun.net/v3/mg.example.com"
|
||||||
|
mailgunURL string
|
||||||
|
|
||||||
// -listen <address>: listen on an HTTP port at this address.
|
// -listen <address>: listen on an HTTP port at this address.
|
||||||
// -l <address>
|
// -l <address>
|
||||||
listen string
|
listen string
|
||||||
|
@ -56,10 +61,16 @@ func init() {
|
||||||
})
|
})
|
||||||
|
|
||||||
flag.BoolVar(&debug, "debug", false, "Enable debug mode for local dev")
|
flag.BoolVar(&debug, "debug", false, "Enable debug mode for local dev")
|
||||||
|
|
||||||
flag.StringVar(&root, "root", rootDefault, "Directory to store the secure notes on disk")
|
flag.StringVar(&root, "root", rootDefault, "Directory to store the secure notes on disk")
|
||||||
flag.StringVar(&root, "r", rootDefault, "Directory to store the secure notes on disk (shorthand)")
|
flag.StringVar(&root, "r", rootDefault, "Directory to store the secure notes on disk (shorthand)")
|
||||||
|
|
||||||
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
||||||
flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)")
|
flag.StringVar(&listen, "l", listenDefault, "HTTP address to listen on (shorthand)")
|
||||||
|
|
||||||
|
flag.StringVar(&mailgunURL, "mailgun-url", "", "Mailgun API URL for sending out emails")
|
||||||
|
flag.StringVar(&mailgunURL, "mg", "", "Mailgun API URL for sending out emails (shorthand)")
|
||||||
|
|
||||||
flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.")
|
flag.BoolVar(&cmdOpen, "open", false, "Immediately open and print a secure note. The passphrase is provided via CLI arguments.")
|
||||||
flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
|
flag.BoolVar(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
|
||||||
flag.Usage = func() {
|
flag.Usage = func() {
|
||||||
|
@ -99,5 +110,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
app := dethnote.NewServer(root, debug)
|
app := dethnote.NewServer(root, debug)
|
||||||
|
app.MailgunURL = mailgunURL
|
||||||
|
app.SetupHTTP()
|
||||||
app.Run(listen)
|
app.Run(listen)
|
||||||
}
|
}
|
||||||
|
|
1
public/test.txt
Normal file
1
public/test.txt
Normal file
|
@ -0,0 +1 @@
|
||||||
|
hello world
|
142
public/ui/dethnote.css
Normal file
142
public/ui/dethnote.css
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
|
font-size: medium;
|
||||||
|
line-height: 1.6rem;
|
||||||
|
color: #000;
|
||||||
|
background-color: #EEE;
|
||||||
|
}
|
||||||
|
p, fieldset {
|
||||||
|
margin: 0 0 1.6rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:link, a:visited {
|
||||||
|
color: #006699;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover, a:active {
|
||||||
|
color: #0000FF;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4 {
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 28pt;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 22pt;
|
||||||
|
}
|
||||||
|
h3 {
|
||||||
|
font-size: 18pt;
|
||||||
|
}
|
||||||
|
h4 {
|
||||||
|
font-size: 14pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form controls */
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
input, textarea, select {
|
||||||
|
width: 96%;
|
||||||
|
border: 1px solid #006699;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #FFF;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
input, select {
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
input:focus {
|
||||||
|
border: 1px solid #0099FF;
|
||||||
|
box-shadow: 0px 0px 2px #006699;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
border: 1px solid #999;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #EEE;
|
||||||
|
color: #000;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background-color: #FAFAFA;
|
||||||
|
}
|
||||||
|
button.primary {
|
||||||
|
background-color: #0099FF;
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
button.primary:hover {
|
||||||
|
background-color: #00CCFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.unstyled {
|
||||||
|
list-style: none;
|
||||||
|
margin: 20px 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
ul.unstyled li {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TOP NAV HEADER **/
|
||||||
|
header {
|
||||||
|
background-color: #000;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
header ul {
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 12px;
|
||||||
|
line-height: 24px;
|
||||||
|
max-width: 950px;
|
||||||
|
}
|
||||||
|
header ul li {
|
||||||
|
display: inline;
|
||||||
|
padding-right: 20px;
|
||||||
|
}
|
||||||
|
header a:link, header a:visited {
|
||||||
|
color: #CCC;
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: large;
|
||||||
|
}
|
||||||
|
header a:hover, header a:active {
|
||||||
|
color: #FFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MAIN CONTENT PANEL **/
|
||||||
|
main {
|
||||||
|
margin: 20px auto;
|
||||||
|
max-width: 950px;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 14px;
|
||||||
|
background-color: #FFF;
|
||||||
|
border: 1px solid #CCC;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UNLOCK FORM **/
|
||||||
|
form.unlock-form {
|
||||||
|
max-width: 550px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
form.unlock-form input {
|
||||||
|
flex: 8 1 0;
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
border-right: 0;
|
||||||
|
}
|
||||||
|
form.unlock-form button {
|
||||||
|
flex: 1 0 0;
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
22
src/cookies.go
Normal file
22
src/cookies.go
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
package dethnote
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// SetCookie sets a cookie.
|
||||||
|
func (s *Server) SetCookie(w http.ResponseWriter, name, value string) {
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: name,
|
||||||
|
Value: value,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCookie reads a cookie.
|
||||||
|
func (s *Server) GetCookie(r *http.Request, name string) string {
|
||||||
|
cookie, _ := r.Cookie(name)
|
||||||
|
if cookie != nil {
|
||||||
|
return cookie.Value
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
52
src/create_handler.go
Normal file
52
src/create_handler.go
Normal file
|
@ -0,0 +1,52 @@
|
||||||
|
package dethnote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.kirsle.net/apps/dethnote/src/vault"
|
||||||
|
)
|
||||||
|
|
||||||
|
// int form values...
|
||||||
|
func intFormValue(r *http.Request, name string) int {
|
||||||
|
value, _ := strconv.Atoi(r.FormValue(name))
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateHandler covers the `/create` endpoint.
|
||||||
|
func (s *Server) CreateHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
WriteError(w, errors.New("Invalid request method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the form fields.
|
||||||
|
var (
|
||||||
|
email = r.FormValue("email")
|
||||||
|
timeout = intFormValue(r, "timeout")
|
||||||
|
message = r.FormValue("message")
|
||||||
|
passwordLength = intFormValue(r, "length")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Make a message object to hold this for now.
|
||||||
|
m, err := vault.NewMessage(email, timeout, message, passwordLength)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Begin the creation process.
|
||||||
|
err = m.Create(s.root)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the hash path in the browser's cookies.
|
||||||
|
s.SetCookie(w, "hash_path", vault.HashToFilename(m.PasswordHash))
|
||||||
|
|
||||||
|
// OK! Redirect them to the "confirm your email" page.
|
||||||
|
w.Header().Set("Location", "/verify-email")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
}
|
|
@ -9,11 +9,14 @@ import (
|
||||||
|
|
||||||
// Server is the master struct for the web app.
|
// Server is the master struct for the web app.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
|
// Public configurable fields.
|
||||||
|
MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com"
|
||||||
|
|
||||||
root string
|
root string
|
||||||
debug bool
|
debug bool
|
||||||
|
|
||||||
n *negroni.Negroni
|
n *negroni.Negroni
|
||||||
r *mux.Router
|
mux *mux.Router
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer initializes the server struct.
|
// NewServer initializes the server struct.
|
||||||
|
@ -27,14 +30,33 @@ func NewServer(root string, debug bool) *Server {
|
||||||
// SetupHTTP configures the HTTP server.
|
// SetupHTTP configures the HTTP server.
|
||||||
func (s *Server) SetupHTTP() {
|
func (s *Server) SetupHTTP() {
|
||||||
// Set up the router.
|
// Set up the router.
|
||||||
|
Log.Debug("Setting up the HTTP router...")
|
||||||
r := mux.NewRouter()
|
r := mux.NewRouter()
|
||||||
s.r = r
|
s.mux = r
|
||||||
|
|
||||||
n := negroni.Classic()
|
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
n.UseHandler(r)
|
if err := s.Template(w, "index.gohtml", nil); err != nil {
|
||||||
|
WriteError(w, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
r.HandleFunc("/create", s.CreateHandler)
|
||||||
|
r.HandleFunc("/verify-email", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.Template(w, "verify-email.gohtml", map[string]interface{}{
|
||||||
|
"HashPath": s.GetCookie(r, "hash_path"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
n := negroni.New(
|
||||||
|
negroni.NewRecovery(),
|
||||||
|
negroni.NewLogger(),
|
||||||
|
negroni.NewStatic(http.Dir("./public")),
|
||||||
|
)
|
||||||
|
s.n = n
|
||||||
|
n.UseHandler(s.mux)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the server.
|
// Run the server.
|
||||||
func (s *Server) Run(addr string) {
|
func (s *Server) Run(addr string) {
|
||||||
http.ListenAndServe(addr, s.r)
|
Log.Info("Listening at %s", addr)
|
||||||
|
http.ListenAndServe(addr, s.n)
|
||||||
}
|
}
|
||||||
|
|
68
src/templates.go
Normal file
68
src/templates.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package dethnote
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
var functions template.FuncMap
|
||||||
|
|
||||||
|
// IncludeFunc implements the "Include" command for the templates.
|
||||||
|
func (s *Server) IncludeFunc(name string, v ...interface{}) template.HTML {
|
||||||
|
buf := bytes.NewBuffer([]byte{})
|
||||||
|
var err error
|
||||||
|
if len(v) > 0 {
|
||||||
|
err = s.Template(buf, name, v[0])
|
||||||
|
} else {
|
||||||
|
err = s.Template(buf, name, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return template.HTML("[include error: " + err.Error() + "]")
|
||||||
|
}
|
||||||
|
|
||||||
|
return template.HTML(buf.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template renders an HTML template for the browser.
|
||||||
|
func (s *Server) Template(w io.Writer, name string, v interface{}) error {
|
||||||
|
// Initialize template functions?
|
||||||
|
if functions == nil {
|
||||||
|
functions = template.FuncMap{
|
||||||
|
"Include": s.IncludeFunc,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Find the template file locally.
|
||||||
|
filename := "./templates/" + name
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) {
|
||||||
|
return errors.New("template not found: " + name)
|
||||||
|
}
|
||||||
|
|
||||||
|
t := template.New(name).Funcs(functions)
|
||||||
|
t, err := t.ParseFiles(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the template into a buffer in case of errors, so we can still
|
||||||
|
// control the outgoing HTTP status code.
|
||||||
|
buf := bytes.NewBuffer([]byte{})
|
||||||
|
err = t.ExecuteTemplate(buf, name, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the HTML result.
|
||||||
|
w.Write(buf.Bytes())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError sends an error to the user.
|
||||||
|
func WriteError(w http.ResponseWriter, err error) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
w.Write([]byte(err.Error()))
|
||||||
|
}
|
37
src/vault/diceware.go
Normal file
37
src/vault/diceware.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Diceware returns a diceware password with `length` words in it.
|
||||||
|
func Diceware(length int) (string, error) {
|
||||||
|
words := []string{}
|
||||||
|
for i := 0; i < length; i++ {
|
||||||
|
// Roll five random dice.
|
||||||
|
rolls := ""
|
||||||
|
for j := 1; j <= DieCount; j++ {
|
||||||
|
roll, _ := rand.Int(rand.Reader, big.NewInt(6))
|
||||||
|
rolls += fmt.Sprintf("%d", roll.Int64()+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
word, err := findWord(rolls)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
words = append(words, word)
|
||||||
|
}
|
||||||
|
return strings.Join(words, " "), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findWord looks up a word based on a dice roll.
|
||||||
|
func findWord(roll string) (string, error) {
|
||||||
|
if word, ok := dict[roll]; ok {
|
||||||
|
return word, nil
|
||||||
|
}
|
||||||
|
return "", errors.New("roll pattern not found? this should not happen")
|
||||||
|
}
|
7817
src/vault/diceware_wordlist.go
Normal file
7817
src/vault/diceware_wordlist.go
Normal file
File diff suppressed because it is too large
Load Diff
67
src/vault/jsons.go
Normal file
67
src/vault/jsons.go
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteEncrypted writes data to a fully encrypted file.
|
||||||
|
func WriteEncrypted(hash []byte, filename string, data []byte) error {
|
||||||
|
ciphertext, err := Encrypt(hash, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write it to a file.
|
||||||
|
fh, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
fh.Write(ciphertext)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteSecureJSON writes an encrypted JSON file to disk using the password.
|
||||||
|
func WriteSecureJSON(profile string, hash []byte, v interface{}) error {
|
||||||
|
Log.Info("WriteSecureJSON started")
|
||||||
|
|
||||||
|
// Serialize the metadata to JSON.
|
||||||
|
meta := bytes.NewBuffer([]byte{})
|
||||||
|
encoder := json.NewEncoder(meta)
|
||||||
|
encoder.SetIndent("", "\t")
|
||||||
|
err := encoder.Encode(v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEBUG: write a plain text JSON file.
|
||||||
|
if true {
|
||||||
|
jsonfile := filepath.Join(profile, "meta.json")
|
||||||
|
Log.Info("Writing plain text JSON file to %s", jsonfile)
|
||||||
|
fh, err := os.Create(jsonfile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
fh.Write(meta.Bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write the encrypted metadata file.
|
||||||
|
metafile := filepath.Join(profile, "meta.bin")
|
||||||
|
err = WriteEncrypted(hash, metafile, meta.Bytes())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSecureJSON loads an encrypted JSON file from disk using the password.
|
||||||
|
func ReadSecureJSON(password string, v interface{}) error {
|
||||||
|
return nil
|
||||||
|
}
|
9
src/vault/log.go
Normal file
9
src/vault/log.go
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import "git.kirsle.net/go/log"
|
||||||
|
|
||||||
|
var Log *log.Logger
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Log = log.GetLogger("dethnote")
|
||||||
|
}
|
102
src/vault/models.go
Normal file
102
src/vault/models.go
Normal file
|
@ -0,0 +1,102 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message is an encrypted file that contains the settings for a secure note,
|
||||||
|
// but does not contain the note itself.
|
||||||
|
type Message struct {
|
||||||
|
Email string `json:"email"` // owner email address
|
||||||
|
Verified bool `json:"verified"` // verified owner email?
|
||||||
|
Timeout int `json:"timeout"` // hours for the unlock window
|
||||||
|
|
||||||
|
PasswordHash []byte `json:"hash"` // to verify the password is correct
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
|
||||||
|
// ephemeral keys that don't save to disk
|
||||||
|
Password string `json:"-"` // randomly generated Diceware password
|
||||||
|
Message string `json:"-"` // temporary holding space for the message text
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMessage creates a new message.
|
||||||
|
func NewMessage(email string, timeout int, message string, passwordLength int) (*Message, error) {
|
||||||
|
password, err := Diceware(passwordLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := GenerateHash(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Message{
|
||||||
|
Email: email,
|
||||||
|
Timeout: timeout,
|
||||||
|
Message: message,
|
||||||
|
Password: password,
|
||||||
|
PasswordHash: hash,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the message for the first time, which will trigger a confirmation email
|
||||||
|
// to be sent to the message's owner.
|
||||||
|
func (m *Message) Create(root string) error {
|
||||||
|
// Make sure defaults are sane.
|
||||||
|
m.Verified = false
|
||||||
|
m.Created = time.Now().UTC()
|
||||||
|
if m.PasswordHash == nil {
|
||||||
|
return errors.New("no hashed password?")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the profile folder.
|
||||||
|
profile, err := m.Profile(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the metadata file.
|
||||||
|
WriteSecureJSON(profile, m.PasswordHash, m)
|
||||||
|
|
||||||
|
// Write the message itself, encrypted.
|
||||||
|
textfile := filepath.Join(profile, "message.bin")
|
||||||
|
return WriteEncrypted(m.PasswordHash, textfile, []byte(m.Message))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profile returns the directory where this message keeps its files.
|
||||||
|
//
|
||||||
|
// May panic if it can't create the directory.
|
||||||
|
func (m *Message) Profile(root string) (string, error) {
|
||||||
|
// Turn the hash into a directory to store its information.
|
||||||
|
profile := filepath.Join(root, HashToFilename(m.PasswordHash))
|
||||||
|
if _, err := os.Stat(profile); os.IsNotExist(err) {
|
||||||
|
err := os.MkdirAll(profile, 0755)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unlocker is a request to open an encrypted message. This document won't
|
||||||
|
// exist until the first time the password is entered to unlock a message.
|
||||||
|
type Unlocker struct {
|
||||||
|
// Email address of the requester.
|
||||||
|
Email string `json:"email"` // email address of the unlocker
|
||||||
|
|
||||||
|
// Is the request email verified? They must verify before the unlock
|
||||||
|
// process can begin, in case the e-mail server has problems sending
|
||||||
|
// messages, we don't want to start unlocking messages!
|
||||||
|
Verified bool `json:"verified"` // unlocker email address is verified.
|
||||||
|
|
||||||
|
// This is set when the unlock request is first created.
|
||||||
|
Created time.Time `json:"created"`
|
||||||
|
|
||||||
|
// This is set after the email is verified, and is the timeout window
|
||||||
|
// before the message will be decrypted.
|
||||||
|
NotBefore time.Time `json:"notBefore"`
|
||||||
|
}
|
76
src/vault/security.go
Normal file
76
src/vault/security.go
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
package vault
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tunable params for newly generated passwords.
|
||||||
|
var (
|
||||||
|
// bcrypt hash cost. The higher, the longer it takes to derive a password.
|
||||||
|
// Helps protect the hashes against brute force attacks.
|
||||||
|
HashCost = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateHash generates a bcrypt hash (slow) of a password, and then returns
|
||||||
|
// a SHA-256 sum of the hash.
|
||||||
|
func GenerateHash(password string) ([]byte, error) {
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte(password), HashCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make it a SHA-256 hash for easy use with AES-256.
|
||||||
|
hasher := sha256.New()
|
||||||
|
_, err = hasher.Write(hash)
|
||||||
|
bin := hasher.Sum(nil)
|
||||||
|
return bin, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// HashToFilename converts a bcrypt hash into a safe filename on disk.
|
||||||
|
//
|
||||||
|
// It takes a SHA-256 sum of the hash and then splits it into a short tree
|
||||||
|
// for filesystem efficiency.
|
||||||
|
func HashToFilename(hash []byte) string {
|
||||||
|
sha := hex.EncodeToString(hash)
|
||||||
|
|
||||||
|
// Split it into a path tree.
|
||||||
|
var (
|
||||||
|
p1 = sha[:2]
|
||||||
|
p2 = sha[2:20]
|
||||||
|
p3 = sha[20:40]
|
||||||
|
p4 = sha[40:]
|
||||||
|
)
|
||||||
|
filename := strings.Join([]string{p1, p2, p3, p4}, "/")
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt data using AES-256.
|
||||||
|
func Encrypt(key []byte, data []byte) ([]byte, error) {
|
||||||
|
// Initialize an AES cipher.
|
||||||
|
encryptor, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Encrypt: aes.NewCipher error: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pad the plaintext until it's a multiple of the AES block size.
|
||||||
|
buf := bytes.NewBuffer(data)
|
||||||
|
for buf.Len()%aes.BlockSize != 0 {
|
||||||
|
buf.WriteByte(' ')
|
||||||
|
}
|
||||||
|
Log.Info("data: %+v", data)
|
||||||
|
Log.Info("buf: %+v", buf.Bytes())
|
||||||
|
|
||||||
|
// Encode the data to ciphertext.
|
||||||
|
ciphertext := make([]byte, aes.BlockSize+buf.Len())
|
||||||
|
encryptor.Encrypt(ciphertext, buf.Bytes())
|
||||||
|
|
||||||
|
fmt.Printf("cipher: %+v\n", ciphertext)
|
||||||
|
return ciphertext, nil
|
||||||
|
}
|
7
src/vault/vault.go
Normal file
7
src/vault/vault.go
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
// Package vault provides the API functions to read and write the encrypted
|
||||||
|
// notes for dethnote.
|
||||||
|
package vault
|
||||||
|
|
||||||
|
func CreateNote(email string, timeout int, message string) {
|
||||||
|
|
||||||
|
}
|
7
templates/footer.gohtml
Normal file
7
templates/footer.gohtml
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
17
templates/header.gohtml
Normal file
17
templates/header.gohtml
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Dethnote</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="/ui/dethnote.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<ul>
|
||||||
|
<li><a href="/">Home</a></li>
|
||||||
|
<li><a href="/about">About</a></li>
|
||||||
|
<li><a href="/faq">FAQ</a></li>
|
||||||
|
</ul>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
141
templates/index.gohtml
Normal file
141
templates/index.gohtml
Normal file
|
@ -0,0 +1,141 @@
|
||||||
|
{{ Include "header.gohtml" }}
|
||||||
|
<section>
|
||||||
|
<h1>Dethnote</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
If you've come here because you found a password, enter it below
|
||||||
|
to unlock the message:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form class="unlock-form" name="unlocker" method="POST" action="/unlock">
|
||||||
|
<input type="text"
|
||||||
|
name="password"
|
||||||
|
size="20"
|
||||||
|
placeholder="enter the pass phrase here">
|
||||||
|
<button type="submit" class="primary">Go</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>What is this?</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This service stores encrypted messages that may be unlocked in the event
|
||||||
|
of an emergency, such as an untimely death. For example, you may want to
|
||||||
|
make your password manager available to relatives after you die.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You can create a Secure Note on this server. It will generate a random,
|
||||||
|
very strong <a href="https://en.wikipedia.org/wiki/Diceware" target="_blank">Diceware</a>
|
||||||
|
password. The password will encrypt your message on the server's hard disk,
|
||||||
|
so that not even the server admin can read the message. The diceware password
|
||||||
|
is strong enough to resist brute force decryption attacks.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You then write a note and stick it in your wallet. "In case of death,
|
||||||
|
visit <this website> and enter this password:"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
When the password is entered: you are alerted via e-mail that somebody
|
||||||
|
has entered your password. You have 72 hours (or however long you prefer)
|
||||||
|
to respond to the e-mail and cancel the request. If you do not cancel
|
||||||
|
the request, the message will be unlocked to the user who entered the
|
||||||
|
password.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h1>Create a Note</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You may create a note to store on this server. The note will be encrypted
|
||||||
|
using a very strong, randomly generated <a href="https://en.wikipedia.org/wiki/Diceware" target="_blank">Diceware</a>
|
||||||
|
passphrase that the server won't store a copy of.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form name="create" method="POST" action="/create">
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
<label for="email">Email Address</label>
|
||||||
|
</legend>
|
||||||
|
<p>
|
||||||
|
You must provide an e-mail address, and you will be sent a confirmation
|
||||||
|
e-mail to prove control of it.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When your note is unlocked, you will be sent a notification e-mail
|
||||||
|
with options. If you don't react to the e-mail, the original requester
|
||||||
|
will be granted access to read the message after a window period
|
||||||
|
you specify below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input type="email"
|
||||||
|
name="email"
|
||||||
|
id="email"
|
||||||
|
placeholder="name@domain.com"
|
||||||
|
required>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
<label for="timeout">Unlock Timeout Window</label>
|
||||||
|
</legend>
|
||||||
|
<p>
|
||||||
|
When somebody enters the password to unlock your message, you will
|
||||||
|
receive a notification e-mail and you may act on it. If you don't
|
||||||
|
react within this timeout window, the requester will be granted
|
||||||
|
access to decrypt your message.
|
||||||
|
</p>
|
||||||
|
<select name="timeout" id="timeout">
|
||||||
|
<option value="0">No unlock timeout</option>
|
||||||
|
<option value="24">24 hours</option>
|
||||||
|
<option value="48">48 hours (2 days)</option>
|
||||||
|
<option value="72" selected>72 hours (3 days)</option>
|
||||||
|
<option value="168">One week (7 days)</option>
|
||||||
|
<option value="336">Two weeks (14 days)</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
<label for="length">Security Settings</label>
|
||||||
|
</legend>
|
||||||
|
<p>
|
||||||
|
Your message will be encrypted using a strong, randomly generated
|
||||||
|
<a href="https://en.wikipedia.org/wiki/Diceware" target="_blank">Diceware</a>
|
||||||
|
password. You may choose how many words will be used; the more the better.
|
||||||
|
</p>
|
||||||
|
<select name="length" id="length">
|
||||||
|
<option value="6">6 words</option>
|
||||||
|
<option value="7">7 words</option>
|
||||||
|
<option value="8">8 words (recommended)</option>
|
||||||
|
<option value="9">9 words</option>
|
||||||
|
<option value="10">10 words</option>
|
||||||
|
<option value="11">11 words</option>
|
||||||
|
<option value="12">12 words</option>
|
||||||
|
</select>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>
|
||||||
|
<label for="message">Message</label>
|
||||||
|
</legend>
|
||||||
|
<textarea
|
||||||
|
name="message"
|
||||||
|
cols="80"
|
||||||
|
rows="20"
|
||||||
|
placeholder="Secure Message"
|
||||||
|
required></textarea>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<button type="submit"
|
||||||
|
class="primary">Continue ></button>
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ Include "footer.gohtml" }}
|
41
templates/verify-email.gohtml
Normal file
41
templates/verify-email.gohtml
Normal file
|
@ -0,0 +1,41 @@
|
||||||
|
{{ Include "header.gohtml" }}
|
||||||
|
<section>
|
||||||
|
<h1>Verify Your Email</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
You're almost there! Check your e-mail inbox for the confirmation link.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
For the protection of your message, this service <strong>will not</strong>
|
||||||
|
allow the decryption process to begin until you have verified your
|
||||||
|
e-mail address. This is because decryption will send you a notification
|
||||||
|
e-mail to contest the decryption, and so your e-mail address must be
|
||||||
|
verified.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This server does not store your e-mail address anywhere. Everything about
|
||||||
|
your message, including your e-mail address itself, is heavily encrypted
|
||||||
|
using a randomly generated pass phrase. See the <a href="/faq">FAQ</a>
|
||||||
|
for more details.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{{ if .HashPath }}
|
||||||
|
<section>
|
||||||
|
<h1>Deletion Link</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Be sure to save the URL below. Following it will allow you to delete your
|
||||||
|
encrypted message completely, without needing to enter its password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<input type="text"
|
||||||
|
readonly
|
||||||
|
value="https://example/delete/{{ .HashPath }}">
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
{{ Include "footer.gohtml" }}
|
Loading…
Reference in New Issue
Block a user