Files
password_generator/api/app.py
2026-03-24 16:30:46 -07:00

434 lines
15 KiB
Python

#!/usr/bin/env python3
# yo dawg i heard you like libraries
from __future__ import annotations
import random
import string
import yaml
from datetime import datetime
from pathlib import Path
from typing import List, Iterable, Set, Dict, Any
from flask import Flask, jsonify, request, Response
import json, time, hashlib
# System Variables
words = []
simple_words = []
password_hashes =set()
SPECIAL_SET = "!@#$%^&*(),.<>?~`;:|][}{=-+_"
WORDS_FILE = "dict.yaml"
password_types = [
"generate_standard_password",
"generate_windows_ad_password",
"generate_simple_password"
]
#################################################
# Hash Record Functions
#################################################
HASH_FILE = Path("/opt/pwdgen/hash_record.txt")
# Create the file if it doesn't exist
if not HASH_FILE.exists():
HASH_FILE.touch(exist_ok=True)
# Read the hashes
try:
with HASH_FILE.open("r", encoding="utf-8") as fh:
password_hashes = {line.strip() for line in fh if line.strip()}
except OSError as exc:
# If we cannot read the file we fall back to an empty set; the
# application will continue to work, but any persisted hashes
# will be lost.
print(f"Warning: cannot read hash file {HASH_FILE!s}: {exc}")
password_hashes = set()
def save_hashes() -> None:
# Write to a temporary file first
tmp_path = HASH_FILE.with_name(HASH_FILE.name + ".tmp")
try:
with tmp_path.open("w", encoding="utf-8") as fh:
for h in sorted(password_hashes):
fh.write(f"{h}\n")
# Replace the original file atomically
tmp_path.replace(HASH_FILE)
except OSError as exc:
print(f"Error: could not write hash file {HASH_FILE!s}: {exc}")
#################################################
# YAML Handler
#################################################
with open(WORDS_FILE, "r", encoding="utf-8") as fh:
data = yaml.safe_load(fh)
# Ensure the key exists and is a list; otherwise return empty list.
raw_words = data.get("words") if isinstance(data, dict) else None
if not isinstance(words, list):
raise ValueError(f"YAML file {path!s} must contain a top-level 'words' list")
# Ensure the key exists and is a list; otherwise return empty list.
raw_simple_words = data.get("simple_words") if isinstance(data, dict) else None
if not isinstance(words, list):
raise ValueError(f"YAML file {path!s} must contain a top-level 'words' list")
# Strip whitespace and keep only non-empty words
words = [w.strip() for w in raw_words if isinstance(w, str) and w.strip()]
simple_words = [w.strip() for w in raw_simple_words if isinstance(w, str) and w.strip()]
total_words = len(words) + len(simple_words)
# Declare Descriptor after loading words
password_function_descriptor= {
"generate_standard_password": {
"name": "Standard Password",
"type": "0",
"description": f"From word lists totalling {total_words} word this algorithm selects 60 of these words that are less than 13 characters. From these 60, it uses the one with the index of the current second, and depending on this word length, it may or may not select additional words from the 60. It then randomly selects an integer and some special characters and randomly puts these all together into one string that is this password."
}
,
"generate_windows_ad_password": {
"name": "Windows AD Password",
"type": "1",
"description": "This password is always in the following format: $Word1Word2Number$<br> - Each word is less than 7 characters, the number is 3 digits, and the $ represents a Special Charater."
}
,
"generate_simple_password": {
"name": "Simple Password",
"type": "2",
"description": f"This simple password is in the following format: !Password123 - this pulls from a list of {len(simple_words)} simple words."
}
}
#################################################
# Flask Routes and Helpers
#################################################
app = Flask(__name__)
@app.route('/get_password', methods=['GET'])
def get_password():
pwd_index = request.args.get('pwd_index')
# Check for presence
if pwd_index is None:
print("Missing required query parameter 'pwd_index'")
return jsonify({
"error": "Missing required query parameter 'pwd_index'"
}), 400 # 400 Bad Request
# Check for int
try:
index = int(pwd_index)
except ValueError:
print(f"Parameter 'index' must be an integer, got '{pwd_index}'")
return jsonify({
"error": f"Parameter 'index' must be an integer, got '{pwd_index}'"
}), 400
# Check bounds
if index < 0 or index >= len(password_types):
print(f"Index {index} is out of bounds (0 ≤ index < {len(password_types)})")
return jsonify({
"error": f"Index {index} is out of bounds (0 ≤ index < {len(password_types)})",
"password_types": password_types
}), 404 # 404 Not Found
# Success - return the password
return jsonify({
"password": get_password_by_index(index)
}), 200
@app.route('/verbose_password', methods=['GET'])
def verbose_password():
pwd_index = request.args.get('pwd_index')
# Check for presence
if pwd_index is None:
return jsonify({
"error": "Missing required query parameter 'pwd_index'"
}), 400 # 400 Bad Request
# Check for int
try:
index = int(pwd_index)
except ValueError:
return jsonify({
"error": f"Parameter 'index' must be an integer, got '{pwd_index}'"
}), 400
# Check bounds
if index < 0 or index >= len(password_types):
return jsonify({
"error": f"Index {index} is out of bounds (0 ≤ index < {len(password_types)})",
"password_types": password_types
}), 404 # 404 Not Found
# Success - return the result
result = {
"password": get_password_by_index(index),
"descriptor": password_function_descriptor[password_types[index]],
"password count": len(password_hashes)
}
return jsonify(result), 200
@app.route('/custom_password', methods=['POST'])
def custom_password():
if not request.is_json:
return jsonify(error="Request body must be JSON"), 400
payload = request.get_json()
# Basic presence check - you can relax this if you want defaults
required_keys = {"w_min", "w_max", "w_count", "s_char", "num_len"}
missing = required_keys - payload.keys()
if missing:
return jsonify(error=f"Missing keys: {', '.join(missing)}"), 400
try:
password = generate_custom_password(payload)
except ValueError as exc:
return jsonify(error=str(exc)), 400
return jsonify({
"password": password
}), 200
@app.route('/get_types', methods=['GET'])
def get_types():
return jsonify(password_function_descriptor)
@app.route('/get_count', methods=['GET'])
def get_count():
password_count = len(password_hashes)
result = {
"total_passwords": password_count
}
return jsonify(result)
def get_password_by_index(pwd_index: int):
if pwd_index < 0 or pwd_index >= len(password_types):
raise IndexError(f"Index {pwd_index} is out of range for list of length {len(password_types)}")
func_name = password_types[pwd_index]
# Look up the actual function object in the module namespace.
func = globals().get(func_name)
if not callable(func):
raise ValueError(f"Function name '{func_name}' does not refer to a callable")
return func()
#################################################
# Password Generators
#################################################
# Standard Password Generator
# This is excessively complicated
def generate_standard_password() -> str:
candidates = [w for w in words if len(w) < 13]
if not candidates:
raise ValueError("No words of length < 13 found")
# Shuffle the list and grab the first 60
random.shuffle(candidates)
candidates = candidates[:60]
# Grab a random-ish word based on the time
sec = int(time.time())
word1 = candidates[sec % len(candidates)]
len1 = len(word1)
# Start building the password
password = ""
# If it picked a short word, add more stuff
if len1 < 5:
word2 = random.choice(words)
len2 = len(word2)
# If this picked a really short word, add moar things
if len2 < 4:
word3 = random.choice(words)
return type3(word1, word2, word3)
password = type2(word1, word2)
# If it picked a really long word, this will be fine
if len1 > 8:
password = type1(word1)
# If the first word is medium-sized, get one more
word2 = random.choice(words)
password = type2(word1, word2)
# Check, and return password
if check_and_add_hash(password):
return password
else:
return "Somehow a password was duplicated"
# Standard Password Helper Functions
# For one really long word
def type1(w1: str) -> str:
num1 = rand_int(100, 9999)
sym = rand_symbol_set(SPECIAL_SET, 3)
components = [ucfirst(w1), str(num1)] + sym
shuffle_list(components)
return "".join(components)
# For two medium-sized words
def type2(w1: str, w2: str) -> str:
num2 = rand_int(100, 9999)
sym = rand_symbol_set(SPECIAL_SET, 3)
components = [ucfirst(w1), ucfirst(w2), str(num2)] + sym
shuffle_list(components)
return "".join(components)
# At least two tiny words
def type3(w1: str, w2: str, w3: str) -> str:
num3 = rand_int(100, 9999)
sym = rand_symbol_set(SPECIAL_SET, 4)
components = [ucfirst(w1), ucfirst(w2), ucfirst(w3), str(num3)] + sym
shuffle_list(components)
return "".join(components)
# Windows AD Password
def generate_windows_ad_password() -> str:
short_words = [w for w in simple_words if len(w) < 7]
if len(short_words) < 2:
raise ValueError("Need at least two words of length < 7")
# Get two words
w1, w2 = random.sample(short_words, 2)
# Get two symbols and a 3-digit number
sym_set = "!@#$%^&*()[]{}|-+<>?"
symbols = rand_symbol_set(sym_set, 2)
number = rand_int(100, 999)
# Build, check, and return password
password = f"{symbols[0]}{ucfirst(w1)}{ucfirst(w2)}{number}{symbols[1]}"
if check_and_add_hash(password):
return password
else:
return "Somehow a password was duplicated"
# Simple Password
def generate_simple_password() -> str:
candidates = [w for w in words if 7 <= len(w) <= 12]
if not candidates:
raise ValueError("No words of length 7-12 found")
# Get a random word, number, and SC
word = random.choice(candidates)
symbol = random.choice("!@#$%^&*()")
number = rand_int(100, 999)
# Build, check, and return password
password = f"{symbol}{ucfirst(word)}{number}"
if check_and_add_hash(password):
return password
else:
return "Somehow a password was duplicated"
#################################################
# Custom Password Generator
#################################################
def generate_custom_password(params: Dict[str, Any]) -> str:
# Helper function for range
def _num_range(num_len: int) -> tuple[int, int] | None:
mapping = {
0: None,
1: (0, 9),
2: (10, 99),
3: (100, 999),
4: (1000, 9999),
5: (10000, 99999),
6: (100000, 999999),
7: (1000000, 9999999),
8: (10000000, 99999999),
}
return mapping.get(num_len)
# 2.1 Pull and validate parameters
w_min = int(params.get('w_min', 3))
w_max = int(params.get('w_max', 10))
w_count = int(params.get('w_count', 2))
s_char = int(params.get('s_char', 2))
num_len = int(params.get('num_len', 3))
words_raw = list(params.get('words', []))
# Basic sanity checks
if not (3 <= w_min <= 10):
raise ValueError("w_min must be between 3 and 10")
if not (3 <= w_max <= 10):
raise ValueError("w_max must be between 3 and 10")
if w_min > w_max:
raise ValueError("w_min cannot be greater than w_max")
if not (1 <= w_count <= 5):
raise ValueError("w_count must be between 1 and 5")
if not (0 <= s_char <= 4):
raise ValueError("s_char must be between 0 and 4")
if not (0 <= num_len <= 8):
raise ValueError("num_len must be between 0 and 8")
# 2.2 Filter the word list by length
candidates = [w for w in words if w_min <= len(w) <= w_max]
if len(candidates) < w_count:
raise ValueError(
f"Not enough words of length {w_min}-{w_max}. "
f"Need {w_count}, found {len(candidates)}."
)
# 2.3 Randomly pick words and capitalize first letter
rng = random.SystemRandom() # cryptographically secure PRNG
selected_words = rng.sample(candidates, w_count)
selected_words = [w.capitalize() for w in selected_words]
# Generate the numeric block (if required)
num_part = ""
num_range = _num_range(num_len)
if num_range:
low, high = num_range
num_part = str(rng.randint(low, high))
# --------------------------------------------------------------------- #
# Pick special characters
# --------------------------------------------------------------------- #
special_chars = "!@#$%^&*()[]{}|-+<>?"
shuffled_specials = list(special_chars)
rng.shuffle(shuffled_specials)
specials = "".join(shuffled_specials[:s_char])
# --------------------------------------------------------------------- #
# Assemble all pieces into a list, shuffle, and join
# --------------------------------------------------------------------- #
all_parts: List[str] = selected_words
if num_part:
all_parts.append(num_part)
if specials:
# We insert specials as individual characters (mirroring PHP code)
all_parts.extend(list(specials))
rng.shuffle(all_parts)
password = "".join(all_parts)
return password
#################################################
# Password helper functions
#################################################
# Return the word with its first character capitalised
def ucfirst(word: str) -> str:
return word[0].upper() + word[1:] if word else ""
def shuffle_list(items: List[str]) -> None:
random.shuffle(items)
def rand_int(low: int, high: int) -> int:
return random.randint(low, high)
def rand_symbol_set(symbols: str, n: int) -> List[str]:
return random.sample(symbols, n)
# Return True when password is unused
def check_and_add_hash(password: str) -> bool:
def sha256_hex(s: str) -> str:
return hashlib.sha256(s.encode('utf-8')).hexdigest()
hash_value = sha256_hex(password)
if hash_value in password_hashes:
return False
# Hash was missing - add it and report that it was newly inserted.
password_hashes.add(hash_value)
save_hashes()
return True
#################################################
# Main Function
#################################################
if __name__ == "__main__":
######################################
# Flask API
######################################
app.run(debug=False, host='0.0.0.0', port=5000)