diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..46ce78c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.send.nrw/sendnrw/passwordsuggester + +go 1.25.1 diff --git a/main.go b/main.go new file mode 100644 index 0000000..9920373 --- /dev/null +++ b/main.go @@ -0,0 +1,590 @@ +// main.go +package main + +import ( + "crypto/rand" + "encoding/json" + "fmt" + "html" + "log" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "unicode" +) + +type Config struct { + Addr string + MinLength int + MinLower int + MinUpper int + MinDigits int + MinSpecial int + Specials string + LeetMap map[rune]string + Suggestions int + RuleSets []string // e.g. ["random","passphrase","hybrid"] +} + +type genRequest struct { + Sentence string `json:"sentence"` +} + +type complexity struct { + Lower int `json:"lower"` + Upper int `json:"upper"` + Digits int `json:"digits"` + Special int `json:"special"` + Length int `json:"length"` +} + +type genResponse struct { + Password string `json:"password"` + Complexity complexity `json:"complexity"` + Ok bool `json:"ok"` + Message string `json:"message,omitempty"` +} + +func main() { + cfg := loadConfig() + + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + fmt.Fprint(w, indexHTML(cfg)) + }) + + http.HandleFunc("/api/generate", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req genRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, genResponse{Ok: false, Message: "Ungültige Eingabe"}) + return + } + pw, cplx := generateFromSentence(strings.TrimSpace(req.Sentence), cfg) + ok := meets(cfg, cplx) + writeJSON(w, genResponse{Ok: ok, Password: pw, Complexity: cplx}) + }) + + http.HandleFunc("/api/suggest", func(w http.ResponseWriter, r *http.Request) { + // Gibt drei+ Vorschläge aus mindestens 3 RuleSets zurück + out := struct { + Suggestions []string `json:"suggestions"` + }{Suggestions: suggestions(cfg)} + writeJSON(w, out) + }) + + log.Printf("Listening on %s ...", cfg.Addr) + if err := http.ListenAndServe(cfg.Addr, nil); err != nil { + log.Fatal(err) + } +} + +/* -------------------- Config & ENV -------------------- */ + +func loadConfig() Config { + minLen := intEnv("MIN_LENGTH", 12) + minLower := intEnv("MIN_LOWER", 2) + minUpper := intEnv("MIN_UPPER", 2) + minDigits := intEnv("MIN_DIGITS", 2) + minSpecial := intEnv("MIN_SPECIAL", 2) + addr := strEnv("SERVER_ADDR", ":8081") + specials := strEnv("SPECIALS", "!@#$%^&*()-_=+[]{};:,.?/") + leet := parseLeetMap(strEnv("LEET_MAP", "a:4,e:3,o:0,t:7")) + sug := intEnv("SUGGESTION_COUNT", 5) + rules := strEnv("RULESETS", "random,passphrase,hybrid") + rs := []string{} + for _, p := range strings.Split(rules, ",") { + p = strings.TrimSpace(p) + if p != "" { + rs = append(rs, p) + } + } + if len(rs) == 0 { + rs = []string{"random", "passphrase", "hybrid"} + } + return Config{ + Addr: addr, + MinLength: minLen, + MinLower: minLower, + MinUpper: minUpper, + MinDigits: minDigits, + MinSpecial: minSpecial, + Specials: specials, + LeetMap: leet, + Suggestions: sug, + RuleSets: rs, + } +} + +func strEnv(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} + +func intEnv(k string, def int) int { + if v := os.Getenv(k); v != "" { + if n, err := strconv.Atoi(v); err == nil { + return n + } + } + return def +} + +func parseLeetMap(s string) map[rune]string { + // Format: "a:4,e:3,o:0,t:7" + out := map[rune]string{} + for _, pair := range strings.Split(s, ",") { + p := strings.Split(strings.TrimSpace(pair), ":") + if len(p) == 2 && len(p[0]) == 1 { + k := []rune(strings.ToLower(p[0]))[0] + out[k] = p[1] + } + } + return out +} + +/* -------------------- Core Generation -------------------- */ + +func generateFromSentence(sentence string, cfg Config) (string, complexity) { + if sentence == "" { + // Leerer Input -> generiere einen Vorschlag nach "hybrid" + pw := genHybrid(cfg) + return ensureAll(pw, cfg) + } + // 1) Normalisieren & Leet + words := strings.Fields(sentence) + if len(words) == 0 { + pw := genHybrid(cfg) + return ensureAll(pw, cfg) + } + // ersetze Leerzeichen durch zufällige Sonderzeichen + var b strings.Builder + for i, w := range words { + if i > 0 { + b.WriteString(randFrom(cfg.Specials)) + } + b.WriteString(applyLeet(w, cfg.LeetMap)) + } + pw := b.String() + + // 2) Zufällige Groß-/Kleinschreibung einstreuen + pw = sprinkleCase(pw) + + // 3) Komplexität & Mindestlänge sicherstellen + return ensureAll(pw, cfg) +} + +func applyLeet(s string, leet map[rune]string) string { + var b strings.Builder + for _, r := range s { + lr := unicode.ToLower(r) + if rep, ok := leet[lr]; ok { + b.WriteString(rep) + } else { + b.WriteRune(r) + } + } + return b.String() +} + +func sprinkleCase(s string) string { + rs := []rune(s) + for i, r := range rs { + if unicode.IsLetter(r) { + // ~50% Chance flip case + if coin() { + if unicode.IsUpper(r) { + rs[i] = unicode.ToLower(r) + } else { + rs[i] = unicode.ToUpper(r) + } + } + } + } + return string(rs) +} + +func ensureAll(pw string, cfg Config) (string, complexity) { + // Schrittweise Komplexität erzwingen, dann Mindestlänge + pw, c := ensureComplexity(pw, cfg) + if runeLen(pw) < cfg.MinLength { + need := cfg.MinLength - runeLen(pw) + allSet := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + cfg.Specials + for i := 0; i < need; i++ { + pw = insertAt(pw, randPos(pw), randFrom(allSet)) + } + c = countComplexity(pw, cfg) + } + return pw, c +} + +func ensureComplexity(s string, cfg Config) (string, complexity) { + pw := s + c := countComplexity(pw, cfg) + + // Ziffern + for c.Digits < cfg.MinDigits { + pw = insertAt(pw, randPos(pw), randFrom("0123456789")) + c = countComplexity(pw, cfg) + } + // Sonderzeichen + for c.Special < cfg.MinSpecial { + pw = insertAt(pw, randPos(pw), randFrom(cfg.Specials)) + c = countComplexity(pw, cfg) + } + // Großbuchstaben + for c.Upper < cfg.MinUpper { + if i := findIndexLetter(pw, false); i >= 0 { // irgendein Buchstabe + pw = setCaseAt(pw, i, true) + } else { + pw = insertAt(pw, randPos(pw), randFrom("ABCDEFGHIJKLMNOPQRSTUVWXYZ")) + } + c = countComplexity(pw, cfg) + } + // Kleinbuchstaben + for c.Lower < cfg.MinLower { + if i := findIndexLetter(pw, false); i >= 0 { + pw = setCaseAt(pw, i, false) + } else { + pw = insertAt(pw, randPos(pw), randFrom("abcdefghijklmnopqrstuvwxyz")) + } + c = countComplexity(pw, cfg) + } + return pw, c +} + +func countComplexity(s string, cfg Config) complexity { + var c complexity + for _, r := range s { + switch { + case unicode.IsLower(r): + c.Lower++ + case unicode.IsUpper(r): + c.Upper++ + case unicode.IsDigit(r): + c.Digits++ + default: + if strings.ContainsRune(cfg.Specials, r) { + c.Special++ + } + } + } + c.Length = runeLen(s) + return c +} + +func meets(cfg Config, c complexity) bool { + return c.Length >= cfg.MinLength && + c.Lower >= cfg.MinLower && + c.Upper >= cfg.MinUpper && + c.Digits >= cfg.MinDigits && + c.Special >= cfg.MinSpecial +} + +/* -------------------- Suggestions (3+ RuleSets) -------------------- */ + +func suggestions(cfg Config) []string { + rs := []string{} + // Mindestens eins je RuleSet + for _, set := range cfg.RuleSets { + switch set { + case "random": + rs = append(rs, genRandom(cfg)) + case "passphrase": + rs = append(rs, genPassphrase(cfg)) + case "hybrid": + rs = append(rs, genHybrid(cfg)) + } + } + // Falls mehr gewünscht, fülle gemischt auf + for len(rs) < cfg.Suggestions { + switch randPick([]string{"random", "passphrase", "hybrid"}) { + case "random": + rs = append(rs, genRandom(cfg)) + case "passphrase": + rs = append(rs, genPassphrase(cfg)) + default: + rs = append(rs, genHybrid(cfg)) + } + } + return rs +} + +func genRandom(cfg Config) string { + all := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + cfg.Specials + // Länge: mindestens MinLength bis MinLength+6 + L := cfg.MinLength + mustCryptoInt(5) + 2 // +2..+6 + var b strings.Builder + for i := 0; i < L; i++ { + b.WriteString(randFrom(all)) + } + pw, _ := ensureAll(b.String(), cfg) + return pw +} + +func genPassphrase(cfg Config) string { + // 3–4 Wörter, zufällige Trenner, zufällige Großschreibung & Leet-sprinkles + n := 3 + mustCryptoInt(2) // 3..4 + parts := make([]string, 0, n) + for i := 0; i < n; i++ { + w := wordList[mustCryptoInt(len(wordList))] + if coin() { + w = strings.ToUpper(w[:1]) + w[1:] + } + // gelegentlich Leet auf einzelne Buchstaben + if coin() && len(w) > 1 { + idx := mustCryptoInt(len(w)) + r := []rune(w) + lr := unicode.ToLower(r[idx]) + if rep, ok := cfg.LeetMap[lr]; ok { + r[idx] = []rune(rep)[0] + w = string(r) + } + } + parts = append(parts, w) + } + sep := randFrom(cfg.Specials) + pw := strings.Join(parts, sep) + // Zahlen zufällig einsprenkeln + if coin() { + pw = insertAt(pw, randPos(pw), strconv.Itoa(10+mustCryptoInt(89))) + } + pw, _ = ensureAll(pw, cfg) + return pw +} + +func genHybrid(cfg Config) string { + // 2 Wörter + 2 Sonderzeichen + 3–5 Ziffern gemischt + w1 := wordList[mustCryptoInt(len(wordList))] + w2 := wordList[mustCryptoInt(len(wordList))] + if coin() { + w1 = strings.ToUpper(w1[:1]) + w1[1:] + } + if coin() { + w2 = strings.ToUpper(w2[:1]) + w2[1:] + } + base := applyLeet(w1, cfg.LeetMap) + randFrom(cfg.Specials) + applyLeet(w2, cfg.LeetMap) + // streue zweite Sonderzeichen + base = insertAt(base, randPos(base), randFrom(cfg.Specials)) + // 3–5 Ziffern an zufälligen Stellen + numN := 3 + mustCryptoInt(3) + for i := 0; i < numN; i++ { + base = insertAt(base, randPos(base), randFrom("0123456789")) + } + pw, _ := ensureAll(base, cfg) + return pw +} + +/* -------------------- Utilities -------------------- */ + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(v) +} + +func runeLen(s string) int { return len([]rune(s)) } + +func randFrom(pool string) string { + i := mustCryptoInt(len(pool)) + return string(pool[i]) +} + +func randPos(s string) int { + L := runeLen(s) + if L == 0 { + return 0 + } + return mustCryptoInt(L + 1) // erlauben "ans Ende" +} + +func insertAt(s string, pos int, piece string) string { + rs := []rune(s) + if pos <= 0 { + return piece + s + } + if pos >= len(rs) { + return s + piece + } + var b strings.Builder + b.WriteString(string(rs[:pos])) + b.WriteString(piece) + b.WriteString(string(rs[pos:])) + return b.String() +} + +func findIndexLetter(s string, onlyLower bool) int { + rs := []rune(s) + // Zufällige Startposition, um Muster zu vermeiden + start := mustCryptoInt(len(rs) + 1) + for i := 0; i < len(rs); i++ { + idx := (start + i) % len(rs) + r := rs[idx] + if unicode.IsLetter(r) { + if onlyLower { + if unicode.IsLower(r) { + return idx + } + } else { + return idx + } + } + } + return -1 +} + +func setCaseAt(s string, idx int, upper bool) string { + rs := []rune(s) + if idx < 0 || idx >= len(rs) { + return s + } + if !unicode.IsLetter(rs[idx]) { + // wenn keine Letter -> füge stattdessen einen passenden Buchstaben ein + if upper { + return insertAt(s, idx, randFrom("ABCDEFGHIJKLMNOPQRSTUVWXYZ")) + } + return insertAt(s, idx, randFrom("abcdefghijklmnopqrstuvwxyz")) + } + if upper { + rs[idx] = unicode.ToUpper(rs[idx]) + } else { + rs[idx] = unicode.ToLower(rs[idx]) + } + return string(rs) +} + +func coin() bool { return mustCryptoInt(2) == 0 } + +func mustCryptoInt(max int) int { + if max <= 1 { + return 0 + } + nBig, err := rand.Int(rand.Reader, big.NewInt(int64(max))) + if err != nil { + panic(err) + } + return int(nBig.Int64()) +} + +func randPick(options []string) string { + return options[mustCryptoInt(len(options))] +} + +/* -------------------- Minimal UI (alles lokal) -------------------- */ + +func indexHTML(cfg Config) string { + var b strings.Builder + + b.WriteString("\n") + b.WriteString("\n\n\n\n") + b.WriteString("Password Forge (Go)\n\n\n\n") + b.WriteString("
\n
\n

🔐 Password Forge

\n") + b.WriteString("

Aus einem Satz ein starkes Kennwort erzeugen – mit konfigurierbaren Regeln (alles lokal).

\n") + b.WriteString("
\n
\n \n") + b.WriteString(" \n") + b.WriteString("
\n") + b.WriteString(" \n") + b.WriteString(" Mindestlänge: ") + b.WriteString(strconv.Itoa(cfg.MinLength)) + b.WriteString(" • lower≥") + b.WriteString(strconv.Itoa(cfg.MinLower)) + b.WriteString(" upper≥") + b.WriteString(strconv.Itoa(cfg.MinUpper)) + b.WriteString(" digits≥") + b.WriteString(strconv.Itoa(cfg.MinDigits)) + b.WriteString(" special≥") + b.WriteString(strconv.Itoa(cfg.MinSpecial)) + b.WriteString("\n") + b.WriteString("
\n
\n \n") + b.WriteString("
\n \n") + b.WriteString("
\n
\n
\n") + b.WriteString("
\n") + b.WriteString(" \n") + b.WriteString(" \n") + b.WriteString("
\n") + b.WriteString("
\n") + b.WriteString("
\n
\n \n
\n
\n\n\n\n") + + return b.String() +} + +/* -------------------- Small built-in word list -------------------- */ + +var wordList = []string{ + "apfel","atlas","anker","balou","blitz","brise","cobalt","cobra","dialog","dorado","dampf","edel","eule", + "falke","fjord","funk","galaxy","garnet","geist","harbor","herz","honig","indigo","ion","juno","jade", + "kiesel","komet","kobalt","lotus","lava","lynx","meteor","moos","nimbus","neon","novum","opal","orbit", + "pixel","polaris","quarz","quell","raven","regen","rythm","saphir","sigma","silber","tau","titan","topas", + "umbra","union","vektor","violett","vulkan","wolke","wogen","xenon","yukon","zenit","zeder", + "amber","boreal","cedar","delta","ember","fable","gamma","helios","iris","jelly","kappa","lemur","magma", + "nova","onyx","plume","quince","ridge","spruce","terra","ultra","vivid","willow","xerox","yodel","zesty", +} + +/* -------------------- END -------------------- */