1"""Generate and validate human-readable portal codes.
2
3Format: {number}-{word}-{word} (e.g. "7-crossword-pineapple")
4
5The number is 1-99, and each word is drawn from a curated list of
6common, unambiguous English words. This gives roughly 20 bits of
7entropy — enough to prevent casual guessing while SPAKE2 protects
8against brute-force by eavesdroppers.
9"""
10
11from __future__ import annotations
12
13import secrets
14
15# ~200 words: common, easy to spell, easy to pronounce, no homophones.
16# Kept short to be memorable over voice/chat.
17WORDLIST = [
18 "acorn",
19 "album",
20 "anchor",
21 "apple",
22 "arrow",
23 "atlas",
24 "badge",
25 "baker",
26 "banjo",
27 "barrel",
28 "basket",
29 "beacon",
30 "blanket",
31 "blizzard",
32 "bonfire",
33 "breeze",
34 "bridge",
35 "bucket",
36 "buffalo",
37 "cabin",
38 "cactus",
39 "camel",
40 "candle",
41 "canyon",
42 "carpet",
43 "castle",
44 "cedar",
45 "channel",
46 "cherry",
47 "chestnut",
48 "chimney",
49 "circuit",
50 "cobalt",
51 "comet",
52 "compass",
53 "copper",
54 "coral",
55 "coyote",
56 "cradle",
57 "crystal",
58 "curtain",
59 "cypress",
60 "dagger",
61 "dolphin",
62 "donkey",
63 "dragon",
64 "drifter",
65 "eagle",
66 "ember",
67 "emerald",
68 "falcon",
69 "fern",
70 "fiddle",
71 "flannel",
72 "fossil",
73 "fountain",
74 "galaxy",
75 "garden",
76 "garlic",
77 "gazelle",
78 "geyser",
79 "glacier",
80 "goblet",
81 "granite",
82 "gravel",
83 "griffin",
84 "guitar",
85 "gypsum",
86 "hammer",
87 "harbor",
88 "harvest",
89 "hazel",
90 "helmet",
91 "horizon",
92 "hornet",
93 "hurricane",
94 "igloo",
95 "indigo",
96 "island",
97 "ivory",
98 "jacket",
99 "jasmine",
100 "jigsaw",
101 "jungle",
102 "juniper",
103 "kayak",
104 "kennel",
105 "lantern",
106 "lasso",
107 "lemon",
108 "leopard",
109 "lighthouse",
110 "lizard",
111 "lobster",
112 "magnet",
113 "mammoth",
114 "maple",
115 "marble",
116 "meadow",
117 "meteor",
118 "mitten",
119 "monsoon",
120 "mosaic",
121 "mustard",
122 "narwhal",
123 "nebula",
124 "nectar",
125 "nimbus",
126 "nutmeg",
127 "obsidian",
128 "octopus",
129 "olive",
130 "orchid",
131 "osprey",
132 "otter",
133 "oyster",
134 "paddle",
135 "panther",
136 "parrot",
137 "peacock",
138 "pebble",
139 "pelican",
140 "penguin",
141 "pepper",
142 "phoenix",
143 "pickle",
144 "pillow",
145 "pineapple",
146 "plumber",
147 "porcupine",
148 "pretzel",
149 "prism",
150 "pumpkin",
151 "python",
152 "quartz",
153 "rabbit",
154 "raccoon",
155 "rainbow",
156 "raven",
157 "ribbon",
158 "riddle",
159 "rocket",
160 "saddle",
161 "salmon",
162 "sandal",
163 "sapphire",
164 "scarlet",
165 "seagull",
166 "shelter",
167 "silver",
168 "sparrow",
169 "spider",
170 "squirrel",
171 "steeple",
172 "summit",
173 "sunset",
174 "tambourine",
175 "tandem",
176 "temple",
177 "thicket",
178 "thistle",
179 "thunder",
180 "timber",
181 "toucan",
182 "tractor",
183 "trident",
184 "trophy",
185 "trumpet",
186 "tulip",
187 "turtle",
188 "umbrella",
189 "unicorn",
190 "valley",
191 "velvet",
192 "vessel",
193 "violet",
194 "volcano",
195 "walnut",
196 "warden",
197 "waffle",
198 "whistle",
199 "willow",
200 "wizard",
201 "wombat",
202 "zenith",
203 "zephyr",
204 "zipper",
205]
206
207
208def generate_code() -> str:
209 """Generate a random portal code like '7-crossword-pineapple'."""
210 number = secrets.randbelow(99) + 1 # 1-99
211 word1 = secrets.choice(WORDLIST)
212 word2 = secrets.choice(WORDLIST)
213 # Avoid identical words
214 while word2 == word1:
215 word2 = secrets.choice(WORDLIST)
216 return f"{number}-{word1}-{word2}"
217
218
219def validate_code(code: str) -> bool:
220 """Check that a code looks structurally valid (number-word-word)."""
221 parts = code.split("-")
222 if len(parts) < 3:
223 return False
224 try:
225 n = int(parts[0])
226 except ValueError:
227 return False
228 return 1 <= n <= 99 and len(parts[1]) > 0 and len(parts[2]) > 0