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/
|
||||
screenshot-*.png
|
||||
map-*.json
|
||||
notes/
|
||||
./dethnote
|
||||
./dethnote.exe
|
||||
|
|
2
Makefile
2
Makefile
|
@ -21,7 +21,7 @@ build:
|
|||
# `make run` to run it in debug mode.
|
||||
.PHONY: run
|
||||
run:
|
||||
go run main.go -debug
|
||||
./go-reload main.go -debug
|
||||
|
||||
# `make test` to run unit tests.
|
||||
.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
|
||||
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.
|
||||
// -l <address>
|
||||
listen string
|
||||
|
@ -56,10 +61,16 @@ func init() {
|
|||
})
|
||||
|
||||
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, "r", rootDefault, "Directory to store the secure notes on disk (shorthand)")
|
||||
|
||||
flag.StringVar(&listen, "listen", listenDefault, "HTTP address to listen on")
|
||||
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(&cmdDelete, "delete", false, "Immediately delete a secure note. The passphrase is provided via CLI arguments.")
|
||||
flag.Usage = func() {
|
||||
|
@ -99,5 +110,7 @@ func main() {
|
|||
}
|
||||
|
||||
app := dethnote.NewServer(root, debug)
|
||||
app.MailgunURL = mailgunURL
|
||||
app.SetupHTTP()
|
||||
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.
|
||||
type Server struct {
|
||||
// Public configurable fields.
|
||||
MailgunURL string // format is like "key-1a2b3c@https://api.mailgun.net/v3/mg.example.com"
|
||||
|
||||
root string
|
||||
debug bool
|
||||
|
||||
n *negroni.Negroni
|
||||
r *mux.Router
|
||||
n *negroni.Negroni
|
||||
mux *mux.Router
|
||||
}
|
||||
|
||||
// NewServer initializes the server struct.
|
||||
|
@ -27,14 +30,33 @@ func NewServer(root string, debug bool) *Server {
|
|||
// SetupHTTP configures the HTTP server.
|
||||
func (s *Server) SetupHTTP() {
|
||||
// Set up the router.
|
||||
Log.Debug("Setting up the HTTP router...")
|
||||
r := mux.NewRouter()
|
||||
s.r = r
|
||||
s.mux = r
|
||||
|
||||
n := negroni.Classic()
|
||||
n.UseHandler(r)
|
||||
r.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
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.
|
||||
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