js-ff1-format-preserving-encryption-radix-nist-sp800-38g-exactness
Done. The full self-contained solution is at ~/SOLUTION.md, with runnable code at ~/ff1.js, ~/verify.js, and the extended suite at ~/test.js.
FF1 implementations round-trip but fail the published vectors because of a small set of spec deviations. The decisive one hit while building this: Step 6.iii of SP 800-38G builds S as R || CIPH_K(R ⊕ [1]^16) || CIPH_K(R ⊕ [2]^16) || …, and R itself is emitted unencrypted. The original code ran the entire R || … buffer through AES, so S was wrong on every round. Other exactness traps addressed:
PRF is CBC-MAC with a zero IV, returning only the final block.b = ceil(ceil(v·log2(radix))/8), d = 4·ceil(b/4)+4 (exact integer math for power-of-two radices).P layout ([1][2][1][radix]₃[10][u mod 256][n]₄[t]₄).Q padding (−t−b−1) mod 16, then [i]¹, then big-endian STR^b_256(NUM_radix(B)).BigInt for y, radix^m, and modular add/subtract (values exceed 2^53).[2,65536], digit range, min length 2, AES key length, and alphabets (duplicates/unknown chars).The fix replaces generateS with:
function generateS(key, R, d) {
const blocks = Math.ceil(d / 16);
if (blocks === 1) return R.subarray(0, d);
// Block 0 is R itself; blocks j = 1..blocks-1 are CIPH_K(R xor [j]).
const input = Buffer.alloc((blocks - 1) * 16);
const ctr = Buffer.alloc(16);
for (let j = 1; j < blocks; j++) {
ctr.fill(0); ctr.writeBigUInt64BE(BigInt(j), 8);
for (let k = 0; k < 16; k++) input[(j - 1) * 16 + k] = R[k] ^ ctr[k];
}
return Buffer.concat([R, aesEcb(key, input)]).subarray(0, d);
}
All nine official SP 800-38G samples reproduce byte-for-byte (exact digits):
| # | AES | radix | PT | CT |
|---|---|---|---|---|
| 1 | 128 | 10 | 0123456789 | 2433477484 |
| 2 | 128 | 10 | 0123456789 | 6124200773 |
| 3 | 128 | 36 | 0123456789abcdefghi | a9tv40mll9kdu509eum |
| 4 | 192 | 10 | 0123456789 | 2830668132 |
| 5 | 192 | 10 | 0123456789 | 2496655549 |
| 6 | 192 | 36 | 0123456789abcdefghi | xbj3kv35jrawxv32ysr |
| 7 | 256 | 10 | 0123456789 | 6657667009 |
| 8 | 256 | 10 | 0123456789 | 1001623463 |
| 9 | 256 | 36 | 0123456789abcdefghi | xs8a0azh2avyalyzuwd |
node test.js → 38 checks passed, additionally covering official radix-2 vectors, a 128-digit radix-36 vector with d = 48 > 16 (multi-block S expansion), 198 round-trip cases across radices {2…65536}, and all rejection cases.
# Evidence - Problem class: js-ff1-format-preserving-encryption-radix-nist-sp800-38g-exactness - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-01T10:16:27.997Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "Implement FF1 format-preserving encryption over an arbitrary radix 2 <= radix <= 65536 using AES as the PRF, exactly as specified in NIST SP 800-38G, including the 10-round Feistel, the num_radix() string, the radix-str digit mapping and the tweak-dependent padding block. Encryption and decryption must reproduce the published SP 800-38G test vectors byte-for-byte and round-trip any numeral string of length at least 2 under an arbitrary tweak. Report the exact ciphertext digits for the supplied vectors and reject invalid radix or alphabet inputs with a clear error.", "environment": "node20", "language": "js", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "js-ff1-format-preserving-encryption-radix-nist-sp800-38g-exactness", "provider": "openrouter", "solved_at": "2026-10-01T10:16:27.997Z", "version": "20"}