dealcode

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 28, 2026 License: MIT Imports: 10 Imported by: 0

README

dealcode (Go)

Collision-free, random-looking codes from a counter. Go implementation of the dealcode spec.

Install

go get github.com/algorix-hq/dealcode/go

Requires Go ≥ 1.21. Standard library only — AES and SHA-256 come from crypto/aes and crypto/sha256.

Releases are tagged with the go/ module prefix per the usual monorepo convention: go/v1.x.y.

Quickstart

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

The key can be raw bytes (Config.Key; 16/24/32 bytes are used as-is as an AES key) or any string (Config.KeyString) or other-length bytes, which are deterministically expanded to an AES-256 key. Generate one with openssl rand -hex 32 and keep it in your secret manager — the mapping is stable only while the key (and every other option) stays fixed.

Options
dealcode.Config{
	Key:       nil,    // []byte — exactly one of Key / KeyString
	KeyString: "",     // string key material (never auto-hex-decoded)
	Alphabet:  "hex",  // "dec" | "hex" | "base32" | "crockford" | "base36"
	                   // | "base58" | "base62" | "base64url" | custom string
	MinLength: 6,      // codes start at this length... (0 means the default, 6)
	MaxLength: 0,      // ...and grow one char at a time up to this
	                   // (0 means the largest length fully reachable by int64 counters)
	Domain:    "",     // namespace: same key, unrelated codes per domain
}
coupon, _ := dealcode.New(dealcode.Config{Key: key, Alphabet: "crockford", Domain: "coupons"}) // human-friendly, e.g. "7Q4WKZ"
order, _ := dealcode.New(dealcode.Config{Key: key, Alphabet: "dec", MinLength: 8, Domain: "orders"}) // digits only
fixed, _ := dealcode.New(dealcode.Config{Key: key, MinLength: 16, MaxLength: 16}) // constant-length hex

Decode returns an error wrapping ErrInvalidCode for malformed input — wrong length, characters outside the alphabet, or a value outside the issuable range. A well-formed code always decodes to some counter, whether or not that counter was ever issued (inherent to a permutation — see SPEC §7). 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). Encode returns an error wrapping ErrRange outside [0, codec.Capacity()); New returns an error wrapping ErrConfig for any invalid configuration. Classify with errors.Is.

Using it with your database

Dealcode does not talk to your database — it only turns a counter into a code. Any source of never-repeating integers works. With PostgreSQL:

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
  ...
);
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
}

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.

If the UNIQUE constraint on code ever fires, do not retry: it means the key/config changed for an existing namespace. Investigate.

Fixed-length cycling mode

For code shapes that must never grow (airline-PNR-style), NewCycling builds a codec whose codes are always exactly Length characters. The counter space is spent in cycles of Capacity() = radix^Length codes each; when a cycle is exhausted, the next cycle refills the same code space through a different permutation (SPEC §11).

pnr, err := dealcode.NewCycling(dealcode.CyclingConfig{
	KeyString: key, Alphabet: "crockford", Length: 6, Domain: "bookings",
})
// counter n belongs to cycle n / pnr.Capacity()
code, _ := pnr.Encode(n)          // always exactly 6 characters
cycle, _ := pnr.CycleOf(n)        // store this next to the code!
m, _ := pnr.Decode(code, cycle)   // m == n; the cycle is required

Configuration mirrors Config (key, alphabet, domain) with a single fixed Length: 2 <= Length <= 128 and 100 <= radix^Length <= 2^63.

Operational rule: codes repeat across cycles by design, so a global UNIQUE(code) index spanning cycles WILL fire — scope uniqueness as UNIQUE(cycle, code), keep at most one cycle's codes live at a time per scope (retire or expire cycle e before issuing from e+1), and persist each live code's cycle (or the currently active cycle): Decode needs it, and the library cannot recover the cycle from the code string. Decoding with a wrong (but in-range) cycle is not an error — it silently returns a different counter; the existence check on the decoded counter is what catches it.

Concurrency & performance

A Codec is immutable and safe for concurrent use by multiple goroutines without locking; create one per namespace at startup and reuse it. Encoding is ten AES-CBC-MAC rounds — single-digit microseconds, O(1) in the counter value, with all per-length FF1 parameters precomputed at construction.

Running the tests

From go/:

go vet ./...
go test -race ./...

The suite covers the official NIST FF1 sample vectors, every shared cross-language vector in ../testvectors/, behavioural cases, and concurrent use under the race detector.

License

MIT — see LICENSE.

Documentation

Overview

Package dealcode maps a non-negative integer counter (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, FF1 format-preserving encryption per NIST SP 800-38G), so two different counters can never produce the same code: uniqueness of codes reduces entirely to uniqueness of counters. Codes start at a minimum length and grow one character at a time only when the current length is exhausted. Without the key, codes carry no usable order or volume information.

This package implements format version 1 of the dealcode specification (SPEC.md at the repository root) and is byte-for-byte interoperable with the other language implementations in the same repository.

A Codec is immutable and safe for concurrent use by multiple goroutines; create one per code namespace at startup and reuse it:

codec, err := dealcode.New(dealcode.Config{
	KeyString: os.Getenv("DEALCODE_KEY"),
	Domain:    "orders",
})
if err != nil {
	log.Fatal(err)
}
code, err := codec.Encode(42)   // e.g. "4b71b7"
n, err := codec.Decode(code)    // 42

Dealcode codes are not authentication tokens: the code space is small and 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.

Example

Example maps counters to codes and back with the default hex alphabet. In production, load the key from your secret manager and never change it once codes have been issued.

package main

import (
	"encoding/hex"
	"fmt"
	"log"

	dealcode "github.com/algorix-hq/dealcode/go"
)

func main() {
	key, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f")
	codec, err := dealcode.New(dealcode.Config{Key: key})
	if err != nil {
		log.Fatal(err)
	}

	code, _ := codec.Encode(1)
	fmt.Println(code)

	n, _ := codec.Decode(code)
	fmt.Println(n)

}
Output:
38fa1e
1

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrConfig reports an invalid codec configuration: bad key material,
	// alphabet, lengths, or domain. It is returned only by New.
	ErrConfig = errors.New("dealcode: invalid configuration")

	// ErrRange reports an Encode counter outside [0, Capacity()).
	ErrRange = errors.New("dealcode: counter out of range")

	// ErrInvalidCode reports a Decode input that fails length, charset, or
	// stage-range validation — i.e. a string this codec never issued.
	ErrInvalidCode = errors.New("dealcode: invalid code")
)

Sentinel errors. Every error returned by this package wraps exactly one of these, so callers can classify failures with errors.Is while the returned error itself carries a descriptive message:

n, err := codec.Decode(input)
if errors.Is(err, dealcode.ErrInvalidCode) {
	// input was never issued by this codec
}

Functions

func Preset

func Preset(name string) (chars string, ok bool)

Preset returns the character set of the named preset alphabet ("dec", "hex", "base32", "crockford", "base36", "base58", "base62", "base64url") and reports whether the name is a known preset. The character at index i represents numeral value i.

Types

type Codec

type Codec struct {
	// contains filtered or unexported fields
}

Codec is a bijective counter <-> code mapping (dealcode format version 1).

A Codec is immutable after New and safe for concurrent use by multiple goroutines without external locking. It is cheap to keep around: create one per code namespace at startup and reuse it.

func New

func New(cfg Config) (*Codec, error)

New validates cfg and builds a Codec. All configuration violations from SPEC.md §2 are reported as errors wrapping ErrConfig.

func (*Codec) Alphabet

func (c *Codec) Alphabet() string

Alphabet returns the codec's alphabet characters in numeral order (the character at index i represents numeral value i).

func (*Codec) Capacity

func (c *Codec) Capacity() uint64

Capacity returns the number of encodable counters: min(radix^MaxLength, 2^63). Encode accepts exactly [0, Capacity()).

func (*Codec) Decode

func (c *Codec) Decode(code string) (int64, error)

Decode maps a code back to its counter (SPEC.md §7). The alphabet's normalization (e.g. hex is case-insensitive, crockford also folds O->0 and I/L->1) is applied first; custom alphabets require an exact match. Any string this codec could never have issued — wrong length, characters outside the alphabet, or a value outside the code's stage or the counter space — yields an error wrapping ErrInvalidCode.

Decode success only proves the code is consistent with the key; the application still decides whether counter n actually exists.

func (*Codec) Domain

func (c *Codec) Domain() string

Domain returns the codec's namespace label.

func (*Codec) Encode

func (c *Codec) Encode(n int64) (string, error)

Encode maps counter n to its code (SPEC.md §5). The code's length depends only on n's stage: MinLength() characters until the counter reaches radix^MinLength, one more character per exhausted stage after that. Encode is O(1) in n and returns an error wrapping ErrRange when n is outside [0, Capacity()).

func (*Codec) MaxLength

func (c *Codec) MaxLength() int

MaxLength returns the length codes may grow to.

func (*Codec) MinLength

func (c *Codec) MinLength() int

MinLength returns the length codes start at.

func (*Codec) Radix

func (c *Codec) Radix() int

Radix returns the number of characters in the alphabet.

func (*Codec) String

func (c *Codec) String() string

String describes the codec's public configuration. Key material never appears in the output.

type Config

type Config struct {
	// Key is binary key material. Bytes of length exactly 16, 24, or 32 are
	// used directly as the AES key; any other non-zero length is expanded to
	// an AES-256 key via SHA-256("dealcode/v1/kdf" || Key).
	Key []byte

	// KeyString is string key material (a passphrase, hex blob, base64 blob —
	// anything). It is always expanded, regardless of length or content, via
	// SHA-256("dealcode/v1/kdf" || UTF-8 bytes); a hex-looking string is not
	// auto-decoded. A passphrase key is exactly as strong as the passphrase;
	// prefer >=128-bit random material (e.g. `openssl rand -hex 32`).
	KeyString string

	// Alphabet is a preset name — "dec", "hex", "base32", "crockford",
	// "base36", "base58", "base62", "base64url" (see Preset) — or a custom
	// alphabet string of 2 to 94 distinct printable ASCII characters
	// (0x21-0x7E). Preset names win on conflict. Empty defaults to "hex".
	Alphabet string

	// MinLength is the length codes start at. Zero defaults to 6. It must be
	// at least 2, with radix^MinLength >= 100 (the FF1 structural minimum).
	MinLength int

	// MaxLength is the length codes may grow to. Zero defaults to the largest
	// L with radix^L <= 2^63-1 (hex: 15, dec: 18, base32/crockford: 12,
	// base58/base62/base64url: 10, ...). It must satisfy
	// MinLength <= MaxLength and radix^MaxLength <= 2^128. Set
	// MinLength == MaxLength for fixed-length codes.
	MaxLength int

	// Domain is an application-chosen namespace label (e.g. "orders",
	// "coupons"), bound into the FF1 tweak: two codecs with the same key but
	// different domains produce unrelated permutations. It must be valid
	// UTF-8 of at most 255 bytes. Empty is a valid (default) domain.
	Domain string
}

Config describes a dealcode codec (SPEC.md §2).

Exactly one of Key and KeyString must be set. For a given code namespace (one counter sequence) the entire configuration — key material, alphabet, lengths, and 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.

type CycleCodec

type CycleCodec struct {
	// contains filtered or unexported fields
}

CycleCodec is a fixed-length cycling codec (dealcode mode v1c): codes are always exactly Length() characters, and the counter space is spent in cycles of Capacity() codes each. Counter n belongs to cycle n / Capacity() with in-cycle value n % Capacity(); every cycle is a different permutation of the same code space (a different FF1 tweak), so when the space is exhausted it refills in a new order instead of growing.

Codes REPEAT across cycles by design (pigeonhole: the same space is being refilled). Keep at most one cycle's codes live per uniqueness scope — a global UNIQUE(code) index spanning cycles WILL fire; scope it as UNIQUE(cycle, code) — and persist which cycle each live code belongs to: Decode needs it, and the library cannot recover the cycle from the code string.

A CycleCodec is immutable after NewCycling and safe for concurrent use by multiple goroutines without external locking.

func NewCycling

func NewCycling(cfg CyclingConfig) (*CycleCodec, error)

NewCycling validates cfg and builds a CycleCodec. All configuration violations from SPEC.md §11.1 are reported as errors wrapping ErrConfig.

func (*CycleCodec) Alphabet

func (c *CycleCodec) Alphabet() string

Alphabet returns the codec's alphabet characters in numeral order (the character at index i represents numeral value i).

func (*CycleCodec) Capacity

func (c *CycleCodec) Capacity() uint64

Capacity returns the number of codes per cycle: radix^Length(). It is a uint64 because the boundary configuration radix^Length == 2^63 is legal and 2^63 overflows int64.

func (*CycleCodec) CycleOf

func (c *CycleCodec) CycleOf(n int64) (int64, error)

CycleOf returns the cycle that counter n belongs to: n / Capacity(). It returns an error wrapping ErrRange when n is negative (every non-negative int64 is a valid counter in cycling mode).

func (*CycleCodec) Decode

func (c *CycleCodec) Decode(code string, cycle int64) (int64, error)

Decode maps a code issued in the given cycle back to its counter (SPEC.md §11.2). The cycle is required: the same string recurs in every cycle, mapping to a different counter each time, so a code alone is ambiguous by design.

A cycle outside [0, MaxCycle()] yields an error wrapping ErrRange. Any string this codec could never have issued in that cycle — wrong length, characters outside the alphabet (after the alphabet's normalization), or a counter at or beyond 2^63 (possible only in the final partial cycle) — yields an error wrapping ErrInvalidCode.

Decode success only proves the code is consistent with the key and cycle; the application still decides whether counter n actually exists.

func (*CycleCodec) Domain

func (c *CycleCodec) Domain() string

Domain returns the codec's namespace label.

func (*CycleCodec) Encode

func (c *CycleCodec) Encode(n int64) (string, error)

Encode maps counter n to its fixed-length code (SPEC.md §11.2). The code belongs to cycle n / Capacity() — the caller must record that cycle (or the currently active cycle) to decode later. Encode returns an error wrapping ErrRange when n is negative; every non-negative int64 is a valid counter.

Codes repeat across cycles: Encode(n) and Encode(n + Capacity()) can return the same string for two different counters. See CycleCodec.

func (*CycleCodec) Length

func (c *CycleCodec) Length() int

Length returns the fixed code length: every code is exactly this many characters, in every cycle.

func (*CycleCodec) MaxCycle

func (c *CycleCodec) MaxCycle() int64

MaxCycle returns the largest usable cycle number: (2^63 - 1) / Capacity(). Decode accepts cycles in [0, MaxCycle()].

func (*CycleCodec) Radix

func (c *CycleCodec) Radix() int

Radix returns the number of characters in the alphabet.

func (*CycleCodec) String

func (c *CycleCodec) String() string

String describes the codec's public configuration. Key material never appears in the output.

type CyclingConfig

type CyclingConfig struct {
	// Key is binary key material, with exactly the rules of Config.Key.
	Key []byte

	// KeyString is string key material, with exactly the rules of
	// Config.KeyString.
	KeyString string

	// Alphabet is a preset name or custom alphabet string, with exactly the
	// rules of Config.Alphabet. Empty defaults to "hex".
	Alphabet string

	// Length is the fixed code length L: every code is exactly L characters
	// in every cycle. Zero defaults to 6. It must be in [2, 128] with
	// 100 <= radix^L <= 2^63 (exactly 2^63 is allowed); for larger fixed
	// spaces use Codec with MinLength == MaxLength instead.
	Length int

	// Domain is an application-chosen namespace label, with exactly the
	// rules of Config.Domain.
	Domain string
}

CyclingConfig describes a fixed-length cycling codec (SPEC.md §11).

Exactly one of Key and KeyString must be set; the key rules are identical to Config's. As with Config, the entire configuration — key material, alphabet, length, and domain — must never change once codes have been issued.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL