# 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.