596 lines
15 KiB
Go
596 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"crypto/rand"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"math"
|
|
"math/big"
|
|
"net/http"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
type WordData struct {
|
|
ByLen map[int][]string
|
|
}
|
|
|
|
var words WordData
|
|
|
|
// Fixed symbol alphabet
|
|
const symbolAlphabet = "!@#$%^&*()-+',?"
|
|
|
|
type PageData struct {
|
|
// Form values
|
|
WordCount int
|
|
MinLen int
|
|
MaxLen int
|
|
NumLen int
|
|
SymbolCount int // symbols per separator
|
|
RandCaps string
|
|
|
|
// Stats
|
|
TotalWords int
|
|
AverageWordLength float64
|
|
BitsPerWord float64
|
|
BitsPerSeparator float64
|
|
BitsForCapsTotal float64
|
|
EffectiveBits int
|
|
TotalCombinations string
|
|
|
|
Error string
|
|
GeneratedPhrases []string
|
|
}
|
|
|
|
// commonLogMiddleware logs requests in Apache/Nginx "Common Log Format"
|
|
func commonLogMiddleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
// Wrap ResponseWriter so we can capture status/size
|
|
lrw := &loggingResponseWriter{ResponseWriter: w, status: 200}
|
|
|
|
start := time.Now()
|
|
next.ServeHTTP(lrw, r)
|
|
duration := time.Since(start)
|
|
|
|
// Determine client IP
|
|
ip := r.RemoteAddr
|
|
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
|
|
ip = xf
|
|
}
|
|
|
|
// Common Log Format:
|
|
// 127.0.0.1 - - [10/Oct/2000:13:55:36 -0700] "GET /path HTTP/1.1" 200 2326
|
|
log.Printf(`%s - - [%s] "%s %s %s" %d %d "%s" "%s" %v`,
|
|
ip,
|
|
time.Now().Format("02/Jan/2006:15:04:05 -0700"),
|
|
r.Method,
|
|
r.RequestURI,
|
|
r.Proto,
|
|
lrw.status,
|
|
lrw.bytes,
|
|
r.Referer(),
|
|
r.UserAgent(),
|
|
duration,
|
|
)
|
|
})
|
|
}
|
|
|
|
// loggingResponseWriter allows us to capture status code & bytes written
|
|
type loggingResponseWriter struct {
|
|
http.ResponseWriter
|
|
status int
|
|
bytes int
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|
lrw.status = code
|
|
lrw.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func (lrw *loggingResponseWriter) Write(b []byte) (int, error) {
|
|
n, err := lrw.ResponseWriter.Write(b)
|
|
lrw.bytes += n
|
|
return n, err
|
|
}
|
|
|
|
// Single-file HTML template
|
|
var pageTmpl = template.Must(template.New("page").Parse(`
|
|
<!doctype html>
|
|
<html>
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<title>Secure Passphrase Generator</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 2rem; background: #f6f7fb; }
|
|
h1 { margin-bottom: 0.5rem; }
|
|
form { background: #fff; padding: 1rem 1.5rem; border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,0.08); max-width: 650px; }
|
|
label { display: inline-block; width: 180px; }
|
|
input, select { margin-bottom: 0.5rem; padding: 0.25rem 0.4rem; }
|
|
button { padding: 0.4rem 0.8rem; border-radius: 4px; border: 1px solid #0d6efd; background: #0d6efd; color: #fff; cursor: pointer; }
|
|
button:hover { background: #0b5ed7; }
|
|
table { border-collapse: collapse; margin-top: 1rem; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.08); }
|
|
th, td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; }
|
|
th { background: #f0f2f8; text-align: left; }
|
|
tr:last-child td { border-bottom: none; }
|
|
tt { font-family: "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
|
.error { color: #b00020; margin-top: 0.5rem; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>Secure Passphrase Generator</h1>
|
|
|
|
<form method="GET" action="/">
|
|
<div>
|
|
<label for="wordcount">Word count</label>
|
|
<input type="number" id="wordcount" name="wordcount" min="1" value="{{.WordCount}}">
|
|
</div>
|
|
<div>
|
|
<label for="minlen">Min word length</label>
|
|
<input type="number" id="minlen" name="minlen" min="1" value="{{.MinLen}}">
|
|
</div>
|
|
<div>
|
|
<label for="maxlen">Max word length</label>
|
|
<input type="number" id="maxlen" name="maxlen" min="1" value="{{.MaxLen}}">
|
|
</div>
|
|
<div>
|
|
<label for="numlen">Digits per separator</label>
|
|
<input type="number" id="numlen" name="numlen" min="0" value="{{.NumLen}}">
|
|
</div>
|
|
<div>
|
|
<label for="symcount">Symbols per separator</label>
|
|
<input type="number" id="symcount" name="symcount" min="0" value="{{.SymbolCount}}">
|
|
</div>
|
|
<div>
|
|
<label for="randcaps">Capitalization</label>
|
|
<select id="randcaps" name="randcaps">
|
|
<option value="first" {{if eq .RandCaps "first"}}selected{{end}}>Random first letter</option>
|
|
<option value="one" {{if eq .RandCaps "one"}}selected{{end}}>Random one letter</option>
|
|
<option value="all" {{if eq .RandCaps "all"}}selected{{end}}>Randomize all letters</option>
|
|
<option value="none" {{if eq .RandCaps "none"}}selected{{end}}>None</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-top: 0.5rem;">
|
|
<button type="submit">Generate Passphrase</button>
|
|
</div>
|
|
{{if .Error}}
|
|
<div class="error">{{.Error}}</div>
|
|
{{end}}
|
|
</form>
|
|
|
|
{{if not .Error}}
|
|
<table>
|
|
<tr><th colspan="2">Statistics</th></tr>
|
|
<tr><td>Word count</td><td>{{.TotalWords}}</td></tr>
|
|
<tr><td>Average word length</td><td>{{printf "%.2f" .AverageWordLength}}</td></tr>
|
|
<tr><td>Bits per word</td><td>{{printf "%.2f" .BitsPerWord}}</td></tr>
|
|
<tr><td>Bits per separator</td><td>{{printf "%.2f" .BitsPerSeparator}}</td></tr>
|
|
<tr><td>Bits for capitalization</td><td>{{printf "%.2f" .BitsForCapsTotal}}</td></tr>
|
|
<tr><td>Effective passphrase bits</td><td>{{.EffectiveBits}}</td></tr>
|
|
<tr><td>Total possible combinations</td><td>{{.TotalCombinations}}</td></tr>
|
|
</table>
|
|
|
|
<table>
|
|
<tr><th>Passphrase</th></tr>
|
|
{{range .GeneratedPhrases}}
|
|
<tr><td><tt>{{.}}</tt></td></tr>
|
|
{{end}}
|
|
</table>
|
|
{{end}}
|
|
<div style="
|
|
margin-top: 2rem;
|
|
background: #fff;
|
|
padding: 1rem 1.5rem;
|
|
border-radius: 8px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
|
|
max-width: 750px;
|
|
">
|
|
<h2 style="margin-top: 0; font-size: 1.2rem;">Notes</h2>
|
|
<ul style="padding-left: 1.2rem; margin: 0;">
|
|
<li style="margin: 0.25rem 0;">
|
|
No data generated by this page is stored on the server at any time. Your passphrases are <strong>never logged or recorded</strong>.
|
|
</li>
|
|
<li style="margin: 0.25rem 0;">
|
|
“All randomness is generated using Go's crypto/rand package, which uses the Linux kernel's secure random generator (same source as /dev/urandom).”
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
`))
|
|
|
|
func main() {
|
|
wordlistPath := os.Getenv("WORDLIST")
|
|
if wordlistPath == "" {
|
|
wordlistPath = "words.txt"
|
|
}
|
|
|
|
log.Printf("Loading wordlist from %s", wordlistPath)
|
|
wd, err := loadWordlist(wordlistPath)
|
|
if err != nil {
|
|
log.Fatalf("failed to load wordlist: %v", err)
|
|
}
|
|
words = wd
|
|
log.Printf("Loaded %d distinct word lengths", len(words.ByLen))
|
|
|
|
http.Handle("/", commonLogMiddleware(http.HandlerFunc(handlePassphrase)))
|
|
|
|
port := os.Getenv("PORT")
|
|
if port == "" {
|
|
port = "8000"
|
|
}
|
|
addr := ":" + port
|
|
log.Printf("Listening on %s", addr)
|
|
log.Fatal(http.ListenAndServe(addr, nil))
|
|
}
|
|
|
|
func loadWordlist(path string) (WordData, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return WordData{}, err
|
|
}
|
|
defer f.Close()
|
|
|
|
byLen := make(map[int][]string)
|
|
scanner := bufio.NewScanner(f)
|
|
for scanner.Scan() {
|
|
w := strings.TrimSpace(scanner.Text())
|
|
if w == "" {
|
|
continue
|
|
}
|
|
// Keep only alphabetic words
|
|
ok := true
|
|
for _, r := range w {
|
|
if !unicode.IsLetter(r) {
|
|
ok = false
|
|
break
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
w = strings.ToLower(w)
|
|
byLen[len(w)] = append(byLen[len(w)], w)
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return WordData{}, err
|
|
}
|
|
return WordData{ByLen: byLen}, nil
|
|
}
|
|
|
|
func handlePassphrase(w http.ResponseWriter, r *http.Request) {
|
|
wordcount := parseIntQuery(r, "wordcount", 3)
|
|
minlen := parseIntQuery(r, "minlen", 5)
|
|
maxlen := parseIntQuery(r, "maxlen", 8)
|
|
numlen := parseIntQuery(r, "numlen", 1)
|
|
symbolCount := parseIntQuery(r, "symcount", 1)
|
|
randcaps := r.URL.Query().Get("randcaps")
|
|
if randcaps == "" {
|
|
randcaps = "first"
|
|
}
|
|
|
|
if minlen < 1 {
|
|
minlen = 1
|
|
}
|
|
if maxlen < minlen {
|
|
maxlen = minlen
|
|
}
|
|
if wordcount < 1 {
|
|
wordcount = 1
|
|
}
|
|
if numlen < 0 {
|
|
numlen = 0
|
|
}
|
|
if symbolCount < 0 {
|
|
symbolCount = 0
|
|
}
|
|
|
|
data := PageData{
|
|
WordCount: wordcount,
|
|
MinLen: minlen,
|
|
MaxLen: maxlen,
|
|
NumLen: numlen,
|
|
SymbolCount: symbolCount,
|
|
RandCaps: randcaps,
|
|
}
|
|
|
|
usewords, totalLen := collectWordsInRange(words.ByLen, minlen, maxlen)
|
|
if len(usewords) == 0 {
|
|
data.Error = "No words available for the specified length range."
|
|
_ = pageTmpl.Execute(w, data)
|
|
return
|
|
}
|
|
|
|
totalWords := len(usewords)
|
|
avgLen := float64(totalLen) / float64(totalWords)
|
|
wordBits := math.Log2(float64(totalWords))
|
|
|
|
// Caps
|
|
var capBitsPerWord float64
|
|
var capMult float64
|
|
switch randcaps {
|
|
case "first":
|
|
capBitsPerWord = 1
|
|
capMult = 2
|
|
case "one":
|
|
capBitsPerWord = math.Log2(avgLen + 1)
|
|
capMult = avgLen + 1
|
|
case "all":
|
|
capBitsPerWord = avgLen
|
|
capMult = math.Pow(2, avgLen)
|
|
default:
|
|
capBitsPerWord = 0
|
|
capMult = 1
|
|
}
|
|
|
|
// Separators: after every word if any digits or symbols are enabled
|
|
segmentsEnabled := (numlen > 0 || symbolCount > 0)
|
|
segments := 0
|
|
if segmentsEnabled {
|
|
segments = wordcount
|
|
}
|
|
|
|
// Bits per separator segment
|
|
separatorBitsPerSegment := 0.0
|
|
if segmentsEnabled {
|
|
separatorBitsPerSegment = separatorBits(numlen, symbolCount, len(symbolAlphabet))
|
|
}
|
|
|
|
// Total bits
|
|
passBits := (wordBits+capBitsPerWord)*float64(wordcount) +
|
|
separatorBitsPerSegment*float64(segments)
|
|
|
|
// Total combinations ≈
|
|
combosPerWord := math.Pow(float64(totalWords), float64(wordcount))
|
|
combosCaps := math.Pow(capMult, float64(wordcount))
|
|
segmentCombos := 1.0
|
|
if segmentsEnabled {
|
|
segmentCombos = math.Pow(separatorCombos(numlen, symbolCount, len(symbolAlphabet)), float64(segments))
|
|
}
|
|
combos := combosPerWord * combosCaps * segmentCombos
|
|
|
|
combosStr := fmt.Sprintf("%.3e", combos)
|
|
if !math.IsInf(combos, 0) && combos < 9.999e15 {
|
|
combosStr = commafyInt(int64(combos + 0.5))
|
|
}
|
|
|
|
data.TotalWords = totalWords
|
|
data.AverageWordLength = avgLen
|
|
data.BitsPerWord = wordBits
|
|
data.BitsPerSeparator = separatorBitsPerSegment
|
|
data.BitsForCapsTotal = capBitsPerWord * float64(wordcount)
|
|
data.EffectiveBits = int(passBits + 0.5)
|
|
data.TotalCombinations = combosStr
|
|
|
|
data.GeneratedPhrases = generatePassphrases(usewords, wordcount, numlen, symbolCount, randcaps)
|
|
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
if err := pageTmpl.Execute(w, data); err != nil {
|
|
log.Printf("template error: %v", err)
|
|
}
|
|
}
|
|
|
|
func parseIntQuery(r *http.Request, name string, def int) int {
|
|
s := r.URL.Query().Get(name)
|
|
if s == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil {
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// collectWordsInRange flattens words[length] into a single slice for [minLen, maxLen].
|
|
func collectWordsInRange(byLen map[int][]string, minLen, maxLen int) ([]string, int) {
|
|
var all []string
|
|
totalLen := 0
|
|
for l := minLen; l <= maxLen; l++ {
|
|
ws := byLen[l]
|
|
if len(ws) == 0 {
|
|
continue
|
|
}
|
|
totalLen += len(ws) * l
|
|
all = append(all, ws...)
|
|
}
|
|
return all, totalLen
|
|
}
|
|
|
|
// separatorCombos returns the number of possible distinct separator segments.
|
|
func separatorCombos(numlen, symCountPerSeg, symAlphabetSize int) float64 {
|
|
// No separators
|
|
if numlen <= 0 && symCountPerSeg <= 0 {
|
|
return 1
|
|
}
|
|
// Only symbols
|
|
if numlen <= 0 && symCountPerSeg > 0 {
|
|
return math.Pow(float64(symAlphabetSize), float64(symCountPerSeg))
|
|
}
|
|
// Only digits
|
|
if numlen > 0 && symCountPerSeg <= 0 {
|
|
return math.Pow(10, float64(numlen))
|
|
}
|
|
// Mixed: exactly symCountPerSeg symbols and numlen digits
|
|
totalLen := numlen + symCountPerSeg
|
|
return comb(totalLen, symCountPerSeg) *
|
|
math.Pow(float64(symAlphabetSize), float64(symCountPerSeg)) *
|
|
math.Pow(10, float64(numlen))
|
|
}
|
|
|
|
// separatorBits = log2(combos) for a single segment
|
|
func separatorBits(numlen, symCountPerSeg, symAlphabetSize int) float64 {
|
|
combos := separatorCombos(numlen, symCountPerSeg, symAlphabetSize)
|
|
if combos <= 0 {
|
|
return 0
|
|
}
|
|
return math.Log2(combos)
|
|
}
|
|
|
|
// generatePassphrases builds N phrases with separators after each word when enabled.
|
|
func generatePassphrases(usewords []string, wordcount, numlen, symCountPerSeg int, randcaps string) []string {
|
|
const numPhrases = 10
|
|
if len(usewords) == 0 {
|
|
return nil
|
|
}
|
|
segmentsEnabled := (numlen > 0 || symCountPerSeg > 0)
|
|
symRunes := []rune(symbolAlphabet)
|
|
|
|
phrases := make([]string, 0, numPhrases)
|
|
for i := 0; i < numPhrases; i++ {
|
|
var b strings.Builder
|
|
for j := 0; j < wordcount; j++ {
|
|
word := usewords[secureRandInt(len(usewords))]
|
|
word = applyRandCaps(word, randcaps)
|
|
b.WriteString(word)
|
|
|
|
if segmentsEnabled {
|
|
segment := makeSeparatorSegment(numlen, symCountPerSeg, symRunes)
|
|
b.WriteString(segment)
|
|
}
|
|
}
|
|
phrases = append(phrases, b.String())
|
|
}
|
|
return phrases
|
|
}
|
|
|
|
// makeSeparatorSegment creates one separator segment.
|
|
// Cases:
|
|
// - numlen <= 0, symCountPerSeg <= 0: empty
|
|
// - numlen <= 0, symCountPerSeg > 0: symbols only
|
|
// - numlen > 0, symCountPerSeg <= 0: digits only
|
|
// - numlen > 0, symCountPerSeg > 0: mixed with exactly symCountPerSeg symbols.
|
|
func makeSeparatorSegment(numlen, symCountPerSeg int, symRunes []rune) string {
|
|
digits := "0123456789"
|
|
symAlphabetSize := len(symRunes)
|
|
|
|
if numlen <= 0 && symCountPerSeg <= 0 {
|
|
return ""
|
|
}
|
|
if numlen <= 0 && symCountPerSeg > 0 {
|
|
// Only symbols
|
|
runes := make([]rune, symCountPerSeg)
|
|
for i := 0; i < symCountPerSeg; i++ {
|
|
runes[i] = symRunes[secureRandInt(symAlphabetSize)]
|
|
}
|
|
return string(runes)
|
|
}
|
|
if numlen > 0 && symCountPerSeg <= 0 {
|
|
// Only digits
|
|
var b strings.Builder
|
|
for i := 0; i < numlen; i++ {
|
|
b.WriteByte(digits[secureRandInt(10)])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// Mixed: exactly symCountPerSeg symbols and numlen digits
|
|
totalLen := numlen + symCountPerSeg
|
|
runes := make([]rune, 0, totalLen)
|
|
|
|
// Add required number of symbols
|
|
for i := 0; i < symCountPerSeg; i++ {
|
|
runes = append(runes, symRunes[secureRandInt(symAlphabetSize)])
|
|
}
|
|
// Add required number of digits
|
|
for i := 0; i < numlen; i++ {
|
|
runes = append(runes, rune(digits[secureRandInt(10)]))
|
|
}
|
|
|
|
// Shuffle runes
|
|
shuffleRunes(runes)
|
|
return string(runes)
|
|
}
|
|
|
|
func secureRandInt(n int) int {
|
|
if n <= 0 {
|
|
return 0
|
|
}
|
|
max := big.NewInt(int64(n))
|
|
v, err := rand.Int(rand.Reader, max)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return int(v.Int64())
|
|
}
|
|
|
|
func shuffleRunes(r []rune) {
|
|
for i := len(r) - 1; i > 0; i-- {
|
|
j := secureRandInt(i + 1)
|
|
r[i], r[j] = r[j], r[i]
|
|
}
|
|
}
|
|
|
|
func applyRandCaps(word, mode string) string {
|
|
if word == "" {
|
|
return word
|
|
}
|
|
switch mode {
|
|
case "first":
|
|
if secureRandInt(2) == 1 {
|
|
return strings.ToUpper(word[:1]) + word[1:]
|
|
}
|
|
case "one":
|
|
k := secureRandInt(len(word) + 1)
|
|
if k < len(word) {
|
|
return word[:k] + strings.ToUpper(word[k:k+1]) + word[k+1:]
|
|
}
|
|
case "all":
|
|
var out []rune
|
|
for _, r := range word {
|
|
if secureRandInt(2) == 1 {
|
|
out = append(out, unicode.ToUpper(r))
|
|
} else {
|
|
out = append(out, unicode.ToLower(r))
|
|
}
|
|
}
|
|
return string(out)
|
|
}
|
|
return word
|
|
}
|
|
|
|
// comb computes "n choose k" as float64 (safe for small n).
|
|
func comb(n, k int) float64 {
|
|
if k < 0 || k > n {
|
|
return 0
|
|
}
|
|
if k == 0 || k == n {
|
|
return 1
|
|
}
|
|
if k > n-k {
|
|
k = n - k
|
|
}
|
|
result := 1.0
|
|
for i := 1; i <= k; i++ {
|
|
result *= float64(n - k + i)
|
|
result /= float64(i)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func commafyInt(n int64) string {
|
|
sign := ""
|
|
if n < 0 {
|
|
sign = "-"
|
|
n = -n
|
|
}
|
|
s := strconv.FormatInt(n, 10)
|
|
if len(s) <= 3 {
|
|
return sign + s
|
|
}
|
|
var parts []string
|
|
for len(s) > 3 {
|
|
parts = append([]string{s[len(s)-3:]}, parts...)
|
|
s = s[:len(s)-3]
|
|
}
|
|
if len(s) > 0 {
|
|
parts = append([]string{s}, parts...)
|
|
}
|
|
return sign + strings.Join(parts, ",")
|
|
}
|