More human-friendly backup codes for 2FA
brawaru opened this issue · comments
Is your suggested enhancement related to a problem? Please describe.
When setting up 2FA, Modrinth provides you with a list of 6 backup codes. Typically those codes are meant to be used when you lost your authenticator, so you would likely want to just write them off on some kind of paper or download as a file, however, Modrinth backup codes are passwords. They're pretty difficult to write by hand. Not only they're 11 characters in length and alphanumeric, they also consist of uppercase and lowercase characters, and have no separators.
Here's an example of existing codes:
- 47qiZVkXxfE
- EbZlmqtkqmU
- FYf7hj0Km2A
- CU5hWULzf7K
- KO8N5YPeFgk
- 8f7ax3DGVVo
Describe the solution you'd like
Backup codes should remain codes, not passwords. Therefore, they should be easy to write on a piece of paper. I propose generating at least 8 codes, each code having two parts separated by a dash character, and each part consisting of alphanumeric characters in uppercase (A-Z, 0-9), although when codes are accepted, the case shouldn't matter, neither the presence of a dash separator.
Example:
- ZU36-YVEU
- 1T31-8173
- K0NX-0265
- M788-7T36
- Q947-6970
- J53Y-5IY7
- RDQ0-1D95
- Q070-4I46
These codes can easily be generated with the following JS code, which is likely portable to Rust:
function getRandomValue() {
const arr = crypto.getRandomValues(new Uint32Array(2));
return ((arr[0] * 0x100000) + (arr[1] >>> 12)) / 0x10_000_000_000_000;
}
const englishA = 'A'.charCodeAt(0)
const letterChance = 0.5
function backupCode(partsCount = 2, partLength = 4) {
let code = ''
partsCount = Math.max(partsCount, 0)
partLength = Math.max(partLength, 0)
for (let i = 0; i < partsCount; i++) {
for (let j = 0; j < partLength; j++) {
if (getRandomValue() > letterChance) {
code += String.fromCharCode(englishA + Math.floor(getRandomValue() * 26))
} else {
code += Math.floor(getRandomValue() * 10)
}
}
if ((i + 1) !== partsCount) code += '-'
}
return code
}
Test
{
const standardCodeRegExp = /^[A-Z0-9]{4}-[A-Z0-9]{4}$/
const charStats = new Map();
console.log('Running test on 100,000 generations')
for (let i = 0; i < 100_000; i++) {
const code = backupCode()
if (!standardCodeRegExp.test(code)) {
throw new Error(`Illegal code: ${JSON.stringify(code)}`)
}
for (const char of code.split('')) {
if (char === '-') continue
charStats.set(char, (charStats.get(char) ?? 0) + 1)
}
}
if (!charStats.has('A')) {
throw new Error('A never generated, are offsets wrong?')
}
if (!charStats.has('Z')) {
throw new Error('Z never generated, are offsets wrong?')
}
console.log('Test complete.')
const sortedStats = [...charStats.entries()].sort(([, countA], [, countB]) => countB - countA)
console.log('=== DISTRIBUTION ===')
let i = 0;
for (const [char, count] of sortedStats) {
console.log(`${++i}. ${char} - ${count}`)
}
}
Result:
Running test on 100,000 generations
=== DISTRIBUTION ===
1. 0 - 40151
2. 1 - 40054
3. 6 - 39986
4. 3 - 39938
5. 5 - 39934
6. 9 - 39926
7. 7 - 39904
8. 4 - 39835
9. 2 - 39812
10. 8 - 39737
11. R - 15771
12. C - 15696
13. F - 15609
14. P - 15604
15. D - 15546
16. M - 15538
17. G - 15486
18. V - 15473
19. K - 15452
20. S - 15451
21. Y - 15429
22. H - 15418
23. E - 15401
24. W - 15389
25. J - 15388
26. O - 15387
27. U - 15376
28. Q - 15369
29. I - 15359
30. L - 15351
31. N - 15308
32. B - 15290
33. X - 15242
34. A - 15155
35. T - 15121
36. Z - 15114
Describe alternatives you've considered
- Seed phrases, similar to those used in crypto currencies to encode wallet recovery code. They encode a seed using one of the dictionaries (thus they can be translatable as well), and you get a phrase like
betray forest execute sock bridge build
, which can be turned back into a seed by the machine. However, this is a lot to write for a single code, and we need only a few one-time use codes, so I don't think is a good idea compared to the proposal above.
Additional context
N/A