first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,74 @@
# `@suaveplan/license` — codec/base32
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Crockford-style Base32 codec used to serialise the packed payload and
signature into a printable license-key string. The alphabet
(`0-9 A-Z` minus `I`, `L`, `O`, `U`) is shared with upstream so any key
generated by upstream parses under suaveplan and vice versa. The encoder
inserts `-` separators every 5 characters for legibility; the decoder
ignores them.
## Features
- 32-character Crockford alphabet, omitting `I`/`L`/`O`/`U`.
- Dash-grouped output, `XXXXX-XXXXX-...`.
- Case-insensitive decode (uppercase canonical).
- Round-trips arbitrary `Uint8Array` data.
- Raises `LicenseError(LICENSE.INVALID_ENCODING)` on any non-alphabet
character, with the offending character in `error.context.character`.
## Usage: Basic
```ts
import { base32Decode, base32Encode } from "@suaveplan/license/codec/base32/base32";
const encoded = base32Encode(new Uint8Array([1, 2, 3, 4, 5]));
// e.g. "0426-1850-A"
const decoded = base32Decode(encoded);
```
## Usage: Advanced
Catching invalid input:
```ts
import { LicenseError } from "@suaveplan/license";
import { base32Decode } from "@suaveplan/license/codec/base32/base32";
try {
base32Decode("INVALID-CHAR-!");
} catch (e) {
if (e instanceof LicenseError && e.code === "LICENSE.INVALID_ENCODING") {
console.warn("Bad character:", e.context?.character);
}
}
```
## API Reference
### `base32Encode(buf: Uint8Array): string`
Returns the Base32 encoding of `buf`, grouped into 5-character chunks
separated by `-`. Empty input returns the empty string.
### `base32Decode(str: string): Uint8Array`
Strips `-`, uppercases, and decodes the resulting characters. Throws
`LicenseError(LICENSE.INVALID_ENCODING)` for any unknown character.
Input is trimmed to a byte boundary — trailing partial-byte bits are
discarded (matching upstream behaviour).
## Implementation Notes
The implementation uses string concatenation of binary digits rather
than bit shifts because the 5-bit-to-8-bit boundary makes manual
shifting verbose; the perf cost is negligible for license-key-sized
inputs (≤73 bytes). For larger inputs, prefer
`@suaveplan/crypto-utils`' base32 implementation. The trailing-bit
truncation in `base32Decode` matches upstream's `Math.floor(bits / 8)`
so identical inputs decode to identical bytes across both codebases.

View File

@@ -0,0 +1,90 @@
# `@suaveplan/license` — codec/bitpack
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bit-precise pack/unpack primitives used to serialise the 72-bit license
payload into 9 bytes. Plain `DataView` / typed-array slicing can only
write whole bytes, so we walk the buffer bit-by-bit. Two thin classes
hold the cursor: `SimpleBitPacker` writes MSB-first, `SimpleBitUnpacker`
reads MSB-first. Both round-trip identically to upstream's
`@suaveplan/license` bitpacker.
## Features
- `packUint(value, bits)` — writes an unsigned integer in `[0, 2^bits)`
using `bits` bits; raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)`
when the value exceeds the declared width or is negative.
- `unpackUint(bits)` — reads the next `bits`-wide unsigned integer;
raises `LicenseError(LICENSE.MALFORMED)` when the read would underflow
the buffer.
- `getBytes()` / `getBitCount()` / `getBitPosition()` introspection
helpers used by the spec-conformance tests.
## Usage: Basic
```ts
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
p.packUint(2, 3); // 3-bit version
p.packUint(17, 5); // 5-bit signingKeyId
const bytes = p.getBytes();
const u = new SimpleBitUnpacker(bytes);
const version = u.unpackUint(3); // 2
const keyId = u.unpackUint(5); // 17
```
## Usage: Advanced
Round-tripping a full 72-bit payload:
```ts
import { FIELD_BITS } from "@suaveplan/license/constants/constants";
import {
SimpleBitPacker,
SimpleBitUnpacker,
} from "@suaveplan/license/codec/bitpack/bitpack";
const p = new SimpleBitPacker();
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
p.packUint(0, bits);
}
const bytes = p.getBytes(); // 9 bytes
const u = new SimpleBitUnpacker(bytes);
for (const [_field, bits] of Object.entries(FIELD_BITS)) {
u.unpackUint(bits); // === 0
}
```
## API Reference
### `class SimpleBitPacker`
- `packUint(value: number, bits: number): void` — write an unsigned
integer; throws on overflow / negative.
- `getBytes(): Uint8Array` — snapshot the packed bytes.
- `getBitCount(): number` — total bits written.
### `class SimpleBitUnpacker`
- `constructor(buffer: Uint8Array)` — wrap a byte buffer.
- `unpackUint(bits: number): number` — read the next integer; throws
`MALFORMED` on underflow.
- `getBitPosition(): number` — current read cursor.
## Implementation Notes
The packer grows its internal `number[]` buffer one byte at a time
because the upstream layout is small (9 bytes) and pre-sizing would
require knowing the total bit count in advance. The `1 << bits` overflow
check is safe for the bit widths used by the license schema (≤19 bits);
for wider integers, callers should use a dedicated bigint bitpacker.

View File

@@ -0,0 +1,98 @@
# `@suaveplan/license` — codec/obfuscation
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Per-key character obfuscation pass run after Base32 encoding. The first
15 non-dash characters of the encoded key (which carry the bitpacked
payload) are rotated within the Base32 alphabet by per-character offsets
drawn from the license's signature. Two different licenses with
different signatures produce two completely different obfuscation
patterns, so attackers cannot pattern-match payload structure across
keys.
The seed used to drive the rotation is derived from the unobfuscated
chars 16-30 of the key (the signature region), which breaks the
circular "decode needs the seed, the seed comes from the decode"
dependency: the seed can be recovered from the obfuscated key alone.
## Features
- `obfuscateLicenseKey` / `deobfuscateLicenseKey` round-trip a key
given the same seed.
- `extractSignatureFromKey` recovers the seed from any obfuscated key
in O(n) without touching the bitpacker.
- Non-Base32 characters (dashes, accidental whitespace) pass through
unchanged so the dash-grouped formatting survives obfuscation.
- Short-key passthrough: keys with fewer than 15 non-dash characters
return unmodified, matching upstream's "no signature region to seed
from" fallback.
## Usage: Basic
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
const rawKey = "0426-1850-A8BC-NPQR-..."; // Base32 of payload || signature
const seed = extractSignatureFromKey(rawKey);
const obfuscated = obfuscateLicenseKey(rawKey, seed);
const original = deobfuscateLicenseKey(obfuscated, seed);
```
## Usage: Advanced
Round-tripping with the exact scheme `generateLicense` uses:
```ts
import {
deobfuscateLicenseKey,
extractSignatureFromKey,
obfuscateLicenseKey,
} from "@suaveplan/license/codec/obfuscation/obfuscation";
function obfuscate(rawKey: string): string {
const seed = extractSignatureFromKey(rawKey);
return obfuscateLicenseKey(rawKey, seed);
}
function deobfuscate(obfKey: string): string {
const seed = extractSignatureFromKey(obfKey);
return deobfuscateLicenseKey(obfKey, seed);
}
```
## API Reference
### `obfuscateLicenseKey(licenseKey: string, signatureBytes: Uint8Array): string`
Rotate the first 15 non-dash characters of `licenseKey` within
`BASE32_ALPHABET`. Per-char offsets cycle through `signatureBytes`.
Returns the input unchanged if it has fewer than 15 non-dash chars.
### `deobfuscateLicenseKey(obfuscatedKey: string, signatureBytes: Uint8Array): string`
Inverse of `obfuscateLicenseKey` — rotates by the negation of each
offset.
### `extractSignatureFromKey(obfuscatedKey: string): Uint8Array`
Recover up to 15 seed bytes from chars 16-30 of the obfuscated key by
reading their Base32-alphabet indices (0-31). Returns an empty array
when the key has no signature region.
## Implementation Notes
The rotation arithmetic uses double-modulo (`((x % len) + len) % len`)
so negative offsets (from `deobfuscateLicenseKey`) wrap correctly.
Non-Base32 characters return early in `rotateChar`, leaving dashes and
any defensive whitespace intact — this matches upstream's
permissive-pass behaviour and means an externally re-formatted key
(e.g. paste with extra spaces) still deobfuscates correctly once the
spaces are stripped by the Base32 decoder.

View File

@@ -0,0 +1,89 @@
# `@suaveplan/license` — codec/payload
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`packPayload` and `unpackPayload` are the 72-bit payload codec on top of
the bitpack primitives. They emit exactly 9 bytes (the binary contract
shared with upstream `@suaveplan/license`) and walk the 9 schema fields
in the canonical order declared by `FIELD_BITS`.
`packPayload` raises `LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` when a
field exceeds its declared bit width (via the underlying bitpacker).
`unpackPayload` raises `LicenseError(LICENSE.MALFORMED)` for any input
length other than `PAYLOAD_BYTES` (9).
## Features
- Bit-for-bit wire compatibility with upstream's `packPayload` /
`unpackPayload`.
- Field-bound validation via `SimpleBitPacker.packUint`, surfaced as
typed `LicenseError`s.
- Strict 9-byte length invariant on both sides — no implicit truncation
or padding.
- Returns a fresh `Uint8Array`; safe to mutate the caller's buffer.
## Usage: Basic
```ts
import {
packPayload,
unpackPayload,
} from "@suaveplan/license/codec/payload/payload";
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
const bytes = packPayload({
version: 2,
signingKeyId: 0,
productId: 1,
packageTier: PackageTier.PRO,
flags: 0,
timeEncodingType: TimeEncodingType.NONE,
timeValue: 0,
featureMask: 0,
nonce: 0x123,
});
console.log(bytes.length); // 9
const round = unpackPayload(bytes);
```
## Usage: Advanced
Catching a field-overflow at the generation boundary:
```ts
import { LicenseError } from "@suaveplan/license";
import { packPayload } from "@suaveplan/license/codec/payload/payload";
try {
packPayload({ /* ...valid */ productId: 64 } as never); // 6 bits, max 63
} catch (err) {
if (err instanceof LicenseError && err.code === "LICENSE.PAYLOAD_OUT_OF_RANGE") {
// surface to the user as a form-validation failure
}
}
```
## API Reference
### `packPayload(payload: LicensePayload): Uint8Array`
Pack a payload to its 9-byte representation. Throws
`LicenseError(LICENSE.PAYLOAD_OUT_OF_RANGE)` on field overflow.
### `unpackPayload(data: Uint8Array): LicensePayload`
Unpack a 9-byte buffer. Throws `LicenseError(LICENSE.MALFORMED)` for
any other length.
## Implementation Notes
The unpacker casts `timeEncodingType` to the `TimeEncodingType` enum
even though the bit width (2) admits any value in `[0, 3]` — all four
values map to legitimate enum members, so the cast is safe. The
constant `PAYLOAD_BYTES = 9` lives in `constants/constants.ts` so any
future schema change (which would require a `LICENSE_VERSION` bump)
updates a single source of truth.