first commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,81 @@
|
||||
# `@suaveplan/license` — constants
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Wire-format constants for the license package. Every value here
|
||||
is part of the binary contract: the per-field bit widths
|
||||
(`FIELD_BITS`), the Base32 alphabet, the signature lengths, the version
|
||||
byte, and the day-to-millisecond conversion factor. Changing any
|
||||
constant breaks every existing license key, so they live in their own
|
||||
module and are imported by every code path that touches the format.
|
||||
|
||||
## Features
|
||||
|
||||
- `LICENSE_VERSION` (currently `2`) — pinned by the 3-bit version field;
|
||||
`validateLicense` rejects any payload with a higher version.
|
||||
- `FULL_SIGNATURE_BYTES` (64) / `SIGNATURE_BYTES` (20) /
|
||||
`MIN_SIGNATURE_BYTES` (16) — the three signature-length thresholds
|
||||
honoured by generation and validation.
|
||||
- `BASE32_ALPHABET` — 32-char Crockford-style alphabet (no I/L/O/U) used
|
||||
by both encode and decode.
|
||||
- `LICENSE_SCHEMA` — per-field maximum values, used by Zod schemas and
|
||||
by the bounds checks in `packPayload`.
|
||||
- `FIELD_BITS` — per-field bit widths summing to 72, consumed by the
|
||||
bitpacker.
|
||||
- `PAYLOAD_BYTES` (9), `OBFUSCATION_PAYLOAD_CHARS` (15), and
|
||||
`MS_PER_DAY` (86 400 000) — derived constants pulled out so callers
|
||||
don't have to recompute them.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LICENSE_VERSION, BASE32_ALPHABET } from "@suaveplan/license/constants/constants";
|
||||
|
||||
console.log(`Targeting wire format v${LICENSE_VERSION}`);
|
||||
console.log(`Alphabet size: ${BASE32_ALPHABET.length}`); // 32
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Bounds-checking a payload field at the application layer before
|
||||
constructing it:
|
||||
|
||||
```ts
|
||||
import { LICENSE_SCHEMA } from "@suaveplan/license/constants/constants";
|
||||
import type { LicensePayload } from "@suaveplan/license/types/license";
|
||||
|
||||
function clampPayload(p: LicensePayload): LicensePayload {
|
||||
return {
|
||||
...p,
|
||||
productId: Math.min(p.productId, LICENSE_SCHEMA.productId),
|
||||
timeValue: Math.min(p.timeValue, LICENSE_SCHEMA.timeValue),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Symbol | Type | Value |
|
||||
|---|---|---|
|
||||
| `LICENSE_VERSION` | `2` | Current format version |
|
||||
| `FULL_SIGNATURE_BYTES` | `64` | secp256k1 compact signature length |
|
||||
| `SIGNATURE_BYTES` | `20` | Default truncated-signature length |
|
||||
| `MIN_SIGNATURE_BYTES` | `16` | Lowest acceptable truncation |
|
||||
| `BASE32_ALPHABET` | `string` | `0-9A-HJKMNP-TV-Z` (32 chars) |
|
||||
| `LICENSE_SCHEMA` | `Readonly<Record<field, max>>` | Per-field max values |
|
||||
| `FIELD_BITS` | `Readonly<Record<field, bits>>` | Per-field bit widths |
|
||||
| `PAYLOAD_BYTES` | `9` | Packed payload size |
|
||||
| `OBFUSCATION_PAYLOAD_CHARS` | `15` | Chars rotated by obfuscation |
|
||||
| `MS_PER_DAY` | `86_400_000` | UTC day in milliseconds |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every constant is declared `as const`, so its TypeScript type is the
|
||||
literal value (e.g. `LICENSE_VERSION: 2`), giving exhaustiveness benefits
|
||||
when compared in `switch` statements. The two records (`LICENSE_SCHEMA`
|
||||
and `FIELD_BITS`) are also frozen at the type level via `as const`,
|
||||
preventing accidental mutation through their `Readonly` view.
|
||||
99
docs/security-stack/security/license/src/error-codes.md
Normal file
99
docs/security-stack/security/license/src/error-codes.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# `@suaveplan/license` — error-codes
|
||||
|
||||
## Purpose
|
||||
|
||||
This module owns the stable error-code registry for `@suaveplan/license`. It
|
||||
exposes `LICENSE_CODES` (a frozen `defineCodes("LICENSE", [...])` record),
|
||||
the `LicenseErrorCode` type union, and the `LicenseError` class. Every
|
||||
public failure path in the package raises a `LicenseError` carrying one of
|
||||
these twelve namespaced `LICENSE.*` codes, so consumers can dispatch on the
|
||||
literal string or `instanceof LicenseError` and let TypeScript narrow the
|
||||
union exhaustively.
|
||||
|
||||
## Features
|
||||
|
||||
- 12 codes covering input validation, encoding / parsing, cryptographic
|
||||
failures, lifecycle (expired / revoked), manager state, feature-mask
|
||||
bounds, and `AbortSignal` cancellation.
|
||||
- `LicenseError` extends `SuaveplanError<LicenseErrorCode>` so the
|
||||
generic `code` type narrows under `instanceof`.
|
||||
- Constructor widened to `public` so all modules can construct errors
|
||||
directly without trampolines.
|
||||
- Codes are namespaced `LICENSE.*` per the suaveplan-wide pattern (see
|
||||
`@suaveplan/crypto-keys`'s `CHAIN_KEYS.*`), making programmatic dispatch
|
||||
consistent across packages.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
|
||||
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_ENCODING,
|
||||
"Invalid Base32 character: !"
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Attach a Zod `cause` and structured `context` for an input-validation
|
||||
boundary failure:
|
||||
|
||||
```ts
|
||||
import { LicenseError, LICENSE_CODES } from "@suaveplan/license/error-codes/error-codes";
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
try {
|
||||
LicensePayloadSchema.parse(input);
|
||||
} catch (zodError) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
"License payload failed schema validation",
|
||||
{ cause: zodError, context: { source: "generateLicense" } }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
Consumer-side narrowing:
|
||||
|
||||
```ts
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
try {
|
||||
await manager.generate({ productId: 1 });
|
||||
} catch (error) {
|
||||
if (error instanceof LicenseError && error.code === "LICENSE.NO_SIGNING_KEY") {
|
||||
// recover or re-prompt
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LICENSE_CODES`
|
||||
|
||||
Frozen `Readonly<Record<Reason, "LICENSE.<Reason>">>` where `Reason` is
|
||||
one of the 12 keys: `INVALID_INPUT`, `INVALID_ENCODING`, `MALFORMED`,
|
||||
`UNSUPPORTED_VERSION`, `UNKNOWN_SIGNING_KEY`, `INVALID_SIGNATURE`,
|
||||
`EXPIRED`, `REVOKED`, `NO_SIGNING_KEY`, `FEATURE_INDEX_OUT_OF_RANGE`,
|
||||
`PAYLOAD_OUT_OF_RANGE`, `ABORTED`.
|
||||
|
||||
### `type LicenseErrorCode`
|
||||
|
||||
String-literal union of all 12 namespaced codes.
|
||||
|
||||
### `class LicenseError extends SuaveplanError<LicenseErrorCode>`
|
||||
|
||||
Concrete error class. Constructor: `(code, message, options?)` where
|
||||
`options` is `SuaveplanErrorOptions` (carries optional `cause`,
|
||||
`context`, `traceId`, `clock`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The registry uses `defineCodes` from `@suaveplan/error` so the type of
|
||||
`LICENSE_CODES.INVALID_ENCODING` is the literal `"LICENSE.INVALID_ENCODING"`
|
||||
(not the broad `string`), giving exhaustiveness checking in `switch`
|
||||
statements. The list is declared `as const` so TypeScript captures the
|
||||
tuple shape and the helper's mapped-type return picks every key.
|
||||
112
docs/security-stack/security/license/src/flags/flags.md
Normal file
112
docs/security-stack/security/license/src/flags/flags.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# `@suaveplan/license` — flags
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bitmask helpers for the 5-bit `flags` field (`LicenseFlags`) and the
|
||||
19-bit `featureMask` field. The flags field stores up to 5 boolean
|
||||
license states (`TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`,
|
||||
`EDUCATIONAL`); the feature mask stores up to 19 product-specific
|
||||
feature toggles. This module gives type-safe accessors for both: the
|
||||
flag helpers are typed against the `LicenseFlags` enum; the feature
|
||||
helpers validate that the supplied index is within `[0, 18]` and raise
|
||||
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` otherwise.
|
||||
|
||||
## Features
|
||||
|
||||
- `hasFlag` / `combineFlags` — flag-bit accessors typed against the
|
||||
`LicenseFlags` enum so misuses surface at compile time.
|
||||
- `hasFeature` / `combineFeatures` / `getEnabledFeatures` — feature-bit
|
||||
accessors with runtime index-range checks.
|
||||
- Out-of-range indices throw a typed `LicenseError`, never a generic
|
||||
`Error` — callers can `instanceof LicenseError` and dispatch on
|
||||
`error.code`.
|
||||
- `getEnabledFeatures` walks only the 19 declared bits, so callers
|
||||
feeding in stray high bits get a clean answer.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
combineFlags,
|
||||
hasFlag,
|
||||
hasFeature,
|
||||
} from "@suaveplan/license/flags/flags";
|
||||
import { LicenseFlags } from "@suaveplan/license/types/license";
|
||||
|
||||
const flags = combineFlags(LicenseFlags.TRIAL, LicenseFlags.SUBSCRIPTION);
|
||||
hasFlag(flags, LicenseFlags.TRIAL); // true
|
||||
hasFeature(0b1010, 1); // true
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Building a payload-shaped feature mask from a domain-specific feature
|
||||
enum, with overflow guard:
|
||||
|
||||
```ts
|
||||
import {
|
||||
combineFeatures,
|
||||
getEnabledFeatures,
|
||||
} from "@suaveplan/license/flags/flags";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
const enum Feature {
|
||||
Export = 0,
|
||||
BulkOps = 1,
|
||||
AdvancedSearch = 4,
|
||||
AiAssistant = 18,
|
||||
}
|
||||
|
||||
try {
|
||||
const mask = combineFeatures(
|
||||
Feature.Export,
|
||||
Feature.BulkOps,
|
||||
Feature.AdvancedSearch,
|
||||
Feature.AiAssistant
|
||||
);
|
||||
const enabled = getEnabledFeatures(mask); // [0, 1, 4, 18]
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.FEATURE_INDEX_OUT_OF_RANGE") {
|
||||
// upstream produced an out-of-range index
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `hasFlag(flags: number, flag: LicenseFlags): boolean`
|
||||
|
||||
Test whether `flag` is set in `flags`.
|
||||
|
||||
### `combineFlags(...flags: LicenseFlags[]): number`
|
||||
|
||||
OR-merge multiple flags into a single bitmask.
|
||||
|
||||
### `hasFeature(featureMask: number, featureIndex: number): boolean`
|
||||
|
||||
Test whether feature index `featureIndex` is set. Throws
|
||||
`LicenseError(LICENSE.FEATURE_INDEX_OUT_OF_RANGE)` when `featureIndex`
|
||||
is outside `[0, 18]`.
|
||||
|
||||
### `combineFeatures(...features: number[]): number`
|
||||
|
||||
OR-merge multiple feature indices into a single bitmask. Same range
|
||||
check as `hasFeature`.
|
||||
|
||||
### `getEnabledFeatures(featureMask: number): number[]`
|
||||
|
||||
Enumerate every enabled feature index in ascending order. Bits past
|
||||
index 18 are ignored (consistent with the 19-bit field width).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 19-feature ceiling matches the 19-bit `featureMask` field in the
|
||||
license payload. The shared `ensureFeatureIndexInRange` helper means
|
||||
the two range-checking call sites stay in lockstep; if the bit budget
|
||||
ever changes, only one constant needs to move. The flag enum uses
|
||||
power-of-two values (`1 << 0`, `1 << 1`, ...) so `combineFlags` is a
|
||||
straight `reduce(|)`.
|
||||
129
docs/security-stack/security/license/src/generate/generate.md
Normal file
129
docs/security-stack/security/license/src/generate/generate.md
Normal file
@@ -0,0 +1,129 @@
|
||||
# `@suaveplan/license` — generate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Top-level license-key generation, payload construction, and parse-only
|
||||
inspection. This is the place that brings together every lower module:
|
||||
it packs the payload, signs the packed bytes with secp256k1 (via
|
||||
`@suaveplan/crypto-curve`) over a SHA-256 digest, optionally truncates
|
||||
the signature, Base32-encodes `payload || signature`, applies the
|
||||
signature-derived obfuscation pass, and emits OpenTelemetry counters
|
||||
and span attributes — all behind a single async function.
|
||||
|
||||
`createPayload` is the quick-builder: callers supply a partial
|
||||
`QuickLicenseOptions` bag, and the function fills sensible defaults
|
||||
(version, signingKeyId, tier, etc.) and computes the time-encoding
|
||||
fields from `expirationDays` / `useAbsoluteTime`. The `Rng` for the
|
||||
nonce and the `Clock` for absolute-day expirations are both injectable
|
||||
so tests run deterministically.
|
||||
|
||||
`parseLicense` is the inverse of generation up to (but not including)
|
||||
signature verification — it deobfuscates, Base32-decodes, and unpacks
|
||||
the payload, returning the three byte slices plus the decoded record.
|
||||
It does NOT verify the signature or check expiration; for that, use
|
||||
`validateLicense`.
|
||||
|
||||
## Features
|
||||
|
||||
- `generateLicense` — full generation pipeline with Zod boundary
|
||||
validation, OTel emission, and `AbortSignal` propagation.
|
||||
- `createPayload` — quick-builder with injectable `Rng` / `Clock` and
|
||||
Zod-validated input.
|
||||
- `parseLicense` — pure decode-and-unpack, returning the byte slices
|
||||
plus the decoded payload.
|
||||
- Wire-format bit-identical to upstream `@suaveplan/license`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
} from "@suaveplan/license/generate/generate";
|
||||
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const result = await generateLicense(
|
||||
createPayload({ productId: 5, expirationDays: 365 }),
|
||||
{ signingKey: { s }, useFullSignature: false }
|
||||
);
|
||||
console.log(result.key); // "XYZ12-ABCDE-..."
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Deterministic generation with `Rng` injection, `AbortSignal`, and
|
||||
truncated signature for a compact key:
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import type { Rng } from "@suaveplan/types";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
import {
|
||||
createPayload,
|
||||
generateLicense,
|
||||
parseLicense,
|
||||
} from "@suaveplan/license/generate/generate";
|
||||
|
||||
const rng: Rng = { nextUint32: () => 0x123, nextFloat: () => 0 };
|
||||
const payload = createPayload(
|
||||
{ productId: 7, expirationDays: 90 },
|
||||
{ rng }
|
||||
);
|
||||
|
||||
const ctrl = new AbortController();
|
||||
try {
|
||||
const r = await generateLicense(payload, {
|
||||
signingKey: { s: secp256k1.randomPrivateKey() },
|
||||
useFullSignature: false,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
const parsed = parseLicense(r.key);
|
||||
console.log("payload bytes:", parsed.payloadBytes.length); // 9
|
||||
console.log("signature bytes:", parsed.signatureBytes.length); // 20
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
|
||||
console.log("cancelled");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `generateLicense(payload, options): Promise<LicenseGenerationResult>`
|
||||
|
||||
Pack + sign + encode + obfuscate. Options: `signingKey` (required),
|
||||
`signingKeyIdOverride?`, `useFullSignature?` (default `true` at the
|
||||
function level; `LicenseManager` overrides to `false`), `signal?`,
|
||||
`rng?` (forwarded to `createPayload` when this function is invoked from
|
||||
the manager). Throws `LicenseError(LICENSE.INVALID_INPUT)` on schema
|
||||
failure, `LicenseError(LICENSE.ABORTED)` on a pre-aborted signal.
|
||||
|
||||
### `createPayload(options?, injection?): LicensePayload`
|
||||
|
||||
Quick-builder; defaults: `productId=1`, `tier=PackageTier.BASIC`,
|
||||
`expirationDays=0` (perpetual), `flags=0`, `features=0`,
|
||||
`useAbsoluteTime=false`. `injection.rng` drives the 10-bit nonce;
|
||||
`injection.clock` drives `getDayNumber` when `useAbsoluteTime`.
|
||||
|
||||
### `parseLicense(licenseKey): LicenseParsedResult`
|
||||
|
||||
Deobfuscate + Base32-decode + slice + unpack. Throws
|
||||
`LicenseError(LICENSE.MALFORMED)` when the decoded byte length is
|
||||
less than `PAYLOAD_BYTES + 1`, or when the Base32 decode surfaces an
|
||||
`INVALID_ENCODING`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The signature scheme is upstream-compatible: SHA-256 over the 9-byte
|
||||
payload, then secp256k1 sign over the digest. The truncated path
|
||||
slices the first 20 bytes of the resulting compact signature; the full
|
||||
path keeps all 64. This means a key generated by upstream verifies
|
||||
under suaveplan and vice versa as long as the same SignKey/PublicKey
|
||||
byte material is used. The `Rng` default uses `crypto.getRandomValues`
|
||||
to build a 32-bit unsigned integer, which is then masked to the 10-bit
|
||||
nonce by the caller.
|
||||
131
docs/security-stack/security/license/src/manager/manager.md
Normal file
131
docs/security-stack/security/license/src/manager/manager.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# `@suaveplan/license` — manager
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
High-level `LicenseManager` class that bundles a public-key registry,
|
||||
an optional signing key, default `signingKeyId` / `productId`,
|
||||
injectable `Clock` / `Rng`, and an optional `RevocationStore` into a
|
||||
single object. Most callers will use only the manager — the lower-level
|
||||
`generateLicense` / `validateLicense` functions are exposed for the
|
||||
rare case where the caller wants stateless calls.
|
||||
|
||||
`LicenseManager.generate` defaults `useFullSignature: false` (compact
|
||||
keys) to match upstream's caller-side default; passing
|
||||
`{ useFullSignature: true }` opts into 64-byte signatures. `revoke` and
|
||||
`isRevoked` reach into the configured `RevocationStore` (lazily
|
||||
allocating an in-memory store on first `revoke` call if none was
|
||||
configured).
|
||||
|
||||
## Features
|
||||
|
||||
- 11 methods covering the full lifecycle: `addPublicKey`,
|
||||
`setSigningKey`, `setRevocationStore`, `generate`, `generateTrial`,
|
||||
`validate`, `isValid`, `parse`, `revoke`, `isRevoked` (+ constructor).
|
||||
- Quick-trial helper `generateTrial({ expirationDays, productId?,
|
||||
tier?, features? })` that sets the `TRIAL` flag and uses
|
||||
`RELATIVE_DAYS` time encoding.
|
||||
- Injectable `Clock` is used both as the default `referenceDate` for
|
||||
`validate` and as the time base for `createPayload` absolute-day
|
||||
computations.
|
||||
- Injectable `Rng` flows through to `createPayload` for deterministic
|
||||
nonces.
|
||||
- Optional `RevocationStore` consulted before signature verification.
|
||||
- Throws `LicenseError(LICENSE.NO_SIGNING_KEY)` when `generate` is
|
||||
called without a configured signing key.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import { LicenseManager, PackageTier } from "@suaveplan/license";
|
||||
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
const p = secp256k1.getPublicKey(s);
|
||||
|
||||
const m = new LicenseManager({
|
||||
signingKey: { s, p },
|
||||
publicKeys: { 0: { p } },
|
||||
});
|
||||
|
||||
const r = await m.generate({ productId: 5, tier: PackageTier.PRO });
|
||||
const v = await m.validate(r.key); // { success: true, details: { ... } }
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
A manager with deterministic test injection, a revocation store, and a
|
||||
multi-key registry:
|
||||
|
||||
```ts
|
||||
import { secp256k1 } from "@suaveplan/crypto-curve";
|
||||
import type { Clock, Rng } from "@suaveplan/types";
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
LicenseManager,
|
||||
} from "@suaveplan/license";
|
||||
|
||||
const kp1 = (() => {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
return { s, p: secp256k1.getPublicKey(s) };
|
||||
})();
|
||||
const kp2 = (() => {
|
||||
const s = secp256k1.randomPrivateKey();
|
||||
return { s, p: secp256k1.getPublicKey(s) };
|
||||
})();
|
||||
|
||||
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
|
||||
const rng: Rng = { nextUint32: () => 0xdead, nextFloat: () => 0 };
|
||||
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
|
||||
|
||||
const m = new LicenseManager({
|
||||
signingKey: { s: kp1.s },
|
||||
publicKeys: { 0: { p: kp1.p }, 1: { p: kp2.p } },
|
||||
defaultSigningKeyId: 0,
|
||||
defaultProductId: 5,
|
||||
clock,
|
||||
rng,
|
||||
revocationStore: store,
|
||||
});
|
||||
|
||||
const trial = await m.generateTrial({ expirationDays: 14 });
|
||||
await m.revoke(fingerprintLicense(trial.key));
|
||||
const v = await m.validate(trial.key);
|
||||
// → { success: false, error: 'REVOKED', message: 'License has been revoked' }
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `class LicenseManager`
|
||||
|
||||
| Method | Returns | Notes |
|
||||
|---|---|---|
|
||||
| `constructor(config?)` | `LicenseManager` | All `config` fields optional |
|
||||
| `addPublicKey(keyId, publicKey)` | `void` | Idempotent overwrite |
|
||||
| `setSigningKey(signingKey, keyId?)` | `void` | Updates default signing key id when supplied |
|
||||
| `setRevocationStore(store)` | `void` | Replaces an existing store |
|
||||
| `generate(options, genOptions?)` | `Promise<LicenseGenerationResult>` | Throws `NO_SIGNING_KEY` if unconfigured |
|
||||
| `generateTrial(opts)` | `Promise<LicenseGenerationResult>` | Sets `TRIAL` flag, `RELATIVE_DAYS` encoding |
|
||||
| `validate(licenseKey, options?)` | `Promise<LicenseValidationResult>` | Consults revocation store first |
|
||||
| `isValid(licenseKey)` | `Promise<boolean>` | Wraps `validate` |
|
||||
| `parse(licenseKey)` | `LicenseParsedResult` | No verification |
|
||||
| `revoke(fingerprint)` | `Promise<void>` | Lazily allocates store if none |
|
||||
| `isRevoked(fingerprint)` | `Promise<boolean>` | Returns `false` if no store |
|
||||
|
||||
### `interface GenerateTrialOptions`
|
||||
|
||||
`{ expirationDays: number, productId?: number, tier?: PackageTier, features?: number }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The lazy-allocation behaviour of `revoke` (auto-creates an in-memory
|
||||
store on first call) means simple consumers can use revocation without
|
||||
constructing a store explicitly, while still letting power users plug
|
||||
in a custom backend. The manager's `clock` injection drives BOTH the
|
||||
generation-time `getDayNumber` (for absolute-day expirations) AND the
|
||||
validation-time `referenceDate` (when none is supplied) — keeping the
|
||||
two sides on the same simulated time line for deterministic tests.
|
||||
89
docs/security-stack/security/license/src/otel/instruments.md
Normal file
89
docs/security-stack/security/license/src/otel/instruments.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# `@suaveplan/license` — otel/instruments
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
OpenTelemetry instrumentation for the license package. Registers a
|
||||
single `@opentelemetry/api` meter (`@suaveplan/license@0.1.0`) and
|
||||
attaches three instruments to it: a generation counter, a validation
|
||||
counter (attribute `license.valid: boolean`), and a validation-duration
|
||||
histogram (unit `ms`). When no SDK is wired the meter falls back to the
|
||||
Noop implementation, so importing this module has zero side effects in
|
||||
production unless the host application registers a real provider.
|
||||
|
||||
## Features
|
||||
|
||||
- `license.generate_total` — counter incremented once per successful
|
||||
`generateLicense` call.
|
||||
- `license.validate_total` — counter incremented per validation call
|
||||
with attribute `license.valid: boolean` (and, on failure, `license.error`
|
||||
carrying the enum value).
|
||||
- `license.validate_duration_ms` — histogram of validation latency in
|
||||
milliseconds.
|
||||
- Five `ATTR_*` constants exported so call sites and tests reference
|
||||
attribute keys by name, not by stringly-typed literals.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
ATTR_LICENSE_VALID,
|
||||
licenseValidateTotal,
|
||||
} from "@suaveplan/license/otel/instruments";
|
||||
|
||||
licenseValidateTotal.add(1, { [ATTR_LICENSE_VALID]: true });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire an in-memory provider for tests and assert that the instruments
|
||||
emit:
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
const exporter = new InMemoryMetricExporter(AggregationTemporality.CUMULATIVE);
|
||||
const reader = new PeriodicExportingMetricReader({ exporter, exportIntervalMillis: 60_000 });
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
|
||||
// ...exercise the license package...
|
||||
|
||||
await provider.forceFlush();
|
||||
console.log(exporter.getMetrics());
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Instruments
|
||||
|
||||
- `licenseGenerateTotal: Counter`
|
||||
- `licenseValidateTotal: Counter`
|
||||
- `licenseValidateDurationMs: Histogram`
|
||||
|
||||
### Attribute keys
|
||||
|
||||
- `ATTR_LICENSE_VALID = "license.valid"`
|
||||
- `ATTR_LICENSE_ERROR = "license.error"`
|
||||
- `ATTR_LICENSE_TIER = "license.tier"`
|
||||
- `ATTR_LICENSE_PRODUCT_ID = "license.product_id"`
|
||||
- `ATTR_LICENSE_SIGNING_KEY_ID = "license.signing_key_id"`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The meter is fetched at module load via `metrics.getMeter(...)`. The
|
||||
`@opentelemetry/api` library guarantees that subsequent calls with the
|
||||
same name+version return the same meter instance, so importing
|
||||
`instruments.ts` from multiple call sites does not create duplicate
|
||||
counters. The Noop fallback means `licenseGenerateTotal.add(...)` is a
|
||||
zero-cost call when no SDK is wired — safe to leave the instrumentation
|
||||
on in every code path.
|
||||
@@ -0,0 +1,102 @@
|
||||
# `@suaveplan/license` — revocation/revocation-store
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pluggable revocation blocklist for the license package. The
|
||||
`RevocationStore` interface defines three methods (`add`, `has`,
|
||||
`list`), each of which may return synchronously or as a `Promise` —
|
||||
that lets callers wire an in-memory store for tests, a Redis-backed
|
||||
store for production, or a remote API without changing the
|
||||
`LicenseManager` call sites. A synchronous in-memory implementation
|
||||
ships with the package; `fingerprintLicense` produces a SHA-256 hex of
|
||||
an obfuscated key suitable as a store key.
|
||||
|
||||
## Features
|
||||
|
||||
- `createMemoryRevocationStore(options?)` — synchronous in-memory store
|
||||
with optional `maxEntries` FIFO eviction cap.
|
||||
- `fingerprintLicense(licenseKey)` — stable SHA-256 hex of the
|
||||
(obfuscated) license-key string.
|
||||
- Insertion-ordered `list()` snapshot for backup / migration.
|
||||
- Re-adding an existing fingerprint moves it to the end (LRU-on-write)
|
||||
so frequently-revoked keys survive eviction longest.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
createMemoryRevocationStore,
|
||||
fingerprintLicense,
|
||||
} from "@suaveplan/license/revocation/revocation-store/revocation-store";
|
||||
|
||||
const store = createMemoryRevocationStore({ maxEntries: 10_000 });
|
||||
await store.add(fingerprintLicense("XXXXX-YYYYY-..."));
|
||||
const blocked = await store.has(fingerprintLicense("XXXXX-YYYYY-..."));
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Wire a custom async store backed by a database:
|
||||
|
||||
```ts
|
||||
import type { RevocationStore } from "@suaveplan/license";
|
||||
|
||||
function createDbRevocationStore(db: SomeDb): RevocationStore {
|
||||
return {
|
||||
async add(fingerprint) {
|
||||
await db.insert("revocations", { fingerprint });
|
||||
},
|
||||
async has(fingerprint) {
|
||||
return await db.exists("revocations", { fingerprint });
|
||||
},
|
||||
async list() {
|
||||
const rows = await db.select("revocations", ["fingerprint"]);
|
||||
return rows.map((r) => r.fingerprint);
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `interface RevocationStore`
|
||||
|
||||
```ts
|
||||
interface RevocationStore {
|
||||
add(fingerprint: string): void | Promise<void>;
|
||||
has(fingerprint: string): boolean | Promise<boolean>;
|
||||
list(): readonly string[] | Promise<readonly string[]>;
|
||||
}
|
||||
```
|
||||
|
||||
### `interface MemoryRevocationStoreOptions`
|
||||
|
||||
```ts
|
||||
interface MemoryRevocationStoreOptions {
|
||||
readonly maxEntries?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### `createMemoryRevocationStore(options?): RevocationStore`
|
||||
|
||||
Build a fresh in-memory store. Returns synchronous methods; safe to
|
||||
`await` regardless.
|
||||
|
||||
### `fingerprintLicense(licenseKey: string): string`
|
||||
|
||||
SHA-256 hex of the UTF-8 bytes of `licenseKey`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The in-memory store uses a `Set<string>` because `Set` preserves
|
||||
insertion order in every modern JS engine and gives O(1) `add`/`has`.
|
||||
The LRU-on-write semantic (re-add moves to end) matches the typical
|
||||
"a fingerprint just got revoked again, so keep it" intuition without
|
||||
needing a separate access timestamp. `fingerprintLicense` uses SHA-256
|
||||
because the fingerprint is the wire-facing key for the store; using
|
||||
the obfuscated key directly would make the store quadratic in key
|
||||
length and leak structure.
|
||||
91
docs/security-stack/security/license/src/schemas/schemas.md
Normal file
91
docs/security-stack/security/license/src/schemas/schemas.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# `@suaveplan/license` — schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Zod schemas for every public input boundary in the license package.
|
||||
`generateLicense`, `validateLicense`, `createPayload`, and the
|
||||
`LicenseManager` methods parse their inputs against these schemas at
|
||||
entry; failed parses raise `LicenseError(LICENSE.INVALID_INPUT)` with
|
||||
the `ZodError` attached as `cause`. Centralising the schemas here keeps
|
||||
the runtime modules thin and ensures downstream Zod-aware consumers
|
||||
(form validators, OpenAPI generators) can reuse the same shapes.
|
||||
|
||||
## Features
|
||||
|
||||
- `LicensePayloadSchema` — every 9 payload fields bounded by the
|
||||
per-field maximums in `LICENSE_SCHEMA`.
|
||||
- `LicenseGenerationOptionsSchema` — wraps `SignKey` (with a `Uint8Array`
|
||||
secret), the optional `signingKeyIdOverride`, `useFullSignature`,
|
||||
`signal`, and `rng`.
|
||||
- `LicenseValidationOptionsSchema` — accepts `publicKeys` as either a
|
||||
`Map` or a `Record`, mirroring the upstream API.
|
||||
- `QuickLicenseOptionsSchema` — covers the quick-builder fields with the
|
||||
same per-field bounds.
|
||||
- Every schema is `safeParse`-friendly; the runtime callers turn parse
|
||||
failures into typed `LicenseError`s.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
const parsed = LicensePayloadSchema.parse(input); // throws ZodError on failure
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Turn a parse failure into a typed `LicenseError`:
|
||||
|
||||
```ts
|
||||
import { LICENSE_CODES, LicenseError } from "@suaveplan/license";
|
||||
import { LicensePayloadSchema } from "@suaveplan/license/schemas/schemas";
|
||||
|
||||
function parsePayload(input: unknown) {
|
||||
const r = LicensePayloadSchema.safeParse(input);
|
||||
if (!r.success) {
|
||||
throw new LicenseError(
|
||||
LICENSE_CODES.INVALID_INPUT,
|
||||
"Invalid license payload",
|
||||
{ cause: r.error, context: { issues: r.error.issues.length } }
|
||||
);
|
||||
}
|
||||
return r.data;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `LicensePayloadSchema`
|
||||
|
||||
`z.object({ version, signingKeyId, productId, packageTier, flags,
|
||||
timeEncodingType, timeValue, featureMask, nonce })` with every field
|
||||
bounded by `LICENSE_SCHEMA`.
|
||||
|
||||
### `LicenseGenerationOptionsSchema`
|
||||
|
||||
`z.object({ signingKey, signingKeyIdOverride?, useFullSignature?, signal?,
|
||||
rng? })`. `signingKey` is `{ s: Uint8Array, p?: Uint8Array }`.
|
||||
|
||||
### `LicenseValidationOptionsSchema`
|
||||
|
||||
`z.object({ publicKeys, referenceDate?, creationDate?, signal? })`.
|
||||
`publicKeys` is a `Map` or `Record<number, { p: Uint8Array }>`.
|
||||
|
||||
### `QuickLicenseOptionsSchema`
|
||||
|
||||
`z.object({ productId?, tier?, expirationDays?, flags?, features?,
|
||||
useAbsoluteTime? })` with per-field bounds.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`rng` is intentionally typed as `z.unknown().optional()` because the
|
||||
`Rng` interface from `@suaveplan/types` is structural (a duck-typed bag
|
||||
of methods); attempting to validate it with a structural Zod schema
|
||||
would force consumers to surface every optional method even when their
|
||||
RNG implementation chooses not to. The runtime modules treat `rng` as
|
||||
opaque and call only the documented `nextUint32` method, so the loose
|
||||
schema is safe.
|
||||
85
docs/security-stack/security/license/src/time/time.md
Normal file
85
docs/security-stack/security/license/src/time/time.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# `@suaveplan/license` — time
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Time-encoding helpers for the four `TimeEncodingType` variants. The
|
||||
72-bit payload encodes time in 18 bits with an additional 2-bit
|
||||
discriminator (`NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` /
|
||||
`ABSOLUTE_TIMESTAMP`), giving four distinct expiration strategies. This
|
||||
module turns those numeric fields into concrete `{ isExpired,
|
||||
expirationDate }` records and lets callers inject a `Clock` (from
|
||||
`@suaveplan/types`) for deterministic test runs.
|
||||
|
||||
## Features
|
||||
|
||||
- Four expiration strategies, one switch — no separate code paths per
|
||||
call site.
|
||||
- Injectable `Clock` parameter; defaults to `Date.now()` so production
|
||||
callers don't have to thread it through.
|
||||
- `getDayNumber` / `dayNumberToDate` round-trip helpers used by
|
||||
generation when computing absolute-day expirations.
|
||||
- Default-arm fall-through returns `{ isExpired: false }` for unknown
|
||||
encodings, matching upstream's permissive behaviour (so a future
|
||||
encoding variant never bricks an older validator).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { checkExpiration } from "@suaveplan/license/time/time";
|
||||
import { TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const r = checkExpiration(TimeEncodingType.NONE, 0);
|
||||
console.log(r.isExpired); // false (perpetual)
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Threading a deterministic `Clock` through to relative-day expiry:
|
||||
|
||||
```ts
|
||||
import type { Clock } from "@suaveplan/types";
|
||||
import { checkExpiration } from "@suaveplan/license/time/time";
|
||||
import { TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const clock: Clock = () => Date.UTC(2026, 5, 17, 12, 0, 0);
|
||||
const creation = new Date("2026-06-01T00:00:00Z");
|
||||
const r = checkExpiration(
|
||||
TimeEncodingType.RELATIVE_DAYS,
|
||||
14,
|
||||
undefined, // use clock
|
||||
creation,
|
||||
clock
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `getDayNumber(date?: Date, clock?: Clock): number`
|
||||
|
||||
Days since the Unix epoch. Uses `date.getTime()` when provided, otherwise
|
||||
the injected `clock` (default `Date.now()`).
|
||||
|
||||
### `dayNumberToDate(dayNumber: number): Date`
|
||||
|
||||
Days since Unix epoch → `Date`.
|
||||
|
||||
### `checkExpiration(timeEncodingType, timeValue, referenceDate?, creationDate?, clock?): { isExpired, expirationDate? }`
|
||||
|
||||
Determine expiration for a license. `referenceDate` defaults to
|
||||
`new Date(clock())`. `creationDate` is consulted only for
|
||||
`RELATIVE_DAYS` (falls back to `referenceDate`).
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The unknown-encoding default arm (`return { isExpired: false }`) is
|
||||
intentional — it makes adding a new encoding variant a backward-
|
||||
compatible change for existing validators, which is important because
|
||||
license keys are long-lived and may outlive the validator that
|
||||
originally minted them. The `clock` parameter sits last in the
|
||||
argument list and defaults to `Date.now()`, so the public
|
||||
`checkExpiration` call site stays compact in normal usage and only
|
||||
expands when a test needs determinism.
|
||||
121
docs/security-stack/security/license/src/types/license.md
Normal file
121
docs/security-stack/security/license/src/types/license.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# `@suaveplan/license` — types
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Public type surface for the license package, ported verbatim from the
|
||||
upstream `index.types.ts` at the wire-format level. This module owns
|
||||
every interface, type alias, and enum that crosses the package
|
||||
boundary; the runtime modules import their shapes from here so the
|
||||
binary contract (72-bit payload layout, signature lengths, validation
|
||||
error variants) is declared in one place.
|
||||
|
||||
Two additive suaveplan extensions sit alongside the upstream surface:
|
||||
`LicenseValidationError.REVOKED` (additional failure variant) and the
|
||||
`LicenseManagerConfig.{clock, rng, revocationStore}` injection trio.
|
||||
Both are opt-in and never affect callers that don't reach for them.
|
||||
|
||||
## Features
|
||||
|
||||
- Three enums verbatim from upstream: `TimeEncodingType`,
|
||||
`LicenseFlags`, `PackageTier`.
|
||||
- `LicensePayload` (the 9-field 72-bit struct), `LicenseDetails`,
|
||||
`LicenseGenerationOptions`/`Result`, `LicenseValidationOptions`,
|
||||
`LicenseValidationResult` discriminated union.
|
||||
- `PublicKey` / `SignKey` shaped to upstream's `{ p, s }` records so the
|
||||
truncated-signature verification path round-trips bit-identically.
|
||||
- `RevocationStore` re-export so the package root can serve a single
|
||||
named entry for downstream consumers.
|
||||
- `CreatePayloadInjection` bag carrying optional `Clock` and `Rng`,
|
||||
threaded through to deterministic test runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import type {
|
||||
LicensePayload,
|
||||
LicenseValidationResult,
|
||||
} from "@suaveplan/license/types/license";
|
||||
import { PackageTier, TimeEncodingType } from "@suaveplan/license/types/license";
|
||||
|
||||
const payload: LicensePayload = {
|
||||
version: 2,
|
||||
signingKeyId: 0,
|
||||
productId: 7,
|
||||
packageTier: PackageTier.PRO,
|
||||
flags: 0,
|
||||
timeEncodingType: TimeEncodingType.NONE,
|
||||
timeValue: 0,
|
||||
featureMask: 0,
|
||||
nonce: 0x123,
|
||||
};
|
||||
|
||||
function isExpired(result: LicenseValidationResult): boolean {
|
||||
return !result.success && result.error === "EXPIRED";
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Narrowing the discriminated union and switching on every error variant:
|
||||
|
||||
```ts
|
||||
import {
|
||||
LicenseValidationError,
|
||||
type LicenseValidationResult,
|
||||
} from "@suaveplan/license/types/license";
|
||||
|
||||
function describe(result: LicenseValidationResult): string {
|
||||
if (result.success) {
|
||||
return `Valid (tier ${result.details.packageTier})`;
|
||||
}
|
||||
switch (result.error) {
|
||||
case LicenseValidationError.MALFORMED: return "Malformed key";
|
||||
case LicenseValidationError.INVALID_ENCODING: return "Invalid encoding";
|
||||
case LicenseValidationError.INVALID_SIGNATURE: return "Bad signature";
|
||||
case LicenseValidationError.UNKNOWN_SIGNING_KEY: return "Unknown key";
|
||||
case LicenseValidationError.EXPIRED: return "Expired";
|
||||
case LicenseValidationError.UNSUPPORTED_VERSION: return "Wrong version";
|
||||
case LicenseValidationError.REVOKED: return "Revoked";
|
||||
default: {
|
||||
const _exhaustive: never = result.error;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Enums
|
||||
|
||||
- `TimeEncodingType` — `NONE` / `ABSOLUTE_DAYS` / `RELATIVE_DAYS` / `ABSOLUTE_TIMESTAMP`.
|
||||
- `LicenseFlags` — 5-bit bitfield: `TRIAL`, `UPGRADE`, `MACHINE_LOCKED`, `SUBSCRIPTION`, `EDUCATIONAL`.
|
||||
- `PackageTier` — `FREE` / `BASIC` / `PRO` / `BUSINESS` / `ENTERPRISE`.
|
||||
- `LicenseValidationError` — string-literal enum with seven variants (six verbatim + additive `REVOKED`).
|
||||
|
||||
### Interfaces
|
||||
|
||||
`PublicKey`, `SignKey`, `LicensePayload`, `LicenseDetails`,
|
||||
`LicenseGenerationOptions`, `LicenseGenerationResult`,
|
||||
`LicenseValidationOptions`, `LicenseValidationFailure`,
|
||||
`LicenseValidationSuccess`, `LicenseParsedResult`, `KeyRegistry`,
|
||||
`LicenseManagerConfig`, `QuickLicenseOptions`, `CreatePayloadInjection`,
|
||||
`RevocationStore` (re-export).
|
||||
|
||||
### Types
|
||||
|
||||
`LicenseValidationResult = LicenseValidationSuccess | LicenseValidationFailure`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Every enum is declared with literal numeric or string values that match
|
||||
the upstream wire format; do not renumber. `LicensePayload` is plain
|
||||
`number` for each field — runtime bounds checks live in
|
||||
`packPayload` (via `LICENSE_SCHEMA`) and the Zod schemas in
|
||||
`src/schemas/schemas.ts`, leaving the type free of brand types so
|
||||
consumers can construct payloads from arbitrary integer math without
|
||||
fighting the type system.
|
||||
110
docs/security-stack/security/license/src/validate/validate.md
Normal file
110
docs/security-stack/security/license/src/validate/validate.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# `@suaveplan/license` — validate
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
License-key validation: parse, verify signature, check version, check
|
||||
expiration, surface every failure as a typed enum on a discriminated
|
||||
union. `validateLicense` accepts `publicKeys` as either a `Map` or a
|
||||
plain `Record` (matching upstream), honours an optional `AbortSignal`,
|
||||
and emits the OpenTelemetry counter / histogram pair on every call.
|
||||
`isValidLicense` is a convenience boolean wrapper.
|
||||
|
||||
The package's truncated-signature scheme (default 20 bytes) cannot use
|
||||
standard secp256k1 verification (which requires the full 64-byte
|
||||
signature). The upstream-compatible fallback hashes `payloadBytes ||
|
||||
publicKey.p` with SHA-256 and compares the first N bytes against the
|
||||
embedded signature bytes — giving ~2^160 forgery resistance for the
|
||||
default 20-byte length.
|
||||
|
||||
## Features
|
||||
|
||||
- Seven failure modes returned on the discriminated union:
|
||||
`MALFORMED`, `INVALID_ENCODING`, `INVALID_SIGNATURE`,
|
||||
`UNKNOWN_SIGNING_KEY`, `EXPIRED`, `UNSUPPORTED_VERSION`, `REVOKED`
|
||||
(`REVOKED` is surfaced by `LicenseManager.validate`).
|
||||
- Full-signature path uses `@suaveplan/crypto-curve`'s
|
||||
`secp256k1.verify` on the SHA-256 digest of `payloadBytes`.
|
||||
- Truncated-signature path uses the upstream hash-prefix scheme.
|
||||
- Zod boundary validation on `options`; failures raise
|
||||
`LicenseError(LICENSE.INVALID_INPUT)`.
|
||||
- OTel emission: counter `license.validate_total{license.valid,
|
||||
license.error?}` and histogram `license.validate_duration_ms`.
|
||||
- Pre-aborted `AbortSignal` rejects with
|
||||
`LicenseError(LICENSE.ABORTED)` before any crypto runs.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateLicense } from "@suaveplan/license/validate/validate";
|
||||
|
||||
const v = await validateLicense(key, {
|
||||
publicKeys: { 0: { p: publicKeyBytes } },
|
||||
});
|
||||
if (v.success) {
|
||||
console.log("Tier:", v.details.packageTier);
|
||||
} else {
|
||||
console.warn("Invalid:", v.error, v.message);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Validate with an injected `referenceDate`, `creationDate` for a
|
||||
relative-day expiry check, and an `AbortSignal` for cancellation:
|
||||
|
||||
```ts
|
||||
import { validateLicense } from "@suaveplan/license/validate/validate";
|
||||
import { LicenseError } from "@suaveplan/license";
|
||||
|
||||
const ctrl = new AbortController();
|
||||
setTimeout(() => ctrl.abort(), 100);
|
||||
try {
|
||||
const v = await validateLicense(key, {
|
||||
publicKeys: new Map([[0, { p }]]),
|
||||
referenceDate: new Date("2026-06-17T00:00:00Z"),
|
||||
creationDate: new Date("2026-06-01T00:00:00Z"),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (v.success) {
|
||||
// ...
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof LicenseError && e.code === "LICENSE.ABORTED") {
|
||||
console.warn("Validation cancelled");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `validateLicense(licenseKey, options): Promise<LicenseValidationResult>`
|
||||
|
||||
Options: `publicKeys` (required), `referenceDate?`, `creationDate?`,
|
||||
`signal?`. Returns the discriminated success/failure union. Throws only
|
||||
on `LicenseError(LICENSE.INVALID_INPUT)` (bad options) or
|
||||
`LicenseError(LICENSE.ABORTED)` (aborted signal); every other failure
|
||||
is surfaced on the result.
|
||||
|
||||
### `isValidLicense(licenseKey, options): Promise<boolean>`
|
||||
|
||||
Thin wrapper that returns `result.success`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The truncated-signature scheme is what makes the keys compact (~50-60
|
||||
chars vs ~120). For most consumer products the ~2^160 forgery
|
||||
resistance is generous; for higher-value licenses callers should opt
|
||||
into `useFullSignature: true` at generation time. Validation auto-
|
||||
detects which scheme the key uses by checking the signature byte
|
||||
length against `FULL_SIGNATURE_BYTES`.
|
||||
|
||||
The OTel emission runs on every code path — including aborts and
|
||||
parse failures — so dashboards always see a complete success/failure
|
||||
ratio. The histogram's unit is documented as `ms`; the recorded value
|
||||
is `Date.now() - start`, a wall-clock measurement (acceptable here
|
||||
because validation latency is dominated by the crypto, not by clock
|
||||
skew).
|
||||
Reference in New Issue
Block a user