package admin
import (
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"fmt"
"html/template"
"math/big"
"net/http"
"strings"
"time"
qrcode "github.com/skip2/go-qrcode"
)
const (
shareTTL = 15 * time.Minute
maxShareAttempts = 5
maxSecretBytes = 8000
)
// newShareID returns 16 random bytes, base64url-encoded (no padding) —
// the only copy of it that ever exists in reversible form is the one
// handed to whoever creates the share; the database only ever sees its
// hash (see hashShareID).
func newShareID() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func hashShareID(id string) string {
sum := sha256.Sum256([]byte(id))
return hex.EncodeToString(sum[:])
}
// newPasscode returns a uniformly random 6-digit code, zero-padded.
func newPasscode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
// shareKey derives the AES-256 key for one share from its id and
// passcode together — never from either alone. A database dump gives an
// attacker id_hash and ciphertext but never the plaintext id, so it's
// insufficient by itself; an attacker who only has the link (the
// plaintext id) still needs the passcode, which is never stored at all —
// GCM's authentication tag is what tells a wrong-passcode attempt apart
// from a right one, not a stored comparison value.
func shareKey(id, passcode string) []byte {
mac := hmac.New(sha256.New, []byte(id))
mac.Write([]byte("graft-share-v1|" + passcode))
return mac.Sum(nil)
}
func encryptShare(id, passcode, plaintext string) (nonceB64, ciphertextB64 string, err error) {
block, err := aes.NewCipher(shareKey(id, passcode))
if err != nil {
return "", "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", "", err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return "", "", err
}
ct := gcm.Seal(nil, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(nonce), base64.StdEncoding.EncodeToString(ct), nil
}
func decryptShare(id, passcode, nonceB64, ciphertextB64 string) (string, error) {
block, err := aes.NewCipher(shareKey(id, passcode))
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
nonce, err := base64.StdEncoding.DecodeString(nonceB64)
if err != nil {
return "", err
}
ct, err := base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", err
}
pt, err := gcm.Open(nil, nonce, ct, nil) // fails (wrong passcode) is the expected common case, not a real error
if err != nil {
return "", errWrongPasscode
}
return string(pt), nil
}
var errWrongPasscode = fmt.Errorf("wrong passcode")
func (h *Handler) shareBaseURL(r *http.Request) string {
if h.cfg.PublicHost != "" {
return "https://" + h.cfg.PublicHost
}
return "https://" + r.Host
}
var shareNewTmpl = template.Must(template.New("share-new").Parse(`
Share a secret, once
Encrypts the text below. Returns a one-time link and a separate 6-digit passcode. Send them over two different channels. Destroyed on first successful claim, after 5 wrong passcodes, or after 15 minutes.
{{if .Error}}
{{.Error}}
{{end}}
`))
var shareCreatedTmpl = template.Must(template.New("share-created").Parse(`
Share created
Link and passcode shown together only this once. Send them over two separate channels.