Initial UX, creation of encrypted note files

master
Noah 2018-06-30 17:07:09 -07:00
parent 412bf79941
commit ac845e8fe7
21 changed files with 8746 additions and 10 deletions

6
.gitignore vendored
View File

@ -1,3 +1,3 @@
fonts/
screenshot-*.png
map-*.json
notes/
./dethnote
./dethnote.exe

View File

@ -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
View 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
View File

@ -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
View File

@ -0,0 +1 @@
hello world

142
public/ui/dethnote.css Normal file
View 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
View 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
View 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)
}

View File

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

File diff suppressed because it is too large Load Diff

67
src/vault/jsons.go Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
</main>
<footer>
</footer>
</body>
</html>

17
templates/header.gohtml Normal file
View 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
View 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 &lt;this website&gt; 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 &gt;</button>
</p>
</form>
</section>
{{ Include "footer.gohtml" }}

View 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" }}