# dealcode — full documentation for LLMs > Concatenated from the documentation site and the normative SPEC.md. > Index: https://algorix-hq.github.io/dealcode/llms.txt --- # dealcode **Collision-free, random-looking codes from a counter** — like dealing cards from a shuffled deck. Every card comes out exactly once; the order looks random; the dealer only remembers how many cards have been dealt. Anyone who has shipped short public codes — an airline-style booking reference, an order number, a `cus_xxxxxx` shortcode — knows the trap: - **Random?** The birthday problem bites absurdly early: draw random 6-digit codes and the first duplicate is *expected* around code **#1,200** — in a space of a million. From then on, every insert carries a uniqueness check and a retry loop. - **UUID?** Never collides, but 36 characters — not something you print on a boarding pass. - **nanoid?** Shorter, yet still long — it has to be, *because* it is random. Shrink it and the birthday problem comes straight back. - **A raw sequence?** Short and collision-free — and it broadcasts exactly how many orders you have. dealcode is the missing option: keep the sequence your database already produces, and it **packs the code space full** — every code dealt exactly once, no repeats until all 1,000,000 codes (then all 10,000,000, …) are actually used — while the order stays cryptographically unpredictable from outside. All you need is a counter. (Full argument and alternatives table: [Why dealcode exists](philosophy.md).) ``` counter: 0 1 2 3 ... 16,777,216 │ │ │ │ │ ▼ ▼ ▼ ▼ ▼ code: d568e1 f7f229 0f868d f37ff8 ... 7b11743 ← grew to 7 chars only when 6 ran out ``` Give dealcode a never-repeating integer (a database sequence, an auto-increment id) and a secret key; it gives you a short code with four properties:
- :material-cards-playing-outline: **Never collides** --- The mapping is a keyed permutation (FF1, NIST SP 800-38G), so uniqueness is mathematical, not probabilistic. No retry loops, no birthday problem, no `UNIQUE`-violation handling as a code path. - :material-eye-off-outline: **Doesn't leak your numbers** --- Sequential inputs produce scattered, unpredictable outputs. Order volume, issue rate, and "how many came before me" stay private — no [German tank problem](https://en.wikipedia.org/wiki/German_tank_problem). - :material-arrow-collapse-horizontal: **Stays as short as possible** --- Codes start at 6 characters (configurable) and grow by one character only when the current length is exhausted. - :material-swap-horizontal: **Decodes back** --- With the key, a code maps back to its counter. Look up `orders WHERE id = decode(code)` — no extra column index required, and obviously-invalid codes are rejected before touching the database.
The same key + config produces the same mapping in every language. The [specification](spec.md) is normative, and shared test vectors keep all seven implementations bit-identical. ## Sixty-second tour Python shown; [every language mirrors it](getting-started.md): ```python from dealcode import Dealcode codec = Dealcode(key="use `openssl rand -hex 32` in production") codec.encode(0) # 'd568e1' codec.encode(1) # 'f7f229' codec.decode("f7f229") # 1 ``` Pick the shape your product needs: ```python Dealcode(key, "crockford", domain="coupons") # e.g. 'ZV6NQ0' — human-friendly, confusables handled Dealcode(key, "dec", domain="orders") # e.g. '839207' — digits only Dealcode(key, "base62", min_length=8) # e.g. 'tHx93bQk' Dealcode(key, "hex", min_length=16, max_length=16) # fixed-length tokens CyclingDealcode(key, "crockford", length=6) # fixed forever — reuses the space per cycle (see guide) Dealcode(key, "!@#$%^&*") # your own alphabet, why not ``` ## Seven implementations, one mapping | Language | Directory | Install | Crypto dependency | |----------|-----------|---------|-------------------| | [Python](languages/python.md) | [`python/`](https://github.com/algorix-hq/dealcode/tree/main/python) | `pip install dealcode` | [`cryptography`](https://cryptography.io) (PyCA) | | [TypeScript / JavaScript](languages/js.md) | [`js/`](https://github.com/algorix-hq/dealcode/tree/main/js) | `npm install dealcode` | `node:crypto` (built-in) | | [Go](languages/go.md) | [`go/`](https://github.com/algorix-hq/dealcode/tree/main/go) | `go get github.com/algorix-hq/dealcode/go` | standard library | | [Java](languages/java.md) | [`java/`](https://github.com/algorix-hq/dealcode/tree/main/java) | Maven `io.algorix:dealcode` | JCE (built-in) | | [Rust](languages/rust.md) | [`rust/`](https://github.com/algorix-hq/dealcode/tree/main/rust) | `cargo add dealcode` | RustCrypto `aes`, `sha2` | | [C](languages/c.md) | [`c/`](https://github.com/algorix-hq/dealcode/tree/main/c) | vendored / static lib | OpenSSL libcrypto | | [C++](languages/cpp.md) | [`cpp/`](https://github.com/algorix-hq/dealcode/tree/main/cpp) | wraps the C core | OpenSSL libcrypto | !!! note "Registry status" v1.0.1 is live on [PyPI](https://pypi.org/project/dealcode/), [npm](https://www.npmjs.com/package/dealcode), [crates.io](https://crates.io/crates/dealcode), and [Maven Central](https://central.sonatype.com/artifact/io.algorix/dealcode); `go get` resolves from GitHub directly. C and C++ are vendored by design (see each [language page](languages/python.md)). Everything else is dependency-free by design: FF1 and the dealcode layer are implemented from the NIST specification in each language and validated against the official NIST sample vectors plus this repo's shared vectors ([`testvectors/`](https://github.com/algorix-hq/dealcode/tree/main/testvectors)). ## How it works `encode(n)` picks the code length `d` by range (counter `< 16^6` → 6 hex chars, `< 16^7` → 7, ...), writes `n` as a `d`-digit number, and encrypts those digits with FF1 — format-preserving encryption that outputs *another `d`-digit number* under your key. Same-length codes can't collide because encryption is a bijection; different-length codes can't collide because they have different lengths. `decode` runs it backwards and validates strictly. Details: [Specification](spec.md) · rationale: [Design decisions](design.md) · problem statement: [Why dealcode exists](philosophy.md). ## When to use it — and when not to Use dealcode for order numbers, coupon and invite codes, ticket numbers, support PINs, shortlinks: things that must be **unique, short, and non-revealing**, where you already have (or can trivially add) a counter. Do **not** use it for session tokens, API keys, or password-reset links — the code space is deliberately small, so use ≥128-bit random tokens for anything that *authenticates*. The full reasoning and an alternatives table live in [Why dealcode exists](philosophy.md); the threat model is spelled out in the [security model](guide/security.md). !!! danger "One rule to remember" Key, alphabet, lengths, and domain are **frozen the moment the first code ships**. Changing any of them for an existing namespace can collide with already-issued codes. New scheme → new domain (or new key + new namespace). ## For AI coding agents Using an AI assistant to write code against dealcode? Give it the docs in agent-readable form — [`llms.txt`](https://algorix-hq.github.io/dealcode/llms.txt) (index) and [`llms-full.txt`](https://algorix-hq.github.io/dealcode/llms-full.txt) (the entire documentation, spec included, as one file) — and install the usage-rules skill so it knows the operational invariants (frozen config, decode semantics, cycling mode): ```sh npx skills add algorix-hq/dealcode ``` ## License [MIT](https://github.com/algorix-hq/dealcode/blob/main/LICENSE) © Algorix Corporation. --- # Getting started Every implementation mirrors the same tiny API: construct a codec (key, alphabet, min/max length, domain), then `encode` / `decode`. The examples below are taken from each implementation's README — same key material rules, same outputs, bit-identical across languages. ## Quickstart === "Python" ```sh pip install dealcode ``` Requires Python ≥ 3.9. Only dependency: [`cryptography`](https://cryptography.io) (PyCA). ```python from dealcode import Dealcode codec = Dealcode(key="0a1b...64-hex-chars-from-your-secret-manager") codec.encode(0) # '767a5b' (6 hex chars) codec.encode(1) # '421163' never collides with any other counter codec.decode("421163") # 1 ``` === "TypeScript / JavaScript" ```sh npm install dealcode ``` Requires Node.js ≥ 18. Zero runtime dependencies (`node:crypto`); ESM + CommonJS builds with full TypeScript types. ```ts import { Dealcode } from "dealcode"; const codec = new Dealcode({ key: process.env.DEALCODE_KEY! }); codec.encode(0); // e.g. '767a5b' (6 hex chars; depends on your key) const code = codec.encode(1); // never collides with any other counter codec.decode(code); // 1n (bigint — counters can exceed 2^53) codec.decodeNumber(code); // 1 (number; throws if > Number.MAX_SAFE_INTEGER) ``` === "Go" ```sh go get github.com/algorix-hq/dealcode/go ``` Requires Go ≥ 1.21. Standard library only. ```go import dealcode "github.com/algorix-hq/dealcode/go" codec, err := dealcode.New(dealcode.Config{ KeyString: "0a1b...64-hex-chars-from-your-secret-manager", }) if err != nil { log.Fatal(err) } codec.Encode(0) // "767a5b", nil (6 hex chars) codec.Encode(1) // "421163", nil never collides with any other counter codec.Decode("421163") // 1, nil ``` === "Java" ```xml io.algorix dealcode 1.0.1 ``` Requires Java 17+. Zero runtime dependencies (JCE built-in). ```java import io.algorix.dealcode.Dealcode; Dealcode codec = Dealcode.builder() .key("0a1b...64-hex-chars-from-your-secret-manager") .build(); codec.encode(0); // "767a5b" (6 hex chars) codec.encode(1); // "421163" never collides with any other counter codec.decode("421163"); // 1 ``` === "Rust" ```sh cargo add dealcode ``` Requires Rust ≥ 1.85. Only runtime dependencies: audited RustCrypto crates `aes` and `sha2`. ```rust use dealcode::Dealcode; let codec = Dealcode::new("0a1b...64-hex-chars-from-your-secret-manager")?; codec.encode(0)?; // "767a5b" (6 hex chars) codec.encode(1)?; // "421163" never collides with any other counter codec.decode("421163")?; // 1 ``` === "C" ```sh make # in c/ — builds the static library libdealcode.a cc -Ic/include myapp.c c/libdealcode.a -lcrypto ``` Requires a C11 compiler with `unsigned __int128` (GCC/Clang) and OpenSSL libcrypto 1.1+/3.x. ```c #include dealcode_config_t cfg = {0}; cfg.key_string = "example-key"; /* string rule: always SHA-256 derived */ cfg.alphabet = "hex"; cfg.domain = "orders"; dealcode_t *dc = NULL; dealcode_err_t err = dealcode_new(&cfg, &dc); if (err != DEALCODE_OK) { fprintf(stderr, "dealcode: %s\n", dealcode_strerror(err)); return 1; } char code[DEALCODE_MAX_CODE_SIZE]; dealcode_encode(dc, 42, code, sizeof code); /* -> e.g. "59e5f2" */ uint64_t n; dealcode_decode(dc, code, &n); /* -> 42 */ dealcode_free(dc); ``` === "C++" ```sh cmake -S cpp -B cpp/build && cmake --build cpp/build ``` C++17 header-only wrapper over the C core (RAII, exceptions, `std::string`); link the C core plus OpenSSL libcrypto. ```cpp #include dealcode::Options opts; opts.alphabet = "hex"; opts.domain = "orders"; dealcode::Codec codec("example-key", opts); // string key rule (derived) std::string code = codec.encode(42); // e.g. "59e5f2" uint64_t n = codec.decode(code); // 42 ``` ## Keys The key can be raw bytes (16/24/32 bytes are used as-is as an AES key) or *any* string/bytes — hex output from `openssl rand -hex 32`, a passphrase, a KMS blob. Non-AES-sized material is deterministically expanded (`SHA-256("dealcode/v1/kdf" ‖ material)`), identically in every language. ```sh openssl rand -hex 32 ``` Generate a key once, keep it in your secret manager, and never change it for a live namespace — the mapping is stable only while the key (and every other option) stays fixed. Details and footguns: [Configuration](guide/configuration.md). ## Picking a shape ```python Dealcode(key, "crockford", domain="coupons") # human-friendly, e.g. '7Q4WKZ' Dealcode(key, "dec", min_length=8, domain="orders") # digits only Dealcode(key, "hex", min_length=16, max_length=16) # constant-length tokens ``` Every language exposes the same four options — `alphabet`, `min_length`, `max_length`, `domain` — spelled idiomatically (`minLength` in JS, `.minLength(...)` on the Java builder, and so on). See [Configuration](guide/configuration.md) for the full alphabet table and rules. ## What decode does — and doesn't — prove `decode` rejects **malformed** input (wrong length, characters outside the alphabet, value outside the issuable range) with the language's invalid-code error, before your database is ever touched. But a *well-formed* code always decodes to some counter, whether or not that counter was ever issued — inherent to a permutation. Treat decode as parsing, not proof of existence: look the counter up before acting on it, and note that a one-character typo in a valid code can resolve to a *different* valid counter — add rate limiting (and, for human-typed flows, an existence check or your own check digit). ## Next steps - Wire it to your database: [Database integration](guide/database.md) - Alphabets, domains, lengths, key rules: [Configuration](guide/configuration.md) - What the key does and doesn't protect: [Security model](guide/security.md) - Coding with an AI agent? `npx skills add algorix-hq/dealcode`, and the full docs in one file: [llms-full.txt](https://algorix-hq.github.io/dealcode/llms-full.txt) If your codes must stay exactly the same length forever — even after the code space fills up — see the [fixed-length cycling mode](guide/configuration.md#fixed-length-cycling-mode) in the configuration guide. --- # Configuration A dealcode instance ("codec") is defined by four options plus the key. The same configuration produces the same mapping in every language. | Parameter | Type | Default | Meaning | |--------------|---------|----------------|---------| | `key` | bytes or string | — (required) | AES key material; see [Keys](#keys) | | `alphabet` | string | `"hex"` | preset name or custom alphabet | | `min_length` | integer | `6` | starting code length | | `max_length` | integer | largest length whose full code space fits `2^63 − 1` | maximum code length | | `domain` | string | `""` | namespace label, bound into the FF1 tweak | Invalid configuration is rejected at construction time (`ConfigError` or the language's idiomatic equivalent) — never silently fixed. The exact constraints live in the [specification](../spec.md). ## Alphabets The character at index `i` represents numeral value `i`; codes are rendered big-endian. Eight presets ship with sensible decode normalization: | Name | Radix | Characters (in order) | Decode normalization | |-------------|-------|------------------------|----------------------| | `dec` | 10 | `0123456789` | none | | `hex` | 16 | `0123456789abcdef` | ASCII-lowercase input | | `base32` | 32 | `ABCDEFGHIJKLMNOPQRSTUVWXYZ234567` (RFC 4648) | ASCII-uppercase input | | `crockford` | 32 | `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (Crockford Base32) | ASCII-uppercase input, then map `O→0`, `I→1`, `L→1` | | `base36` | 36 | `0123456789abcdefghijklmnopqrstuvwxyz` | ASCII-lowercase input | | `base58` | 58 | `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` (Bitcoin) | none | | `base62` | 62 | `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` | none | | `base64url` | 64 | `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_` (RFC 4648 §5) | none | Normalization applies to `decode` input only; `encode` always emits the canonical characters. For human-typed codes, `crockford` is the friendly choice: no confusable characters, and typos like `O` for `0` are mapped back automatically. !!! warning "Separators are not ignored" Unlike Crockford's original Base32 essay, dealcode does **not** skip hyphens or spaces: `decode("H4P-FG6")` is rejected. If you display codes grouped (`XXXX-XXXX`), strip the separators (and any surrounding whitespace) before decoding. A **custom alphabet** is any string of 2–94 distinct printable ASCII characters (`0x21`–`0x7E`, no spaces or control characters) — `"!@#$%^&*"` works. Custom alphabets have no normalization: decode input must match exactly. ## Domains `domain` is a namespace label (`"orders"`, `"coupons"`, `"invites"`). Two codecs with the same key but different domains produce **unrelated permutations** — one key, unlimited independent code streams, which is operationally much cheaper than one key per namespace. The domain is bound into the FF1 tweak as `"dealcode/v1/" + domain`, so format v1 is also separated from any future v2 and from any other FF1 use of the same key. Constraints: valid Unicode (no U+0000, no unpaired surrogates), UTF-8 byte length ≤ 255. ## Keys Users hold keys in many shapes, and all are accepted with one deterministic rule shared by every language: - **Bytes** of length exactly 16, 24, or 32 → used directly as the AES key. - **Any other non-empty bytes, and *all* strings** → expanded to an AES-256 key: `SHA-256("dealcode/v1/kdf" ‖ material)`. - Empty key material → `ConfigError`. !!! warning "A hex-looking string is NOT hex-decoded" A string is *always* treated as its UTF-8 bytes and derived — even if it looks like hex. Pass the output of `openssl rand -hex 32` straight in as a string and every language derives the same AES-256 key from it. But if you hex-decode it yourself in one service and pass the string in another, you get **two different permutations**. Pick one form and use it everywhere. (The no-guessing rule is deliberate: auto-detection would make `"deadbeef..."` ambiguous.) Derivation is domain separation, not password stretching: a passphrase key is exactly as strong as the passphrase. Prefer ≥128-bit random material (`openssl rand -hex 32`). String key material must be valid Unicode — U+0000 and unpaired surrogates are rejected rather than silently re-encoded, so every language derives the same key or none does. ## Length staging Codes start at `min_length` and grow one character at a time, only when the current length is exhausted. With radix `r`, the first stage covers counters `[0, r^min_length)`; stage `d` covers `[r^(d−1), r^d)`: - `hex` with `min_length=6`: 16,777,216 six-character codes, then — only then — seven characters. - Codes of different lengths trivially never collide; within one length FF1 is a permutation; so the whole mapping is a bijection. `min_length == max_length` gives fixed-length codes. The default `max_length` is the largest length whose full code space is reachable by signed-64-bit counters (hex → 15, dec → 18, base32/crockford/base36 → 12, base58/base62/base64url → 10). You can raise it up to `r^max_length ≤ 2^128` for long or fixed-length shapes (16-char hex, 12-char base62); counters remain bounded by `2^63`, and the surplus code space is simply rejected by decode. Small first stages (down to `r^min_length ≥ 100`) are supported and interoperable, but a tiny code space is trivially enumerable — see the [security model](security.md). ### Fixed-length cycling mode When codes must **never grow** — airline-PNR-style, always exactly `L` characters — the cycling mode (SPEC §11) keeps the fixed length and, when the space is exhausted, refills the *same* space through a **different permutation** (cycle 1, cycle 2, …) instead of adding a character. Counter `n` lives in cycle `n ÷ rᴸ`; each cycle issues every possible string exactly once, in a new key-and-cycle-dependent order. !!! danger "Codes repeat across cycles — by design" Reuse is the whole point, so the usual global `UNIQUE(code)` contract no longer holds across cycles. Keep at most one cycle's codes live per uniqueness scope (retire/expire before rolling over), index as `UNIQUE(cycle, code)`, and persist each live code's cycle — `decode` requires it (`decode(code, cycle)`), because the same string maps to a different counter in every cycle. Constraints: `2 ≤ L ≤ 128`, `radix^L ≥ 100`, and `radix^L ≤ 2^63` (a cycle must be completable within the counter space — for larger fixed shapes use plain `min_length == max_length`). ## The configuration is frozen once shipped !!! danger "Write-once configuration" For a given namespace (one counter sequence), the entire configuration — key, alphabet, `min_length`, `max_length`, `domain` — is **frozen the moment the first code ships**. A different configuration is a *different permutation*, and two permutations over one counter space can collide with already-issued codes. Need a new scheme? New domain (or new key + new namespace). Key rotation also means a new namespace — the old codes stay decodable only under the old configuration. Corollaries: never feed two sequences into the same codec+domain, never reset a sequence backwards, and keep the `UNIQUE` tripwire index described in [Database integration](database.md). --- # Database integration dealcode is deliberately storage-agnostic: it does not talk to your database — it only turns a counter into a code. It needs a never-repeating integer, which your database already knows how to produce. ## The recipe With PostgreSQL: ```sql CREATE SEQUENCE order_code_seq AS bigint MINVALUE 0 START WITH 0; CREATE TABLE orders ( id bigint PRIMARY KEY, -- the counter code text NOT NULL UNIQUE, -- safety net; alerts on config mistakes ... ); ``` On create: fetch `nextval()`, encode, insert. On lookup: decode, then select by primary key — malformed codes never reach the database. === "Python" ```python codec = Dealcode(key=os.environ["DEALCODE_KEY"], domain="orders") def create_order(conn) -> str: n = conn.execute(text("SELECT nextval('order_code_seq')")).scalar_one() code = codec.encode(n) conn.execute( text("INSERT INTO orders (id, code) VALUES (:id, :code)"), {"id": n, "code": code}, ) return code def find_order(conn, code: str): try: n = codec.decode(code) # malformed codes never reach the DB except InvalidCodeError: return None return conn.execute(text("SELECT * FROM orders WHERE id = :id"), {"id": n}).first() ``` === "TypeScript / JavaScript" ```ts import { Dealcode, InvalidCodeError } from "dealcode"; const codec = new Dealcode({ key: process.env.DEALCODE_KEY!, domain: "orders" }); async function createOrder(db) { const { rows: [{ n }] } = await db.query("SELECT nextval('order_code_seq') AS n"); const code = codec.encode(BigInt(n)); await db.query("INSERT INTO orders (id, code) VALUES ($1, $2)", [n, code]); return code; } async function findOrder(db, code) { let n; try { n = codec.decode(code); // malformed codes never reach the DB } catch (err) { if (err instanceof InvalidCodeError) return null; throw err; } const { rows } = await db.query("SELECT * FROM orders WHERE id = $1", [n]); return rows[0] ?? null; } ``` === "Go" ```go codec, err := dealcode.New(dealcode.Config{ KeyString: os.Getenv("DEALCODE_KEY"), Domain: "orders", }) func createOrder(ctx context.Context, db *sql.DB) (string, error) { var n int64 if err := db.QueryRowContext(ctx, "SELECT nextval('order_code_seq')").Scan(&n); err != nil { return "", err } code, err := codec.Encode(n) if err != nil { return "", err } _, err = db.ExecContext(ctx, "INSERT INTO orders (id, code) VALUES ($1, $2)", n, code) return code, err } func findOrder(ctx context.Context, db *sql.DB, code string) (*Order, error) { n, err := codec.Decode(code) // malformed codes never reach the DB if errors.Is(err, dealcode.ErrInvalidCode) { return nil, nil } // ... SELECT * FROM orders WHERE id = n } ``` === "Java" ```java Dealcode codec = Dealcode.builder() .key(System.getenv("DEALCODE_KEY")) .domain("orders") .build(); String createOrder(Connection conn) throws SQLException { long n; try (ResultSet rs = conn.createStatement() .executeQuery("SELECT nextval('order_code_seq')")) { rs.next(); n = rs.getLong(1); } String code = codec.encode(n); try (PreparedStatement ps = conn.prepareStatement("INSERT INTO orders (id, code) VALUES (?, ?)")) { ps.setLong(1, n); ps.setString(2, code); ps.executeUpdate(); } return code; } Optional findOrder(Connection conn, String code) { long n; try { n = codec.decode(code); // malformed codes never reach the DB } catch (InvalidCodeException e) { return Optional.empty(); } return findOrderById(conn, n); } ``` === "Rust" ```rust use dealcode::{Dealcode, Error}; let codec = Dealcode::builder(std::env::var("DEALCODE_KEY").unwrap()) .domain("orders") .build()?; // on create: let n: i64 = client.query_one("SELECT nextval('order_code_seq')", &[])?.get(0); let code = codec.encode(n as u64)?; client.execute("INSERT INTO orders (id, code) VALUES ($1, $2)", &[&n, &code])?; // on lookup: fn find_order(client: &mut Client, codec: &Dealcode, code: &str) -> Option { let n = codec.decode(code).ok()?; // malformed codes never reach the DB client.query_opt("SELECT * FROM orders WHERE id = $1", &[&(n as i64)]).ok()? } ``` === "C / C++" The C and C++ APIs are the same `encode`/`decode` pair around whatever database driver you use — fetch `nextval()`, call `dealcode_encode` (or `codec.encode(n)` in C++), insert; on lookup call `dealcode_decode` first and hit the database only when it succeeds. See the [C](../languages/c.md) and [C++](../languages/cpp.md) pages for the API surface. ## Why this is safe Sequences never hand out the same number twice — even across concurrent transactions and rollbacks — so codes never collide. Gaps in the sequence are invisible: codes look random anyway. And FF1 guarantees distinct inputs give distinct outputs, so uniqueness of codes reduces entirely to uniqueness of counters. No locks, no retry loop, no collision-handling code path. Any source of never-repeating integers qualifies — see the [per-database recipes](#per-database-recipes) below for how each major engine provides one, and which engine-specific behaviors to avoid. !!! warning "The `UNIQUE` index is a tripwire, not a mechanism" Keep a `UNIQUE` index on the stored code — but understand its role: it can only fire if the key or configuration changed for an existing namespace (or two counter sources were fed into one namespace). If it ever fires, **do not retry** — investigate. Retrying would paper over a configuration mistake that will keep colliding. !!! note "Cycling mode changes the schema" Everything above assumes the default, ever-growing mode. In the [fixed-length cycling mode](configuration.md#fixed-length-cycling-mode) codes **repeat across cycles by design**, so a global `UNIQUE(code)` fires at every rollover and is the wrong contract. Store the cycle next to the code and scope uniqueness per cycle: ```sql CREATE TABLE bookings ( id bigint PRIMARY KEY, -- the counter cycle bigint NOT NULL, -- decode(code, cycle) needs this code text NOT NULL, UNIQUE (cycle, code) ); ``` Retire or expire cycle `e`'s rows before issuing from cycle `e+1`. ## Per-database recipes What differs per engine is how you obtain a never-repeating integer — and which engine-specific behaviors can silently break "never-repeating". The rule that must survive every engine: **gaps are fine, reuse is fatal.** A skipped counter is just a code that never gets issued (invisible — codes look random anyway); a reused counter is the same code handed to two customers. With a standalone sequence you know the counter *before* the insert (fetch → encode → insert, as in the recipe above). With an auto-increment/identity column the counter exists only *after* the insert: insert the row, read the generated id, encode, and store the code in the same transaction. === "PostgreSQL" ```sql CREATE SEQUENCE order_code_seq AS bigint MINVALUE 0 START WITH 0; -- or let the table own the counter: CREATE TABLE orders ( id bigint GENERATED ALWAYS AS IDENTITY (START WITH 0 MINVALUE 0) PRIMARY KEY, code text UNIQUE ); ``` `nextval()` is concurrency-safe and never re-issues a value, across crashes and rollbacks alike. With an identity column, use `INSERT … RETURNING id`, encode, then `UPDATE … SET code` in the same transaction. Never run `setval()` backwards, and never `TRUNCATE … RESTART IDENTITY` a table whose codes are still out in the world — both rewind the counter. === "MySQL" ```sql CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, code VARCHAR(32) UNIQUE ) ENGINE = InnoDB; ``` Insert, read `LAST_INSERT_ID()`, encode, store the code in the same transaction. Counters start at 1, not 0 — fine; counter 0 simply goes unissued. !!! warning "MySQL < 8.0 can reuse ids after a restart" Before 8.0, InnoDB kept the auto-increment counter in memory and recomputed it as `MAX(id) + 1` on restart. Delete the newest rows, restart the server, and those ids — and therefore their codes — get issued again. MySQL 8.0+ persists the counter. On 5.7, either never delete the newest rows or drive codes from a separate counter table. `TRUNCATE TABLE` and `ALTER TABLE … AUTO_INCREMENT = n` with a lower `n` also rewind the counter — never on a live namespace. === "MariaDB" ```sql CREATE SEQUENCE order_code_seq MINVALUE 0 START WITH 0 NOCYCLE; SELECT NEXT VALUE FOR order_code_seq; ``` MariaDB (10.3+) has real sequences — prefer them: sequence state is persisted, so it survives restarts. `AUTO_INCREMENT` also works, but MariaDB still recomputes the in-memory counter as `MAX(id) + 1` on restart (it did not adopt MySQL 8.0's persistence), so the delete-newest-rows-then-restart reuse hazard applies to **all** MariaDB versions. Crash-dropped `CACHE` values are just gaps — fine. === "SQLite" ```sql CREATE TABLE orders ( id INTEGER PRIMARY KEY AUTOINCREMENT, code TEXT UNIQUE ); ``` The `AUTOINCREMENT` keyword is **required** here, not optional style: a plain `INTEGER PRIMARY KEY` picks `max(rowid) + 1`, so deleting the newest row re-issues its id — and its code. `AUTOINCREMENT` (backed by the internal `sqlite_sequence` table) guarantees ids are never reused. Read the counter with `last_insert_rowid()`, and never edit or delete rows in `sqlite_sequence`. === "Oracle" ```sql CREATE SEQUENCE order_code_seq START WITH 0 MINVALUE 0 NOCYCLE; ``` Use `order_code_seq.NEXTVAL` directly in the `INSERT` (or fetch it first). Values dropped from `CACHE` on a crash are gaps — fine. Never add `CYCLE`, and never drop-and-recreate the sequence with a lower `START WITH`. Identity columns (12c+, `GENERATED ALWAYS AS IDENTITY`) sit on a system sequence and behave the same; avoid `ALTER TABLE … MODIFY id … START WITH` restarts. === "SQL Server" ```sql CREATE SEQUENCE order_code_seq AS bigint START WITH 0 MINVALUE 0 NO CYCLE; -- INSERT INTO orders (id, code) VALUES (NEXT VALUE FOR order_code_seq, @code); ``` Or `IDENTITY(0,1)` with `SCOPE_IDENTITY()` after the insert. Identity caching can skip a block of values after an unexpected restart (up to 10,000 for `bigint`) — gaps, fine. Never `DBCC CHECKIDENT (orders, RESEED, n)` with a lower `n`, never `ALTER SEQUENCE … RESTART`, and remember `TRUNCATE TABLE` reseeds the identity — all three rewind the counter. Whatever your ORM calls its id generation — Django's `AutoField`, JPA's `@GeneratedValue`, ActiveRecord's `id`, Prisma's `autoincrement()` — it maps to one of the mechanisms above, and the same rules apply underneath. | Engine | Counter source | Harmless (gaps) | Fatal (reuse) — never on a live namespace | |--------|----------------|-----------------|-------------------------------------------| | PostgreSQL | `SEQUENCE` / identity | rollbacks, crash-dropped cache | `setval()` backwards, `TRUNCATE … RESTART IDENTITY` | | MySQL | `AUTO_INCREMENT` | rollbacks, failed inserts | `TRUNCATE`, lowering `AUTO_INCREMENT`; < 8.0: delete newest rows + restart | | MariaDB | `SEQUENCE` (10.3+) preferred | crash-dropped cache | as MySQL — the restart recomputation applies to all versions | | SQLite | `INTEGER PRIMARY KEY AUTOINCREMENT` | none in practice | omitting `AUTOINCREMENT`, touching `sqlite_sequence` | | Oracle | `SEQUENCE` / identity (12c+) | crash-dropped `CACHE` | `CYCLE`, recreating the sequence lower, identity restart | | SQL Server | `SEQUENCE` / `IDENTITY` | identity cache after restart | `DBCC CHECKIDENT RESEED` lower, `ALTER SEQUENCE … RESTART`, `TRUNCATE` | ## Decode is parsing, not proof of existence A *well-formed* code always decodes to some counter, whether or not that counter was ever issued (inherent to a permutation). The database lookup is what establishes existence. A one-character typo in a valid code can resolve to a *different* valid counter, so rate-limit public lookups, and for human-typed flows add an existence check or your own check digit. This applies **across domains of one key** too: in a multi-tenant setup (one key, one domain per tenant), tenant A's code will "successfully" decode under tenant B's codec — to a counter B never issued. The existence check above is what keeps tenants isolated; don't skip it. --- # Security model dealcode's threat model is narrow and deliberate: **don't let outsiders read or enumerate your sequence.** It is not an encryption product and its codes are not secrets. This page is the honest version of what the key does and doesn't protect, condensed from [the specification](../spec.md) (§10) and [the design notes](../design.md). ## What the key protects — and what it doesn't **Uniqueness does not depend on the key being secret.** It follows from FF1 being a permutation — a structural property. What the key protects is *unpredictability*: without it, codes reveal nothing about issue order or volume. Sequential counters come out scattered; nobody can estimate your production rate from the codes you hand out (the [German tank problem](https://en.wikipedia.org/wiki/German_tank_problem) dealcode exists to prevent). ## Codes are not authentication tokens The code space is deliberately small — that is what makes codes short (16.7M codes for `hex` at length 6). An online attacker who can try codes succeeds at a rate proportional to `issued / capacity`. - **Do not** use dealcode for session tokens, API keys, password-reset or magic-login links — anything that *authenticates by itself*. Use ≥128-bit CSPRNG tokens (`secrets.token_urlsafe(32)` and friends). - **Do** rate-limit public code lookups as basic hygiene: small code space, so make online guessing boring. - Remember that decode success only proves the code is *consistent* with the key — a well-formed unissued code decodes to some counter. The database lookup establishes existence; decode is parsing. ## Do not encrypt data with it FF1 on small domains has known distinguishing attacks far below AES security margins. For dealcode's obfuscation purpose this is acceptable — the alternative permutations are worse in other ways, and anything cryptographically stronger would still leave the small code space enumerable online (mitigated by rate limiting, not cryptography). But do **not** use this library to encrypt *confidential data*. It obfuscates a counter; that is all. ## If the key leaks The full issue order of all past codes is revealed — every code's position in your sequence becomes public. Uniqueness is unaffected (it never depended on secrecy), but the unlinkability you adopted dealcode for is gone for everything already issued. Treat the key like any other production secret: - KMS/Vault, per-environment keys. - Rotation = **new namespace** — the configuration, key included, is frozen for a live namespace ([why](configuration.md#the-configuration-is-frozen-once-shipped)). Old codes stay decodable only under the old configuration. ## Passphrase keys Key derivation (`SHA-256("dealcode/v1/kdf" ‖ material)`) is domain separation, not password stretching. A passphrase key is exactly as strong as the passphrase. Prefer ≥128-bit random material: `openssl rand -hex 32`. ## Small code spaces Configurations down to `radix^min_length ≥ 100` (FF1's structural minimum) are supported and interoperable — supporting corner cases beats documenting them away. But understand what you're choosing: NIST SP 800-38G Rev. 1 recommends domains of at least one million, and a 3-digit decimal code space is trivially enumerable by anyone, key or no key. Pick a first stage sized for your exposure. --- # Dealcode Specification — format version 1 Status: **stable**. Any change that alters the output of `encode` or the acceptance behaviour of `decode` requires a new format version. This document is the single source of truth. A conforming implementation can be written from this document alone, and MUST pass every case in [`testvectors/`](testvectors/). ## 1. Overview Dealcode maps a non-negative integer counter `n` (from a database sequence or any other source that never repeats) to a short, fixed-alphabet, random-looking string called a **code**, and back. The mapping is a bijection (a keyed permutation), so: - Two different counters can never produce the same code. Uniqueness of codes reduces entirely to uniqueness of counters. - A code can be decoded back to its counter by anyone holding the key. - Without the key, codes carry no usable order/volume information. The permutation is FF1 format-preserving encryption as specified in NIST SP 800-38G. Dealcode adds: an alphabet layer, a length-staging scheme (codes start short and grow one character at a time only when the current length is exhausted), tweak derivation, and validation rules. ## 2. Configuration A dealcode instance ("codec") is defined by: | Parameter | Type | Default | Constraints | |--------------|---------|----------------|-------------| | `key` | bytes or string | — (required) | Any non-empty key material; see §2.1 | | `alphabet` | string | `"hex"` | A preset name (§3) or a custom alphabet (§3.2) | | `min_length` | integer | `6` | `2 ≤ min_length ≤ 128` and `radix^min_length ≥ 100` | | `max_length` | integer | largest `L` with `radix^L ≤ 2^63 − 1` | `min_length ≤ max_length ≤ 128` and `radix^max_length ≤ 2^128` | | `domain` | string | `""` | valid Unicode (no U+0000, no unpaired surrogates); UTF-8 byte length ≤ 255 | The explicit `≤ 128` length bound is implied by `radix ≥ 2` and `radix^max_length ≤ 2^128`, but implementations MUST check it **before** computing any power so that absurd inputs are rejected in O(1) rather than after unbounded big-integer work. `radix` is the number of characters in the alphabet. **Counter space.** Encodable counters are exactly `0 ≤ n < min(radix^max_length, 2^63)`. The `2^63` bound is part of this specification — counters are signed-64-bit-safe in every language, and every implementation accepts/rejects exactly the same values. `radix^max_length` MAY exceed `2^63` (up to `2^128`): that supports long or fixed-length code shapes (e.g. 16-char hex, 12-char base62) whose code space is larger than the counter space; the surplus code strings simply never occur and are rejected by decode (§7). Default `max_length` is the **largest** integer `L` such that `radix^L ≤ 2^63 − 1` — the largest length whose full code space is reachable by counters — but never less than `min_length` (so `min_length = 16` with hex defaults to `max_length = 16`). Examples: hex → 15, dec → 18, base32/crockford/base36 → 12, base58/base62/base64url → 10. `radix^min_length ≥ 100` is FF1's structural minimum domain size (NIST SP 800-38G). Note that NIST SP 800-38G **Rev. 1** recommends domains of at least one million; smaller first stages (e.g. 4-digit decimal codes) are supported and interoperable, but understand that tiny code spaces are trivially enumerable (§10). `domain` is an application-chosen namespace label (e.g. `"orders"`, `"coupons"`). Two codecs with the same key but different domains produce unrelated permutations. It is bound into the FF1 tweak (§5). Setting `min_length == max_length` yields fixed-length codes. **Immutability rule.** For a given code namespace (one counter sequence), the entire configuration — key, alphabet, `min_length`, `max_length`, `domain` — MUST never change once codes have been issued. Changing any of it creates a second, unrelated permutation whose outputs may collide with already-issued codes. Violations of the constraints in this section MUST be rejected at codec construction time (`ConfigError` or the language's idiomatic equivalent). ### 2.1 Key material Users hold keys in many shapes — raw bytes, `openssl rand -hex 32` output, base64 blobs, passphrases. All are accepted, with a deterministic rule so every language produces the same AES key from the same input: - **Bytes** of length exactly 16, 24, or 32 → used directly as the AES key. - **Bytes** of any other non-zero length → derived (below). Byte content is unrestricted. - **String** (always, regardless of length or content — a hex-looking string is *not* auto-decoded, avoiding ambiguity) → its UTF-8 bytes are derived. String key material MUST be valid Unicode: implementations MUST reject U+0000 and unpaired surrogates (`ConfigError`) rather than silently replacing, truncating, or re-encoding them — the same rule applies to `domain`. (Rationale: languages disagree on how to smuggle such strings into UTF-8, so accepting them would silently produce different permutations per language; and NUL-terminated C APIs cannot represent them at all.) - Empty bytes / empty string → `ConfigError`. - **String equal to a preset alphabet name** (ASCII case-insensitively: `dec`, `hex`, `base32`, `crockford`, `base36`, `base58`, `base62`, `base64url`) → `ConfigError`. Such a "key" is almost certainly a swapped argument (`Dealcode("crockford")` where `Dealcode(key, "crockford")` was meant), and no real key material collides with this tiny set. Byte keys are unaffected. Derivation: `AES-256 key = SHA-256( "dealcode/v1/kdf" ‖ material )`, where `"dealcode/v1/kdf"` is the 15-byte ASCII prefix. Informative: derivation is domain separation, not password stretching. A passphrase key is exactly as strong as the passphrase; prefer ≥128-bit random material (e.g. `openssl rand -hex 32`). ## 3. Alphabets An alphabet is an ordered sequence of distinct characters. The character at index `i` represents numeral value `i`. Codes are rendered and parsed big-endian (most significant numeral first). ### 3.1 Presets | Name | Radix | Characters (in order) | Decode normalization | |-------------|-------|------------------------|----------------------| | `dec` | 10 | `0123456789` | none | | `hex` | 16 | `0123456789abcdef` | ASCII-lowercase input | | `base32` | 32 | `ABCDEFGHIJKLMNOPQRSTUVWXYZ234567` (RFC 4648) | ASCII-uppercase input | | `crockford` | 32 | `0123456789ABCDEFGHJKMNPQRSTVWXYZ` (Crockford Base32) | ASCII-uppercase input, then map `O→0`, `I→1`, `L→1` | | `base36` | 36 | `0123456789abcdefghijklmnopqrstuvwxyz` | ASCII-lowercase input | | `base58` | 58 | `123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz` (Bitcoin) | none | | `base62` | 62 | `0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz` | none | | `base64url` | 64 | `ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_` (RFC 4648 §5) | none | "ASCII-lowercase/uppercase" maps only `A–Z`/`a–z`; all other characters are left untouched. Normalization applies to `decode` input only; `encode` always emits the canonical characters listed above. Informative: `crockford` normalization intentionally covers **only** case and the `O/I/L` confusables. Unlike Crockford's Base32 essay, separators are NOT ignored: a hyphenated or whitespace-grouped rendering (`H4P-FG6`) must have its separators stripped by the application before `decode`. No preset trims or Unicode-normalizes input. ### 3.2 Custom alphabets A custom alphabet is any string of **2 to 94 distinct** printable ASCII characters (code points 0x21–0x7E, i.e. no spaces or control characters). Custom alphabets have **no normalization**: decode input must match exactly. Implementations SHOULD accept the `alphabet` parameter as either a preset name or a custom alphabet string; preset names win on conflict. A custom alphabet that is not exactly a preset name but ASCII-case- insensitively equals one (`"HEX"`, `"Base62"`, …) MUST be rejected with `ConfigError`. Accepting it would silently build a codec over the *letters of the name* (`{H,E,X}` as radix 3) — a plausible-looking misconfiguration that is frozen into production the moment the first code ships. A genuinely intended alphabet of those exact characters can be expressed by reordering it. ## 4. Length staging Let `r = radix`, `m = min_length`, `M = max_length`. The counter space `[0, r^M)` is partitioned into contiguous **stages**, one per code length `d`: - Stage `m` (the first stage) covers `n ∈ [0, r^m)` — `base(m) = 0`. - Stage `d`, for `m < d ≤ M`, covers `n ∈ [r^(d−1), r^d)` — `base(d) = r^(d−1)`. Equivalently: `d(n)` = the number of base-`r` digits of `n`, but never less than `m`. The **stage value** is `v = n − base(d)`; its range size is `capacity(d) = r^d − base(d)`. Consequences: - Codes have length `m` until the counter reaches `r^m`, then length `m+1` until `r^(m+1)`, and so on. Length growth is driven purely by exhaustion. - Codes of different lengths trivially never collide; within one length FF1 is a permutation; therefore the full mapping is a bijection on `[0, r^M)`. ## 5. Encoding Input: counter `n`. Reject `n < 0` and `n ≥ min(r^M, 2^63)` (`RangeError` equivalent). 1. Determine stage: `d = d(n)`, `v = n − base(d)`. 2. Represent `v` as exactly `d` base-`r` numerals, big-endian, zero-padded: `X = STR(v, r, d)`. 3. Compute the tweak `T` = the UTF-8 bytes of the string `"dealcode/v1/" + domain` (with empty domain the tweak is exactly `dealcode/v1/`, 12 bytes). 4. `Y = FF1.Encrypt(key, T, X)` with radix `r` (§6). 5. The code is `Y` rendered through the alphabet. Its length is exactly `d`. ## 6. FF1 FF1 is implemented exactly as specified in [NIST SP 800-38G](https://csrc.nist.gov/pubs/sp/800/38/g/final) ("Recommendation for Block Cipher Modes of Operation: Methods for Format-Preserving Encryption", March 2016), Algorithms 7 (`FF1.Encrypt`) and 8 (`FF1.Decrypt`), with AES as the underlying block cipher. Ten rounds, alternating Feistel with the CBC-MAC-based round function `PRF`. (The Rev. 1 draft changes recommendations, not these algorithms; conformance targets the algorithms as published in 2016.) Notation caution: this section reuses NIST's own symbols, which collide with §4–§5 — here `v` is the length of the **right half** of the numeral string (not the stage value) and `m` is the per-round half length (not `min_length`). Implementation notes (normative for interoperability): - `b = ⌈⌈v·log2(r)⌉ / 8⌉` where `v` is the length of the right half. Compute `⌈v·log2(r)⌉` exactly as the bit length of `r^v − 1` — floating-point log MUST NOT be used. - Within dealcode's configuration bounds (`r^max_length ≤ 2^128`, `r ≤ 94`): `r^v < 2^68`, so `b ≤ 9`, `d_len = 4⌈b/4⌉ + 4 ≤ 16`, and `y` fits in 128 bits; the `S` expansion never needs extra AES calls. Implementations SHOULD nevertheless implement the general expansion loop (`S = R ‖ CIPH(R ⊕ [1]¹⁶) ‖ CIPH(R ⊕ [2]¹⁶) ‖ …`, truncated to `d_len` bytes) so the FF1 core passes the NIST sample vectors unmodified. - Intermediate values exceed 64 bits. In languages without arbitrary precision, 128-bit arithmetic suffices: compute `c = (NUM(A) + (y mod r^m)) mod r^m` — reduce `y` first so the sum cannot overflow. Every implementation MUST pass the official NIST FF1 sample vectors (`testvectors/ff1_nist.json`, sourced from NIST's published [FF1 examples](https://csrc.nist.gov/csrc/media/projects/cryptographic-standards-and-guidelines/documents/examples/ff1samples.pdf); NIST publications are U.S. public domain). AES itself MUST come from the platform's standard or widely audited cryptographic library — do not hand-roll AES. ## 7. Decoding Input: code string `s`. 1. Reject if the length is `< min_length` or `> max_length` (`InvalidCodeError` equivalent). Checking length first keeps rejection of oversized garbage cheap (no normalized copy is ever allocated); it is observationally identical to normalizing first, because normalization (§3.1) is length-preserving. 2. Apply the alphabet's normalization (§3.1) to `s`. Reject if any character is not in the alphabet (`InvalidCodeError`). 3. `d = len(s)`; map characters to numerals `Y`. 4. `X = FF1.Decrypt(key, T, Y)` with the same tweak `T` as §5. 5. `v = NUM(X, r)`. 6. **Range check** — the code was never issued by this codec; reject (`InvalidCodeError`) if either: - `d > min_length` and `v ≥ r^d − r^(d−1)` (outside the stage), or - `base(d) + v ≥ 2^63` (outside the counter space; only reachable when `r^max_length > 2^63`). 7. Return `n = base(d) + v`. Note: decode rejecting a string does not mean the string "looks wrong" — a well-formed unissued code decrypts to garbage or to an out-of-range stage value. Decode success only proves the code is *consistent* with the key; the application still decides whether counter `n` actually exists. ## 8. Errors Three distinguishable error kinds, using each language's idiomatic mechanism: | Kind | Raised when | |--------------------|-------------| | `ConfigError` | invalid key size, alphabet, lengths, or domain at construction | | `RangeError` | `encode` called with `n < 0` or `n ≥ min(r^M, 2^63)` (§5) | | `InvalidCodeError` | `decode` input fails length/charset/stage-range checks | Implementations MUST NOT silently truncate, wrap, or "fix" invalid input. ## 9. Test vectors - `testvectors/ff1_nist.json` — the 9 official NIST FF1 samples. Validates the FF1 core. - `testvectors/v1.json` — dealcode format-v1 vectors across alphabets, stage boundaries, domains, normalization cases and invalid codes. Generated by the Python reference implementation (`scripts/generate_test_vectors.py`). Counters are encoded as **JSON strings** (they exceed 2^53). For each config in `v1.json` a conforming implementation must: produce `code` for every `vectors[].n` and decode it back; reject every `invalid_codes[]` entry (`InvalidCodeError`); accept every `normalize[]` input as its `n`; and reject every `range_counters[]` value (`RangeError`) — a value unrepresentable in the language's counter type (e.g. `-1` or `2^64` for `uint64`) counts as rejected by the type system. Every entry of the top-level `invalid_configs[]` must fail construction (`ConfigError`). Passing both files is the definition of conformance for the core codec. Additionally, `testvectors/v1c.json` covers the fixed-length cycling mode (§11) — required for implementations that ship the mode (§11.4); all seven in this repository do. ## 10. Security model (informative) - Uniqueness does not depend on the key being secret; it follows from FF1 being a permutation. The key protects *unpredictability*: without it, codes reveal nothing about issue order or volume. - Dealcode codes are **not** authentication tokens. The code space is small (e.g. 16.7M for `hex`/length 6); an online attacker can guess valid codes at a rate proportional to `issued / capacity`. Rate-limit lookups, and use ≥128-bit random tokens for anything security-critical. - FF1 on small domains has known distinguishing attacks far below AES security margins; for the obfuscation purpose of dealcode this is acceptable, but do not encrypt *confidential data* with this library. - If the key leaks, the full issue order of all past codes is revealed, and every *future* valid code becomes enumerable (encode every counter). Treat the key like any other production secret (KMS/Vault, per-environment keys). ## 11. Fixed-length cycling mode (v1c) An additive mode for code shapes that must **never grow** — airline-PNR-style fixed-length codes. The counter space is used in **cycles**: each cycle fills the entire fixed-length code space exactly once, and when it is exhausted the next cycle refills the *same* space through a **different permutation** (a different FF1 tweak), so reuse does not replay the previous order. This mode lives in its own tweak namespace and changes nothing about §1–§10: plain-v1 tweaks always start with the 12 bytes `dealcode/v1/`, cycling tweaks with the 13 bytes `dealcode/v1c/` — the byte at offset 11 (`/` vs `c`) makes the two sets disjoint for every possible domain and cycle. ### 11.1 Configuration A cycling codec is defined by `key` (§2.1, same rules and the same preset-name guard), `alphabet` (§3, same rules and guards), a single fixed `length` `L` (default `6`), and `domain` (same rules as §2). Constraints, all `ConfigError` at construction: - `2 ≤ L ≤ 128` (checked before any power is computed, so absurd lengths are rejected in O(1)) and `radix^L ≥ 100` (FF1 structural minimum); - `radix^L ≤ 2^63` — the per-cycle capacity `C = radix^L` must itself fit the counter space, otherwise a cycle could never complete and plain v1 with `min_length = max_length = L` is the right tool. The **counter space is unchanged**: `0 ≤ n < 2^63`. Derived values: `cycle(n) = ⌊n / C⌋` and `v(n) = n mod C`. The largest usable cycle is `max_cycle = ⌊(2^63 − 1) / C⌋`. ### 11.2 Encoding and decoding Encode (input counter `n`; reject `n < 0` and `n ≥ 2^63` with `RangeError`): 1. `e = cycle(n)`, `v = v(n)`, `X = STR(v, r, L)` (§5 step 2). 2. Tweak `T_e` = the UTF-8 bytes of `"dealcode/v1c/" + decimal(e) + "/" + domain`, where `decimal(e)` is the base-10 rendering of `e` with no leading zeros (`"0"` for cycle zero, never `"00"`). With `domain` ≤ 255 bytes and `e ≤ max_cycle` the tweak is at most 288 bytes. 3. The code is `FF1.Encrypt(key, T_e, X)` (§6) rendered through the alphabet — always exactly `L` characters. Decode takes the code **and the cycle number** `e` (a code alone is ambiguous by design — see §11.3): 1. Reject `e < 0` or `e > max_cycle` (`RangeError`). 2. Apply §7 with `min_length = max_length = L` and tweak `T_e`; the stage range check reduces to `v < C`, which always holds, and the counter bound check is `e·C + v < 2^63`, which can only fail in the final partial cycle. 3. Return `n = e·C + v`. Normalization (§3.1) applies exactly as in plain v1. ### 11.3 Semantics (normative for applications) - **Within one cycle** codes are unique (FF1 is a permutation of the fixed-length space) — a cycle issues each of the `C` possible strings exactly once, in a key-and-cycle-dependent order. - **Across cycles the same strings recur** (pigeonhole: the space is being refilled). Cycling mode is therefore only sound when at most one cycle's codes are *live* at a time in a given uniqueness scope: retire or expire cycle `e`'s codes before issuing from cycle `e+1`, or scope storage by cycle. A global `UNIQUE(code)` index spanning cycles WILL fire — scope it as `UNIQUE(cycle, code)` or equivalent. - The application must persist which cycle each live code belongs to (or equivalently, the currently active cycle) to decode; the library cannot recover `e` from the code string. ### 11.4 Test vectors `testvectors/v1c.json` (generated by the same `scripts/generate_test_vectors.py`) covers cycling-mode configs: for each, `vectors[]` entries are `{n, code}` with `cycle(n)` implied by `n`; conforming implementations must produce `code` for every `n`, decode it back under `cycle(n)`, reject every `invalid_codes[]` entry for the cycle it names (`InvalidCodeError`), accept every `normalize[]` input as its `n` under its cycle, reject every `range_counters[]` value (`RangeError`), reject every `invalid_cycles[]` value when passed as the cycle to decode (`RangeError`), and fail construction for every `invalid_configs[]` entry (`ConfigError`). Passing it is required for conformance of any implementation that ships the mode, and all seven in this repository do.